字节跳动春节抖音视频红包系统设计与实现–图文解析
原作者:字节跳动技术团队
原文链接:https://www.toutiao.com/article/7114224228030841374
原标题:2022 春节抖音视频红包系统设计与实现
我们做了什么
业务背景
在春节活动期间,抖音将视频和春节红包相结合,用户可以通过拍视频发红包的方式来给粉丝和好友送祝福。
简单说就是和平时发视频一样,在这个发视频的基础上,增加一个红包的功能,下图的红框部分为演示,视频作者发布视频的时候可以设置一个红包,红包来源可以是自己出钱,也可以通过参与抖音官方的红包雨活动获得抖音官方补贴红包,用补贴红包来发视频。视频发出后,观众在观看视频的同时可以通过点击红框中的“抢视频红包”按钮领红包。
业务玩法
整个活动玩法分为 B2C 和 C2C 两种玩法
B2C 红包
B2C:B指的是抖音官方,C是客户(用户)。
如下图:
1.用户参加抖音的春节红包雨活动获取抖音官方发放的红包
2.可以看到红包的总额(注意红包不止一个),此时可以选择提现,或者发一个包含红包的抖音视频。
C2C 红包
和B2C类似,只不过需要自己掏钱发红包。
红包领取
我们碰到的一些问题
通用红包系统的设计
需要设计一个通用的红包系统,同时满足B2C,C2C红包需求,B2C 的红包发放需要通过使用补贴来发送,而 C2C 的红包发放需要用户去完成支付。B2C 的红包用户领取后需要去提现,而 C2C 的红包用户领取后直接到零钱。因此需要设计一个通用的红包系统来支持多种红包类型。领取过程是一样的。
大流量补贴的发放处理
抖音官方给出的红包雨活动红包补贴会有上亿人参与,如果每次抢到红包后直接将红包金额存入数据库中用户财务账户对应的表,面对上亿的流量以及高并发的场景,数据库扛不住。
红包领取方案的选型
红包发出后,会有一堆人过来抢,原文为“多个用户同时去领取同一个红包可能会导致热点账户问题”。热点账户就是会被高频操作的账户。这种并发操作如何解决?常规解决方案:加锁,队列,随机。我们一起向下阅读吧,看看字节技术团队是如何解决的。
稳定性容灾
容灾:灾难性事件(如地震、火灾、洪水等)发生时,保持系统业务不间断地运行。
容错:计算机系统的软件、硬件发生故障时,保证系统仍能正常工作。
两者有一个通用的解决方案就是冗余,也就是多个副本,多个机房,面对容灾解决方案可以考虑异地机房(比如某个城市暴雪,导致全市电力瘫痪,可以无缝切将流量换到城市的副本等)。
红包系统的压测
传统压测通常针对某个大流量接口进行测试,也就是单个接口压力测试,而在红包系统中,用户的发红包、领取红包和查看领取详情等操作是同时进行的,且相互依赖。这需要对多个接口进行压力测试,这种对相互依赖的多个接口的压力测试被称为全链路压测。
我们怎么做的
通用红包系统的设计
- 核心操作:
- 红包发送:用户发起红包,将一定金额的资金分配给其他用户。
- 领取:其他用户接收红包,获取分配的资金。
- 未领取的退款:如果红包未被领取,系统需要将资金退还给发送者。
- 状态维护:
- 对于这三个核心操作,系统需要维护它们的状态:
- 红包发送状态:记录红包是否已发送、发送时间等。
- 领取状态:记录哪些用户领取了红包、领取时间等。
- 退款状态:如果红包未被领取,需要标记为退款状态。
- 状态维护通常涉及数据库记录、事务管理和异步处理。
- 对于这三个核心操作,系统需要维护它们的状态:
- B2C 补贴状态:
- 在 B2C(企业对消费者)业务中,补贴是常见的促销手段。
- 补贴的发放也需要状态管理:
- 补贴发放状态:记录哪些用户获得了补贴、发放时间等。
- 补贴使用状态:如果补贴有使用限制,需要记录用户是否已使用补贴。
大流量补贴的发放处理
同步奖励发放
一共设计了若干的版本,我来给大家简化一下
初代版本:
大家还记得上面说的,用户抢到红包是需要到数据库更新状态的对吧。但是抢红包领补贴红包的用户太多太多了,况且当时这个游戏貌似是规定在某个时段统一进行抢的(为了避开同时段的春节系列节目),这样一来必然会导致高并发问题,如下图演示,高并发请求数据库,数据库无法承受。
2代版本:
把用户的海量请求存入消息队列(MQ),通过MQ进行削峰填谷,逐个(或批量)访问数据库。
但依然存在问题,用户抢到的红包无法实时存入数据库,因为要排队,原文中也说了,
需要拍十分钟的队,大量用户在抢到红包后,马上就要发视频红包,或者领取红包后马上查询操作,但这些操作要等十分钟!
最终版本:
原文中的"token"是什么?
根据上面的时序图和原文讲解可以得知,用户进入主会场领取红包后,并不会马上入账,而是返回由主会场返回给客户端一个“token”,这个"token"是什么呢?我们可以简单理解为服务端给我们的手机中的抖音客户端,传入了一条领取的红包列表的数据(注意我们领取的补贴红包不是一个,而是多个,发红包视频的时候,系统会自动为我们选择领取红包中最大的那个供我们只做红包视频)。
这个“token”就是客户端的这样一条数据吗?是,又不是。大家想一想,如果我们领取了红包,系统给我们的抖音客户端发送了这条包含红包信息的“token”后,我们什么都不做,直接卸载抖音,又从应用商店重新下载抖音安装包重新安装并重新登录,我们手机中的抖音客户端里的"token"会因为我们重新安装app而自动删除吗?肯定不会!所以说,这个"token"本身是存放在缓存中的,并和我们的客户端保持同步,通过上下文阅读可以推断,这个token应该是存在redis中的,并和我们手机客户端的本地缓存保持同步。
最终方案的具体实现
初始红包存入缓存,不入库
用户制作红包视频,从缓存的红包列表中选择1个红包,选中的红包落库,落库后同步删除缓存中对应的红包
1.用户领取红包后,红包数据不马上入库,而是存入缓存
2.用户发送红包视频真正使用到红包的时候,才会入库操作,注意,这个入库是同步的!因为抢红包和查询红包属于高频操作,尤其是抢红包操作,属于高并发操作,但用户使用红包进行红包视频制作,由于不同的用户制作视频的时间是有区别的,毕竟要写视频标题,选择相册视频或者拉起相机拍摄视频,这些操作的复杂度远高于抢红包点一下按钮,原本会高并发的操作会因为制作视频的过程,并发数被大大降低,即便有并发也不会是高并发了。一些秒杀活动会出现类似验证码的操作,目的也是如此,通过附加一个相对复杂的操作来解决单纯点击一个按钮的简单操作造成的高并发。
3.红包入库操作发生代表着红包视频已经制作并发放成功,此时红包入库后会删除缓存中token里对应的红包数据。
4.用户查询红包,需要合并token中的红包与已入账的红包总额。
红包领取方案的选型
悲观锁方案
原图画的比较笼统,领红包这里我重新画一下:
上图中,用户1抢到锁执行扣减红包流程,执行流程时其他用户需要排队等待,原文中也说了这种加锁方式,如果抢红包的用户比较多,排队的时间就会很长,上下游服务会因为排队问题导致调用接口超时。另外,过多的用户排队也占用了太多的数据库链接,数据库链接被耗尽后会导致系统崩溃。
红包预拆分方案
用户领取的补贴红包有多个,每个红包可以制作一个视频。制作视频使用的这个红包,如果直接作为数据库表中的一条记录,有一定概率会导致排队,这个上文中说过了。所以要对这一个红包进行预拆分,比如说这个红包一共有100元,十个人来抢,则把该红包拆成10个小红包,在数据库表中用10条记录存储,这样就由原来的一个行锁多人竞争变成了更细粒度的10个行锁了。
另外文中也提到一种使用redis分配红包的常用做法:在预拆分红包时,系统会将每个红包分配一个唯一的序列号,并将这些序列号存储在数据库中。当用户请求领取红包时,Redis 的自增方法生成的序列号会与数据库中预先定义的序列号进行匹配,从而确保每个红包都能正确分配给用户。每个用户在请求领取红包时,都会获得一个独一无二的序列号,这样就能确保每个红包只会被一个用户领取,避免了多人获得同一个红包的情况。
该方案的缺点:但是这种方式强依赖 redis,在 redis 网络抖动或者 redis 服务异常时,需要降级到去查询 DB 还未领取的红包来获取序列号,整体实现比较复杂。
红包拆分demo:
本demo来自知乎:https://zhuanlan.zhihu.com/p/248175188
import java.util.*;
public class RedPacketDemo {
/*Random 随机生成一个区间在[min , max]的数值
randNumber 将被赋值为一个 MIN 和 MAX 范围内的随机数
int randNumber =rand.nextInt(MAX - MIN + 1) + MIN; */
/**
* 生成min到max范围的浮点数
**/
public static double nextDouble(final double min, final double max) {
return min + ((max - min) * new Random().nextDouble());
}
public static String format(double value) {
return new java.text.DecimalFormat("0.00").format(value); // 保留两位小数
}
//二倍均值法
public static List<Double> doubleMeanMethod(double money,int number){
List<Double> result = new ArrayList<Double>();
if(money<0&&number<1)
return null;
double amount,sum=0;
int remainingNumber=number;
int i=1;
//remainingNumber剩余红包数量,初始值=输入的红包总数
while(remainingNumber>1){ //如果剩余红包大于1
//取一个从(2*(总红包金额/剩余红包数量)-0.01)的随机值最后在加一个0.01,保证至少还有1分钱
amount= nextDouble(0.01,2*(money/remainingNumber));
sum+=amount;//sum:已发出红包的金额,用于验证是否和传递进来的初始总金额相同,在业务中没有作用
System.out.println("第"+i+"个人领取的红包金额为:"+format(amount));
money -= amount;//从总金额中减去当前发出的金额
remainingNumber--;//剩余红包数量-1
result.add(amount);
i++;
}
//最后一个红包,在循环外执行
result.add(money);
System.out.println("第"+i+"个人领取的红包金额为:"+format(money));
sum+=money;
System.out.println("验证发出的红包总金额为:"+format(sum));
return result;
}
//线段切割法,这个算法本身是有问题的,有一定概率导致后面抢红包的人一分钱也抢不到,但通过这个代码搞清楚原理还是足够的。
public static void lineSegmentCutting(double money,int number){
if(money<0&&number<1)
System.out.println("输入错误!");
double begin=0;//剩余金额
double end=money; //总金额
double y=0;
for(int i=0;i<number-1;i++){
double nn=0;//领取的红包金额,初始值为0
double amount=nextDouble(begin,end);//取一个end-bengin之间的随机数,在加一个0.01,begin初始为0,end等于总金额,越是靠前begin越低
nn=amount-begin;//获得的红包金额为begin与end之间的随机数减去begin,begin初始为0,越是靠前减的越少
System.out.println("第"+(i+1)+"个人领取的红包金额为:"+format(nn));
y+=nn;//y就是一个验证值,验证最终发出的红包金额是否和初始值一样,与业务无关。
begin=amount;//修改begin的值为begin与end之间的随机数
System.out.println("begin====" +begin);
}
System.out.println("第"+number+"个人领取的红包金额为:"+format(end-begin));//最后一个红包直接用总金额减去begin即可。
y+=(end-begin);
System.out.println("验证发出的红包总金额为:"+format(y));
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println("这是一段模拟抢红包的代码。");
int number;
double money;
System.out.print("请输入红包总金额:");
money = sc.nextDouble();
System.out.print("请输入红包数量:");
number = sc.nextInt();
//System.out.println(money + " " + number);
//二倍均值法
doubleMeanMethod(money,number);
//System.out.println(doubleMeanMethod(money,number).toString());
//也是可以直接输出list的,为了观察方便,我就在循环中输出了,存在list里主要是为了后续方便数据的使用
System.out.println();
//线段切割法
lineSegmentCutting(money,number);
}
}
最终方案
最终方案选择了悲观锁方案并对其进行了优化,这个方案的最大问题是有一定概率会导致大量请求对一条数据(行锁)的锁竞争导致排队,从而引发接口调用超时以及数据库连接被耗尽的问题。
原文中认为红包视频是通过推荐系统推荐给用户的,抢红包的用户需要通过推荐系统获得一系列视频(Feed流),并通过手动刷视频直到刷到红包视频才会点击领取,并不像微信群发红包那样,一群的人就在那里等着,红包出现一刹那疯狂点抢红包按钮。简单说就是Feed流会把抢红包的流量分散,所以并发数并没有很高。
为了缓解锁竞争问题,在悲观锁方案基础上增加了限流以及内存锁功能。
限流:按照红包单号进行限流,每次允许剩余红包个数*1.5 的请求量通过,多出的请求返回“特殊错误码”到前端,前端收到特殊错误码后会进行重试(虽然即便重试成功,也很难抢到红包了)。被限流放过的请求在获取数据库链接之前,会先进行一个内存锁竞争,竞争到内存锁后才会获取数据库链接访问数据库,这样一来大大减少了数据库行锁排队从而引发接口调用超时以及数据库连接被耗尽的概率。
内存锁:
在 Java 中,锁是保证多线程安全的重要手段。Java 提供了多种类型的锁来满足不同的同步需求。让我详细介绍一下 Java 中的锁种类、原理、使用场景以及注意事项:
1. synchronized
隐式锁:synchronized 是 JVM 层面实现的隐式锁。它可以修饰方法、代码块,用于实现方法级和代码块级的同步。
锁对象:如果修饰具体对象,锁的是该对象;如果修饰成员方法,锁的是 `this`;如果修饰静态方法,锁的是该类的 `Class` 对象。
原理:synchronized 通过管程(Monitor)实现,获取锁后执行方法,最后释放锁。
适用场景:适合简单的同步需求,但效率较低。
2. Lock 接口:
显式锁:Lock 接口及其实现类提供了与 synchronized 类似的同步功能,但需要显式地获取和释放锁。
特性:支持可操作性、可中断的获取锁,以及超时获取锁等特性。
常用实现类:`ReentrantLock`、`ReadWriteLock`、`StampedLock` 等。
原理:Lock 接口的实现类直接依赖 CPU 指令,无关 JVM 实现。
适用场景:适合复杂同步需求,提供更灵活的控制。
3. ReentrantLock:
可重入锁:允许同一线程多次获取同一个锁。
使用方式:创建 `ReentrantLock` 实例,调用 `lock()` 获取锁,`unlock()` 释放锁。
4. ReadWriteLock:
读写锁:允许多个线程同时读取数据,但只允许一个线程写入数据。
使用方式:通过 `ReentrantReadWriteLock` 创建读写锁。
5. StampedLock:
乐观锁:允许多个线程同时读取数据,但写入数据时需要获取独占锁。
使用方式:通过 `StampedLock` 创建乐观锁。
具体的实现参见原文:
文中的TCE 指的是 Toutiao Cloud Engine。这是字节跳动自研的云引擎平台,基于 Kubernetes 构建,用于管理和调度公司内部的各种服务和资源。TCE 平台支持字节跳动的核心业务,包括今日头条、抖音等,提供高效的应用部署和资源管理能力
channel 通常指的是 Go 语言中的通道机制。
通过channel实现内存锁的好处:
线程安全:channel 本身是线程安全的,能够避免数据竞争问题。
简化并发控制:使用 channel 可以简化并发控制逻辑,通过通信来共享内存,而不是通过共享内存来通信。
避免死锁:channel 可以帮助避免死锁,因为它们提供了一种更自然的方式来处理并发操作。
转账异步化:
简单说就是抢到红包的用户往往只关注是否抢到了,抢到了多少钱。所以对钱立即到账采用了异步化处理。
稳定性容灾
整个红包系统的容灾主要从接口限流,业务降级和多重机制保证状态机的推进这几个方式来进行的
接口限流
接口限流的概念我们并不陌生,主要看一下原文是如何做的:
首先需要和上下游以及产品沟通得到一个预估的红包发放和领取量,然后根据发放和领取量进行分模块地全链路的大盘流量梳理,有个各个模块的请求量后,汇总之后就可以得到各个接口,红包系统各个服务以及下游依赖的各个服务的流量请求,这个时候再做限流就比较方便了。简单说就是先预估各个接口的量后再实施限流。
业务降级
依赖链路:
如图:服务A需要依赖服务B返回的数据,而服务B又依赖服务C返回的数据,这成为依赖链路。假设服务C出现了问题,服务A请求服务B,服务B请求服务C很长时间没有响应,这时候服务A不知道究竟是服务B还是服务C出现了问题,由于服务B请求服务C请求到的只有一堆的异常信息,和原本的返回格式不同,无法有效的返回信息,所以需要再服务C出现问题的时候,服务B能够返回一个相对友好的文案给服务A,告知服务A究竟是什么地方出现了问题。
经过我的解释,再看原文就能看懂了:
在春节活动期间,红包系统整个链路依赖的服务有很多,这些下游的链路依赖可以分为核心依赖和非核心依赖,当下游核心服务异常时,可能某一个链路就不可用,此时可以在 API 层直接降级返回一个比较友好的文案提示,等下游服务恢复后再放开。比如在 C2C 的红包发送流程中,用户需要完成支付才可以发红包,如果财经的支付流程异常或者支付成功状态长时间未完成,会造成用户支付后红包发送不成功,也会导致前端来不停的轮训查询红包状态,导致请求量陡增,造成服务压力,甚至影响 B2C 的红包发放和查询。此时可以通过接口降级的方式,将 C2C 的红包发放降级返回,减少服务压力,同时降低对其他业务逻辑的影响。
多重机制保证状态机的推进
状态机的状态必须和业务进度保持一致,比如只有在红包已被领取的时候财务系统才会给领取的用户打款等。理想状态就是常规的业务代码执行完成后直接通过红包系统更新状态机中的状态。但有一定概率是当业务处理完成之后,给红包系统发送更新信息之前,业务系统出现了异常,没有把更新信息发给红包系统导致状态机不能更新,本来已经发放的红包状态没有及时更新,依然是待发放。
原文中提供了两种保障,一种是mq(消息队列),(我个人的理解的实现方式)在业务代码执行的过程中,会有若干次数据库写入操作,专门找出与状态对应的写入操作,比如红包领取后数据库中的红包数据发生了变化时,给红包系统发一个延时消息,过一点时间后主动查询红包系统的状态机流向是否已经达到预期的节点,如果没有,则主动更新,如果已经更新了,就什么也不用做了。另外一个保障是定时任务,每隔一段时间查询一次状态机是否已经到最终态,如果连续多次已经超出正常时间依然发现没有到达最终状态,则发出lark通知告知技术人员介入。(lark通知简单理解就是一个统一告警,可通过飞书,钉钉等app通知指定的技术人员系统出现了哪些问题)
送更新信息之前,业务系统出现了异常,没有把更新信息发给红包系统导致状态机不能更新,本来已经发放的红包状态没有及时更新,依然是待发放。
原文中提供了两种保障,一种是mq(消息队列),(我个人的理解的实现方式)在业务代码执行的过程中,会有若干次数据库写入操作,专门找出与状态对应的写入操作,比如红包领取后数据库中的红包数据发生了变化时,给红包系统发一个延时消息,过一点时间后主动查询红包系统的状态机流向是否已经达到预期的节点,如果没有,则主动更新,如果已经更新了,就什么也不用做了。另外一个保障是定时任务,每隔一段时间查询一次状态机是否已经到最终态,如果连续多次已经超出正常时间依然发现没有到达最终状态,则发出lark通知告知技术人员介入。(lark通知简单理解就是一个统一告警,可通过飞书,钉钉等app通知指定的技术人员系统出现了哪些问题)