库存超卖的核心防线是“缓存原子扣减 + 分布式锁防并发 + 数据库乐观锁兜底”,三者各管一层,缺一不可,共同把并发写操作压成串行,同时保证最终数据能对上账。
下面我们直接拆开讲,这套体系在真实业务场景里是怎么“各司其职”的。
缓存层兜底:用原子操作扛住第一波流量
绝大多数秒杀或大促场景,流量打到数据库基本就躺平了,行业通用做法是把库存预热到Redis,让大部分请求在缓存层就完成扣减判断,这里的关键不是“用Redis存库存”,而是扣减动作必须自带原子性。
为什么不能用“先查再扣”的常规逻辑
不少团队在早期会写类似“GET库存 -> 判断大于0 -> DECR库存”的代码,这个思路在低并发下没问题,一旦流量上来,两个请求同时读到库存为1,同时判断“大于0”,同时执行DECR,超卖就发生了,问题不在Redis,在于你的逻辑把“读”和“写”分开了。
真正兜底的是“一条命令走完扣减流程”
目前最稳妥的方案是使用Lua脚本,把“检查库存”“扣减库存”“返回结果”这几步打包成一个原子操作提交给Redis执行,Redis本身是单线程处理命令,Lua脚本执行期间不会插入其他指令,这意味着你不需要加分布式锁,也能保证同一时刻只有一个请求在修改这个key。
实操上,脚本逻辑就是判断当前库存是否大于0,大于则扣减并返回成功,小于或等于则直接返回失败,这个方案能扛住每秒上万次的扣减请求,基本无性能损耗。
缓存层兜不住的两个“隐形坑”
第一个坑是库存预热时必须做超卖保护,如果你在初始化Redis库存之前,数据库里已经没有库存,那缓存扣减就毫无意义,前置条件是先冻结数据库库存,再写入Redis。
第二个坑是缓存和数据库的数值同步,Redis扣减的只是“热数据”,最终账目要落回数据库,常见的做法是异步同步,但如果同步失败且没有补偿机制,就会出现“Redis说卖了100件,数据库只扣了90件”的不一致,缓存层兜底能防并发,但防不住数据丢失,所以必须有下面两层配合。
分布式锁兜底:给并行扣减加一道“单行通道”
有些业务场景不允许用Lua脚本,比如扣减逻辑里还涉及其他操作,或者你用的是纯内存扣减方案,这时候就得引入分布式锁,把“并发”强行改成“串行”。
不要自己写锁,用成熟实现
网上流传的“SETNX加锁、DEL解锁”方案是早期做法,存在两个致命问题:锁没有过期时间,进程崩溃直接死锁;删锁时可能误删别人的锁,现在业内普遍使用Redisson这类成熟客户端,它内置了看门狗机制,可以自动续期,还支持锁的可重入。
锁的最小粒度是“SKU-ID”,不是“全店”
一个常见误区是给所有商品共享一把大锁,锁整个店铺的库存服务”,那同一个店铺的不同商品互相阻塞,吞吐直接废掉,正确的做法是锁的粒度细化到SKU维度,每个商品的库存扣减互不干扰。
锁的兜底意义在于:即使Lua脚本用了,或者你想在代码里做“先查再扣”的复杂逻辑,锁也能保证这段代码同一时间只有一个线程在跑,但从实际效果来看,锁方案的性能上限远低于Redis原子操作,所以更多时候它被用在非热点商品或后台管理系统手动物料调整的场景里。
锁方案最怕“锁超时释放”
如果业务代码执行时间比锁的过期时间还长,锁自动释放后,另一个线程进来了,前一个线程的扣减逻辑还没跑完,一样会出问题,行业共识是:锁的过期时间必须大于业务最大执行时长,且要配合看门狗或手动续期,这也是为什么建议优先用Lua脚本,而不是依赖分布式锁。
数据库乐观锁兜底:最后一层基石,稳到不能再稳
不管缓存层和锁层怎么折腾,最终库存的准确值必须落在数据库里,而数据库层的防超卖,靠的是乐观锁或者条件更新的原子性。
最常用的“扣减条件”写法
数据库防超卖的核心SQL很简单,就是加上“库存大于等于购买数量”这个条件:
UPDATE sku_stock SET stock = stock - #{count}
WHERE sku_id = #{skuId} AND stock >= #{count};
这条SQL执行后,返回值是受影响的行数,如果等于1,说明扣减成功;如果等于0,说明库存不足或已被其他事务扣完,直接判定为超卖失败。
这个方案的精髓在于“stock >= 0”这个判断被集成到了UPDATE语句里,数据库的行锁保证了同一行数据在事务提交前其他更新都会被阻塞,所以不需要额外加锁也能保证不超卖。
乐观锁和悲观锁怎么选
秒杀场景下,行锁竞争激烈,悲观锁(SELECT FOR UPDATE)会让大量线程阻塞,整体吞吐很难看,所以绝大多数互联网业务选乐观锁,也就是上面的那种无条件更新,但乐观锁在极端高并发时会有大量更新失败的情况,这正好和缓存层的原子扣减配合:缓存已经帮你拦掉了大部分流量,数据库层承受的并发量本来就不大了。
要特别注意“更新失败后的事务回滚”
很多超卖问题的根因不是SQL不对,而是执行UPDATE之后,后续代码发生异常,事务没有回滚,要确保在事务边界内调用库存更新,一旦后续业务(比如创建订单)失败,整个事务连同库存扣减一起回滚,实践上建议把库存更新放在事务的最后一步,尽可能缩短行锁的持有时间。
数据库兜底的“最终屏障”
有朋友会问,既然数据库这么稳,为什么还要缓存?答案是性能,数据库每秒能支撑的更新事务量有限,大促流量是它的几十倍,如果缓存和锁层都失效了,数据库的条件更新依然能保证不超卖,这是“保底中的保底”。
用异步对账和幂等来兜“最终一致性”
有些订单场景,用户下单后支付,支付回调扣库存,这个链路里,库存扣减和订单状态写入不是同一个时刻完成的,甚至可能跨系统,这就涉及最终一致性的兜底工程。
用“先扣库存,再创建订单”或反向操作
订单和库存的相对顺序在行业内大致分两种做法,一种是把库存当作下单前置条件,先扣库存,创建订单失败再回补库存,另一种是先创建订单,再尝试扣库存,扣减失败则自动取消订单,前者更贴近用户侧下单体验,后者更容易做售后流程,不管哪种,都要配合延迟消息去检查订单和库存的最终状态。
对账机制是最后一道“人工护栏”
即使你用了上面提到的所有技术,仍需一个定时任务做“库存对账”,比如每分钟扫描一次:Redis库存 + 冻结库存 = 数据库总库存,订单中心已支付未回滚的订单数 + 当前库存 = 初始库存,一旦不等,就触发警报,甚至自动执行回补脚本,据行业共识,绝大多数严重超卖问题都是被对账任务先发现,而不是用户投诉。
幂等表兜底重复扣减
支付回调可能因为网络超时被重复推送,如果同一个订单号扣了两次库存,也会造成实际超卖,应对方案是在库存流水表上增加唯一索引(订单号+SKU_ID),重复插入直接失败,从源头挡住。
实际业务中的选型建议:库存扣减方案怎么选
不同阶段的业务,技术选型差别很大,不要照搬大厂方案。
- 日订单量千级以内:直接走数据库条件更新,配合事务,不需要引入Redis,成本最低。
- 日订单量万级到十万级:引入Redis预热库存,使用Lua脚本原子扣减,异步同步数据库。
- 十亿级秒杀大促场景:Redis原子操作 + 分布式锁(只锁热点SKU) + 数据库乐观锁 + 对账补偿四层全上,任何一层挂掉都有下层兜底。
关于订单库存一致性怎么做的争论,其实没有标准答案,核心思路就是层间互备、失败可恢复。
Q&A:防超卖相关问题速答
问:商品库存怎么防超卖最省钱且有效?
直接使用数据库的“条件UPDATE扣减库存”,即UPDATE sku SET stock = stock - 1 WHERE id = ? AND stock > 0,配合事务处理,这套方案不需要额外中间件,足够解决绝大部分中小商家的超卖问题。
问:Redis缓存库存和数据库库存不一致怎么办?
构建一个定时对账任务,周期性对比Redis剩余库存加上冻结数量与数据库剩余库存,发现差异时以数据库为准进行校正,并回滚异常的Redis库存值,这套机制也是最终一致性的核心保障。
问:秒杀场景下分布式锁和Lua脚本选哪个?
优先选Lua脚本,处理速度更快,没有锁竞争开销,仅有当扣减逻辑涉及多个缓存key的复杂事务时,才需要用分布式锁来保证整体原子性,如Redisson实现。
技术兜底从来不是靠某一招制胜,而是每一层都在做自己那部分“防呆”,最后用对账把问题暴露出来并修掉。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/636976.html





