0.1 原子操作
0.2 令牌库存
除了这种用数值记录库存的方式外,还有一种比较科学的方式就是“发令牌”方式,通过这个方式可以避免出现之前因为抢库存而让库存出现负数的情况。
具体是使用 Redis 中的 list 保存多张令牌来代表库存,一张令牌就是一个库存,用户抢库存时拿到令牌的用户可以继续支付:
//放入三个库存
redis> lpush prod_1475_stock_queue_1 stock_1
redis> lpush prod_1475_stock_queue_1 stock_2
redis> lpush prod_1475_stock_queue_1 stock_3
//取出一个,超过0.5秒没有返回,那么抢库存失败
redis> brpop prod_1475_stock_queue_1 0.5
在没有库存后,用户只会拿到 nil。当然这个实现方式只是解决抢库存失败后不用再补偿库存的问题,在我们对业务代码异常处理不完善时仍会出现丢库存情况。
同时,我们要注意 brpop 可以从 list 队列“右侧”中拿出一个令牌,如果不需要阻塞等待的话,使用 rpop 压测性能会更好一些。
不过,当我们的库存成千上万的时候,可能不太适合使用令牌方式去做,因为我们需要往 list 中推送 1 万个令牌才能正常工作来表示库存。如果有 10 万个库存就需要连续插入 10 万个字符串到 list 当中,入库期间会让 Redis 出现大量卡顿。
到这里,关于库存的设计看起来已经很完美了,不过请你想一想,如果产品侧提出“一个商品可以抢多个库存”这样的要求,也就是一次秒杀多个同种商品(比如一次秒杀两袋大米),我们利用多个锁降低锁争抢的方案还能满足吗?
0.3 多库存秒杀
其实这种情况经常出现,这让我们对之前的优化有了更多的想法。对于一次秒杀多个库存,我们的设计需要做一些调整。

之前我们为了减少锁冲突把库存拆成 10 个 key 随机获取,我们设想一下,当库存剩余最后几个商品时,极端情况下要想秒杀三件商品(如上图),我们需要尝试所有的库存 key,然后在尝试 10 个 key 后最终只拿到了两个商品库存,那么这时候我们是拒绝用户下单,还是返还库存呢?
这其实就要看产品的设计了,同时我们也需要加一个检测:如果商品卖完了就不要再尝试拿 10 个库存 key 了,毕竟没库存后一次请求刷 10 次 Redis,对 Redis 的服务压力很大(Redis O(1) 指令性能理论可以达到 10w OPS,一次请求刷 10 次,那么理想情况下抢库存接口性能为 1W QPS,压测后建议按实测性能 70% 漏斗式限流)。
这时候你应该发现了,在“一个商品可以抢多个库存”这个场景下,拆分并没有减少锁争抢次数,同时还加大了维护难度。当库存越来越少的时候,抢购越往后性能表现越差,这个设计已经不符合我们设计的初衷(由业务需求造成我们底层设计不合适的情况经常会碰到,这需要我们在设计之初,多挖一挖产品具体的需求)。
那该怎么办呢?我们不妨将 10 个 key 合并成 1 个,改用 rpop 实现多个库存扣减,但库存不够三个只有两个的情况,仍需要让产品给个建议看看是否继续交易,同时在开始的时候用 LLEN(O(1))指令检查一下我们的 List 里面是否有足够的库存供我们 rpop,以下是这次讨论的最终设计:
//取之前看一眼库存是否空了,空了不继续了(llen O(1))
redis> llen prod_1475_stock_queue
3
//取出库存3个,实际抢到俩
redis> rpop prod_1475_stock_queue 3
"stock_1"
"stock_2"
//产品说数量不够,不允许继续交易,将库存返还
redis> lpush prod_1475_stock_queue stock_1
redis> lpush prod_1475_stock_queue stock_2
通过这个设计,我们已经大大降低了下单系统锁争抢压力。要知道,Redis 是一个性能很好的缓存服务,其 O(1) 类复杂度的指令在使用长链接的情况下多线程压测,5.0 版本的 Redis 就能够跑到 10w OPS,而 6.0 版本的网络性能会更好。
这种利用 Redis 原子操作减少锁冲突的方式,对各个语言来说是通用且简单的。不过你要注意,不要把 Redis 服务和复杂业务逻辑混用,否则会影响我们的库存接口效率。
0.4 自旋互斥超时锁
如果我们在库存争抢时需要操作多个决策 key 才能够完成争抢,那么原子这种方式是不适合的。因为原子操作的粒度过小,无法做到事务性地维持多个数据的 ACID。
这种多步操作,适合用自旋互斥锁的方式去实现,但流量大的时候不推荐这个方式,因为它的核心在于如果我们要保证用户的体验,我们需要逻辑代码多次循环抢锁,直到拿到锁为止,如下:
//业务逻辑需要循环抢锁,如循环10次,每次sleep 10ms,10次失败后返回失败给用户
//获取锁后设置超时时间,防止进程崩溃后没有释放锁导致问题
//如果获取锁失败会返回nil
redis> set prod_1475_stock_lock EX 60 NX
OK
//抢锁成功,扣减库存
redis> rpop prod_1475_stock_queue 1
"stock_1"
//扣减数字库存,用于展示
redis> decr prod_1475_stock_1
3
// 释放锁
redis> del prod_1475_stock_lock
这种方式的缺点在于,在抢锁阶段如果排队抢的线程越多,等待时间就越长,并且由于多线程一起循环 check 的缘故,在高并发期间 Redis 的压力会非常大,如果有 100 人下单,那么有 100 个线程每隔 10ms 就会 check 一次。
0.5 CAS 乐观锁:锁操作后置
除此之外我再推荐一个实现方式:CAS 乐观锁。相对于自旋互斥锁来说,它在并发争抢库存线程少的时候效率会更好。通常,我们用锁的实现方式是先抢锁,然后,再对数据进行操作。这个方式需要先抢到锁才能继续,而抢锁是有性能损耗的,即使没有其他线程抢锁,这个消耗仍旧存在。
CAS 乐观锁的核心实现为:记录或监控当前库存信息或版本号,对数据进行预操作。
//开启事务
redis> multi
OK
// watch 修改值
// 在exec期间如果出现其他线程修改,那么会自动失败回滚执行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1
//事务内对数据进行操作
redis> rpop prod_1475_stock_queue 1
QUEUED
//操作步骤2
redis> decr prod_1475_stock_1
QUEUED
//执行之前所有操作步骤
//multi 期间 watch有数值有变化则会回滚
redis> exec
3
如果 Redis 是 Cluster 模式,使用 multi 时必须在一个 slot 内才能保证原子性。
0.6 Redis Lua 方式实现 Redis 锁

1 课后留言
课后题:
- 请你思考一下,通过原子操作 + 拆开库存方式实现库存方案时,如何减少库存为 0 后接口缓慢的问题?
面试原题,请指教:既然你们减库存用了 redis,那如果 redis 挂了怎么办
作者回复: 你好,目前为止我了解到的情况是,首先数据层有故障基本都是有损的,只是数据多少的问题。如线上数据层出现故障很多基础服务是不会马上就切换的,因为 redis 以及类似的数据服务都会有个探测过程,以防止只是一时业务查询导致的卡顿导致误判,像 redis 使用 keepalive 或哨兵时,也是通过多次 ping 检测来判断服务是否真正失联,多次延迟确认后才会切换主从,但是这样过程探测过程会过去五分钟以上,所以这个问题严谨一些的说他想问你能做哪些措施预防服务损坏,而不是让我们去讲完美无缺的提供服务强一致,秒杀肯定是要保证库存不要出错的,所以故障了多副本保证切换后数据十分精准的要求是无法满足的,行业出现这个情况是立即停止秒杀活动或者降级服务然后将请求放入队列后做相应补救。另外 redis mysql 等如果对一致性要求高的话,需要做的代价就特别大,每次切换主后从库都是需要同步主库所有数据进度,想要做好这里需要有性能很好的两个从库一起同步数据。所以做好前期流量预测,做好压测,预备措施可以准备一些但是多少都会丢一点
请教个问题,全文讲的都是扣减库存的安全性和性能,但是秒杀系统经常会面临一些爬虫,在活动开始瞬间大量的爬虫请求导致库存被扣减完毕(这些些请求都的 ip 都是经过伪装的),导致真实用户无法购买到商品,面对这种情况老师有没有一些好的解决方案~
作者回复: 你好,这里可以通过 js 对单次请求进行加密签名,并且保证每次秒杀接口入口流程和校验方式每个活动不同,这样能预防对方写一次脚本刷全站,同时这样他的爬虫必须使用我们的脚本计算才能正确请求,提高爬虫的门槛,这样对方就需要可编程浏览器才能模拟用户来抢,这个代价就会大很多,另外要求必须真实绑定手机号用户才可以刷,限制单个用户指定时间内并发请求量,限制一个用户购买量,有未成交的不允许再下单,不用数字代表商品 id,让商品标识非数字连贯等多个组合不断提高技术要求和门槛来降低他们抢的性价比
问题 1:秒杀秒杀肯定是很快就没库存了,只要分片够均匀,在一个分片查不到就返回‘没有库存’或者‘参与用户太多,稍后再试‘呗,如果是要持续几分钟的才能抢完的,这种级别的流量一般也不用分片
作者回复: 你好,这确实是一个知识点