《高并发红包炸弹项目性能优化》系列二:方案设计

一、开端

​ 在上一篇文章《高并发红包炸弹项目性能优化》系列一:项目介绍 中,我介绍了下该红包项目的需求流程,以及一些此项目会涉及到的难点。这一篇文章,主要介绍针对此红包炸弹需求,我们的方案是如何设计的。由于项目是1.0版上线之后才交接到我手上的,因此方案并非我牵头设计的。但是对于别人的设计,我们还是可以怀着自己的思想去审视下,此方案的好坏,以及是否可以进一步优化。

二、方案设计前的思考

​ 1. 首先我们对此项目有一个整体的交互模型,如下图所示, 管理员负责发红包,用户负责抢红包;

  1. 发红包最需要考虑的事情
    1. 发红包后用户抢到的子红包是提前生成好,还是用户抢的时候实时计算能抢多少?(离线计算还是实时计算?)
    2. 投放的红包炸弹在炸开前1分钟,如何通知到用户端,让用户端展示出红包炸弹倒计时?(推还是拉?)
    3. 怎样设计让一个红包拆分出的子红包能够具备一定的分布特性(如正态分布),使用户抢到的单个子红包金额在一个控制返回?(单个子红包不能太大,也不能太小)
  2. 抢红包最需要考虑的事情
    1. 如何应对瞬时高并发请求,怎样保护服务器,怎样保护DB?(羊毛党可能只是为了抢到红包,但黑客可能只是单纯地想把你的服务器打挂)
    2. 如何防止超抢?即所有用户抢到的子红包之和应该小于等于红包总金额;
    3. 正常用户和黑客一起抢红包,黑客利用技术手段可以更及时的在红包炸弹爆开的一瞬间发起请求,如何识别非正常的请求?如何保证正常用户都能够公平的参与竞争?

三、看看别人的方案设计

1. 数据库表设计

​ 如下图所示,是为满足红包炸弹相关需求所设计的表结构:

image-20210114110226714

红包炸弹相关的有三张表:

  • 红包表 : 记录了在什么时间投放了一个红包炸弹,该红包炸弹的金额,可拆分的子红包数目,红包的炸开时间,红包的状态,以及记录了此红包什么时间被抢完;
  • 子红包表: 记录了当前子红包属于哪个红包,当前子红包的金额,当前子红包的状态;
  • 红包明细表:记录了哪个用户,在什么时间抢了一个怎样的子红包,明细表中冗余了子红包金额字段

支持红包炸弹的一张业务表:

  • 报名记录表:记录了用户的报名信息和状态,相对于红包炸弹来讲,需要满足”用户报名成功才能抢红包“。

2. 消息通知流程

整个红包炸弹需要进行消息通知的有这么几处地方:

  1. 后台创建了红包炸弹,用户端需要收到通知,然后在页面展示有一个红包炸弹即将在某个时间点炸开;
  2. 当距离红包炸弹炸开前1分钟时,用户端需要收到通知,然后在页面开始红包炸弹炸开的60秒倒计时;
  3. 当红包炸弹被管理员取消,或者红包炸开后15s用户没有抢完子红包,系统自动将红包置完成时,会通知用户端,红包已失效或者进入红包历史界面;

基于公司的中台IM系统(依赖第三方云服务即时通信),可以进行消息的及时下发,采用的是长链接的方式。

3. 前端请求限流

当红包炸弹60秒倒计时完成,就可以点击红包云朵,进行抢红包了。这里为了限制请求,前端做的处理是,必须在发起一次抢红包接口返回后,才能发起下一次请求。目前用户端分别做了小程序版和H5版。很明显这种请求限流方式,在H5方案情况下,如果用户开多个浏览器,多个Tab窗口进行同时请求,是拦截不住的。这里我让前端做了一些优化,因为H5页面最终是嵌套在客户端内打开的,所以只要在判断当前环境是在客户端才能进行请求即可。单独抓包H5页面在浏览器是无法请求接口的。

4. 服务端核心接口逻辑实现

申明:先申明下以下代码并非我写的,而是此红包项目交接到我手里时的代码。代码的问题多,后续我将会对这些代码进行针对性的问题优化,我们先只关注业务主流逻辑,我在其源码上加了必要的注释方便大家看懂,暂不要关心代码的严谨性和性能等问题

1)创建红包

  1. 创建红包接口逻辑大概如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@ApiOperation(value = "后台创建红包炸弹")
@PostMapping("/createForAuditor")
@ValidateBody
public RpcResponse<String> createForAuditor(@RequestBody CreateRedEnvelopeVO createRedEnvelopeVO){
//省略一些基本条件校验逻辑
String redisLock = "createForAuditor" + inspectId;
//获取分布式锁
redisLocker.lock(redisLock);
//创建红包
redEnvelopeLogic.create(num, price, inspectId, TYPE_AUDITOR, startTime);
//通过IM通知用户端,红包已经创建了,用户端收到通知后将会在页面显示一个红包炸弹将在什么时间开爆
redEnvelopeLogic.sendNoticeToIM(redEnvelopeDAO.queryLatestRedEnvelope(), CelebrationConstants.Hdf_Celebration_Bomb);
//释放锁
redisLocker.unlock(redisLock);
return RpcResponse.success("创建成功");
}
  1. 看下上面第10行里调用的创建红包的核心逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void create4Auditor(Integer num, BigDecimal price, Long inspectId, HaoDate startTime) {
RedEnvelopeDO redEnvelopeDO = new RedEnvelopeDO();
redEnvelopeDO.setNum(num);
redEnvelopeDO.setSourceId(inspectId);
//红包的类型还有其他多种,这个类型代表时红包炸弹
redEnvelopeDO.setSourceType(RedEnvelopeConstants.TYPE_AUDITOR);
redEnvelopeDO.setPrice(price);
redEnvelopeDO.setStatus(RedEnvelopeConstants.STATUS_VALID);
redEnvelopeDO.setTime(startTime);
//保存红包到数据库
redEnvelopeDAO.save(redEnvelopeDO);
//异步创建子红包
celebrationMqProcessor.sendCreateRedEnvelopeItem(redEnvelopeDO);
//创建一个延迟MQ消息,在红包炸开后15s自动置红包炸弹完成
celebrationMqProcessor.sendAutoComplete(redEnvelopeDO);
//创建一个延迟MQ消息,在红包炸开前1分钟,通知用户端,开始60s倒计时
celebrationMqProcessor.sendCountDownMsg(redEnvelopeDO);
}
  1. 可以看出,这里是提前创建好子红包的,我们来看下子红包是如何生成的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//createItems此方法已经是在MQ的消费者线程中执行的了
public boolean createItems(RedEnvelopeMessage message){
log.info("MQ Monitor createItems msg: {}", message);
RedEnvelopeDO redEnvelopeDO = redEnvelopeDAO.findById(message.getRedEnvelopeId());
if(redEnvelopeDO == null){
return true;
}
//这里给价格乘以了100,看起来意思是把元/角/分的分单位整数化
Integer totalAmount = redEnvelopeDO.getPrice().intValue() * 100;
Integer num = redEnvelopeDO.getNum();
//可以知道就是这个工具方法,把一个红包分成了指定份数的子红包
List<Integer> redEnvelopeList = RedEnvelopeUtil.divideRedEnvelope(totalAmount, num);
redEnvelopeList.forEach(integer -> {
RedEnvelopeItemDO redEnvelopeItemDO = new RedEnvelopeItemDO();
redEnvelopeItemDO.setRedEnvelopeId(redEnvelopeDO.getId());
redEnvelopeItemDO.setPrice(BigDecimal.valueOf((double) integer / 100));
redEnvelopeItemDO.setStatus(ITEM_STATUS_DOING);
redEnvelopeItemDO.setDescription("红包炸弹");
//子红包存库
redEnvelopeItemDAO.save(redEnvelopeItemDO);
//把子红包的id Rpush到了redis的一个list
redisClient.rpush(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId(), String.valueOf(redEnvelopeItemDO.getId()));
});
return true;
}
  1. 拆分红包为若干子红包的工具方法逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 生成红包一次分配结果
* @param totalAmount 总红包量
* @param totalNum 总份数
* @return List<Integer>
*/
public static List<Integer> divideRedEnvelope(Integer totalAmount, Integer totalNum) {
//这个sendedAmount变量原作者应该是想表达,已经生成的红包消耗了多少钱了
Integer sendedAmount = 0;
//这个sendedNum变量原作者应该是想表达,已经生产几个红包了
Integer sendedNum = 0;
//这里min应该代表一个子红包最低应该是平均子红包金额的十分之一
int min = (totalAmount / totalNum ) / 10;
//rdMin代表红包最低的真实价格,如果算出来min==0了,最少也要为1,此时单位应该是分,即子红包最小1分钱
Integer rdMin = min == 0 ? 1 : min;
//rdMax代表子红包最大为平均值的2倍
Integer rdMax = (totalAmount / totalNum * 2);
List<Integer> redEnvelope = new ArrayList<>();
while (sendedNum < totalNum) {
//循环的计算每个子红包的价格,子红包价格区间在【1分,2倍平均红包的价格】
Integer bonus = randomOneRedEnvelope(totalAmount, totalNum, sendedAmount, sendedNum, rdMin, rdMax);
redEnvelope.add(bonus);
sendedNum++;
sendedAmount += bonus;
}
//返回由计算出来的子红包金额组成的List
return redEnvelope;
}

/**
* 随机分配第n个红包
* @param totalAmount 总红包量
* @param totalNum 总份数
* @param sendedAmount 已发送红包量
* @param sendedNum 已发送份数
* @param rdMin 随机下限
* @param rdMax 随机上限
* @return Integer
*/
private static Integer randomOneRedEnvelope(Integer totalAmount, Integer totalNum, Integer sendedAmount,
Integer sendedNum, Integer rdMin, Integer rdMax) {
Integer boundMin = Math.max((totalAmount - sendedAmount - (totalNum - sendedNum - 1) * rdMax), rdMin);
Integer boundMax = Math.min((totalAmount - sendedAmount - (totalNum - sendedNum - 1) * rdMin), rdMax);
return getRandomVal(boundMin, boundMax);
}

/**
* 返回min~max区间内随机数,含min和max
* @param min
* @param max
* @return Integer
*/
private static int getRandomVal(int min, int max) {
return rand.nextInt(max - min + 1) + min;
}

2)抢红包

  1. 抢红包主接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@GetMapping("/grabRedEnvelopes")
@ApiOperation(value = "抢红包接口", notes = "userId 用户Id | redEnvelopeId:红包id")
public RpcResponse<GrabRedEnvelopeInfoVO> grabRedEnvelopes(@RequestParam Long userId, @RequestParam Long redEnvelopeId){
//空参校验
if(!ObjectUtils.allNotNull(userId, redEnvelopeId)){
return RpcResponse.error(ErrorCode.PARAMS_EXCEPTION);
}
//校验红包是否存在,公司的当前基础服务框架中,DAO查询的findById方法会查询实体缓存
//实体缓存存放于redis中,如果实体缓存失效了,会去查询MySQL集群中的主库
RedEnvelopeDO redEnvelopeDO = redEnvelopeDAO.findById(redEnvelopeId);
if(null == redEnvelopeDO){
return RpcResponse.error(ErrorCode.PARAMS_EXCEPTION);
}
//校验是否红包还没开爆,还没开爆就进来抢红包的,肯定要拦截
if(HaoDate.currentTimeSecond() - redEnvelopeDO.getTime().getTimeSecond() < 0){
return RpcResponse.error(ErrorCode.RED_NO_BEGAIN);
}
//判断红包是否已经被取消了,如果已经取消了,也是抢不了的
if(ObjectUtils.notEqual(STATUS_VALID,redEnvelopeDO.getStatus())){
return RpcResponse.error(ErrorCode.RED_BE_OVER);
}
//构造结构返回的VO,gain字段表示是否抢到了红包
GrabRedEnvelopeInfoVO grabRedEnvelopeInfoVO =
GrabRedEnvelopeInfoVO.builder().gain(false).userId(userId).build();
//获取分布式锁
String lockName = "celebration_" + userId;
//这里用了tryLock,本质上是一个带超时返回的自旋锁
boolean isUserLock = redisLocker.tryLock(lockName, 5, 5);
if(!isUserLock){
//超时都没拿到锁,就返回请稍后重试
return RpcResponse.error(ErrorCode.NO_GET_LOCK_TIP);
}
//判断有没有报名,这个getApplyUserByUserId接口逻辑是查询报名表,是否有报名成功的记录,此接口没有做缓存
ApplyUserDO applyUserDO = applyUserLogic.getApplyUserByUserId(userId);
if(null == applyUserDO) {
//提示没报名,释放锁
grabRedEnvelopeInfoVO.setType(NO_SIGN_UP.getType());
redisLocker.unlock(lockName);
return RpcResponse.success(grabRedEnvelopeInfoVO);
}
//判断是否已经抢过当前红包了,这里Dao层查询了红包明细表,但是红包明细表中没有红包id,只有子红包id
//所以这里连表查询了,这个dao方法一会儿放在下面的代码块中
Long redEnvelopeRefId = redEnvelopeRefDAO.getRedEnvelopeRef(redEnvelopeId, userId);
if(null != redEnvelopeRefId){
//如果已经抢过此红包了,就提示已抢过并释放锁,接口返回
grabRedEnvelopeInfoVO.setType(ALREADY_GAIN.getType());
redisLocker.unlock(lockName);
return RpcResponse.success(grabRedEnvelopeInfoVO);
}
//判断一天内是否已经抢过三次
//这个dao查询方法一会儿也放在下面的代码块中
List<RedEnvelopeRefDO> redEnvelopeRefDOList = redEnvelopeRefDAO.getRedEnvelopeRefList(userId);
if(redEnvelopeRefDOList.size() >= overLimit){
//如果已经抢过三次了,则提示您已经三连冠了,把机会让给别人吧。
grabRedEnvelopeInfoVO.setType(OVER_GAIN.getType());
//释放分布式锁
redisLocker.unlock(lockName);
//接口返回
return RpcResponse.success(grabRedEnvelopeInfoVO);
}
try {
//从redis中Lpop一个子红包id出来,这个是在创建子红包时Rpush进入这个list的
//这里进行了id转换,如果抛NumberFormatException异常了,则说明list中已经空了
//代表红包已经被抢完了,在catch中处理了抢完的逻辑
Long redEnvelopeItemId = Long.valueOf(redisClient.lpop(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId()));
log.info("redis itemId:{}",redisClient.lrange(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId(), 0, -1).toString());
//这里llen查询了下redis中存放子红包id的list长度,如果空了,并且当前红包还没有置完成,就直接给红包置完成了
if(!ObjectUtils.notEqual(0L,redisClient.llen(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId())) && HaoDate.isZeroTime(redEnvelopeDO.getCompleteTime())){
redEnvelopeDO.setCompleteTime(new HaoDate());
redEnvelopeDAO.update(redEnvelopeDO);
}
//这里根据拿到的子红包id查询子红包实体。findById会先查询实体缓存,没有就查主库
RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(redEnvelopeItemId);
//开启事务,创建红包明细,更新子红包状态为完成,代表已经抢成功了。这里居然没关心事务返回结果,后面也会优化处理
jdbcDAO.withTransaction(()->{
RedEnvelopeRefDO redEnvelopeRefDO = new RedEnvelopeRefDO();
redEnvelopeRefDO.setPrice(redEnvelopeItemDO.getPrice());
redEnvelopeRefDO.setType(TYPE_AUDITOR);
redEnvelopeRefDO.setRedEnvelopeItemId(redEnvelopeItemDO.getId());
redEnvelopeRefDO.setApplyUserId(applyUserDO.getId());
redEnvelopeRefDO.setUserId(applyUserDO.getUserId());
redEnvelopeRefDAO.save(redEnvelopeRefDO);
redEnvelopeItemDO.setStatus(ITEM_STATUS_DONE);
redEnvelopeItemDAO.update(redEnvelopeItemDO);
});
//设置接口返回的VO中Gain为true,表示抢成功了
grabRedEnvelopeInfoVO.setGain(true);
grabRedEnvelopeInfoVO.setType(GAIN.getType());
grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice());
}catch (NumberFormatException e){
//如果这里捕获到异常,说明从redis的list取空了,设置为抢失败
grabRedEnvelopeInfoVO.setType(NO_GAIN.getType());
//更新红包的完成时间
if(HaoDate.isZeroTime(redEnvelopeDO.getCompleteTime())) {
redEnvelopeDO.setCompleteTime(new HaoDate());
redEnvelopeDAO.update(redEnvelopeDO);
}
//接口返回
return RpcResponse.success(grabRedEnvelopeInfoVO);
}finally {
//这里释放锁,但时就目前这个代码严谨程度上看,还真不一定能及时释放掉这个锁,后面会讲我是怎么优化的
redisLocker.unlock(lockName);
}
//接口返回vo
return RpcResponse.success(grabRedEnvelopeInfoVO);
}
  1. 上面第43行的dao查询方法getRedEnvelopeRef,具体实现如下
1
2
3
4
5
6
7
8
9
10
11
12
public Long getRedEnvelopeRef(@NotNull Long redEnvelopeId, @NotNull Long userId){
//看这个sql是要连接redenvelopes红包表、redenvelopeitems子红包表、redenveloperefs红包明细表三表连接查询当前用户是否已经抢过这个红包了
//这个sql过于复杂了,而且性能不高,连起码的limit 1都没有加上,后面讲如何优化掉这个多表查询
String sql = "select c.id from redenvelopes a inner join redenvelopeitems b on a.id = b.redenvelopeid inner join " +
"redenveloperefs c on b.id = c.redenvelopeitemid where c.userid = :userId and a.id = :redEnvelopeId " +
"and b.status = :status and a.sourcetype = :sourceType";

SqlParam sqlParam = SqlParam.create("userId", userId).
add("redEnvelopeId", redEnvelopeId).
add("status", ITEM_STATUS_DONE).
add("sourceType", TYPE_AUDITOR);
return jdbcDAO.findField(Long.class, sql, sqlParam);
  1. 上面第52行dao查询方法getRedEnvelopeRefList,具体实现如下
1
2
3
4
5
6
7
8
9
10
11
12
13
public List<RedEnvelopeRefDO> getRedEnvelopeRefList(@NotNull Long userId){
//可以看出来就是查询了所有的这一天时间短内,这个用户抢了这类型的红包记录
//直接返回了明细的DO,这里最起码的优化返回主键id列表或者sql Count一下就可以了。当然还有更好的优化。
String whereSql = "where userid = :userId and type =:type and ctime >= :startTime and ctime <= :endTime";
HaoDate now = new HaoDate();
String startTime = now.dateString();
String endTime = now.offsetDay(1).dateString();
SqlParam sqlParam = SqlParam.create("userId", userId).
add("type", TYPE_AUDITOR).
add("startTime", startTime).
add("endTime", endTime);
return jdbcDAO.findList(RedEnvelopeRefDO.class, whereSql, sqlParam);
}

通过上面几段最核心的代码,大概能看出来这些问题了。

  1. 代码不规范,比如很多地方不关心返回值,理所应当的认为调用成功
  2. 逻辑不严谨,像使用分布锁的地方,加锁地方和最终try…finally…的代码短隔了很多中间逻辑,无法保证一定就能进入try代码块,更无法保证一定会释放锁了
  3. 性能问题很多,使用缓存的地方,除了公司dao层框架提供的实体缓存和分布式锁,就没有别的地方使用缓存了。大量的查询库表,甚至出现多表连接的复杂SQL。在应对抢红包这样大并发的情况下,显然是不够的。
  4. 安全性问题:
    1. 锁的力度小(锁用户),不同用户并发进来,没有任何阻碍,无法很好做到限流
    2. 使用了tryLock带超时的自旋锁,当发生恶意攻击时,可能一个用户就把所有机器的线程打满了,服务就不能提供给其他人了
    3. 接口整体没有限流,不能做到削峰填谷,流量洪峰下,可能打挂服务,也可能打挂DB
    4. 框架的dao层查询实体缓存是OK的,但是不能解决缓存穿透的问题。恶意用户一直使用不存在的userId请求的话,会给DB造成很大的压力

还有更多的问题就不一一列举了,在后面的文章中,我将一步步的优化代码优化前后端交互流程、使用一些限流/熔断/降级压力测试等手段一步步将这个抢红包项目彻底优化,以能够应付各种高并发下的问题。

四、总结

这里篇文章里,我对红包炸弹交接前的设计方案、核心实现等做了一些介绍,在后面的文章中我将正式开始一步步地去优化这个项目。