为了担保用户体验、活动效果和资金安全,红包雨系统须要担保超高的稳定性。在系统设计上不能强依赖任何外部系统,在极度情形下仅须要红包雨做事可用,用户要求即可正常处理并返回结果。褒奖系统作为红包系统的下贱做事,卖力用户褒奖的入账,须要承载最高180万 QPS 的褒奖发放要求,并且在涌现非常情形时担保用户体验无损,褒奖可以终极入账,做到不超发不少发。
2. 技能寻衅
2.1 峰值流量高
除夕当天会进行7场红包雨,从12:00起每小时进行一场,集卡开奖和烟火大会于19:30开始。当晚20:00前后,红包雨、集卡开奖和烟火大会的发奖流量将会叠加在一起,届时可能产生超过200万 QPS 的发奖流量。下贱资产中台做事仅供应30万 QPS 的现金红包、40万 QPS 的优惠券入账能力。褒奖系统须要削峰限流,异步入账褒奖,确保下贱做事不过载。

2.2 褒奖种类多
除现金红包外,在集卡和烟火大会场景会发放10多种优惠券、实物褒奖、头像挂件等。不同的优惠券由不同的下贱系统发放,且每个别系的吞吐能力不同,乃至部分系统只能供应2000 TPS 的处理能力。褒奖系统在进行削峰限流时,不同褒奖种类限流的阈值须要根据下贱系统吞吐能力进行个性化配置。下贱系统能力有限的情形下,须要担保现金优先入账。
2.3 系统高可靠
引入行列步队进行褒奖异步发放后,须要尽可能担保褒奖事宜的可靠投递和可靠消费,任何褒奖终极都要入账,还需兼顾行列步队集群的稳定和容灾。
在内部做事出灾的情形下,或褒奖事宜在行列步队中堆积时,须要做到用户无感知,用户在活动钱包页可见褒奖流水,随时可以正常提现。除通过消费褒奖事宜入账外,还需引入用户提现行为触发逼迫入账的能力,与此同时还要担保安全可靠,不能被黑产攻击造成资金丢失。
3. 技能方案基于春节活动峰值流量高、稳定性哀求高的特点,为了担保高峰值流量下褒奖系统稳定可靠,技能方案选型时选择了基于行列步队削峰、异步处理要求的总体方案。褒奖发放的大概流程如下:
在褒奖事宜生产侧,为了尽可能降落上游接入方的开拓本钱,基于不同接入场景特性,由褒奖系统供应褒奖 SDK ,并定义大略清晰的发奖接口,供接入方选用。褒奖事宜的可靠投递由 SDK 内部担保。褒奖事宜 MQ 利用了公司内 ByteMQ 和 RocketMQ 两种行列步队,防止因单个行列步队集群宕机导致全体系统不可用。
在褒奖事宜消费侧,针对每一个 Topic 创建一个消费者做事,四个消费者功能完备同等。由消费者做事担保可靠消费和消费限速。
除勉励金币外,其他褒奖类型通过资产中台做事调用各个下贱发放。春节活动期间,资产中台暂未支持发奖要求的削峰,须要在褒奖系统前置进行。业务上,同一订单号只能发放一种褒奖一次,由于资产中台和勉励中台系统之间数据隔离,须要褒奖系统支持单一订单号跨做事发放幂等。
3.1 褒奖SDK设计
SDK 以代码“内嵌”的办法运行在接入方做事内,可以避免 RPC 办法网络传输、要求数据序列化和返回数据反序列化带来的时延和性能花费。只管 SDK 的整体时延和性能优于 RPC 办法,对 SDK 本身的稳定性、性能花费和接口相应时延依然有非常高的哀求。以红包雨场景为例,发奖接口须要50ms内返回,若相应韶光超过50ms将会增加全体活动玩法接口的处理韶光,影响红包雨做事的吞吐量,终极会影响用户参与春节活动的体验。
褒奖 SDK 在功能上实现了褒奖Token 的天生和存储和褒奖事宜的可靠投递。 接口设计上面向不同接入场景针对性地供应定制接口,最大限度的降落利用方的理解和接入本钱,减少开拓周期。
为了担保 SDK 代码构造清晰,并具有较高的拓展性和可掩护性,在代码构造层面,SDK 内部利用了分层设计,分为了对外接口层、内部接口层和内部实现层。
3.1.1 对外接口层
对外接口层定义了暴露给利用者的外部接口,除初始化、反初始化等接口和通用的异步发奖接口外,还为红包雨、烟火大会和集卡分别供应差异化定制接口。通用异步发奖接口定义和褒奖 RPC 做事的异步发奖接口保持同等,通过调用 RPC 接口和通过 SDK 发奖的接入方可以低本钱的双向迁移。
定制接口结合利用场景的特点,固化诸如活动 ID、场景 ID、褒奖类型等通用参数,减少接口入参个数,函数名称语义更清晰,可进一步降落接入方的利用本钱,提升接入方代码的可读性和可掩护性。对付部分场景,还承担了全局幂等 ID的拼接事情。
发奖要求除用户信息(用户 ID、设备 ID 和 AppID )、褒奖信息(褒奖类型、数值)外,还需携带一个全局唯一 ID 作为订单号,以实现根据订单号幂等的能力。订单号由接入方根据活动信息和用户信息拼接而成。所有的接口都支持调用方写入拓展字段(Map 格式的键值对)保存业务自定义信息。
3.1.2 内部接口层
内部接口层供应了通用的褒奖异步发放接口(SendBonus)、Token 天生和存储接口(GenBonusToken)、初始化接口和反初始化接口。外部接口基于内部接口进行差异化封装,供应更细化的功能。内部接口层对上层屏蔽内部实现细节。
以异步发放接口 SendBonus 函数为例,紧张集成了参数检讨、打点监控、虚拟行列步队(Queue)选择、褒奖的布局和发送、褒奖 Token 的天生和存储等功能。参数校验通过后,SendBonus 接口即返回褒奖 Token,供上层调用者利用(一样平常是返回给前端和客户端)。
/ SendBonus @act 活动信息 @user 用户信息 @bonus 褒奖信息/func SendBonus(ctx context.Context, act Activity, user User, bonus BonusContent) (string, error) { // 参数检讨 if err := CheckParams(act, user); err != nil { // 输出错误日志,监控非常要求 return "", err } // 检讨褒奖类型是否合法 cfg, err := CheckBonus(bonus) if err != nil { // 输出错误日志,监控非常要求 return "", err } // 布局褒奖 message := &event.BonusEvent{...} // SendEvent内部根据褒奖属性选择行列步队 if err = queue.SendEvent(ctx, message); err != nil { return GenBonusToken(ctx, act, user, info, true), err } // 布局并返回褒奖Token return GenBonusToken(ctx, act, user, info, true), nil}
3.1.3 内部实现层
内部实现层紧张包含褒奖 Token 和虚拟行列步队 Queue 两大模块。Token 模块卖力 Token 的天生、存储和查询;Queue 模块卖力实现的可靠投递。
A. Token 模块
在全体活动系统内部,褒奖系统通过消费褒奖事宜(异步)进行真实的褒奖发放。在褒奖系统内部出灾或褒奖实际入账存在压单的情形下,引入 Token 机制来担保用户体验无损、担保用户在活动页面可见褒奖流水、担保用户利用褒奖时可操作(现金可提现、优惠券可利用等)。Token 作为用户得到褒奖的凭据而存在,和褒奖事宜逐一对应。Token 的产生和流转过程如下图所示:
Token 数据构造和加解密
Token 内部数据构造利用 Protobuf 定义,相对付 JSON 办法序列化和反序列化性能均有提升、序列化后的数据大小减小了50%。Token 数据会返回给客户端并保存在本地,为防止黑产解析 Token 布局数据恶意要求做事端接口,须要对Token 数据进行加密。Token 工具利用 Protobuf 进行序列化后的明文利用公司内的 KMS 工具进行加密。加密后的密文利用 Base64 算法进行编码,以便在网络传输和客户端本地存储。解密时前辈行 Base64 解码,再利用 KMS 工具进行解密,拿到的明文利用 Brotobuf 进行反序列化后即可得到 Token 工具。
Token 数据内容如下所示:
syntax = "proto3";message BonusToken { string TradeNo = 1; // 订单号,全局唯一,用于幂等 int64 UserID = 2; // 发奖当时的APP内的UID string Activity = 3; // 活动 string Scene = 4; // 场景 int64 AwardType = 5; // 褒奖类型 int32 AwardCount = 6; // 褒奖数值 int64 AwardTime = 7; // 褒奖发放韶光戳 string Desc = 8; // 褒奖文案}
Token 存储
Token 存储是范例的写多读少场景,底层存储须要直接承载发奖的峰值流量(预估350万 QPS ,部分场景一次要求会发放多个褒奖),用户进入钱包页面才会读取存储(预估40万QPS),读写要求量级相差较多。数据的有效期较短,褒奖真正入账后即可删除。写入场景均为插入单个 Token,读取场景均为读 Token 列表。
Token 紧张由红包雨、集卡开奖和烟火大会发奖产生,个中红包雨和集卡开奖的褒奖数量有明确的数量上限。在烟火大会玩法中,用户最快每30秒即可领取一次褒奖,对用户领奖次数没有限定,理论上单个用户在全体烟火大会活动可以产生500个 Token。
基于预估的线上流量、读写模型和活动特点,决定利用 Redis 作为底层存储,数据构造利用 Hash,用户的 ActID 作为 Hash 数据的 Key、Token 的订单号 TradeNo 作为 Hash 的 Field、Token 序列化后的明文作为 Hash 的 Value。
Token 做事
Token 做事供应了查询用户 Token 列表和加密 Token 合法性校验接口。根据Token 密文是否可以正常解密、解密后的 Token 是否存在于 Redis 中,Token 合法性校验接口返回三种结果:
造孽 Token:密文无法解密未知 Token:密文可解密,但存储无记录合法 Token:密文可解密,且存储有记录褒奖 SDK 在写 Token 的 Redis 时不会进行失落败重试,存在极少数 Token 没有保存成功的情形。为了担保资金安全、防止黑产恶意攻击,可解密的未知 Token 不能用作逼迫入账。
Token 利用
用户参与活动得到褒奖后,Token 由活动前端调用客户端 JSB 进行保存。用户查看褒奖流水时,活动钱包页前端会通过 JSB 读取本地 Token 列表,在要求资产中台做事时携带。资产中台做事利用 TokenSDK 进行解密,同时会要求 Token 做事读取做事端 Token 列表,并进行合并操作。资产中台还会在合并后的列表中删除已经入账的 Token,在返回给用户的流水里插入暂未入账的流水并改动活动钱包余额,担保用户褒奖及时可见。
用户在活动钱包页进行提现时,也会将客户端本地 Token 带给资产中台做事。资产中台做事对未入账的合法 Token 进行逼迫入账,担保用户可以完成提现操作。
客户端和做事端 Token 的浸染
当褒奖系统依赖的行列步队出灾导致无法写入或消费时、或由于削峰限流导致褒奖真实入账存在延迟时,两种 Token 都可以在一定程度上担保用户体验无损。
客户端 Token 通过用户设备和后台做事之间的网络通报,保存于用户设备存储。做事端 Token 通过内部网络通报,保存于中央化的 Redis 存储。两种 Token 互为备份,在本地 Token 不可取时,可以依赖做事端 Token。做事端 Token 做事出灾时,客户端 Token 仍旧可以担保用户体验。
本次活动在字节系8个 APP 同时上线,Token 做事还可以担保用户在不同 APP 上,乃至不同的设备上的体验同等。
B. Queue模块
Queue 模块卖力供应 “可靠” 的投递做事。对外暴露的 SendEvent 函数能够根据褒奖选用对应的虚拟行列步队进行发送、并供应统一的监控能力。
func SendEvent(ctx context.Context, msg BonusEvent) error { // 根据褒奖信息选择专用的虚拟行列步队 queue := GetQueue(msg.Activity, msg.Scene, msg.BonusType) data, err := proto.Marshal(message) if err != nil { return err } return queue.Send(ctx, message.UserID, message.UniqueID, data)}
虚拟行列步队(Queue)是对公司内 ByteMQ 和 RocketMQ 的封装,内部通过代码封装屏蔽了两种行列步队 Producer-SDK 的利用细节,并支持利用两种 MQ 进行互备,提升全体系统的容灾能力。虚拟行列步队的类图如下所示:
虚拟行列步队的 Send 方法可根据用户 ID 动态的调度主备生产者的利用比例,在单个生产者失落败的情形下供应自动容灾能力。
func (q Queue) Send(ctx context.Context, uid int64, tradeNo string, data []byte) error { var err error if (uid % 100) < GetQueueRatio(q.Name()) { err = q.Master.Send(ctx, tradeNo, data) if err != nil { err = q.Backup.Send(ctx, tradeNo, data) } } else { err = q.Backup.Send(ctx, tradeNo, data) if err != nil { err = q.Master.Send(ctx, tradeNo, data) } } return err}
利用 RocketMQ 或 ByteMQ 的 SDK 异步批量发送功能时,由 Producer 屏蔽两个 SDK 失落败回调的差异,统一利用失落败通道返回给上层。虚拟行列步队的 Retry 逻辑卖力读取主备 Producer 的失落败,并采纳主备轮转的办法进行发送重试。在做事进程无非常退出的情形下,可担保终极发送成功。进程正常退出时,Close 方法会等待所有处理完成再返回。
行列步队 Topic可配置
虚拟行列步队内部利用了 Master 和 Backup 两个行列步队,通过代码抽象和底层行列步队类型做理解耦。在真实线上环境,为了达到灾备的目的,单个虚拟行列步队的 Master 和 Backup 须要利用不同类型或者不同物理集群的行列步队 Topic。
在春节活动期间,ByteMQ 和 RocketMQ 的研发和运维团队分别供应了一个活动专用集群,并做重点运维保障。褒奖系统在 ByteMQ 和 RocketMQ 的活动集群申请各申请了两个 Topic。基于4个 Topic,在上层构建了3个虚拟行列步队。
Topic 的 Producer 实例可以在不同的 Queue 中复用。上图中,ByteMQ 的生产者 S 在 Special Queue 中作为 Master,在 Express Queue 中作为 Backup;RocketMQ 的生产者 B 同时在 Massive 和 Special Queue 中作为 Backup。
褒奖 SDK 内部利用的行列步队 Topic 配置在了动态配置 TCC 中,虚拟行列步队和 Producer 实例之间的映射关系也可通过 TCC 配置。做到了代码和行列步队集群、Topic 解耦。开拓测试、线上运行阶段可以非常方便的改换行列步队Topic。
褒奖对应的虚拟行列步队可配置
褒奖类型和虚拟行列步队的对应关系配置在 TCC 中,不同的褒奖类型可以动态的指定发送的虚拟行列步队,没有配置时默认利用 Massive 虚拟行列步队。在 SendEvent 方法中,调用 GetQueue 发放选用虚拟行列步队。春节活动期间,Massive 虚拟行列步队承载所有场景发放的现金褒奖;Special 虚拟行列步队承载了所有场景发放的优惠券;Express 虚拟行列步队承载了所有场景下的勉励金币褒奖。
异步批量发送
ByteMQ 和 RocketMQ 的生产者 SDK 均支持同步发送和异步批量发送。RocketMQ 同步发送时延 P99为20 ms,而 ByteMQ 同步发送时延 P99为秒级。在发送同等数量级的时,RocketMQ 的 CPU 占用明显高于 ByteMQ。在异步发送模式下,行列步队的生产者 SDK 会启动协程定时或当缓冲区内的达到阈值时发送。定时的韶光间隔和缓冲区阈值可以在初始化时配置。批量发送可以降落生产者对行列步队做事的要求次数,假设每100个批量发送一次,最高可以将行列步队做事的 QPS 降落100倍,极大的减轻行列步队集群的负载。
为了降落褒奖事宜发送接口的相应时延,以及保持行列步队集群负载低水位,在大流量发奖场景均利用异步批量发送模式,并配置 ByteMQ 承载紧张的流量。
3.2 消费者设计
行列步队的削峰功能,基于掌握消费者的消费速率实现。RocketMQ 消费办法基于长轮训办法实现,兼具了推拉两种模式的优点。ByteMQ 消费办法为拉模式。消费者实例可通过掌握拉的频率和单次拉取消息的数量来掌握消费速率。
在春节活动褒奖发放场景,不仅须要动态的调度多个行列步队的总消费速率,担保下贱褒奖做事、资产中台做事、勉励中台做事不过载,且充分利用机器资源;还须要动态的掌握不同褒奖类型的消费速率,支持现金等主要褒奖优先入账。
活动中发放的褒奖类型较多,不能为每种褒奖单独分配行列步队 Topic。不同褒奖类型发放的数量差异显著,发放量级大和入账优先级高的褒奖独占 Topic,发放量级小和入账优先级低的褒奖共用一个 Topic。不同褒奖类型的真实入账做事(资产中台做事的下贱做事)入账能力不同,入账能力最小的做事每秒仅能处理2000的发放要求。须要支持褒奖类型维度的灵巧消费控速能力。
在多维度的控速根本上,还须要供应可靠消费的能力,每个褒奖至少成功处理一次(At least Once),所有褒奖终极成功入账。
基于上述背景,褒奖消费者做事拉取速率(从 Topic 读取消息)和处理速率(通过褒奖类型限速,调用褒奖系统发放褒奖)可能存在差异。当拉取速率小于处理速率时,褒奖做事吞吐量低落,在 Broker 中堆积韶光变长;当拉取速率大于处理速率时,不能通过褒奖类型限速的会堆积在消费者做事进程内存中,并壅塞消费,差异显著时可能造成消费者做事进程因 OOM 而退出,影响做事稳定性。对付被褒奖类型限速的,须要立即进行重入 行列步队,消费者做事连续处理后续。由于网络颠簸等缘故原由,暂时处理失落败的,也须要重入行列步队,担保可以终极处理成功。
3.2.1 消费控速实现
A. 消费限速
RocketMQ 消费者实例在启动时可配置单实例消费速率和消费 Worker 数量。动态调度消费速率,须要重启消费者实例。ByteMQ 兼容 Kafka 协议,Golang 代码中消费 ByteMQ 行列步队利用了 sarama-cluster (https://github.com/bsm/sarama-cluster)。sarama-cluster 比较于RocketMQ 的 SDK 更加大略,没有供应单实例消费限速能力。单实例可以订阅多个 Partition,每个 Partition 会启动一个协程从 Broker 读取消息,多个 Partiton 共用一个全局通道(Channel)写入待处理。业务代码须要从全局通道中读取消息进行处理。限速逻辑只能在业务逻辑中实现,动态调度消费速率无需重启消费者实例。
基于 sarama-cluster 的特点,利用 Go 原生限速器(golang.org/x/time/rate)实现了 ByteMQ 消费者的单实例限速器。代码实现如下:
type Limiter struct { Open bool Fetcher LimitFetcher inner rate.Limiter stop chan struct{}}// Wait 处理前调用,返回后进行处理func (s Limiter) Wait() { if s.Open { _ = s.inner.Wait(context.Background()) }}// Loop 用于监听限速变革func (s Limiter) Loop() { for s.Open && s.Fetcher != nil { select { case <-time.After(time.Second 5): newLimit := s.Fetcher() if newLimit != int(s.inner.Limit()) { s.inner.SetLimit(rate.Limit(newLimit)) } case <-s.stop: return } }}
Go 原生限速器采取令牌桶算法实现限流,内部没有掩护 Timer,而是采取了惰加载的思路,在获取 Token 时根据韶光差打算更新可用 Token 数量。没有任何外部依赖,非常适宜用于单实例限流。
动态调度限流器的速率时,通过限速器 Reserve 和 Wait 接口花费但未利用的Token 不会被取消。利用 Wait 方法壅塞的韶光不会由于速率的调度而变革。速率调度发生后,对下贱产生的 QPS 由三部分组成:调度前已经在等待的要求(壅塞在 rate.Limiter::Wait()) 、调度后新增的 Token 带来的要乞降 Burst(桶容量)带来的要求。调度后短韶光内的对下贱产生的 QPS 可能超过预期的速率。对付突发流量场景,Burst 不宜设置过大。
// SetLimitAt sets a new Limit for the limiter. The new Limit, and Burst, may be violated// or underutilized by those which reserved (using Reserve or Wait) but did not yet act// before SetLimitAt was called.func (lim Limiter) SetLimitAt(now time.Time, newLimit Limit)
B. 并发消费
RocketMQ 有序消费时,单个 Queue 只能分配一个 Worker 进行消费,只有当前 Queue 上一个成功处理后,才会处理下一个,消费速率受限于Queue 的数量和单个的处理时延;无序消费时,所有 Worker 共用一个缓冲区,随机消费不同 Queue 的,Worker 之间并发处理,Worker 数量越多消费速率越快。
RocketMQ 进行确认(ACK)时,本地处理成功的数量超过一定数量时,或者间隔上一次提交超过一定韶光后,消费者实例会批量提交(BatchCommit)成功消费信息给 Broker。批量提交要求中包含每个的 MsgID、QueueID 和 Offset 等。Broker 侧供应了确认窗口机制,每次保存对应Queue 的窗口中最小 Offset 到磁盘。若 Broker 发生宕机,窗口中大于磁盘保存 Offset 的,将会被再次消费。在消费者视角,会消费到已经成功确认的。因此,RocketMQ 不能担保 At Most Once,处理逻辑须要担保幂等。
ByteMQ 确认机制相对大略,Broker 没有供应确认窗口机制,收到消费者实例的 Commit 要求时,直接保存当前 Offset,偏移量小于当前 Offset 的将不会再次被消费。在消费者实例中,业务代码调用的 MarkOffset 方法,会基于确认的 Offset+1并记录在内存中,由协程定时提交到 Broker。若消费者实例发生宕机,Offset 未提交到 Broker 的将会被 Broker 再次下发,ByteMQ 也不能担保 At Most Once,消费者也须要担保处理逻辑须要担保幂等。
消费 ByteMQ 时,从 sarama-cluster 暴露的全局通道中读取消息后,同步处理成功后调用 MarkOffset 方法可以担保顺序消费。但同步处理会严重降落消费速率(单实例同一时候只能处理一个)。启动协程异步处理可以并发处理,并可通过增加协程数量来提升消费速率。但在消费者进程非常退出、消费者宕机等情形下会造成丢失。例如:Offset 较大的处理后并成功确认(Offset 成功提交到 Broker)后,Offset 较小的还未处理成功时消费者宕机,Broker 不再下发该,导致该漏处理,不知足 At Least Once 语义。
// MarkOffset marks the provided message as processed, alongside a metadata string// that represents the state of the partition consumer at that point in time. The// metadata string can be used by another consumer to restore that state, so it// can resume consumption.//// Note: calling MarkOffset does not necessarily commit the offset to the backend// store immediately for efficiency reasons, and it may never be committed if// your application crashes. This means that you may end up processing the same// message twice, and your processing should ideally be idempotent.func (c Consumer) MarkOffset(msg sarama.ConsumerMessage, metadata string) { c.subs.Fetch(c.client.config.TryWrapTopicByEnv(msg.Topic), msg.Partition).MarkOffset(msg.Offset+ 1, metadata)}
办理上述漏处理的问题,须要针对 ByteMQ 的确认机制在业务层进行优化,即在消费者代码中自助实现确认窗口机制。在消费者进程中,按照顺序将其 Offset 缓存在链表中,同时以 Offset 为 Key 在 HashMap 中存储链表节点指针。成功处理时,通过 HashMap 寻址,修正链表节点状态。本地协程定时从链表头部扫描,严格按照顺序向 Broker 提交成功消费的 Offset。并发处理时,担保较大 Offset 的不会提前确认给 Broker。
3.2.2 事宜处理逻辑
RocketMQ 供应了失落败行列步队,并供应重试能力,但 ByteMQ 没有失落败处理机制,为抹平两种行列步队的差异,事宜处理方法(HandleMessage)须要尽最大可能担保成功处理,对付处理失落败的须要进行重入行列步队(SendEventToBackup)。
RocketMQ 消费者失落败多次重入行列步队失落败后,会连续利用行列步队 SDK 供应的失落败重试能力。由于 ByteMQ 的 SDK 没有失落败处理机制, 失落败多次重入行列步队失落败后,依然会对其 Offset 进行确认,担保不会壅塞后续处理。
HandleMessage
// HandleMessage for ByteMQfunc HandleMessage(msg sarama.ConsumerMessage) error { err := DoReward(msg.Context, msg.Value, limiter) MarkOffser(msg, err) // 本地确认,由异步协程定时提交 return nil}// HandleMessage for RocketMQfunc (w wrapper) HandleMessage(ctx context.Context, msg pb.ConsumeMessage) error { return handler.DoReward(ctx, msg.Msg.MsgBody, limiter)}type Limiter interface { Allow(BonusEvent) bool}func DoReward(ctx context.Context, data []byte, rate Limiter) error { bonus := &BonusEvent{} if err := proto.Unmarshal(data, bonus); err != nil { return err } // 按照褒奖类型限流,当rate为nil时不限流,熔断时直接重入行列步队 if rate == nil || rate.Allow(bonus) { // 同步调用褒奖做事进行发奖 if err := callReward(ctx, bonus); err == nil { return nil } } // 处理失落败:重新写入行列步队 return SendEventToBackup(ctx, bonus.UniqueID, bonus)}
SendEventToBackup
func SendEventToBackup(ctx context.Context, tradeNo string, bonus BonusEvent) error { bonus.Retry++ // 增加Retry次数 data, err := proto.Marshal(bonus) if err != nil { return err } // 利用新PartitonKey进行重发 newPartitionKey := fmt.Sprintf("%s{%d}", bonus.UniqueID, bonus.Retry) for _, queue := range instances { // 多个备选行列步队用于重入行列步队 if err = queue.Send(ctx, newPartitionKey, data); err == nil { return nil } } // 极度情形下通过日志回捞的办法处理 logs.CtxError(ctx, "%s", base64.StdEncoding.EncodeToString(data) ) return err}
3.2.3 褒奖类型限速
由于不同褒奖类型终极由不同的下贱系统入账,为担保下贱系统都稳定性,减少下贱系统返回限流缺点和无效调用,针对每一个褒奖类型单独配置了单实例限速。
func NewLimiter() Limiter { l := &Limiter{ m: sync.Map{}, ticker: time.NewTicker(5 time.Second), } l.loop() return l}type Limiter struct { m sync.Map ticker time.Ticker}type innerLimiter struct { rate.Limiter Fuse bool}// Allow 返回true时处理;返回false时不处理,直接重入行列步队func (L Limiter) Allow(event BonusEvent) bool { if event == nil { return true } if v, exist := L.m.Load(GetBonusType(event)); exist { if inner, ok := v.(innerLimiter); ok { if inner.Fuse { // 开启了熔断开关 return false } return inner.Allow() } } return true}func (L Limiter) loop() { go func() { defer Recover() L.run() for range L.ticker.C { L.run() } }()}// 监听配置变更,动态调度限速func (L Limiter) run() { for wt, config := range tcc.GetRateCfg() { value, exist := L.m.Load(wt) if !exist || value == nil { // 创建新增限流器 L.m.Store(wt, &innerLimiter{ Limiter: rate.NewLimiter(rate.Limit(config.Rate), config.Burst), Fuse: config.Fuse, }) continue } if inner, ok := value.(innerLimiter); ok { // 更新已有限流器 inner.Fuse = config.Fuse if int(inner.Limiter.Limit()) != config.Rate { inner.Limiter.SetLimit(rate.Limit(config.Rate)) } continue } L.m.Delete(wt) L.m.Store(wt, &innerLimiter{ Limiter: rate.NewLimiter(rate.Limit(config.Rate), config.Burst), Fuse: config.Fuse, }) }}func (L Limiter) Close() { if L.ticker != nil { L.ticker.Stop() L.ticker = nil }}
3.2.4 消费和褒奖类型限速折衷
消费者类似于一个管道,消费限速相称于流入管道的流量限定,褒奖类型限速相称于流出管道的流量限定。当消费速率大于所有类型速率之和时,会导致要求重入行列步队。减少重入行列步队须要担保两点:
消费限速和褒奖类型限速联动,调度类型限速时消费速率自动调度适配上游发放褒奖时,不同褒奖涌现的概率分布和类型限速配置匹配在春节活动中,褒奖发放的概率由算法策略掌握。在红包雨、烟火大会、集卡开奖等场景下,概率分布符合预期,没有发生重入行列步队。
3.3 褒奖做事设计
褒奖做事卖力调用资产中台做事和勉励中台做事发放详细的褒奖。对上层供应全局幂等的担保、失落败托管重试、预算掌握等能力。
由于上游存在利用同一个幂等 ID 发放不同褒奖的情形,且不同的下贱系统之间数据隔离,故须要褒奖做事存储所有发奖要求处理状态及结果,用于担保全局幂等。发放要求利用公司自研的 Abase 进行存储,同时利用了 Abase 供应的 CAS 能力,对褒奖发放行为进行了并发掌握,确保同一个幂等 ID 仅能用于一次发放行为。上游重试要求的褒奖类型和数值须要和原始要求保持同等,才能通过校验,进入真正的发放流程。
褒奖做事对外供应同步发奖和异步发奖两类接口。对付须要感知褒奖发放结果的场景,上游须要利用同步发奖接口。例如褒奖事宜消费者,须要明确感知发放是否成功,来决策是否须要重试等。同步接口稳定性和相应时延强依赖下贱做事。部分褒奖下贱发放逻辑较重,耗时较长,随意马虎导致上游调用超时,稳定性降落。
对付无需实时感知发放结果,或对接口相应实验非常敏感的场景,上游须要利用异步发奖接口。异步接口在通过预算掌握,成功将投递到行列步队后返回。异步接口可以提升系统吞吐能力,降落上游等待韶光。利用行列步队的削峰和异步能力,褒奖做事可以直接承接中等规模(发放 QPS 在10万到50万)的发奖场景接入。对付大规模(发放 QPS 在50万之上)的发奖场景,须要通过褒奖 SDK 接入。相对付同步接口,异步接口支持通用的失落败重试逻辑和非常处理能力,接入方无需再次开拓干系逻辑,可降落研发投入。
3.3.1 同步发奖
同步发奖接口会实时返回下贱系统返回的入账结果。对付失落败要求由上游做事卖力处理,褒奖做事不进行托管。褒奖同步发放的流程如下图所示:
上述流程图中,写行列步队、添加记录节点可以根据场景哀求,可设置为强依赖节点,也可设置为弱依赖节点。当写行列步队和添加记录节点被设置为弱依赖时,褒奖做事不能严格担保全局幂等,此时的幂等性须要下贱系统担保;在行列步队和 Abase 存储系统出灾时,褒奖做事可正常对外供应做事。
3.3.2 异步发奖
上游调用异步发奖接口虽然不会实时返回发放结果,但会在上游要求时同步调用预算掌握做事进行扣减预算。异步发奖流程中,发奖要求成功写入行列步队后,立即返回。后续发奖流程由褒奖系统的消费者做事通过消费触发,并担保终极成功入账。
异步发奖要求处理过程中,收到下贱系统返回的不可重试缺点时,会将非常要求写入专用的失落败行列步队并落 Hive 表存档,以便后续处理。
3.3.3 预算掌握
预算掌握是担保资金安全的手段之一。在春节活动中,除活动玩法自身的频控逻辑和预算掌握策略外,褒奖系统、资产中台和下贱账户做事都有自身的预算掌握策略。
褒奖系统中场景预算通过动态配置 TCC 配置,可支持动态调度。预算花费情形通过 KV 存储,为防止涌现热点 Key,根据接入场景的流量大小做了分 Key,单预算 Key 承载小于500 QPS 的要求。进行预算扣减时,通过对唯一订单号进行哈希求余来决定详细的预算 Key,并在预算 Key 的 Value 中存储多少条最新的订单号,基于存储系统的 CAS 能力供应有限的预算扣减幂等能力。若在单预算 Key 上产生较高的并发要求,存储的订单号被淘汰的情形下发生超时重试,会导致预算超扣。进行预算配置时,做了一定比例的超配,防止由于流量不均和预算超扣导致误拦截。
资产中台系统中,基于 Redis 实行 Lua 脚本的能力,实现了多 Key 事务预算掌握方案,供应了相对严格的预算掌握能力。不才游的账户做事中,基于关系型数据的事务能力进行了严格的预算掌握,担保在活动场景不会发生超发。
4. 总结春节活动于2022年1月24日正式上线,2022年1月31日(除夕)结束,共持续7天。活动期间通过褒奖系统发放各种褒奖约70亿笔,仅除夕当天就发放20亿笔。在多场红包雨中,褒奖系统从生产端到消费端做到了全部的可靠处理,离线对账未检测到任何有效差异,现金褒奖全部成功入账。
在春节活动中对干系做事的性能、稳定性和可靠性有着极高的哀求。在设计技能方案时,技能选型和常规需求有所不同,须要在可供选择的组件中权衡性能和可靠性。降落系统繁芜度,减少外部依赖,并对依赖部分进行充分的深入的理解是担保全体系统稳定可靠的关键。