经典系统设计题:大型抢红包系统设计
尽量不要在每个拆红包的请求中都去修改红包记录的状态(比如扣减金额),因为红包记录只有一条,只能串行的去执行,当拆分出大量红包时(比如春晚红包,几百万个),会严重影响拆红包的效率。发红包阶段,新建一张临时表,表名和红包id对应,将拆分的金额存入到表中,每个金额都有一个自增id(从1开始),假如拆分出了100个金额,那么id就是1到100,然后hash(用户id)来获取金额id从而决定金额,最简单的就
业务流程
以微信红包为例,分为如下流程:
- 发红包(包红包);
- 抢红包;
- 拆红包(开红包)。
发红包
设置金额和份数,完成支付,红包详情入库,为红包生成一个唯一ID。
红包拆分
拆分时机
红包金额拆分分为实时拆分和提前拆分,实时拆分对系统性能和拆分算法要求较高,在拆分时既要保证线程安全,又要保证并发效率,开发难度较大。而且拆分过程中要一直保证后续待拆分红包的金额不能为0,不容易做到拆分的红包金额服从正态分布规律。
大型红包系统一般选择提前拆分,从而降低系统复杂度。
拆分算法
随机数
我们假设有一个100元的红包。第一个人可以在0.01元到100元之间,随机地分配到一定金额。如果我们把第一个人抽到的所有可能的金额都计算在内,并取个平均值,那么他平均能获得50元。这50元在数学上还有个形象的名字,叫作数学期望。既然是“期望”,就总会有落空的时候,也不排除会有意外的惊喜。因此,第一个人抽到的金额可能不足50元,也可能大于50元。
第二个人就只能在0.01到剩下的金额之间随机了,显然,第二个人平均能获得的金额比第一个人小,而且越到后面的人,这个平均金额越小。
也就是说,越在前面抢的人越有优势,因此,这个算法非常不公平。
二倍均值法
二倍均值法拆分的金额比较平均,属于“雨露均沾”类型。
假设红包总金额是a,红包个数为p,每个红包的最低金额是0.01元。
那么每次抢到的红包金额的范围在 [0.01, (a / p) * 2] 之间。
算法代码如下:
public class DoubleAVG {
/**
* 拆分金额
* @param amount 总金额,单位:元
* @param part 份数
* @return
*/
public static float[] compute(int amount, int part) {
// 红包最低金额0.01元,乘以100以后就可以全部用整型计算,不用担心精度丢失的问题
int amt = amount * 100;
float[] amounts = new float[part];
Random random = new Random();
int maxIndex = part - 1;
for (int i = 0; i < maxIndex; i++) {
// 随机出来的值的范围[0, 金额 / 份数 * 2), 所以需要加1
int packetAmount = random.nextInt(amt / part << 1) + 1;
amounts[i] = packetAmount / 100f; // 转换为元
amt -= packetAmount; // 剩余金额
--part; // 剩余份数
}
// 最后一个红包直接赋予剩余金额
amounts[maxIndex] = amt / 100f;
return amounts;
}
public static void main(String[] args) {
testCase();
}
public static void testCase() {
int amount = 100;
int part = 20;
float[] amounts = compute(amount, part);
for (float amt : amounts)
System.out.println(amt);
}
}
抢红包
抢红包是整个流程中并发最高的阶段,对系统的性能要求最高。在此阶段,至少要做如下业务校验:
- 红包还有没有剩余,有剩余才能抢;
- 已经抢过的人不能重复抢。
不能重复抢可以通过客户端交互界面限制,但是碰到直接调接口的“高端玩家”就没办法了,所以还是需要后端校验。这些请求如果全部放到数据库,可能会把数据库搞宕机,所以在这里我们需要限流。
限流
分布式锁
只有抢到锁才能抢红包,说白了就是让请求串行,但是遇到高并发,会导致大量请求排队,可能会耗尽线程资源,可以设置抢锁的等待超时时间和分段锁机制来缓解这种状况。抢到锁的请求,在通过业务校验之后,需要修改数据库里的数据(或是插入一条数据,或是修改一个标识位),表示抢到了红包。
Redis限流
设置了超时时间的分布式锁在高并发场景下会导致大量请求超时,用户体验非常不友好。相比之下,redis限流更适合这种场景。
在发红包阶段,以红包ID生成count_key(比如固定前缀 + 红包ID),红包份数作为value保存到redis,抢红包时,执行lua脚本(保证原子性),lua脚本的逻辑如下:
- 先判断count_key存不存在,不存在则说明红包不存在或已抢完,直接返回没抢到;
- 若count_key存在,接着判断份数是否等于零,若等于零,说明红包已经抢完,直接返回没抢到;
- 若份数大于零,接着判断该用户是否已经抢过红包,可以通过redis的set数据结构来判断,同样固定前缀+红包id作为key,记为repeat_key,用户id作为value保存到set。若用户已经抢过红包,则直接返回;
- 若用户还没抢过红包,则将红包份数扣减一,同时将用户id保存到set,返回抢红包成功的响应给客户端。
redis的数据全部保存在内存中,查询效率非常高,所以能支撑大量的并发,为了应对高并发,redis还可以部署集群。
拆红包
拆红包阶段要做的事情大概如下:
- 修改红包状态(比如标记为已抢);
- 打款;
- 记录红包流水。
这个阶段要严格保证数据准确性和完整性,太高的并发会增加系统出故障的几率。
好在,我们在抢红包阶段已经过滤了大部分无效请求,能到这个阶段的都是已经抢到了红包的。在调拆红包接口时,我们需要通过上个阶段提到的set来判断用户是否有资格拆这个红包,这一步也能过滤掉非法请求。
如何分配金额
消息队列
发红包阶段,新建一个消息队列,队列名称和红包id对应,将拆分的金额存入到消息队列,拆红包时按照先来后到的顺序从队列poll一个金额。这种方案有些异常场景需要考虑,比如poll到金额后还没来得及处理服务就挂了,导致金额丢失,通过消费成功后ack能解决这个问题。
哈希算法
发红包阶段,新建一张临时表,表名和红包id对应,将拆分的金额存入到表中,每个金额都有一个自增id(从1开始),假如拆分出了100个金额,那么id就是1到100,然后hash(用户id)来获取金额id从而决定金额,最简单的就是用户id % 份数。这种方式简单可靠。
并发修改的效率问题
尽量不要在每个拆红包的请求中都去修改红包记录的状态(比如扣减金额),因为红包记录只有一条,只能串行的去执行,当拆分出大量红包时(比如春晚红包,几百万个),会严重影响拆红包的效率。我们可以选择去修改金额表的状态,每一个金额记录都是单独的行,不会受数据库行锁限制而串行。
异步处理
异步处理加快响应速度,不用等所有业务逻辑都完成了再返回,可以将比较耗时的任务放进消息队列异步执行,只要将任务成功的放进了可靠的消息队列就可以响应成功了。比如记录红包流水等可以异步的执行。
其他引申问题
- 红包id生成引出分布式系统生成唯一id的方式;
- redis set引出redis数据结构
- redis集群和主备
- redis数据持久化
- 数据库分库分表
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)