首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >秒杀一开库存变负数、订单还超卖?商城防超卖与库存一致性排查实战

秒杀一开库存变负数、订单还超卖?商城防超卖与库存一致性排查实战

原创
作者头像
数字化落地笔记
发布于 2026-10-10 15:33:13
发布于 2026-10-10 15:33:13
700
举报

秒杀一开库存变负数、订单还超卖?商城防超卖与库存一致性排查实战

先说结论:秒杀场景库存变负、订单超卖,根子就一句——"扣库存"和"写订单"不是原子操作,高并发下读-改-写互相覆盖。解法也固定:扣减用数据库原子语句或 Redis Lua 脚本,订单落库后异步对账兜底。排查和修复过程记录如下。

先说背景。客户是一个做了两年的女装商城,商品、订单、会员这些基础台账,以及订单和库存数据入库,都跑在一套托管式建站的SaaS底座上,日常用着没出过问题。这篇复盘的是他们第一次做整点秒杀时的一次故障:问题不在底座,而在秒杀接口的库存扣减逻辑上,属于我们自己实现业务段里的事。

现象:开场 5 分钟,库存显示 -35,订单超卖 210 单

双11 当天 10 点整点秒杀,运营盯着后台,第 5 分钟就发现不对:

  • 秒杀款库存 300 件,后台库存显示 -35 件,前端仍在正常下单
  • 当天拉出的订单里,秒杀款成交 510 件,超卖 210 件
  • 同一用户重复下单的现象不多,说明问题不在重复提交,而在扣减本身

顺手做了个并发压测:100 并发请求同一秒杀款,库存 300,结果卖出 400 件。问题锁定在"库存扣减不是原子操作"。

根因:库存扣减是"读-改-写",高并发下互相覆盖

当时的扣减代码是标准三段式:

代码语言:python
复制
stock = get_stock(sku_id)      # 第一步:读
if stock >= 1:
    stock = stock - 1          # 第二步:减
    update_stock(sku_id, stock)  # 第三步:写回

两个请求同时读到 300,各自减 1 写回 299,300 件库存被两次请求各卖一次,实际只扣了 1 件——超卖就是这么来的。这在并发场景是经典的丢失更新(Lost Update)。同一时刻到达的请求越多,覆盖的次数越多,库存负得越厉害,这也是为什么秒杀这种瞬时高并发最容易暴露这类问题。

方案一:扣减改成数据库原子更新

把三段式换成一条 SQL,让"判断库存够不够"和"扣减"在数据库内一次完成:

代码语言:sql
复制
UPDATE product_sku
SET stock = stock - 1
WHERE sku_id = 1001 AND stock >= 1;
代码语言:python
复制
cursor = db.cursor()
cursor.execute(
    "UPDATE product_sku SET stock = stock - 1 "
    "WHERE sku_id = %s AND stock >= 1",
    (sku_id,)
)
if cursor.rowcount == 0:
    return {"ok": False, "msg": "已售罄"}
# rowcount = 1 才允许创建订单

影响行数为 0 就是库存不足,直接返回售罄。库存列永远不可能被减成负数。这一版上线后,压测 100 并发、库存 300,成交稳定在 300 件,0 超卖。

方案二:扛秒杀流量,用 Redis 预扣 + Lua 原子脚本

数据库原子更新能保证不错,但秒杀瞬间流量打过来,单库单表的 UPDATE 会成为瓶颈。我们的做法是把"预扣"前置到 Redis,用 Lua 保证多条命令原子执行:

代码语言:lua
复制
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
return 0

Redis 预扣通过后,请求才继续创建订单;订单最终确认支付后,再异步把扣减同步到数据库。注意 Redis 只是"挡流量 + 快速失败",数据库里那份库存才是最终账本,两者必须对账。

这里有个容易忽略的时序问题:预扣成功但用户最终没支付(超时关单、取消订单),预扣的库存必须释放。释放同样要走 Lua 原子 INCRBY,并且只在订单状态确实变为"已关闭"时执行,防止关单流程和支付回调并发时重复释放、多放库存。库存释放和订单状态流转,要放在同一个状态机里处理,谁先执行都要幂等。

方案三:对账任务兜底,库存偏差自动回补

预扣和最终账本之间一定会短暂不一致,必须有对账任务收口:

代码语言:bash
复制
# 每分钟执行一次的对账脚本
# 比对:已支付订单数 vs 数据库实际扣减数
# 偏差 > 0:说明预扣了但没落库,用原子 UPDATE 回补
# 偏差 < 0:说明多扣了,回滚并告警人工处理

对账任务上线当天就要跟着上,不能等出现偏差再补。我们压测里人为注入 50 条"预扣未落库"的脏数据,对账任务 3 个周期内全部回补,库存与已支付订单数对齐。

回补的逻辑要分两种情况写清楚:偏差为正是"预扣了但订单没落库",按订单维度逐条回补扣减,补完再核一遍总数;偏差为负是"多扣了",多半来自重复扣减或状态机并发,这类不能自动加库存(容易把库存放成负数),要停掉相关渠道并告警,人工核对订单流水后处理。宁可告警多一点,不要自动放库存。

踩坑 5 条

  • 踩坑1:扣库存和写订单不在同一个事务里,扣成功但订单没建成,库存白白少一件。扣减与订单创建要放同一事务,或靠对账回补兜住。
  • 踩坑2:用 Redis GET + DECRBY 两条命令做预扣,中间有间隙,并发下一样超卖。必须用 Lua 脚本包成原子操作。
  • 踩坑3:预扣 Redis 和数据库库存没有对账任务,跑一天后两边数字就对不上了。预扣上线当天必须同步上线对账。
  • 踩坑4:对账发现偏差后只记录日志不自动回补,人工处理不及时,库存继续漂移。能自动回补的偏差要自动回补,不能的才告警。
  • 踩坑5:压测只测"下单接口",没测"支付回调 + 库存同步"全链路,预扣扣了、支付回调丢了,库存照样对不上。压测必须覆盖支付回调。

结语

这次故障的排查路径很典型:先压测复现,再定位"读-改-写"非原子,再用数据库原子更新和 Redis Lua 预扣两级解决,最后用对账任务兜底。顺序别反——先保证单机不错,再考虑扛并发,最后补对账。库存一致性这类问题,越早用原子能力兜底,业务量上来了越省心。

如果只想记住三件事:第一,扣减必须原子,stock >= N 的 WHERE 条件不能省;第二,预扣和落库必须对账,别相信"应该一致";第三,压测一定要覆盖支付回调全链路,只压下单接口测不出真问题。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 秒杀一开库存变负数、订单还超卖?商城防超卖与库存一致性排查实战
    • 现象:开场 5 分钟,库存显示 -35,订单超卖 210 单
    • 根因:库存扣减是"读-改-写",高并发下互相覆盖
    • 方案一:扣减改成数据库原子更新
    • 方案二:扛秒杀流量,用 Redis 预扣 + Lua 原子脚本
    • 方案三:对账任务兜底,库存偏差自动回补
    • 踩坑 5 条
    • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档