电商库存防超卖全链路方案:从数据库锁到Redis+Lua原子扣减
发布时间:2026/10/18 8:09:10 锦皓数字建站

做电商后端这些年库存这块的坑我几乎都踩过一遍。最典型的就是大促期间商品详情页明明显示仅剩3件用户下单、支付都成功了过两个小时却收到一条库存不足订单已取消的短信客服电话直接被打爆。这种事故说白了就是超卖系统在高并发下把同一件库存卖给了多个用户。今天这篇不聊虚的专门把防止超卖这件事从原理到落地彻底讲透——从最底层的数据库锁到目前主流的 Redis Lua 原子扣减再到下单、支付、超时释放的完整库存生命周期最后再加上一道对账兜底全部串成一套可以直接拿去用的方案。适合正在做电商订单、库存模块的后端同学也适合想搞清楚库存到底怎么管才不漏的架构师参考。1. 超卖到底是怎么发生的一个老问题的本质拆解超卖的本质一句话就能说清系统并发执行了多个读库存、扣库存操作但它们之间没有做原子性保护导致实际扣减量超过了真实库存量。举个生活里的例子。电影院只剩最后一张票两个窗口同时卖票售票员A查了一下系统还有1张售票员B也查了一下还有1张。两个人几乎同时把票卖了出去最后系统记账发现卖出2张但座位只有一个。这就是超卖。落到技术层面核心问题出在查剩余库存和扣减库存之间的时间窗口。正常流程是读到剩余库存为 N判断 N 是否大于等于购买数量执行扣减把库存改成 N - 1如果两个并发请求同时完成了第1步都读到了 N1那么它们都能通过第2步的判断然后各自执行第3步——最终库存被扣成了 -1而订单却生成了两笔。哪些场景最容易触发超卖我总结下来基本逃不出这几类秒杀、大促瞬间流量集中单个SKU的并发请求可能冲到每秒上万任何一点先读后写的竞态都会被急剧放大。平时可能几十个并发都没事大促一来全暴露了。用户重复点击或服务端重试用户手快连点了三次提交订单或者服务端调用下游超时后自动重试同一个用户的多笔请求同时到达每一笔都以为自己能买到。多个服务都有扣库存的代码微服务拆分后购物车服务、订单服务、秒杀服务各自维护一套扣减逻辑彼此之间没有统一的锁或状态管理各扣各的想不超都难。缓存与数据库脱节库存提前加载到了 Redis但 Redis 里的剩余值没有及时和数据库同步用户在抢的其实是 Redis 里那份已经过期的数据。明白了超卖的本质和触发场景就能理解一个关键结论业务上必须做到宁可少卖不能超卖。超卖是资金风险和客诉灾难少卖只是流量损失。两害相权任何团队都应该把不超卖作为库存系统的第一原则。2. 数据库锁方案能用但撑不住大促的两板斧先聊最经典的数据库层方案。很多团队的第一版库存系统都是靠数据库锁来防超卖的它逻辑简单、容易理解在低并发场景下完全够用。但如果你指望它扛住大促大概率会翻车。2.1 悲观锁用行锁把并发请求排队悲观锁的思路是既然并发会出问题那就让请求排队。查询库存的时候直接把这一行锁住其他事务只能等它提交后才能继续操作。BEGIN; SELECT stock FROM inventory WHERE sku_id #{skuId} FOR UPDATE; -- 应用层判断 stock 购买数量 -- 如果足够执行扣减 UPDATE inventory SET stock stock - 1 WHERE sku_id #{skuId}; COMMIT;这里有几个必须注意的细节。sku_id必须有索引否则FOR UPDATE会从行锁升级成表锁直接把整张表的写入全堵死。事务要尽量短锁在事务提交或回滚时才释放任何慢查询、远程调用都不能放在这个事务里。悲观锁的优点是正确性有数据库兜底实现简单不容易出错。缺点也很致命并发能力极差。所有对同一SKU的请求都变成了串行大促时一个热门商品的库存行会被上千个事务排队等待数据库连接池很容易被占满整个系统的吞吐量跟着崩。所以我的判断是悲观锁适合后台运营人工改库存、或者低频的管理操作不适合用户侧的购买链路。2.2 乐观锁用版本号代替行锁乐观锁的思路反过来平时不锁更新的时候检查数据有没有被改过。给inventory表加一个version字段每次扣减时带上上次读到的版本号更新成功则版本号1。UPDATE inventory SET stock stock - 1, version version 1 WHERE sku_id #{skuId} AND stock 0 AND version #{oldVersion};执行后看影响行数影响行数为 1扣减成功当前请求拿到库存。影响行数为 0说明版本号已经变了或者库存已经不足需要重新查询再决定是重试还是提示用户。注意这里的AND stock 0是最后一道防线。即使版本号校验因为某些极端情况被绕过库存也不会被扣成负数。重试也不能无脑循环一般最多重试3次每次加一个随机退避比如 sleep 10到50毫秒避免重试风暴把数据库打垮。2.3 为什么光靠数据库撑不住交易链路数据库锁方案在小流量下没问题但在真正的电商交易链路里它有三个绕不过去的坎。第一同一行库存是热点行。一个SKU的库存数据在数据库里就是一行记录所有并发的扣减都集中在这行上行锁竞争极其激烈。即使只是乐观锁的条件更新大量请求同时 UPDATE 同一行也会造成锁等待和死锁检测开销。第二数据库连接资源有限。悲观锁方案里每笔订单都要持有一个数据库连接直到事务结束大促时连接池一旦被打满所有业务都会跟着雪崩。第三引入 Redis 之后数据库已经不是实时库存了。这个很多人容易忽略。当库存被提前加载到 Redis 里用户抢购时扣的是 Redis 里的数字数据库里存的那个 stock 字段已经不代表当前可售数量这时候你再怎么给数据库加锁防的也不是真正的超卖——用户超卖发生在那份 Redis 缓存上。但数据库仍然不能丢。它是整个库存系统的最终账本所有扣减的落库、财务对账、人工修复都以它为准。Redis 负责扛高并发数据库负责记账兜底这才是一个健康的架构。所以下一章才是真正的主链路方案。3. Redis Lua 原子扣减目前最实用的主链路方案说到高并发下的库存扣减目前业界用得最多、也最实用的就是 Redis Lua 脚本。先解释一下为什么是 Redis。Redis 是单线程模型所有命令在服务端是逐个执行的不存在两个命令同时修改同一个 key 的竞态问题。而且它是纯内存操作单机就能扛住每秒几万到十几万的 QPS完美匹配秒杀场景。但有个关键限制单个 Redis 命令是原子的多个命令组合在一起却不是。如果你先GET判断库存够不够再DECRBY扣减这两步之间同样有竞态窗口。正确做法是把判断和扣减写进同一个 Lua 脚本让 Redis 整体执行。3.1 最基础的扣减脚本-- KEYS[1] 库存key例如 inv:sku:10086:stock -- ARGV[1] 本次购买数量 local stock tonumber(redis.call(GET, KEYS[1]) or -1) if stock 0 then return -1 -- key不存在表示未初始化或商品已下线 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 扣减成功调用的方式以典型的 Java 客户端为例String script ……上面的 Lua 脚本……; // keys 是库存 keyargs 是购买数量 Long result redisTemplate.execute( new DefaultRedisScript(script, Long.class), List.of(inv:sku:10086:stock), 1 );返回值的处理逻辑返回1预占成功继续创建订单。返回0库存不足直接提示用户手慢了商品已抢光。返回-1key 不存在需要去数据库拉取最新库存重新初始化或者直接降级到数据库扣减。为什么必须用脚本而不是两条命令因为 Lua 脚本被发送给 Redis 后Redis 在执行整个脚本期间不会处理任何其他命令相当于把判断库存是否足够和扣减库存合并成了一个不可分割的原子操作。并发过来100个请求脚本会逐个执行绝不会出现两个请求同时对同一个剩余值做判断的情况。3.2 key 设计与库存初始化库存相关的 key 我一般按这套设计Key含义示例生命周期inv:sku:{skuId}:stock当前剩余可售库存inv:sku:10086:stock活动期间长期存在inv:sku:{skuId}:sold已售数量inv:sku:10086:sold活动期间长期存在inv:sku:{skuId}:limit每个用户限购数量inv:sku:10086:limit活动期间长期存在注意我推荐直接保存剩余可售库存而不是保存已售数再反推。因为商品详情页频繁需要展示剩余库存直接取一个值比总量减已售少一步计算也少一次命中的不确定性。库存初始化要在商品上架或者活动开始前完成从数据库的库存字段一次性写入 Redis。为了防止多实例并发初始化导致互相覆盖推荐用SETNXBoolean first redisTemplate.opsForValue() .setIfAbsent(inv:sku:10086:stock, String.valueOf(dbStock)); if (Boolean.TRUE.equals(first)) { // 初始化成功 } else { // key 已存在说明已经有服务初始化过了直接使用现有值 }3.3 在脚本里顺便做限购实际业务里光防超卖还不够还得防一个人把库存全买了。限购逻辑也可以塞进同一个 Lua 脚本用 Set 记录已购买用户local stockKey KEYS[1] local boughtKey KEYS[2] local userId ARGV[1] local qty tonumber(ARGV[2]) if redis.call(SISMEMBER, boughtKey, userId) 1 then return 2 -- 该用户已购买过 end local stock tonumber(redis.call(GET, stockKey) or -1) if stock 0 then return -1 end if stock qty then return 0 end redis.call(DECRBY, stockKey, qty) redis.call(SADD, boughtKey, userId) return 1SISMEMBER和SADD都在脚本里和库存扣减一起原子执行不存在先判断没买过下单后才发现买过了的问题。这个 Set 的过期时间可以跟随活动周期活动结束后让它自然淘汰避免 Redis 内存被无限堆积。4. 从下单到支付预占库存的生命周期管理扣减 Redis 成功不等于订单就稳了。一个订单从创建到支付、取消、超时关闭库存状态要跟着走完一整套生命周期。很多团队把超卖的锅甩给Redis 扣减没做好实际上是生命周期管理没做完整——比如用户下单后不付款库存一直被锁着看起来像超卖实则是库存被无效订单占用了。完整的库存生命周期分四个阶段下单预占、支付确认、超时释放、取消回补。4.1 下单预占先锁住库存再创建订单用户提交订单后第一步就是调库存服务执行 Lua 扣减扣减成功即完成预占然后才去创建待支付订单。顺序很重要先扣库存后建订单。为什么不能反过来因为如果先建订单再扣库存很可能订单建好了库存扣减失败又得把订单改成无效产生一堆脏数据。先扣库存的好处是只要扣减成功这个商品就确定属于当前用户了后续创建订单基本不会因为库存问题失败。但这里有一个必须处理的异常Redis 扣减成功但订单写库失败比如数据库连接超时、字段校验报错。这种情况下必须立即回补库存否则这个用户明明没下单成功库存却被白白扣掉了。回补直接用INCRBY同时要考虑幂等——订单创建失败的通知可能来自重试链路同一个预占可能被回补两次。我的做法是给每一次预占生成一个唯一流水号回补时以流水号作为去重键只有第一次回补真正执行。4.2 支付确认预占转实扣回写数据库用户支付成功后支付回调触发库存的预占转实扣。这一步要做两件事更新订单状态为已支付。把这次扣减正式写入数据库的库存账更新 DB 里的最终库存字段。这里最容易踩的坑是支付回调的重复通知。支付平台为了保证回调可达通常会重试很多次如果每次回调都执行一次库存落库同一个订单就会被扣减多次。解法是在订单状态上做幂等回调处理开始时用条件更新把订单从待支付改成已支付如果更新影响行数为 0说明订单已经被处理过直接忽略本次回调。落库这一步也不能掉以轻心。理想情况是 Redis 扣减成功的那一刻就发一条消息给库存落库服务异步把差额同步到数据库。但如果写库失败Redis 和数据库就会产生差异。稳妥的做法是引入本地消息表在创建订单的同一个数据库事务里同时写入一条待回写库存的消息记录定时任务把消息捞出来投递到库存服务成功后再标记消息为已处理。这样即使中间任何环节失败消息还会重试保证最终落库成功。4.3 超时释放别让无效订单锁死库存用户下单后不付款库存不能一直被占用。常规做法是给待支付订单设置一个支付超时时间比如30分钟超时后自动取消订单并回补库存。超时触发的实现方式很多可以用 RocketMQ 的延迟消息、Redisson 的延迟队列也可以用一个定时任务定期扫描待支付且创建时间超过30分钟的订单。无论用哪种核心逻辑都一样更新订单状态前先判断它是否仍然是待支付。UPDATE orders SET status CANCELLED, cancel_time NOW() WHERE order_no #{orderNo} AND status PENDING AND created_at NOW() - INTERVAL 30 MINUTE;如果影响行数为 1说明取消成功可以安全回补库存如果为 0说明订单已经支付或已被取消不能动库存。这一步是防止用户刚好在超时那一刻完成支付订单却被取消的竞态。谁先更新成功谁就拥有对这笔库存的处置权。回补库存同样要用 Lua 脚本并且要设置一个上限防止数据错乱导致库存被加爆超过初始值-- KEYS[1] 剩余库存keyKEYS[2] 初始库存keyARGV[1] 回补数量 local current tonumber(redis.call(GET, KEYS[1]) or 0) local total tonumber(redis.call(GET, KEYS[2]) or 0) if current tonumber(ARGV[1]) total then redis.call(SET, KEYS[1], total) else redis.call(INCRBY, KEYS[1], ARGV[1]) end return 1用户主动取消订单的场景与此完全相同调用同一套回补逻辑。4.4 生命周期状态一览把上面的流程整理成一张表代码评审或者画时序图的时候可以直接用阶段触发条件库存动作订单状态一致性要求下单预占用户提交订单Redis 扣减预占待支付先扣后建单失败立即回补支付确认支付平台回调预占转实扣回写数据库已支付回调必须幂等超时释放超过支付时限回补 Redis 库存已取消只有待支付可取消只释放一次主动取消用户点击取消回补 Redis 库存已取消幂等回补一句话总结这章的核心思想下单时预占支付时实扣超时或取消时回补。这样既保证了库存数据的真实性又不会因为用户不付款而锁死大量库存。5. 对账与兜底让库存系统自己纠错再完美的方案也扛不住所有意外。Redis key 被意外淘汰、延迟消息丢失、同一订单重复扣减、回补逻辑在极端并发下重复执行……这些我在线上都真实遇到过。所以一套滴水不漏的库存系统必须有一层兜底对账机制让系统即使出了问题也能自己发现、自己修复。5.1 定期扫描未完成订单兜底释放第一层兜底是针对超时释放做补偿。比如延迟消息由于队列积压或消费失败丢了订单一直停留在待支付状态库存也一直占着不放。解决办法是启动一个定时任务周期性地扫描超时未支付订单SELECT order_no, sku_id, quantity FROM orders WHERE status PENDING AND created_at NOW() - INTERVAL 30 MINUTE LIMIT 500;扫到之后执行和超时释放完全相同的逻辑先尝试把订单状态从待支付更新为已取消更新成功才回补库存。这个任务跟延迟消息不冲突二者互为兜底重复执行也不会扣两次库存因为订单状态一改变另一个任务就扫不到它了。5.2 库存数字对账分钟级发现差异第二层兜底是对账。核心思想是围绕初始库存建立一条守恒公式无论哪个环节出错最终都能通过公式反推出来。以数据库为基准的公式是这样的当前DB库存 初始库存 - 已支付且未退款的实扣数量 - 未释放的预占数量对账任务可以分批扫描所有SKU用 SQL 聚合出已支付、待支付的订单数量SELECT sku_id, SUM(quantity) AS paid_qty FROM order_item WHERE status IN (PAID, FINISHED) GROUP BY sku_id;然后在应用层把聚合结果和 Redis 里的剩余库存做比较差异超过某个阈值——比如 5 件或 1%——就触发告警。更激进一点的团队可以直接做自动修复把 Redis 库存重置为初始库存 - 实扣数量 - 活跃预占数量SET inv:sku:{skuId}:stock ${initialStock - paidQty - pendingQty}但自动修复一定要选流量低峰期并且写操作日志万一误判还能人工回滚。我的建议是先告警人工确认差异原因再决定要不要自动重置。自动化程度可以随着对系统信心的增加逐步提升。5.3 幂等是兜底逻辑的生命线对账和修复逻辑本身必须做到幂等否则兜底反而变成新的故障源。怎么保证幂等两个常用的手段。一是基于唯一约束去重。给库存操作流水表加唯一索引(order_no, operation_type)每次释放、回补前先插入一条操作流水如果插入报唯一键冲突说明这条流水已经处理过直接跳过。缺点是每次操作多一次写入开销但对账任务低频运行完全可接受。二是基于状态机保护。依赖订单状态字段做判断只有处于待支付或已取消状态的订单才允许释放库存。因为状态更新本身是原子的重复调用时第二次发现状态不对自然就不执行了。这也是最省事、最快的幂等方案。6. 实战避坑清单几个必须提前知道的细节最后分享几个我在实战中踩过、或者帮别人排查过的坑。这些细节单独看不致命但组合在一起就能让一套看似完善的库存系统在关键时刻翻车。6.1 热点SKU的单 key 性能问题一个 SKU 对应一个 Redis key大促时所有请求都打在这一个 key 上。Redis 虽然是单线程、速度很快但也是有上限的。如果单个 key 的 QPS 超过几万就可能出现响应延迟拉长。解决方案是库存分片把1000件库存拆到10个 key 上每个 key 100件请求随机分配到一个 key 上尝试扣减不足就换下一个。代价是部分 key 可能提前耗尽而其他 key 还有余量回补时要指定 key复杂度明显上升。我的建议是一般业务 QPS 不超过 5k别做分片单 key 完全够用真到了需要分片的体量团队也应该有专门的性能评估手段。6.2 Redis 宕机和数据丢失的降级预案Redis 是内存数据库如果没有可靠的持久化配置进程重启后库存数据可能全部丢失。库存这类关键数据建议开启 AOF 持久化并且根据业务对数据安全的要求配置刷盘策略可能是每一笔写都刷盘也可以每秒刷一次在性能和安全性之间取舍。但这只是减少丢失概率真正让系统稳的是对账机制——Redis 丢了对账任务很快会发现差异重新从数据库回填正确的库存值。另一手准备是降级开关。如果 Redis 整体不可用把扣减流量切到数据库乐观锁方案。性能差一点没关系底线是不能超卖。6.3 批量扣减购物车多商品必须全部成功或全部失败一次下单包含多个SKU时不能有的扣成功、有的扣失败。要么全部预占要么全部回滚。实现方法是把多个 key 的扣减放进同一个 Lua 脚本循环处理任何一个 SKU 库存不足就把前面已经扣减的全部加回来for i 1, #KEYS do local stock tonumber(redis.call(GET, KEYS[i]) or 0) if stock tonumber(ARGV[i]) then for j 1, i - 1 do redis.call(INCRBY, KEYS[j], ARGV[j]) end return 0 end redis.call(DECRBY, KEYS[i], ARGV[i]) end return 1这种脚本在 SKU 数量多的时候要注意KEYS和ARGV的一一对应关系写错了就会把 A 商品的库存扣到 B 商品头上这是最容易出 bug 的地方。6.4 商品售罄后的展示与入口控制超卖防住了还得避免用户反复提交无效订单。库存为 0 时商品详情页要实时展示已售罄下单按钮要置灰。这属于体验层面的优化但反过来也在保护后端——大量明知没货还在疯狂点击下单的请求本身就是在给库存系统增加无谓的压力。常用做法是扣减返回 0 时由库存服务发一个售罄事件商品服务收到后更新 Redis 里的商品状态缓存前端据此展示售罄。6.5 监控指标不能只看扣减成功率最后提醒一点库存系统的监控不能只盯着 Redis 扣减成功率。还要关注几个关键指标Redis 与数据库的库存差异值、待支付订单超时释放的延迟时间、库存落库消息队列的积压量、对账任务的执行耗时和告警次数。这些指标才能真正反映系统的健康度。我见过不少团队只盯着扣减成功率结果 Redis 扣得飞起数据库落库早就积压了几万条消息等到财务对账时才发现账对不上那时候排查成本就高了。最后说点个人体会。我做库存系统最早用的是纯数据库乐观锁后来扛不住大促切到 Redis Lua再后来才明白真正让我心里有底的其实不是扣减那一下而是超时释放加上对账兜底这套机制。系统不怕出问题怕的是出了问题没人知道、没法自动恢复。如果你准备从头搭一套库存系统我建议先把数据库的账本算清楚再把 Redis 预占的生命周期打通最后一定把对账任务放上去哪怕一开始只是每天跑一次日报。这套方案在几个真实项目里跑过大促线上超卖订单做到过零库存差异也能在分钟级发现并自动修复。希望你能少踩几个我踩过的坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。