一、开端 在上一篇文章《高并发红包炸弹项目性能优化》系列一:项目介绍 中,我介绍了下该红包项目的需求流程,以及一些此项目会涉及到的难点。这一篇文章,主要介绍针对此红包炸弹需求,我们的方案是如何设计的。由于项目是1.0版上线之后才交接到我手上的,因此方案并非我牵头设计的。但是对于别人的设计,我们还是可以怀着自己的思想去审视下,此方案的好坏,以及是否可以进一步优化。
二、方案设计前的思考 1. 首先我们对此项目有一个整体的交互模型,如下图所示, 管理员负责发红包,用户负责抢红包;
发红包最需要考虑的事情发红包后用户抢到的子红包是提前生成好,还是用户抢的时候实时计算能抢多少?(离线计算还是实时计算?) 投放的红包炸弹在炸开前1分钟,如何通知到用户端,让用户端展示出红包炸弹倒计时?(推还是拉?) 怎样设计让一个红包拆分出的子红包能够具备一定的分布特性(如正态分布),使用户抢到的单个子红包金额在一个控制返回?(单个子红包不能太大,也不能太小) 抢红包最需要考虑的事情如何应对瞬时高并发请求,怎样保护服务器,怎样保护DB?(羊毛党可能只是为了抢到红包,但黑客可能只是单纯地想把你的服务器打挂) 如何防止超抢
?即所有用户抢到的子红包之和应该小于等于红包总金额; 正常用户和黑客一起抢红包,黑客利用技术手段可以更及时的在红包炸弹爆开的一瞬间发起请求,如何识别非正常的请求?如何保证正常用户都能够公平的参与竞争? 三、看看别人的方案设计 1. 数据库表设计 如下图所示,是为满足红包炸弹相关需求所设计的表结构:
红包炸弹相关的有三张表:
红包表 : 记录了谁
在什么时间
投放了一个红包炸弹,该红包炸弹的金额
,可拆分的子红包数目
,红包的炸开时间
,红包的状态
,以及记录了此红包什么时间被抢完
; 子红包表: 记录了当前子红包属于哪个红包,当前子红包的金额
,当前子红包的状态
; 红包明细表:记录了哪个用户,在什么时间抢了一个怎样的子红包,明细表中冗余了子红包金额字段 支持红包炸弹的一张业务表:
报名记录表:记录了用户的报名信息和状态,相对于红包炸弹来讲,需要满足”用户报名成功才能抢红包“。 2. 消息通知流程 整个红包炸弹需要进行消息通知的有这么几处地方:
后台创建了红包炸弹,用户端需要收到通知,然后在页面展示有一个红包炸弹即将在某个时间点炸开; 当距离红包炸弹炸开前1分钟时,用户端需要收到通知,然后在页面开始红包炸弹炸开的60秒倒计时; 当红包炸弹被管理员取消,或者红包炸开后15s用户没有抢完子红包,系统自动将红包置完成时,会通知用户端,红包已失效或者进入红包历史界面; 基于公司的中台IM系统(依赖第三方云服务即时通信),可以进行消息的及时下发,采用的是长链接的方式。
3. 前端请求限流 当红包炸弹60秒倒计时完成,就可以点击红包云朵,进行抢红包了。这里为了限制请求,前端做的处理是,必须在发起一次抢红包接口返回后,才能发起下一次请求。目前用户端分别做了小程序版和H5版。很明显这种请求限流方式,在H5方案情况下,如果用户开多个浏览器,多个Tab窗口进行同时请求,是拦截不住的。这里我让前端做了一些优化,因为H5页面最终是嵌套在客户端内打开的,所以只要在判断当前环境是在客户端才能进行请求即可。单独抓包H5页面在浏览器是无法请求接口的。
4. 服务端核心接口逻辑实现 申明:先申明下以下代码并非我写的,而是此红包项目交接到我手里时的代码。代码的问题多,后续我将会对这些代码进行针对性的问题优化,我们先只关注业务主流逻辑,我在其源码上加了必要的注释方便大家看懂,暂不要关心代码的严谨性和性能等问题
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); redEnvelopeLogic.sendNoticeToIM(redEnvelopeDAO.queryLatestRedEnvelope(), CelebrationConstants.Hdf_Celebration_Bomb); redisLocker.unlock(redisLock); return RpcResponse.success("创建成功" ); }
看下上面第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); celebrationMqProcessor.sendAutoComplete(redEnvelopeDO); celebrationMqProcessor.sendCountDownMsg(redEnvelopeDO); }
可以看出,这里是提前创建好子红包的,我们来看下子红包是如何生成的 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 public boolean createItems (RedEnvelopeMessage message) { log.info("MQ Monitor createItems msg: {}" , message); RedEnvelopeDO redEnvelopeDO = redEnvelopeDAO.findById(message.getRedEnvelopeId()); if (redEnvelopeDO == null ){ return true ; } 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); redisClient.rpush(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId(), String.valueOf(redEnvelopeItemDO.getId())); }); return true ; }
拆分红包为若干子红包的工具方法逻辑 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 public static List<Integer> divideRedEnvelope (Integer totalAmount, Integer totalNum) { Integer sendedAmount = 0 ; Integer sendedNum = 0 ; int min = (totalAmount / totalNum ) / 10 ; Integer rdMin = min == 0 ? 1 : min; Integer rdMax = (totalAmount / totalNum * 2 ); List<Integer> redEnvelope = new ArrayList<>(); while (sendedNum < totalNum) { Integer bonus = randomOneRedEnvelope(totalAmount, totalNum, sendedAmount, sendedNum, rdMin, rdMax); redEnvelope.add(bonus); sendedNum++; sendedAmount += bonus; } return redEnvelope; } 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); } private static int getRandomVal (int min, int max) { return rand.nextInt(max - min + 1 ) + min; }
2)抢红包 抢红包主接口 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); } 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); } GrabRedEnvelopeInfoVO grabRedEnvelopeInfoVO = GrabRedEnvelopeInfoVO.builder().gain(false ).userId(userId).build(); String lockName = "celebration_" + userId; boolean isUserLock = redisLocker.tryLock(lockName, 5 , 5 ); if (!isUserLock){ return RpcResponse.error(ErrorCode.NO_GET_LOCK_TIP); } ApplyUserDO applyUserDO = applyUserLogic.getApplyUserByUserId(userId); if (null == applyUserDO) { grabRedEnvelopeInfoVO.setType(NO_SIGN_UP.getType()); redisLocker.unlock(lockName); return RpcResponse.success(grabRedEnvelopeInfoVO); } Long redEnvelopeRefId = redEnvelopeRefDAO.getRedEnvelopeRef(redEnvelopeId, userId); if (null != redEnvelopeRefId){ grabRedEnvelopeInfoVO.setType(ALREADY_GAIN.getType()); redisLocker.unlock(lockName); return RpcResponse.success(grabRedEnvelopeInfoVO); } List<RedEnvelopeRefDO> redEnvelopeRefDOList = redEnvelopeRefDAO.getRedEnvelopeRefList(userId); if (redEnvelopeRefDOList.size() >= overLimit){ grabRedEnvelopeInfoVO.setType(OVER_GAIN.getType()); redisLocker.unlock(lockName); return RpcResponse.success(grabRedEnvelopeInfoVO); } try { 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()); if (!ObjectUtils.notEqual(0L ,redisClient.llen(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId())) && HaoDate.isZeroTime(redEnvelopeDO.getCompleteTime())){ redEnvelopeDO.setCompleteTime(new HaoDate()); redEnvelopeDAO.update(redEnvelopeDO); } 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); }); grabRedEnvelopeInfoVO.setGain(true ); grabRedEnvelopeInfoVO.setType(GAIN.getType()); grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice()); }catch (NumberFormatException e){ 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); } return RpcResponse.success(grabRedEnvelopeInfoVO); }
上面第43行的dao查询方法getRedEnvelopeRef,具体实现如下 1 2 3 4 5 6 7 8 9 10 11 12 public Long getRedEnvelopeRef (@NotNull Long redEnvelopeId, @NotNull Long userId) { 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);
上面第52行dao查询方法getRedEnvelopeRefList,具体实现如下 1 2 3 4 5 6 7 8 9 10 11 12 13 public List<RedEnvelopeRefDO> getRedEnvelopeRefList (@NotNull Long userId) { 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); }
通过上面几段最核心的代码,大概能看出来这些问题了。
代码不规范,比如很多地方不关心返回值,理所应当的认为调用成功 逻辑不严谨,像使用分布锁的地方,加锁地方和最终try…finally…的代码短隔了很多中间逻辑,无法保证一定就能进入try代码块,更无法保证一定会释放锁了 性能问题很多,使用缓存的地方,除了公司dao层框架提供的实体缓存和分布式锁,就没有别的地方使用缓存了。大量的查询库表,甚至出现多表连接的复杂SQL。在应对抢红包这样大并发的情况下,显然是不够的。 安全性问题:锁的力度小(锁用户),不同用户并发进来,没有任何阻碍,无法很好做到限流 使用了tryLock带超时的自旋锁,当发生恶意攻击时,可能一个用户就把所有机器的线程打满了,服务就不能提供给其他人了 接口整体没有限流,不能做到削峰填谷,流量洪峰下,可能打挂服务,也可能打挂DB 框架的dao层查询实体缓存是OK的,但是不能解决缓存穿透的问题。恶意用户一直使用不存在的userId请求的话,会给DB造成很大的压力 还有更多的问题就不一一列举了,在后面的文章中,我将一步步的优化代码 、优化前后端交互流程 、使用一些限流/熔断/降级 、压力测试 等手段一步步将这个抢红包项目彻底优化,以能够应付各种高并发下的问题。
四、总结 这里篇文章里,我对红包炸弹交接前的设计方案、核心实现等做了一些介绍,在后面的文章中我将正式开始一步步地去优化这个项目。