业务描述
模拟微信设计开发一个发、抢红包的通用功能,提供给公司业务线使用。
需求分析
- 在特殊的活动日的时候,发红包+抢红包,要应对高并发的业务请求,所以不可以直接用MySQL来做,考虑使用redis 、异步处理的机制。
- 拆分红包的时候,如何做到尽量平均一点,不要出现100的红包经过拆分后其中一个红包到99元这种情况,会影响用户参与的活跃性。
- 每个用户只能抢一次,并且需要记录下那些人抢到了红包。
- 所有红包被抢完的总时间是否记录? 回显使用。
- 红包过期,剩余金额的退回。或者没人抢红包,金额原封不动的退回。
架构设计
抢红包其实主要有 2 个核心流程:红包金额拆分->抢红包
- 红包金额拆分:是指将指定金额拆分为指定数目红包的过程,用来确定每个红包的金额数;
- 抢红包:是用户抢红包的这个操作,典型的高并发场景,需要系统扛流量且避免红包超发的情况,重复抢的情况;
红包金额拆分
可选的方案
- 实时拆分
实时拆分,指的是在抢红包时实时计算每个红包的金额,以实现红包的拆分过程,对系统性能和拆分算法要求较高,例如拆分过程要一直保证后续待拆分红包的金额不能为空,不容易做到拆分的红包金额服从正态分布规律。
- 预先生成
预先生成,指的是在红包开抢之前已经完成了红包的金额拆分,抢红包时只是依次取出拆分好的红包金额,对拆分算法要求较低,可以拆分出随机性很好的红包金额,通常需要结合队列使用。
我们这次的场景对红包金额的随机性要求不高,但是对系统可靠性要求较高,所以我们选用了预先生成方式,使用 二倍均值法 的算法拆分红包金额。
拆分算法可以描述为:假设剩余拆分金额为 M,剩余待拆分红包个数为 N,红包最小金额为 1 元,那么定义当前红包的金额为:m=rand(1,floor(M/N?2))
代码实现
/**
* 拆红包的算法--->二倍均值算法
* @param totalMoney
* @param redPackageNumber
* @return
*/
private Integer[] splitRedPackageAlgorithm(int totalMoney,int redPackageNumber) {
Integer[] redPackageNumbers = new Integer[redPackageNumber];
//已经被抢夺的红包金额,已经被拆分塞进子红包的金额
int useMoney = 0;
for (int i = 0; i < redPackageNumber; i++) {
if(i == redPackageNumber - 1) {
redPackageNumbers[i] = totalMoney - useMoney;
}else{
//二倍均值算法,每次拆分后塞进子红包的金额 = 随机区间(1,(剩余红包金额M ÷ 未被抢的剩余红包个数N) * 2)
int avgMoney = ((totalMoney - useMoney) / (redPackageNumber - i)) * 2;
redPackageNumbers[i] = 1 + new Random().nextInt(avgMoney - 1);
}
useMoney = useMoney + redPackageNumbers[i];
}
return redPackageNumbers;
}
抢红包
我们使用的在发红包的时候使用的是redis的 list
/**
* 发红包
* @param totalMoney
* @param redPackageNumber
* @return
*/
@RequestMapping(value = "/send")
public String sendRedPackage(int totalMoney,int redPackageNumber) {
//1 拆红包,将总金额totalMoney拆分为redPackageNumber个子红包
Integer[] splitRedPackages = splitRedPackageAlgorithm(totalMoney,redPackageNumber);//拆分红包算法通过后获得的多个子红包数组
//2 发红包并保存进list结构里面且设置过期时间
String key = RED_PACKAGE_KEY+ IdUtil.simpleUUID();
redisTemplate.opsForList().leftPushAll(key,splitRedPackages);
redisTemplate.expire(key,1, TimeUnit.DAYS);
//todo 异步记录当前用户以及当前红包的基本信息到DB
//3 发红包OK,返回前台显示
return key;
}
所以在抢红包整个过程先只操作 Redis,且都是简单高效的 Pop 和 Push 命令操作。
抢红包流程:先从红包队列中 Pop 占有红包,然后通过mq进一步做统计处理,例如:每一年你发出多少红包,抢到了多少红包,年度总结,打款处理等),并同步告知用户抢到红包的结果,抢红包流程就结束了。
/**
* 抢红包
* @param totalMoney
* @param redPackageNumber
* @return
*/
@RequestMapping(value = "/rob")
public String robRedPackage(String redPackageKey,String userId) {
//1 验证某个用户是否抢过红包,不可以多抢
Object redPackage = redisTemplate.opsForHash().get(RED_PACKAGE_CONSUME_KEY + redPackageKey, userId);
//2 没有抢过可以去抢红包,否则返回-2表示该用户抢过红包了
if(null == redPackage) {
//2.1 从大红包(list)里面出队一个作为该客户抢的红包,抢到了一个红包
Object partRedPackage = redisTemplate.opsForList().leftPop(RED_PACKAGE_KEY + redPackageKey);
if(partRedPackage != null) {
//2.2 抢到红包后需要记录进入hash结构,表示谁抢到了多少钱的某个子红包
redisTemplate.opsForHash().put(RED_PACKAGE_CONSUME_KEY + redPackageKey,userId, partRedPackage);
//TODO 后续异步进mysql或者MQ进一步做统计处理,每一年你发出多少红包,抢到了多少红包,年度总结
return String.valueOf(partRedPackage);
}
// 抢完了
return "红包抢完了";
}
//3 某个用户抢过了,不可以作弊抢多次
return "你已经抢过红包了,不能重新抢";
}
看上述的代码我们可以看出大致流程如下
- 我们首先验证了一下合格用户是否抢过红包,避免重复抢。
- 没有抢过我们就要从list中拿出一个红包,给到当前的用户,如果没有拿到就代表这当前红包已经被抢完了。
- 抢到红包后,我们要记下这条记录,谁抢到哪个红包中的多少钱。这个地方我们选用的是redis 的 hash 结构。
后续
当然上面只是一个抢红包功能简单的介绍,目的还是用于理解redis的数据结构该如何使用,真实的抢红包还是有太多的地方要考虑的,比如:限流,降级等等。
限流
前端限流
前端限制用户在 n 秒之内只能提交一次请求,虽然这种方式只能挡住小白(99% 的用户),所以也必须得做。
后端限流
常用的后端限流方法有 漏桶算法 和 令牌桶算法。漏桶算法 主要目的是控制请求数据注入的速率,如果此时漏桶溢出,后续的请求数据会被丢弃。而 令牌桶算法 是以一个恒定的速度往桶里放入令牌,而如果请求数据需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌时,这些请求才被丢弃,令牌桶算法的一个好处是可以方便地改变应用接受请求的速率。
降级措施
在每个关键节点都增加开关,一但出现异常,可以通过配置中心人工介入做降级处理。