作者 | 齐建朝
背景
近几年春节期间,抖音都会为用户带来各式各样的春节活动,每年都会有数亿用户参与其中。2022 年春节,抖音支付也参与了春节活动,面向海量用户发放抖音支付券,帮助用户在抖音春节活动中获得更好体验。对抖音支付来说,这是一个很大的挑战,因为之前抖音支付团队没有真正意义上经历过春节活动这种量级的应用场景,这对抖音支付营销系统是极大的考验。
抖音支付营销系统简介
当前营销系统分层及架构如下:
营销团队业务主要分为三大方向,营销投放、营销活动、营销资产。营销投放主要负责营销权益触达,将营销利益点曝光给用户,营销活动主要负责营销玩法的建设以及权益发放,营销资产则主要负责用户营销资产的管理,比如优惠券、立减的发放及使用等。对于春节发券,链路是由营销活动对接春节主会场玩法,调用营销资产接口将支付券发放到用户手中。
挑战
- 春节活动并发量大,同时参与领券的人数会非常多,高峰期对系统造成的冲击会非常大。
- 春节活动面向全国用户,受众非常广,用户体验非常重要,因此发券动作耗时需要尽可能的低。
- 春节活动参与人数基数非常大,春节期间预计发放的支付券数量也会非常多,资金安全需要重点保障。
方案
性能保障
异步发券-提升接口响应速度
考虑到支付券使用大多在抖音电商场景,且春节期间用户线上购物流量较小,用户领到券后立即使用的概率较低,只需要保证用户领券到实际感知券(查看券或使用券) 的时间延迟可控,那么用户体验就不会受到影响,因此我们采用了异步发券的模式,营销收到上游发券请求后,落单后立即返回,通知上游受理成功。
异步发券带来的一个问题是用户领券后感知到领券成功,实际券最终没有发给用户,大部分为库存不足、风控拦截等原因。针对这个问题,我们与运营同学做了讨论,春节期间营销活动库存会尽量配置的足够多,风控拦截率会降到最低,除异常刷单用户外均不做拦截,尽量降低异步发券失败的可能性。
双层本地队列-提升处理能力,平滑流量
将流量异步化后,因为活动流量大都是心电图的结构,波峰波谷很明显,我们借鉴了生产者-消费者模型,引入了队列来平滑流量。一开始我们考虑了 RocketMQ,但一旦在活动发券核心链路上引入了额外的中间件,便对其产生了依赖,需要额外考虑它的可用性和容灾方案,且 RocketMq 属于远程队列,生产者消费者之间的延迟也不易控制,因此我们设计了一套本地队列模型,来规避上述问题。
本地队列模型如上,队列消费端逻辑首先会从分布式限流器获取令牌,获取成功后再从队列中获取数据,新建一个 goroutine 处理活动发奖逻辑,之后重复此流程。
虽然队列本身有削峰的作用,但还是不能精确控制消费速率,当上游流量过大时,队列消费端消费速率也会随之上升,有打崩系统的风险,所以还是需要限流层做较为精准的流量控制,但仅靠接口维度的限流粒度又太粗,所以在业务逻辑层又引入了一层业务限流。这里的分布式限流器使用的支付自研的分布式限流组件,优先会从本地获取令牌,当本地令牌不足时,会从远程批量拉取令牌到本地。
另外这个模型我们使用了双层队列,第一层队列用于保护营销活动层,基于营销活动处理能力设置限流;活动决策通过后,再将请求放入第二层队列,第二层队列的限流则是基于营销资产的系统承载能力来设置。通过双层队列,可以规避掉营销活动和营销资产间的容量差异,两方系统吞吐量都可以最大程度地发挥出来。
库存扣减优化-减热点,降压力
出于性能的考虑,营销资产的券批次库存放在了 Redis,目前营销资产库存操作的逻辑是:收到一个用户的发券请求->读取 Redis,查看发放的券批次库存是否充足->写入 Redis,扣减券批次库存。
为了使 Redis 集群流量均匀,不同券批次的库存数据被打散到了不同的 Redis 分片上,但是当在一段时间内集中发放某一券批次时,流量仍然会大量偏移到一个分片内,造成 Redis 数据热点问题。如果想办法能将某个券批次多次零散扣减库存的操作合并到一起,那么数据热点问题就可以会得到较大的缓解。
合并发券逻辑如上图所示,营销活动尝试首先从二层队列中非阻塞地获取 N 条发券请求数据,如果可以拿到,则将这些数据打包发送到营销资产,如果从队列中拿到的数据小于 N 条,说明此刻队列没有更多的数据,直接将券尽快发出;如果获取不到数据,则随机睡眠一小段时间之后,重新尝试获取,如果经过有限次重试后仍获取不到数据,则结束此次循环。
营销资产收到合并发券的请求后,会尝试对请求中相同券批次的发券请求进行合并,扣减库存时进行集中扣减,比如之前为 N 个不同用户发放同一券批次 A,每次库存减 1,需要对 Redis 进行 N 次写操作;合并发券后,只需对 Redis 进行 1 次写操作,库存扣减 N 即可。
另外,扣减库存前的校验逻辑,实际上不需要每次都去访问 Redis,这次校验本身只是一个前置校验,最终扣减是否成功还是取决于之后扣减操作的执行结果,校验的最大作用是在 Redis 库存不足后能将不必要的扣减动作拦截掉,并不需要十分精准,因此我们考虑在应用本地内存维护了一份券批次的库存信息,定时将 Redis 库存信息同步到本地,发券时只需要在本地内存简单校验库存信息即可,不需要再访问远程的 Redis。
优雅退出-完善系统鲁棒性
使用本地队列进行数据处理的一大弊端是内存易失性会使数据无法持久性地存储,在应用重新发布或升级时,本地队列中的发券数据有可能会丢失,用户发券请求无法得到正常处理。
为了内存中的数据不丢失,我们需要能够感知到应用退出的信号,在应用退出前将内存中的数据处理掉。因此我们调研了字节云应用实例的生命周期,在实例终止时,首先会将当前应用实例从服务注册中心中摘除,此操作执行后意味着当前实例不再接收新的外部流量;之后会发送 SIGINT 退出信号给业务进程。应用收到 SIGINT 信号后,不再消费队列中剩余的发券请求数据,而是将这些数据发送到远程队列中,由当前仍存活的其他应用实例来消费这些数据。
兜底补偿-保证最终一致性
尽管已经实现了应用优雅退出,但是在极端情况下,比如 panic、oom、物理机宕机等异常情况引起的应用退出,应用是接收不到 SIGINT 信号的,也就无法执行优雅退出的业务逻辑。因此我们额外增加了兜底补偿机制,通过定时任务扫表,将卡在中间状态时间较久的数据重新投递到本地队列中进行处理。
既然有了定时任务做兜底补偿,那么优雅退出逻辑是否还有存在的必要呢?其实还是有必要的,当应用上线过程中会发生频繁的应用重启,此时很可能存在大量本地队列的请求未得到处理,如果仅依赖定时任务兜底处理,那么用户从领券到实际收到券的时延可能会非常大,有可能会造成用户体验变差。因此优雅退出和兜底补偿是一个互补的关系,优雅退出最大程度保证用户体验,兜底补偿保证数据的最终一致性。
绿色通道-提升用户体验
异步发券的假设是用户从领券动作发生,到实际感知到券的存在,中间是有一段时间缓冲的,但是用户有可能领到券后直接进入春节钱包查看,如果此时异步发券还未完成,有可能会造成客诉。针对这种情况,我们与上游春节主端做了约定,当用户领券后短时间内进入春节钱包查看优惠券时,上游会再次调用发券接口,并增加绿色通道的标识,我们收到这个标识后会将异步发券变更为同步,优先为当前用户发券,保证用户体验。
资金防控
除了性能方面的保障,资金安全也需要重点关注,在本次春节发券活动中,我们主要做了以下防控措施。
幂等校验
每一次发券动作都会生成一个全局唯一的序列号,发券时会将序列号作为唯一索引落入数据库中,当发生用户连续点击领券或网络异常重试等情况时,相同序列号由于唯一索引冲突落库无法成功,从而避免重复发券带来的资金损失。
用户维度领取限制
通过序列号进行幂等校验可以解决一部分问题,但对于一些较为专业的黄牛党来说,可能会绕过这个限制,通过伪造序列号的方式绕过幂等校验。对于这种情况,我们维护了一份用户券批次的领取数据,发券会校验每个用户是否达到了领取上限,未达到领取上限则会正常发券,同时对用户领取记录进行更新,否则终止发券动作。
券批次组互斥
用户维度领取限制主要防止同一用户多次领取同一批次的可能性,但是在整个春节活动中,运营同学可能会发放多个用途不同的券批次,但是发放的群体有可能会重叠甚至是同一批,如果对用户多次发放不同批次的券,有可能会拉高营销成本。基于以上原因,我们抽取出了券批次组的概念,处于同一组的券批次,营销目的基本是一致的,比如都是拉新、促活或留存的目的,当用户领取了券批次组内的一种券后,用户便不可以再领取组内的其他券批次,即组内的券批次间为一种互斥关系,通过这种方式避免造成营销费用重复补贴。
库存防超卖
上文提到过,营销券批次的库存数据存放在了 Redis,每次对 Redis 进行库存扣减时,可能会存在网络超时、失败等异常情况,造成扣减库存的结果处于未知状态。当这种情况出现时,我们选择"容忍",认为发券失败,直接结束发券逻辑,不做回滚处理;扣减库存成功后,再为用户实际发券,如果发券失败,此时可以尝试回滚 Redis 库存,因为已经确定本次请求成功扣减了库存,但是回滚失败,不再做额外的重试处理。
上述方案,有可能造成库存少卖,但这种较保守的策略可以有效防止库存超卖的可能性,可以看做在数据一致性和可用性之间的一种取舍与平衡。
风控平台接入
在发券链路中,我们也接入了字节内部的风控平台,风控平台会采集分析用户及设备等信息,通过风险评估将黄牛及恶意用户识别出来,拦截发券动作,避免潜在的资损产生。
数据监控与核对
除了以上的资金防控措施外,我们还对发券活动做了大量监控,包括券批次发券量,券批次发券速率、本地队列堆积情况、本地队列消费者速率等等,当监控数据出现同比或环比异常时会及时报警并人工介入排查。另外,当券批次发放完毕后,我们会再次核对数据的一致性,包括比对用户发放券张数与库存消耗数量是否一致、校验单用户是否超过券批次领取上限等等。
总结
经过以上方案的优化,我们顺利的支撑了今年的春节主会场发券活动,并取得了不错的效果:
系统上
- 营销整体对外可承接十万级 TPS 的发券吞吐量。
业务上
- 春节期间发放了数千万张抖音支付、DOU 分期券,支持了抖音支付、DOU 分期两大核心业务的活动诉求。
- 99%的券可以在 0.5s 内发放到用户账户中,异步发券实际延迟很低,用户体验较好,符合业务预期。
后续规划
经过今年春节活动流量的考验后,营销沉淀下了不少经验和系统能力,不过仍有需要后续持续迭代和完善的地方:
- 异步发券能力标准化。我们初步尝试了异步发券并应用在春节活动中,取得的效果不错,可以预见之后的 618、双十一等大促节日仍会有很多适合异步发券的场景,因此我们准备将营销对外的发券接口标准化,将异步发券作为一种可选能力,与接入方、场景等关联起来,做到发券模式的灵活选择与配置。
- 本地队列模式推广。本次设计并实现的双层本地队列,很好地完成了发券任务处理,任务执行相比远程队列时延更低,队列分层、限流、优雅退出、补偿等辅助功能对系统鲁棒性也有较好的保障,后续我们会将此模块抽象成一个通用的小框架,使其可以支持更多适合异步处理的业务场景。