《高并发红包炸弹项目性能优化》系列三:接口性能优化

一、开始

​ 在上一篇文章《高并发红包炸弹项目性能优化》系列二:方案设计 中,我具体介绍了下该红包炸弹需求的实现方案,包括数据库表的设计、前端的限流请求措施、服务端关键接口的代码实现。在这一篇博客中,我将从服务端接口代码优化开始,逐步展开对整个红包炸弹项目的优化历程。毕竟,最重要的还是核心的业务接口。接口写得性能够好了,就成功一半了。剩下的无非是采取一些常见的手段,进行二次优化而已。真正需要动刀子的,还是在业务接口上。

二、接口优化原则

​ 面对接口的性能优化,我这样思考:

​ 接口,做了什么事?无非就是读/写。所有的网络请求都是在读/写。那优化的最终目标是什么?

​ 答案就是:让单位时间能够支持“更多”的读/写请求!

​ 剩下的问题就很简单了,我们采取各个击破的办法。

  1. 如何让读取更快
  2. 如何让写入更快

接下来就这两点,我来谈谈自己的处理意见。

1. 如何让读取更快

  1. 减少DB查询,能走缓存就一定要走缓存;特殊业务场景要,可以在硬件上加大投入,给Redis集群进行扩容什么的都是OK的;
  2. 既然要走缓存,一定要考虑到数据一致性、缓存穿透、缓存击穿、缓存雪崩这三个问题,既然要用缓存就一定要用好缓存;
  3. 实在要走库的查询,一定要注意查询性能。主要从以下两方面优化:
    1. 建立好自己的业务数据表模型,该垂直拆表的地方就拆,该冗余字段的地方就冗余,尽量保证自己的业务接口使用简单SQL查询。当然不管拆表还是冗余都是有代价的,可能需要维护数据的一致性,可能需要开事务进行多表写入。具体问题具体分析。
    2. 一旦数据表一定,很难再做修改的情况下,那就要更多地在SQL优化上下功夫。一般建议SQL查询计划至少要达到ref级别,能到const级别那当然更好;

2. 如何让写入更快

  1. 写入的话,能写缓存,当然不建议写库。但是对于缓存,要考虑的问题更多,选用哪种数据结构,怎样保证数据一致性,什么时候缓存落库,缓存淘汰策略是什么,还有不要造成大面积缓存失效导致的缓存雪崩;
  2. 直接写库的话,要考虑下是否要优化表结构,尽可能在设计的时候就考虑减少锁冲突,表设计时还可以考虑使用乐观锁,能不用事务就不用事务,可以考虑定时任务来兜底,也可以考虑走MQ进行异步写库;
  3. 要考虑数据所使用的事务隔离级别,隔离级别越高,并发性越低;如果根本不存在事务问题,不使用带事务支持的数据库引擎也OK;

三、高并发下还需要考虑哪些优化

  1. 如果用到了分布式锁,一定要保证锁的及时释放,锁不及时释放,反而可能导致接口的吞吐量降低;另外就是分布式锁的选型,一般有基于Redis、Zookeeper以及数据库实现这三种,一般来说Redis锁更适合高并发下使用,Redis实现分布式锁相对于ZK和数据库来说也更简单;

  2. 还需要再提下分布式锁的使用,在高并发下不推荐使用带有超时退出的自旋锁,因为线程自旋会加大CPU的使用负担,同时也会持续占用线程,如果有大量的线程自旋,会导致把机器上的最大线程数打满(这个机器的线程数在Linux上是可以配置的,但仍然有上线),如此一来服务就不能分配出可用的线程继续提供服务了,会直接导致高并发下接口的吞吐量降低;

  3. 线程池技术的使用。要注意线程池的参数配置、以及阻塞队列是否有界、容量最大为多少,避免高并发下造成OOM,另外还需要注意使用线程池时线程复用下会不会有内存泄漏的问题,在合理的节点进行内存释放;

  4. 接口限流。在不考虑实际业务场景时,限流根本目的就是就是为了保护DB,因为在大多数情况下,DB是公共资源,不能因为一个业务,就把整个公共资源搞挂吧;

  5. 优化代码逻辑。所谓条条大路通罗马,但是通往罗马所付出的代价却是不一样的。简单的说就是用更好的算法处理业务逻辑。更好的算法往往可以更快的处理完业务逻辑,占用更小的空间,更少的IO次数。面对高并发,我们有必要绞尽脑汁去思考更聪明的做法。

OK,至此,我总结了一些自己对于优化接口性能这件事上的一些宏观上的认识。当然性能优化可以从各个角度入手,远远不止我提到的这些。对于性能优化,我们应该采用贪心算法的思想,在各个环节优化到最好,最后的整体性能一定不会低到哪儿去。接下来,我们就正式开始进行红包炸弹项目的关键接口性能优化工作。

四、简单优化创建红包接口

​ 在《高并发红包炸弹项目性能优化》系列二:方案设计 我贴上了项目交接前红包炸弹的具体创建接口的实现代码。

这里我再次把红包炸弹的创建逻辑再次说明下,如下图中的表结构所示,创建红包炸弹其实就是在红包表中新增一条红包记录,以及新增与此红包记录所关联的”若干”条子红包记录。这里的“若干”就是红包表中的num字段所指定的数量。

image-20210116143738416

图1-红包炸弹的表设计

这里再补充一下需求,产品认为同一时间只能有一个正在进行中的红包。也就是说,如果在创建红包时还有处于未开抢的,或者正在被抢的红包,或者还没有置完成的红包,那么这时不能投放新的红包的。另外,投放红包只有管理员才可以进行操作。因为对于此接口我们并没有太多的并发度要求。我只要保证此接口可以逻辑正确、可靠的执行完成就OK了。

image-20210116152350191

图2-创建红包接口的优化前后diff截图

如上图2所示,是对创建接口的一些基本优化后的前后diff截图。

  1. 在创建接口的入口处,添加了一处判断逻辑:
1
2
3
4
5
//爆炸时间必须至少在当前时间的1分钟后,这样才能做到爆炸前60s通知IM
if (!startTime.after(new HaoDate().offsetMinutes(1))){
return RpcResponse.error(ErrorCode.RED_ENVELOPE_TIME_ERROR);
}

​ 为什么加这么一个判断呢,因为按照需求,我们需要在爆炸前60s发送一个IM消息,通知前端开始进行红包炸弹的一分钟倒计时。如果在创建红包时所指定的红包炸弹炸开时间在将来的不到一分钟内。那么我们无法就无法满足需求了。即红包一创建,里炸开时间就已经小于60秒了,这种情况下进行红包60秒倒计时显然是不合适的。

  1. 优化锁使用,这里应该明显是应该先获取锁,再判断是否还有进行中的红包。使用try…finally…保证锁最终会被释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
HdfAssert.isTrue(redisLocker.lock(redisLock),ErrorCode.ACTION_FAST);
try {
if (redEnvelopeLogic.isExistDoingRedEnvelope()){
return RpcResponse.error(ErrorCode.EXIST_DOING_RED_ENVELOPE);
}
redEnvelopeLogic.create(num, price, inspectId, TYPE_AUDITOR, startTime);
redEnvelopeLogic.sendNoticeToIM(redEnvelopeDAO.queryLatestRedEnvelope(), CelebrationConstants.Hdf_Celebration_Bomb);
}finally {
redisLocker.unlock(redisLock);
}
return RpcResponse.success("创建成功");


另外这里,为了防止MySQL的主从延迟导致调用上面的第3行redEnvelopeLogic.isExistDoingRedEnvelope()这个方法得到错误的结果,对于此查询强制走主库。

image-20210116152937652

image-20210116152956577

  1. 深入优化创建红包逻辑,如下图3所示
image-20210116155917787
图3-创建红包的核心逻辑修改后的diff

这里改动逻辑是:

  • 创建红包记录,要关注其返回值,确认创建成功了,才继续进行后续的创建子红包任务、创建自动完成延迟任务、创建倒计时延迟任务;

  • 创建红包成功后,这里将红包id缓存了一份在Redis中。用于记录最新的正在进行中的红包id。这里缓存此id其实是为了后续优化用户端调用的接口查询,将在后续讲解到解决IM推送不及时的问题时提到其用途;

  • 红包炸开后15秒自动完成以及红包炸开前1分钟倒计时,原本是使用延迟MQ实现,这里改使用ScheduledThreadPoolExecutor实现了。因为这个延迟MQ需要随机计算出一个从创建红包的时刻到目标时刻的延迟时间,每次创建都有可能产生一个延迟MQ队列,受限于公司的MQ架构延迟队列数量限制,此方法不可取。所以这里使用了ScheduledThreadPoolExecutor来实现。

    具体封装如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Component
    public class ScheduledThreadPool {
    private ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
    @PostConstruct
    public void init() {
    int coreSize = Runtime.getRuntime().availableProcessors();
    scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(coreSize
    , Executors.defaultThreadFactory()
    , new ThreadPoolExecutor.CallerRunsPolicy());
    }
    /**
    * 延迟调用
    *
    * @param runnable runnable
    * @param time 秒数
    */
    public void delayCall(Runnable runnable, long time) {
    scheduledThreadPoolExecutor.schedule(runnable, time, TimeUnit.SECONDS);
    }
    @PreDestroy
    public void destroy(){
    scheduledThreadPoolExecutor.shutdown();
    }
    }

    因为在这个业务可以提前确认只会有1分钟倒计时15秒自动完成这两个延迟任务,所以对于这个ScheduledThreadPoolExecutor的参数配置,并无太多考究。可以用即可。

    但是ScheduledThreadPoolExecutor也存在一个严重的问题,那就是这不是一个分布式的调度器,也不具备持久化任务的能力,所以在服务重启等特殊情况下,延迟任务可能丢失。具体使用一定要看场景。

    比如,在红包炸弹里,15s自动完成的延迟任务是这样使用ScheduledThreadPoolExecutor的:

    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
    private void setAutoComplete(RedEnvelopeDO redEnvelopeDO) {
    long now = HaoDate.currentTimeSecond();
    long delay = redEnvelopeDO.getTime().getTimeSecond() - now + Long.parseLong(cloudCountDown) + 5;
    if (delay <= 0) {
    delay = 0;
    }
    RedEnvelopeMessage msg = new RedEnvelopeMessage();
    msg.setRedEnvelopeId(redEnvelopeDO.getId());
    //存redis标记,表示又一个异步任务待执行
    redisClient.set(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG, redEnvelopeDO.getId().toString());
    scheduledThreadPool.delayCall(() -> {
    this.autoComplete(msg);
    //清redis标记,表示该异步任务执行完成了
    redisClient.delete(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG);
    }, delay);
    }

    @PostConstruct
    private void init(){
    ...
    //查询异步任务标记是否存在,存在说明上一次任务没有执行完,bean就被销毁了,重新拉起调度
    String redEnvelopeIdStr = redisClient.get(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG);
    if (StringUtils.isNotEmpty(redEnvelopeIdStr) && NumberUtils.isNumber(redEnvelopeIdStr)) {
    long redEnvelopeId = Long.parseLong(redEnvelopeIdStr);
    if (redEnvelopeId > 0) {
    RedEnvelopeDO redEnvelope = redEnvelopeDAO.findById(redEnvelopeId);
    if (Objects.nonNull(redEnvelope) && redEnvelope.getCompleteTime().isZeroTime()) {
    //需要重新拉起任务
    this.setAutoComplete(redEnvelope);
    }
    }
    }
    }

    如代码所示,在将延迟任务丢给ScheduledThreadPoolExecutor前,将红包id缓存到了用于表示未完成的任务标志redis key中,在延迟任务调度完成后,删除缓存key。这样在服务重启后,当bean被初始化时,回调到init方法,此时再次检查redis中是否还有记录有未完成的红包id。有的话,就让他重新进延迟。说到底,这是一个兜底的方案,因为真有高并发的抢红包状况发生的话,一定在红包炸开后很快就被抢完,用户的动作会主动触发置红包完成。

  1. 再回到图3中改动后的178行,看下创建子红包的改动:

    image-20210116204754728

    图4-子红包的创建与子红包缓存

    如上图中的diff所示,主要改动的是关注了子红包创建的返回值,只有当子红包创建成功的时候,才会向Redis中维护子红包id的List进行Rpush。可以说这里非常重要,如果无法维护子红包和缓存中的子红包List的数据一致性,则可能出现以下两种情况:

    • 某一子红包写库成功了,但是写入redis的失败了:这种情况还好,因为抢红包不可能抢到没有写入库的子红包,不会出现超抢
    • 某一子红包写库失败了,但是写入redis的成功了:这种情况就会出现问题,因为可能抢到的子红包id,在子红包表中并不存在,如果抢红包的接口逻辑不足够健壮,可能导致用户虚抢一场

    最后,可能大家会发现,这里并没有使用事务来批量创建数据。原因是,这里可以不用考虑事务,因为创建丢失某个子红包也无伤大雅,顶多就是用户抢不到这个红包了,可以接受。

五、深入优化抢红包接口

在第四节里,我简单介绍了下创建红包的接口做了哪些小优化,因为创建接口并不难,所以对此接口也没有太高的性能要求,只要保证接口是好使的,子红包缓存是可靠的即可。接下来的优化抢红包接口才是重头戏。

image-20210116235601835

图5-创建红包后的存储模型

如上图5所示,在创建红包成功后,数据的存储模型应该是这样的。缓存中既存入了正在进行中的红包id,也存如了子红包的id所组成的List。

所以抢红包要做的事情很简单:

  1. 基本的条件校验,不满足则不能抢
  2. 尝试从子红包id列表缓存中Lpop一个子红包id出来,如果pop不出来数据了,说明红包被抢完了
  3. Lpop出有效的红包后,更新子红包状态,创建一条红包明细记录

看下整体代码优化Diff如下:

image-20210117000316891

image-20210117000340251

image-20210117000427294

这里具体说下在抢红包接口中都做了哪些优化:

  1. 首先是在原来的基础校验逻辑上补充了判断,当红包已经完成后,就直接返回,不需要在走后续的代码逻辑了。这里需要说明的是原来的表结构对于状态位的设计是太合理的。红包的状态目前是需要根据statuscomplete这两个字段共同判断的,但是由于项目已上线,红包表并非只有红包炸弹这个模块在使用,无法更改了,只好对两个字段都进行考虑了;

  2. 然后是对于判断用户是否报名了,原来是直接sql查询报名表。现在在这个applyUserLogic.getApplyUserByUserId(userId)方法上维护了一层缓存,处理逻辑如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public ApplyUserDO getApplyUserByUserId(Long userId) {
    ApplyUserDO applyUser = null;
    String redisKey = CELEBRATION + GET_APPLY_USER_BY_USER_ID + userId;
    String cache = cacheUtil.getCache(redisKey);
    if (Objects.equals(NULL,cache)){
    //这里是刻意缓存了NULL,表示不用再次查库了,查库也是null,以防止恶意缓存穿透,从而保护DB
    return null;
    }
    if (StringUtils.isNotEmpty(cache)){
    applyUser = JsonUtils.toObject(cache, ApplyUserDO.class);
    }
    if (Objects.nonNull(applyUser)){
    return applyUser;
    }
    applyUser = applyUserDAO.findApplyUserByUserIdAndStatus(userId, ApplyUserConstants.STATUS_DONE);
    if (Objects.nonNull(applyUser)){
    //随机缓存5到10分钟,防止缓存大面积失效导致缓存雪崩
    cacheUtil.setCache(redisKey, JsonUtils.toJson(applyUser), (long) RandomUtils.randomInt(MINUTE_5,MINUTE_10));
    }else{
    //如果不存在,缓存一个NULL,防止恶意的缓存穿透
    cacheUtil.setCache(redisKey, NULL,(long) RandomUtils.randomInt(MINUTE_5,MINUTE_10));
    }
    return applyUser;
    }

    如上面的代码所示,这里编程式地维护了一层缓存,并且考虑到了缓存穿透和缓存雪崩。另外在新增报名记录、报名状态变更时对缓存进行了失效处理,这里就不贴代码了。

  3. 再往后面,原来通过sql查询,分别先后判断了此用户是否已经抢过当前红包了、一天内是否已经成功抢了三次红包。这里优化为一处缓存查询就可以了。如下面的代码所示,getGrabSuccessEnvelopeIds方法的作用就是获取此用户在今天所有抢到的红包id所组成的List。所以可以将代码逻辑优化为先判断是否超过次数,再判断是否已经抢过此红包。将两次sql查询,转换为一次Redis IO。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    List<Long> grabSuccessEnvelopeIds = getGrabSuccessEnvelopeIds(userId);
    //判断是否超过次数
    if (grabSuccessEnvelopeIds.size() >= overLimit){
    grabRedEnvelopeInfoVO.setType(OVER_GAIN.getType());
    return RpcResponse.success(grabRedEnvelopeInfoVO);
    }
    //判断是否已经抢过红包,这里不可能进来昨天的红包id,前面就拦截了
    if (grabSuccessEnvelopeIds.contains(redEnvelopeId)){
    grabRedEnvelopeInfoVO.setType(ALREADY_GAIN.getType());
    return RpcResponse.success(grabRedEnvelopeInfoVO);
    }

    /**
    * 获取今日已抢成功的红包id列表
    * @param userId 用户id
    * @return 已抢成功的红包id列表
    */
    private List<Long> getGrabSuccessEnvelopeIds(long userId){
    String todayDateString = new HaoDate().dateString();
    List<String> envelopeIdStrList = redisClient.lrange(ONE_DAY_GRAB + todayDateString + userId, 0, -1);
    return envelopeIdStrList.stream().map(Long::parseLong).collect(Collectors.toList());
    }

    需要注意的是,这里没有用HASH来存储此缓存是因为目前公司的框架对于Redis提供的Hash相关的api实在难用之际,还不如使用List来实现。getGrabSuccessEnvelopeIds方法中使用了redis的LRANGE操作,这是一个时间复杂度为O(N)的操作,一般情况要慎用。但是这里这处缓存,我们明确可以知道list最大长度为3。因为一条最多能抢成功三次红包。所以LRANGE的时间复杂度对接口的影响可以忽略。

  4. 再往后,我优化了Redis锁的使用。

    • 首先是把原本的tryLock设定的5秒超时时间置0了。之所以这样做是因为,在当前这样的高并发需求下,我们需要接口能够处理更多的请求,但是机器可以开启的线程数是有上限的,所以我们不能让线程在获取分布式锁这件事情上自旋等待超时了,正确的做法应该是拿不到锁就立即返回,把线程资源释放出来给别的请求使用。
    • 然后是调整了锁的位置。原来的锁位置台靠前了。获取锁后,还有各种条件判断,在返回语句中还要释放锁。万一中间某一步有异常抛出,这么操作的无法保证锁真的可以及时释放。分布式锁应该锁住的是最核心的需要处理并发的代码块。
  5. 获取到锁后,在原先的实现中会先从redis中弹出子红包id,而后又llen查询了一遍子红包id缓存的List长度,如果为空的就将红包置完成。这里的实现又有两个问题被优化:

    1. 尝试LPOP一个子红包id,原逻辑中通过try…catch…来区分是不是取到了非正常的子红包id,如果出现异常,则认为子红包已经抢完了。这里完全不需要这么搞,完全可以避免此处异常的判断。
    2. 主动LLEN查看子红包idList的长度,来置红包状态完成。这也是一个不必要且拉低接口性能的操作。首先完全可以由用户在LPOP为空时置完成,其次就又回到了LLEN的时间复杂度问题,List的长度越大,LLEN的性能越低,再List没有取空的时候,做这么多LLEN操作完全是在浪费时间。这一次IO完全是可以省去的。

    优化代码如下,只需要一次IO操作进行一个时间复杂度为O(1)的LPOP操作即可:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    String redEnvelopeItemIdStr = redisClient.lpop(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId());
    //如果不是一个数字,说明取空了,置红包状态即可
    if (!NumberUtils.isNumber(redEnvelopeItemIdStr)) {
    setRedEnvelopeComplete(redEnvelopeId);
    grabRedEnvelopeInfoVO.setType(NO_GAIN.getType());
    //将缓存中记录的正在进行的红包id值清0
    redEnvelopeLogic.removeDoingRedEnvelopeIdCache();
    return RpcResponse.success(grabRedEnvelopeInfoVO);
    }
  6. 再往后这一处优化,我觉得都不能算优化吧。应该是意识问题了,原逻辑这么写的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(redEnvelopeItemId);
    //这里都不需要对redEnvelopeItemDO判个空么?
    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());
    ...
    return RpcResponse.success(grabRedEnvelopeInfoVO);

    这里有什么问题,问题就出在存在多处不严谨:

    1. findById后返回的redEnvelopeItemDO对象竟然没判空。如果说创建红包时,能够确认存子红包到数据库成功后再Rpush到子红包id缓存List,我觉得都没有问题。问题就是,创建红包时,并没有关注新增子红包后的返回值就直接写Redis了。这种情况下,很有可能在redis中写入了一个不存在的红包id。
    2. 如果去除了一个不存在的红包id,好吧,也罢。事务中肯定会报NPE,事务一定会失败。但为何后续也不关注事务的返回结果就直接设置接口响应的VO,标记已经抢红包成功了,并返回了结果???这将会直接导致用户端展示为抢红包成功了,但根本看不到自己抢了多少钱…

    优化后如下,具体关键步骤逻辑如注释描述:

    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
    RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(Long.parseLong(redEnvelopeItemIdStr));
    //先对redEnvelopeItemDO判空,不为空,说明子红包表才是真的存在这么一个红包
    if (Objects.isNull(redEnvelopeItemDO)) {
    grabRedEnvelopeInfoVO.setType(NO_GAIN.getType());
    return RpcResponse.success(grabRedEnvelopeInfoVO);
    }
    //关注事务的返回结果
    boolean transaction = jdbcDAO.withTransaction(() -> {
    RedEnvelopeRefDO redEnvelopeRefDO = new RedEnvelopeRefDO();
    redEnvelopeRefDO.setPrice(redEnvelopeItemDO.getPrice());
    redEnvelopeRefDO.setType(TYPE_AUDITOR);
    redEnvelopeRefDO.setRedEnvelopeItemId(redEnvelopeItemDO.getId());
    redEnvelopeRefDO.setApplyUserId(applyUser.getId());
    redEnvelopeRefDO.setUserId(applyUser.getUserId());
    int saveRes = redEnvelopeRefDAO.save(redEnvelopeRefDO);
    //保存完红包明细记录后,也要关注返回结果,如果不成功,需要主动抛出异常
    if (!Objects.equals(CONST_1, saveRes)) {
    //如果没保存ref成功,抛异常使事务失败
    throw new RuntimeException("创建红包明细失败~");
    }
    redEnvelopeItemDO.setStatus(ITEM_STATUS_DONE);
    int updateRes = redEnvelopeItemDAO.update(redEnvelopeItemDO);
    //更新子红包状态不成功,也需要主动抛出异常
    if (!Objects.equals(CONST_1, updateRes)) {
    //如果更新红包项状态失败,抛异常使事务失败
    throw new RuntimeException("更新红包项状态失败~");
    }
    });
    if (!transaction) {
    //事务失败了,那就是抢红包失败了,此时子红包缓存list中此id被消耗掉了也无所谓
    grabRedEnvelopeInfoVO.setType(NO_GAIN.getType());
    return RpcResponse.success(grabRedEnvelopeInfoVO);
    }
    //将此已经抢成功的红包id记录到缓存,用于下一次接口请求进来判断,今天是否已经抢成功三次了,当前红包是否已经抢过了
    recordGrabSuccess(userId, redEnvelopeId);
    grabRedEnvelopeInfoVO.setGain(true);
    grabRedEnvelopeInfoVO.setType(GAIN.getType());
    grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice());

    至此,对与抢红包这个接口就算时基本优化完成了。目前从整体接口逻辑来看,我们做了的优化如下:

业务逻辑优化前优化后好处
查询是否报名成功SQL查询报名表查询缓存性能提升
查询一天成功抢红包的次数SQL查询查询一次缓存性能提升
查询是否已经抢过了当前红包SQL查询利用上面的缓存查询结果即可性能提升
获取一个子红包idLPOPLPOP(不变)不变
查询子红包Id缓存List的剩余长度LLEN省略此次查询性能提升
分布式锁锁位置不对,不一定能释放,锁带有超时时间,线程自旋获取锁,影响接口吞吐量优化锁位置,不自旋等待,获取不到锁直接返回性能提升
记录红包明细,更细子红包状态子红包在表中和缓存中数据可能不一致,并且没有关心事务结果保证子红包在表中和缓存的一致性,关心事务结果可靠性提升

经过这么一番优化后,结果的性能提升是可以预估的。几乎所有的读操作全都打到了缓存上。而真正能写次数最多只有红包设定的子红包个数次。

六、总结

​ 这样就算优化完了吗?当然还没有!对于高并发的处理,接口仅仅是比较重要的一步罢了。在后续的文章中,我将继续从其他角度对此项目进行优化。