前言

        近期组员接手了一个领券的业务,涉及到了对券批次库存的扣减操作,在多次尝试优化后压测起来仍有一些性能问题,由于接近deadline,于是自己也尝试上手优化了一下。让我对日常在论坛看到的redis秒杀库存的实现有了进一步的认知。

领券逻辑

        首先,先简单介绍下领券的逻辑,当然中间有一些业务定制化的细节我做了简化。

         这时候,为了保证券库存不会超领,我们接手的第一版里,逻辑是直接对券库存加了一把redis锁,这样确实是保证库存不会超领,但是由于线上更多的场景是多用户并发对一种券的领取,在压测这种场景的时候,tps只达到20,90线达到2s以上,显然不满足实际需求。

(第一版中直接锁库存ID,导致并发领同一种券时变成串行请求) 

Redis锁优化思路

        我们在网上经常看到的利用redis去做秒杀业务时,是利用redis的单线程特性,将库存存储在redis中,然后用incr方法,保证库存在并发扣减情况下,不会超扣。

        但是这里涉及到几个问题:

  1. 什么时候将库存存入redis,存永久还是定义一个有效期?
  2. 什么时候让库存数据实际落库,扣减到0的时候,还是定时同步? 如果业务需要知道库存要不足了,去做补货操作怎么办。
  3. 库存如果永久存在redis中,然后如果是扣减到0的时候才进行删除,再同步数据库,那么如果一直没有扣减完怎么办,是不是会一直占用着redis,而且管理台看到的库存也一直没有被扣减?

        这些问题,相关博客里并没有明说,我们只知道我们要从redis扣,但不知道什么放redis,什么时候同步到数据库,这些都是不清楚的,而且相关文章都有点千篇一律,那么只能结合后想一套自己的方案了。

        在查阅相关资料后,我自己采用的方案是,在查询的时候将库存存放到redis,设定一定有效期,因为我们的redis平时也需要做各种缓存,所以如果不设置有效期的话,就会留下一些脏数据。

        这里我们先给出一个lua脚本。

 if (redis.call("exists", KEYS[1])) == 1 then
    redis.call("expire", KEYS[1], ARGV[2])
    return redis.call("incr", KEYS[1], ARGV[1])
 else return nil end

        解释一下这个脚本的意思就是,判断传入key是否存在,如果存在执行incr,不存在返回null。

        为什么要这么做呢?因为我们的redis库存是有有效期的,我们每次去扣减库存的时候,就需要去判断库存数据是否存在,如果存在的话才能够去做incr操作。

        那么为什么不放在Java里进行呢?因为涉及到原子性操作。Java中的执行代码大概如下

if (redisUtil.exist(key)) { //1. 执行时判断到key存在
    // 2.但此时去incr时可能已经不存在
    redisUtil.incr(key, 1); // 3.返回库存剩余值为 1
    redisUtil.expire(key, 300);
}

        那么就可能出现库存被重置的情况。

        所以需要利用lua脚本在redis中执行的原子性,来确保不会发生这种情况(其实lua脚本执行会不会发生这个情况也是需要讨论的,目前没有查阅到相关资料会出现上面java执行的情况,测试中也是没有问题的)。

        这里还有一个点就是需要重新 expire ,为什么需要expire呢,这个我们放后面再讨论。

        重新优化完后,我们的代码逻辑大致如下图:

        

         虽然代码逻辑比上面看起来要复杂得多,不过做到了无锁化的设计,实际上只需要争抢一次redis锁,后续只需要在redis中做扣减即可。

        说明:这里同步数据库库存是直接 update goods set goodsCount = goodsCount - 1 where goodsId = xxx,也就是实时保持了数据库库存做同步的扣减。(这个尽量需要单独开一个事务,并放在大事务中的最后做执行)

        此时压测单机TPS上升到300~400+,90线都在1s以下。

        那么做到这一步,其实就可以交差了。其他的,比如失败回退库存,更新库存时去除库存缓存,这些逻辑就不在本文讨论之列了,可以自行发挥。

       

库存同步优化思路

         下面我们要讨论的就是数据库库存的同步扣减时机问题。注:以下操作没有在生产实践过,仅为思路。

        一般来说,我们翻阅的资料里,做完库存扣减的检查后,后续的比如商品秒杀的生成订单,这类操作,就会交由MQ去做处理,利用MQ的重试机制来保证事务的最终一致性,同时降低数据库压力,大致流程如下。

(当然实际像RocketMQ的事务消息是先发Half-Message,这里这样画是为了方便理解) 

        这里我们首先解答上面的一个问题,我们在incr后需要重新expire,这是为什么呢?因为我们需要考虑一种情况,我们的库存是有时效的,也就是中间可能会有一次请求需要到数据库取,如果当我们去数据库取的时候,而其他线程没有执行到数据库库存的同步扣减,那么就会导致缓存库存与数据库不一致,如下图:

        

         而我们如果在每次做redis incr扣减库存后,重新刷新过期时间,那么就可以保证我们在incr后,有expire的时间来保证我们去对数据库的库存做同步,这里就允许我们用MQ去做数据库的写入。

        而我们后面要做的,就是再把库存同步的时间再往后延迟。为什么呢,假设我们现在更新库存的语句为

        update goods set goodsCount = goodsCount - 1 where goodsId = xxx

        那么我们调1000次,实际上我们要的就是调一次

        update goods set goodsCount = goodsCount - 1000 where goodsId = xxx

        所以我们完全可以将库存更新同步数据库的请求进行累加,这里我们就可以利用MQ,每当有MQ消息扣减库存过来后,我们可以先在本地利用 LongAdder,进行累加,累加到一定值后,再执行对数据库的扣减,以及定期判断 LongAdder 是否 大于 0,如果 大于 0,则从 LongAdder 或 Reids 同步库存然后重置 LongAdder 。这里考虑用 Redis 里的库存是为了防止服务重启等状况的影响,当然如果能够做到优雅停机,那就可以定义destory方法,在destory方法里做。

        这里同时也需要考虑JVM内存问题,这些累加值实际上在Redis上都有,如果存JVM中,是否顶得住也是一个问题,所以也可以接收到MQ消息后,延迟几秒后去同步 Redis 中的库存。

        上述的思路可以大致如下图:

 

结语

        这次接触了这么一个业务,对redis秒杀库存这一套有了更进一步的认识,在此对过程中一些疑问做了总结,但是还是有一些点感觉云里雾里没有真正地去实践弄懂的话,可能还是无法完全掌握。另外就是对其他公司对于这类业务的实现有了更多的好奇心,因为后面大部分处理逻辑都只是自己的思路了,没有经历过生产的检验,所以也很想去了解下别人是怎么做的。

        

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐