我们做了什么
业务背景
在春节活动期间,抖音将视频和春节红包相结合,用户可以通过拍视频发红包的方式来给粉丝和好友送祝福。
业务玩法
整个活动玩法分为 B2C 和 C2C 两种玩法,下面对这两个玩法的流程简单介绍下
B2C 红包
在 B2C 红包玩法中,用户需要先来抖音或者抖 Lite 参加春节红包雨活动,有一定概率在春节红包雨活动中获得红包补贴。用户可以在获得补贴后直接跳转到相机页面,或者在之后拍摄视频跳转到相机页面,在相机页面用户拍摄完视频后会看到一个红包挂件,在挂件中可以看到已发放补贴。用户选择补贴后点击下一步完成投稿后即可完成视频红包的发放。
图 1 春节红包雨活动 图 2 红包补贴 图 3 红包挂件 图 4b2c 红包发送 tab 页
C2C 红包
在 C2C 红包玩法中,用户拍摄视频点击挂件,填写红包的金额和个数信息,选择红包的领取范围后,点击发送红包会拉起收银台,用户支付完成后点击下一步发布视频,即可完成 C2C 红包的发放。
图 1C2C 红包发送 Tab 页 图 2 支付界面 图 3 红包支付后挂件展示
红包领取
B2C 和 C2C 红包的领取流程都是一样的。用户在抖音刷视频遇到有视频红包的视频时,视频下方有个领取红包按钮,用户点击红包领取,会弹出到红包封面,用户点击红包封面的开红包后即可领取红包,领取成功后会显示领取结果弹窗,在领取结果中用户可以看领取金额,以及跳转到领取详情页,在领取详情页中可以看到这个红包其他用户的领取手气。
图 1 红包视频 图 2 红包封面 图 3 红包领取结果 图 4 红包领取详情
我们碰到的一些问题
通用红包系统的设计
在上文中有提到,本次春节活动需要同时支持 B2C 和 C2C 两种类型的红包,这两个类型的红包既有一些相似的业务,也有很多不同的业务。在相同点上他们都包括红包的发放和领取这两个操作。在不同点上,比如 B2C 的红包发放需要通过使用补贴来发送,而 C2C 的红包发放需要用户去完成支付。B2C 的红包用户领取后需要去提现,而 C2C 的红包用户领取后直接到零钱。因此需要设计一个通用的红包系统来支持多种红包类型。
另外对于红包系统本身而言,除了发领红包外,还涉及到一些红包信息的查询,以及各种状态机的推进,这些功能模块之间如何划分也是需要考虑的一个点。
大流量补贴的发放处理
前面提到过,B2C 红包玩法会先进行补贴的发放。在春节活动期间,每场红包雨都会有大量的用户进入参与,如果将这些流量直接打到数据库,将需要大量的数据库资源,而春节期间数据库的资源是非常稀缺的,如何减少这部分的资源消耗也是一个需要考虑的问题。
红包领取方案的选型
在红包业务中,领取是一个高频的操作,在领取方式的设计中,需要业务场景考虑一个红包是否会被多个用户同时领取。多个用户同时去领取同一个红包可能会导致热点账户问题,成为系统性能的瓶颈。解决热点账户问题也有多个方案,我们需要结合视频红包的业务场景特点来选取合适的方案。
稳定性容灾
在本次春节活动中,包括 B2C 和 C2C 两种业务流程,其中每个业务流量链路都依赖很多的下游服务和基础服务。在这种大型活动中,如果出现黑天鹅事件时,如何快速止损,减少对系统的整体影响,是一个必须要考虑的问题。
资金安全保证
在春节活动期间,B2C 会发放大量的红包补贴,如果补贴发生超发,或者补贴的核销出现问题,一个补贴被多次核销,将会造成大量的资损。另外 C2C 也涉及到用户的资金流入流出,如果用户领取红包后如果发现钱变少了,也可能会造成大量的客诉和资损。因此资金安全这块需要做好充足的准备。
红包系统的压测
在传统的压测方式中,我们一般会对某个大流量接口进行压测从而得到系统的瓶颈。然而在红包系统中,用户的发领和查都是同时进行的,并且这几个接口之间也是相互依赖的,比如需要先发红包,有了红包后才能触发多个人的领取,领取完成后才可以查看领取详情。如果使用传统的单接口的压测方式,首先 mock 数据会非常困难,和支付相应的压测数据因为涉及实名还需要特殊生成,而且单个接口单个接口的压测很难得到系统的真实瓶颈,因此如何对系统进行全链路的压测从而得到系统准确的瓶颈也是我们需要解决的一个问题。
我们怎么做的
通用红包系统的设计
对于红包系统,核心操作包括红包发送,领取,以及未领取的退款这三个操作。另外我们还会需要去查一些红包的信息和领取的信息等。同时对于发送,领取和退款这三个核心操作,我们需要对它们的状态进行一个维护。同时在我们的业务场景中,还存在 B2C 特有的补贴的发放,我们也需要维护补贴的状态。
在上面初步介绍红包系统后,可以看到红包的几个功能模块,有发放,领取,退款,补贴发放以及各种信息查询,另外还有状态机的维护等,对红包的功能进行梳理后,我们开始对红包的模块进行划分。
划分原则
- 功能内聚,每个系统只处理一个任务,方便之后系统的开发和迭代,以及问题的排查
- API 网关层只进行简单的 proxy 处理
- 异步任务拆解
- 读写分离,将红包的核心操作和红包的查询分成两个服务
划分模块
红包网关服务
- HTTP API 网关,对外对接客户端和 h5,对内封装各个系统 rpc 接口,限流,权限控制、降级等功能
红包核心服务
- 主要承载红包核心功能,包括红包的发放、领取、退款,以及红包补贴的发放,维护红包状态机,红包的状态推进
红包查询服务
- 主要承载红包查询功能,包括红包详情、红包发送状态、红包领取状态、红包领取详情、红包补贴信息
红包异步服务
- 主要承载红包异步任务,保证状态机的流转,包括红包的转账,红包的退款,以及红包补贴的状态推进
红包基础服务
- 主要承载红包各个系统的公共调用,例如对 DB,redis、tcc 的操作,公共常量和工具类,相当于红包的基础工具包
红包对账服务
- 主要承载红包和财经的对账逻辑,按天和财经对账
整体架构
最后整个视频红包的系统架构如图所示
大流量补贴的发放处理
同步奖励发放
在红包补贴发放链路流程中,为了应对春节的大流量,整个链路流程也经历过几次方案的迭代。
在最初的方案设计中,我们是按照同步的补贴发放流程来处理的,上游链路调用红包系统接口发券,发券成功后用户感知到券发放成功,可以使用该券来发放红包,最初方案的整体流程如下图:
上面方案的一个问题是在春节活动期间,整个链路都需要能扛住活动期间的总流量,而且最终流量都会打到数据库,而数据库的资源在春节期间也是比较紧缺的。
异步奖励发放
为了解决同步奖励发放的问题,整体流程改为通过 MQ 进行削峰,从而降低下游的流量压力,相当于是从同步改为异步,用户参与活动后会先下发一个加密 Token 给客户端,用于客户端的展示以及和服务端的交互处理。活动异步发券方案如下图
这样解决了大流量的问题,但是相应地引入了其他的问题,在最初方案中,用户的红包补贴都会先在红包系统中落库,后续用户对补贴的查询和核销我们都能在红包数据库中找到对应的记录,但是在异步的方式中,整个补贴的入账预估需要 10min,而用户在 APP 界面感知到发券后可能马上就会开始使用用补贴来发放视频红包,或者会去红包挂件查看自己已经领取的红包补贴,而此时补贴还未在红包系统中入账。
最终方案
为了解决上面问题,我们对红包补贴的视频红包发放和红包补贴查询的整个逻辑进行了修改,在用户使用红包补贴进行视频红包发放时,我们会先对该补贴进行一个入库操作,入库成功后才可以用这个补贴进行红包发放。另外对于查询接口,我们无法感知到所有补贴是否完全入账,因此每次查询时我们都需要去奖励发放端查询全量的 Token 列表,同时我们还需要查询出数据库中用户的补贴,对这两部分数据进行一次 merge 操作,才能得到全量的补贴列表。
在上面的流程中,为了解决 MQ 异步会有延迟的问题,我们在用户进行请求时主动地进行入账,而用户主动的操作包括使用补贴发放红包和查询补贴,我们为什么只在补贴发放红包时入账而在查询补贴时不入账呢?因为用户的查询行为是一个高频行为,同时涉及到批量的操作,在操作 DB 前我们无法感知该补贴是否入账,所以会涉及到 DB 的批量处理,甚至用户每次来查询时我们都需要重复这个操作,会导致大量的 DB 资源浪费。而补贴的发放时入账则是一个低频的,单个补贴的操作,我们只需要在用户核销时入账即可,这样可以大量减轻数据库的压力,节省数据库资源。
红包领取方案的选型
在视频红包领取的技术方案中,我们也有一些方案的选择和思考,这里和大家分享下。
悲观锁方案
方案一也是最常见的思路,在用户领取时对数据库的红包进行加锁,然后扣减金额,然后释放锁完成整个红包领取。这个方案的优点是清晰明了,但是这种方案的问题会导致多个用户同时来领取红包时,会造成数据库行锁的冲突,需要排队等待,当排队请求过多时会造成数据库链接的浪费,影响整体系统的性能。同时在上游长时间未收到反馈导致超时,用户侧可能会不停重试,导致整体数据库链接被耗尽,从而导致系统崩溃。
红包预拆分方案
方案一的问题是多个用同时领取会造成锁冲突,解锁锁冲突可以通过拆分的方式,来将锁化成更细的粒度,从而提高单个红包的领取并发量。具体方案如下:
在方案二中,对发红包的流程进行了一个改动,在发红包时会对红包进行一个预拆分的处理,将红包拆成多个红包,这样就完成了锁粒度的细化,在用户领取红包时从之前的争抢单个红包锁变为现在多个红包锁分配。从而在领取红包时问题就变为如何给用户分配红包,一种常用的思路是当用户请求领取红包时,通过 redis 的自增方法来生成序列号,该序列号即对应该领取那一个红包。但是这种方式强依赖 redis,在 redis 网络抖动或者 redis 服务异常时,需要降级到去查询 DB 还未领取的红包来获取序列号,整体实现比较复杂。
最终方案
在视频红包的场景中,整个业务流程是用户拍摄视频发红包,然后在视频推荐 feed 流中刷到视频时,才会触发领取。相对于微信和飞书这种群聊场景,视频红包中同一个红包的领取并发数并不会很高,因为用户刷视频的操作以及 feed 流本身就完成了流量的打散,所以对于视频红包来说,领取的并发数并不会很高。从业务的角度来看,在需求实现上,我们在用户领取完成后需要能获取到未领取红包的个数信息下发给用户展示,方案一获取红包库存很方便,而方案二获取库存比较麻烦。另外从系统开发复杂度和容灾情况看,方案一相对来说是一个更合适的选择。但是方案一中的风险我们需要处理下。我们需要有其他的方式来保护 DB 资源,尽量减少锁的冲突。具体方案如下:
红包 redis 限流
- 为尽可能少的减少 DB 锁冲突,首先会按照红包单号进行限流,每次允许剩余红包个数*1.5 的请求量通过。被限流返回特殊错误码,前端最多轮训 10 次,在请求量过多的情况下通过这种方式来慢慢处理
内存排队
- 除了 redis 限流外,为了减少 DB 锁,我们在领取流程中加个一个红包内存锁,对于单个红包,只有获取到内存锁的请求才能继续去请求 DB,从而将 DB 锁的冲突迁移到内存中提前处理,而内存资源相对于 DB 资源来说是非常廉价的,在请求量过大时,我们可以水平扩容。
- 为了实现内存锁,我们进行了几个改动。首先需要保证同一个红包请求能打到同一个 tce 实例上,这里我们对网关层路由进行了调整,在网关层调用下游服务时,会按照红包单号进行路由策略,保证同一单号的请求打到同一个实例上。另外我们在红包系统的 core 服务中基于 channel 实现了一套内存锁,在领取完成后会释放该红包对应的内存锁。另外为了防止锁的内存占用过大或者未及时释放,我们起了一个定时任务去定期地处理。
转账异步化
- 从接口耗时来看,转账是一个耗时较长的操作,本身涉及和第三方支付机构交互,会有跨机房请求,响应延时较长,将转账异步化可以降低领取红包接口的时延,提高服务性能和用户体验
- 另外从用户感知来看,用户更关注的是领取红包的点击开后是否领取成功,至于余额是否同步到账用户其实感知没那么强烈,另外转账本身也是有一个转账中到转账成功的过程,将转账异步化对于用户的感知基本没有影响
稳定性容灾
整个红包系统的容灾我们主要从接口限流,业务降级和多重机制保证状态机的推进这几个方式来进行的,下面对这几个方式分别介绍下:
接口限流
接口限流是一种常见的容灾方式,用于保护系统只处理承受范围内的请求,防止外部请求过大将系统打崩。在进行接口限流前,我们首先需要和上下游以及产品沟通得到一个预估的红包发放和领取量,然后根据发放和领取量进行分模块地全链路的大盘流量梳理,下面是当时我们梳理的一个 b2c 全链路的请求量。
有个各个模块的请求量后,汇总之后就可以得到各个接口,红包系统各个服务以及下游依赖的各个服务的流量请求,这个时候再做限流就比较方便了。
业务降级
核心依赖降级
在春节活动期间,红包系统整个链路依赖的服务有很多,这些下游的链路依赖可以分为核心依赖和非核心依赖,当下游核心服务异常时,可能某一个链路就不可用,此时可以在 API 层直接降级返回一个比较友好的文案提示,等下游服务恢复后再放开。比如在 C2C 的红包发送流程中,用户需要完成支付才可以发红包,如果财经的支付流程异常或者支付成功状态长时间未完成,会造成用户支付后红包发送不成功,也会导致前端来不停的轮训查询红包状态,导致请求量陡增,造成服务压力,甚至影响 B2C 的红包发放和查询。此时可以通过接口降级的方式,将 C2C 的红包发放降级返回,减少服务压力,同时降低对其他业务逻辑的影响。
非核心依赖降级
除核心依赖外,红包系统还有一些非核心的下游依赖,对于这些依赖,如果服务出现异常,我们可以降低用户部分体验的方式来保证服务的可用。比如在 4.2 中我们提到的,用户在发 B2C 红包前需要先获取所有可用的红包补贴,我们会去奖励发放端查询到所有的 Token 列表,然后查询我们自己的 DB,然后进行 merge 返回。如果获取 Token 列表的接口异常时,我们可以降级只返回我们自己 DB 中的补贴数据,这样可以保证用户在这种情况下还可以进行红包的发放,只影响部分补贴的展示,而不是影响整个红包发送链路。
多重机制保证状态机的推进
在红包系统中,如果某个订单长时间未到终态,比如用户领取红包后长时间未到账,或者用户 C2C 红包未领取长时间未给用户退款都有可能造成用户的客诉。因此需要及时准确地保证系统中各个订单的状态能推到终态。
这里我们有几种方式去保证,首先是回调,在依赖方系统订单处理完后会及时地通知给红包系统,这种方式也是最及时的一种方式。但是只依赖回调可能会出现依赖方异常或者网络抖动导致回调丢失,此时我们在红包的各个阶段都会给红包系统发一个 mq,间隔一定的时间去消费 mq 主动查询依赖方的订单状态进行更新。最后我们对每个状态机都会有一个定时任务用于兜底,在定时任务多次执行仍未到终态的会 lark 通知,及时人工介入发现问题。
资金安全保证
交易幂等
在编程中,幂等指任意多次执行一个请求所产生的影响与一次执行的影响相同。在资金安全中,通过订单号来进行相应的幂等逻辑处理可以防止资损的发生。具体来说在红包系统中,在红包的发放,领取和退款中,我们都通过订单号唯一键来保证接口幂等。另外红包系统的补贴发放接口是幂等的,外部同一个单号多次请求发放补贴,我们需要保证只会发一张券。
实现幂等的方案很多,包括有通过数据库或者 redis 来实现幂等的。最可靠的就是通过数据库的唯一键冲突来实现,但是这种方式在数据库存在分片实例时会引入一些额外的问题。这里我们就补贴的发放来简单介绍下,在业务系统的设计中,我们是按照 uid 分片的方式来建立业务的数据库表,这就导致补贴的分片键是 uid,虽然我们也设置了红包的补贴单号作为唯一键。但是其中存在一个风险就是如果上游的系统调用补贴发放时,同一个外部单号更换了 uid,就可能会导致两个请求分别打到不同的数据库实例上,导致唯一索引失效,造成资损。为了解决这个问题,我们又额外的引入一个以补贴发放外部单号作为分片键的数据库来解决这个风险。
B2C 红包核对
除了在开发过程的系统设计上进行相应的资金安全考虑,我们还需要通过对账的方式来校验我们的系统是否有资金安全问题。
在 B2C 链路中,整个链路主要是从补贴发放到红包领取,我们对这几个链路的上下游的数据都进行相应的小时计 hive 对账。
C2C 红包核对
在 C2C 链路中,整个主要从用户发起支付,到用户领取转账以及最后红包过期退款。在支付,转账,退款这三个流程都需要进行相应的核对。同时,还需要保证用户的红包发放金额大于等于红包转账金额+红包退款金额,这里大于等于是因为红包从发放成功到退款成功整个周期会在 24h 以上,另外可能存在转账在途的这种订导致会有多笔退款单,如果要求严格等于的话具体对账时机没法控制。
红包系统的压测
前面提到过,红包系统的链路包含有多个接口,发领查等,需要模拟用户的真实行为来进行压测才能得到系统的真实性能。这里我们使用了压测平台的脚本压测方式来进行压测。
首先需要对整个压测链路整个改造,和上下游沟通是否可以压测,不能压测的需要进行相应的 mock 处理。另外对于存储服务,数据库,redis 和 mq 都要确保压测标的正确传递,否则可能会影响到线上。
改造完压测链路后,需要构造相应的压测脚本,对于 B2C 和 C2C 分为两个脚本。
B2C 红包链路压测
上面是 B2C 压测的整个链路,首先是补贴的发放,然后通过查询补贴,通过补贴来发放红包,为了模拟多人来领取的情况,我们起了多个 goroutinue 来并发的领取红包。
C2C 红包链路压测
C2C 红包因为涉及到支付相关的操作,整个链路又是另外一套流程,因此对于 C2C 也需要有一个单独的脚本。在压测流程中,因为涉及到外部系统的依赖,如果等待全链路 OK 时再一起压测可能会导致一些未知的问题出现。因此我们需要自己压测没问题后再开始全链路一起压测,在图中和支付相关的蓝色模块我们都添加了相应的 mock 开关,来控制压测的结果。在 mock 开关打开时,会直接构造一个结果返回,在 mock 开关关闭时,会正常地去请求财经获取结果。
后续规划
服务 Set 化
在前面提到的系统容灾中,如果红包核心服务改掉,或者数据库 DB 主机房挂掉,将影响所有的用户,此时只能降级返回,整个系统无法快速切换和恢复。后续考虑将服务改为 set 化的架构。将服务 Server 和对应的存储划分为一个单独的 Set,每个 Set 只处理对应划分单元内的流量,同时多个单元之间实现流量拆分和故障隔离,以及 Set 之间数据备份。这样后续在某个单元异常时,可以及时将对应单元的流量切到备份单元中。