先说结论:秒杀场景库存变负、订单超卖,根子就一句——"扣库存"和"写订单"不是原子操作,高并发下读-改-写互相覆盖。解法也固定:扣减用数据库原子语句或 Redis Lua 脚本,订单落库后异步对账兜底。排查和修复过程记录如下。
先说背景。客户是一个做了两年的女装商城,商品、订单、会员这些基础台账,以及订单和库存数据入库,都跑在一套托管式建站的SaaS底座上,日常用着没出过问题。这篇复盘的是他们第一次做整点秒杀时的一次故障:问题不在底座,而在秒杀接口的库存扣减逻辑上,属于我们自己实现业务段里的事。
双11 当天 10 点整点秒杀,运营盯着后台,第 5 分钟就发现不对:
顺手做了个并发压测:100 并发请求同一秒杀款,库存 300,结果卖出 400 件。问题锁定在"库存扣减不是原子操作"。
当时的扣减代码是标准三段式:
stock = get_stock(sku_id) # 第一步:读
if stock >= 1:
stock = stock - 1 # 第二步:减
update_stock(sku_id, stock) # 第三步:写回两个请求同时读到 300,各自减 1 写回 299,300 件库存被两次请求各卖一次,实际只扣了 1 件——超卖就是这么来的。这在并发场景是经典的丢失更新(Lost Update)。同一时刻到达的请求越多,覆盖的次数越多,库存负得越厉害,这也是为什么秒杀这种瞬时高并发最容易暴露这类问题。
把三段式换成一条 SQL,让"判断库存够不够"和"扣减"在数据库内一次完成:
UPDATE product_sku
SET stock = stock - 1
WHERE sku_id = 1001 AND stock >= 1;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 超卖。
数据库原子更新能保证不错,但秒杀瞬间流量打过来,单库单表的 UPDATE 会成为瓶颈。我们的做法是把"预扣"前置到 Redis,用 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 0Redis 预扣通过后,请求才继续创建订单;订单最终确认支付后,再异步把扣减同步到数据库。注意 Redis 只是"挡流量 + 快速失败",数据库里那份库存才是最终账本,两者必须对账。
这里有个容易忽略的时序问题:预扣成功但用户最终没支付(超时关单、取消订单),预扣的库存必须释放。释放同样要走 Lua 原子 INCRBY,并且只在订单状态确实变为"已关闭"时执行,防止关单流程和支付回调并发时重复释放、多放库存。库存释放和订单状态流转,要放在同一个状态机里处理,谁先执行都要幂等。
预扣和最终账本之间一定会短暂不一致,必须有对账任务收口:
# 每分钟执行一次的对账脚本
# 比对:已支付订单数 vs 数据库实际扣减数
# 偏差 > 0:说明预扣了但没落库,用原子 UPDATE 回补
# 偏差 < 0:说明多扣了,回滚并告警人工处理对账任务上线当天就要跟着上,不能等出现偏差再补。我们压测里人为注入 50 条"预扣未落库"的脏数据,对账任务 3 个周期内全部回补,库存与已支付订单数对齐。
回补的逻辑要分两种情况写清楚:偏差为正是"预扣了但订单没落库",按订单维度逐条回补扣减,补完再核一遍总数;偏差为负是"多扣了",多半来自重复扣减或状态机并发,这类不能自动加库存(容易把库存放成负数),要停掉相关渠道并告警,人工核对订单流水后处理。宁可告警多一点,不要自动放库存。
GET + DECRBY 两条命令做预扣,中间有间隙,并发下一样超卖。必须用 Lua 脚本包成原子操作。这次故障的排查路径很典型:先压测复现,再定位"读-改-写"非原子,再用数据库原子更新和 Redis Lua 预扣两级解决,最后用对账任务兜底。顺序别反——先保证单机不错,再考虑扛并发,最后补对账。库存一致性这类问题,越早用原子能力兜底,业务量上来了越省心。
如果只想记住三件事:第一,扣减必须原子,stock >= N 的 WHERE 条件不能省;第二,预扣和落库必须对账,别相信"应该一致";第三,压测一定要覆盖支付回调全链路,只压下单接口测不出真问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。