大促复盘发现超卖,根因不外乎三种:库存扣减时机设计不合理、并发控制手段选错、以及分布式环境下库存一致性保障缺失。搞清楚这三条线,下个档期才能堵住漏洞。
超卖问题为什么总在大促后集中爆发
大促复盘会开得多了,你会发现超卖报告长得都差不多:订单表里躺着超出库存的支付记录,仓库那边却发不出货,技术团队第一反应是“并发太高扛不住”,但扛不住只是表象,真正的问题藏在业务逻辑和架构设计里。
超卖的本质是“卖出去了但没货”,这中间隔着下单、锁库存、支付、扣减多个环节,每个环节都有可能出现数据不一致,但绝大多数情况能归结为三种根因,理解这三种可能,比急着改代码更重要,因为修复方案完全不一样。
超卖原因分析:先搞懂数据是怎么“串”的
把一次购买行为拆开看,库存扣减发生的时间点决定了风险面,你是在用户点击“提交订单”时扣,还是在支付成功后才扣?这是第一个分叉口。
- 下单即扣减:用户还没付款,库存已经少了,订单取消率高的话,库存被大量无效占用,看起来不超卖,但真实可售量被高估。
- 支付才扣减:下单时不锁库存,用户付完款才发现没货,这种方式最容易超卖,因为从下单到支付的窗口期里,库存可能已经被别人买走。
- 预占后扣减:下单时预占库存,支付后转正式扣减,方案合理,但预占和扣减之间的状态同步一旦出错,就会出现数据回滚不一致。
行业共识认为,绝大多数超卖发生在“支付才扣减”和“预占后扣减”这两种模式下,前者是逻辑缺陷,后者是状态一致性缺陷。
第一种可能:库存扣减时机设计不合理
很多中小电商平台从单体架构起步,库存扣减逻辑简单粗暴:用户下单后调一个update stock set quantity = quantity - 1 where quantity > 0的SQL,这个SQL本身没毛病,但放在什么业务节点执行,差别很大。
库存扣减时机怎么选:下单锁库存还是支付扣库存
如果你做的是标品、低客单价商品,用户决策快、退款率低,下单即扣减是安全的,但如果你做的是高客单价、预售型商品,下单到支付的转化率可能不到六成,下单即扣减会浪费大量库存容量。
实际操作中,大多数平台选择下单预占、支付确认两步走,这个方案没问题,问题出在预占超时释放这个补偿机制上,很多团队把超时释放做成定时任务扫描,每五分钟跑一次,遇到数据库慢查询就延迟,用户那边看到的状态是“库存充足”,实际可卖量已经被未支付订单占完了。
场景案例:女装店铺的预占失效
一个真实场景:女装类目大促,某款风衣SKU备货500件,活动开始后10分钟内产生1200个待支付订单,预占逻辑生效,但用户支付时间集中在最后两小时,前两小时大量未支付订单占据库存,导致真实库存不足,后续用户看到“有货”但实际无法购买,到活动结束时,未支付订单释放不及时,系统实际成交800单,超卖300件。
这种超卖不是并发问题,是预占策略和支付窗口不匹配,修复方向也不是加缓存、加队列,而是调整预占超时时间、监控支付转化率,甚至在高峰期设置动态库存阈值。
第二种可能:并发控制手段选错
如果扣减时机没问题,那超卖大概率出在并发控制上,库存扣减是一个典型的“读-改-写”操作,两个人同时读到库存是1,各自减1,最后库存变成0而不是-1,这就是超卖。
乐观锁和悲观锁怎么选才不超卖
很多人一听并发控制就上Redis分布式锁,但锁不是万能的。
悲观锁方案:select from stock where sku_id = ? for update,锁住这行记录,其他事务必须等待,优点是绝对安全,缺点是并发高时数据库连接被占满,数据库CPU直接飙到90%以上,大促峰值每秒几千单的场景下,这种方案会拖垮主库。
乐观锁方案:update stock set quantity = quantity - 1 where sku_id = ? and quantity > 0,不用锁,靠数据库行锁保证原子性,这个方案在很多场景下是够用的,因为它把“判断库存是否充足”和“扣减库存”合并成一条SQL,没有中间状态。
但乐观锁也有坑,如果你的库是分库分表的,库存扣减和订单创建不在同一个事务里,那这一步只能保证库存不扣成负数,保证不了订单和库存的一致性,说白了,数据库层面不超卖了,但业务层面可能出现“订单创建失败但库存扣减成功”的情况。
Redis预扣库存的常见误用
大促场景下,纯数据库扣库存扛不住,所以很多团队引入Redis做预扣,方案是:
- 库存预热到Redis
- 下单时执行
decr命令 - 异步同步回数据库
这个方案的问题在于:Redis的decr是原子的,但Redis和MySQL之间的同步不是,如果扣减成功但同步失败,Redis里的库存和数据库库存就对不上了,更麻烦的是,Redis宕机恢复后,内存里的库存数据可能丢失,重新加载数据库库存时,已经扣减的部分会被还原。
业内专家指出,Redis预扣库存适合高并发抢购,但不适合对账要求严格的场景,比较好的做法是Redis扣减+数据库最终一致性对账,通过MQ异步同步扣减记录,然后定时任务比对Redis库存和数据库库存的差值。
第三种可能:分布式环境下库存一致性保障缺失
库存系统不可能独立存在,它要和订单系统、支付系统、物流系统联动,一旦拆分微服务,库存服务的数据和订单服务的数据就不在同一个事务里了。
分布式事务的三种处理模式对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 本地消息表 | 实现简单,不依赖外部中间件 | 需要额外维护消息表,对账逻辑复杂 | 中小团队、单数据库场景 |
| 事务消息(RocketMQ) | 消息和业务操作原子提交,可靠性高 | 引入额外中间件,运维成本高 | 已经有消息队列的团队 |
| TCC(Try-Confirm-Cancel) | 业务侵入性低,回滚精细 | 实现复杂,每个操作都要写三套逻辑 | 核心资金链路、极高一致性要求 |
三种模式没有绝对优劣,但大部分超卖事故不是事务方案不行,而是是否所有操作都走到了正确的事务链路上。
退款退货流程中的二次释放漏洞
大促结束后的第3到第7天,会有一波退货高峰,这个阶段出现的超卖很隐蔽,容易被团队当作“历史遗留问题”忽略。
场景描述:用户下单支付后,库存已扣减,用户申请退款,退款单流转到库存服务,触发库存释放,但释放操作依赖退款单状态为“已完成”,如果退款单在审批环节卡住,或者退款单状态更新和库存释放不在同一个事务里,库存就不会加回来。
更常见的情况是:退款单和订单是分库的,退款单更新为“退款成功”,但库存服务没收到消息,或者收到了但数据库更新失败,库存就一直少着,后面继续卖,按错误的库存量上架,自然超卖。
这种问题的排查思路不能只盯代码,要按单号追踪全链路:订单状态、支付回调、退款单状态、库存流水,四者对照着查,哪里断了,就是哪里出了问题。
高并发下库存流水表的数据倾斜
另一个容易被忽略的点是库存流水表的写入性能,大促期间,每笔扣减都要写一条流水,流水表的数据量几千万起步,按SKU维度做分表的话,爆款SKU的流水全部落在一个分片里,导致这个分片写入压力巨大,其他分片闲着。
最终表现就是:数据库CPU不高,但爆款SKU的库存扣减响应时间从10ms涨到800ms,大量超时重试,重试请求又叠加到同一个分片上,形成恶性循环。
这种场景下要复盘的不是SQL效率,而是分片键设计,按SKU分表在大促场景下一定会遇到热点问题,要么用Redis挡在数据库前面,要么把流水表和库存余额表分开设计,余额表按SKU分片,流水表按时间分片,各扛各的压力。
大促复盘超卖怎么处理:从定位到修复的完整路径
复盘超卖不能只看代码,要按数据流逐层排查,推荐一条可落地的排查路径,这也是你在下次大促前应该提前演练的:
- 先对账:拉取“订单支付成功表”和“库存扣减流水表”,找出支付成功但无扣减流水的订单,以及扣减流水存在但订单未支付的记录
- 再分场景:未支付但扣减了库存,属于预占未释放;已支付但无扣减记录,属于扣减链路断掉
- 定位环节:从入口日志到MQ消息到数据库事务,查每一条扣减消息的消费状态,确认是消息丢失还是消费失败
- 看数据恢复:如果库存已经被改乱,先跑一遍库存校准脚本,以真实发货单和退款单为依据,反推每个SKU的正确库存量
- 修复设计:针对根因,选择加事务消息、优化锁粒度、调整预占超时策略,或者增加对账补偿机制
这套路径的核心逻辑是:先恢复数据,再定位根因,最后改架构,顺序不能反,否则你还在分析问题,仓库那边已经发不出货了。
库存校准脚本的合适写法
实操层面,很多团队卡在“库存乱了怎么校准”这一步,推荐做法是写一个幂等的校准脚本,以库存流水表为唯一事实源:
-- 重建每个SKU的当前库存
SELECT sku_id,
SUM(CASE WHEN change_type = 'IN' THEN quantity ELSE -quantity END) AS stock
FROM stock_flow
WHERE status = 'SUCCESS'
GROUP BY sku_id;
用这个结果对比库存余额表,差值就是异常部分,修正之前,建议先查有没有未发货的订单,优先保证已售订单有货可发,然后再修正剩余库存,避免修完库存导致新的超卖。
Q&A:超卖原因分析与处理方案
超卖问题怎么彻底杜绝?
没有百分之百杜绝的方案,但可以通过三条措施把超卖率压到接近零:一是核心链路用数据库行锁兜底,哪怕上层有Redis预扣,数据库扣减仍然用where quantity > 0的原子条件;二是库存扣减和订单状态更新放同一个事务,要么都成功要么都失败;三是每天凌晨跑一次全量对账,用库存流水重建余额,提前发现不一致。
小团队没有分布式中间件,怎么避免超卖?
如果订单量和库存量都在单库单表的量级,最合适的办法是把库存扣减和订单创建放在同一个数据库事务里,用select for update锁住库存行,更新后用受影响行数判断是否成功,这是最简单也最可靠的方案,没必要为了技术先进而引入Redis和MQ,架构复杂度本身就是超卖的诱因之一。
大促期间已经超卖了,怎么紧急处理?
第一步,立刻把商品下架,停止继续销售,第二步,用库存流水表重建实际库存,找出哪些订单无货可发,第三步,主动联系超卖订单的用户,提供退款加补偿方案,紧急处理的核心原则是:承认超卖、尽快退款、别拖拉,用户投诉的爆发期集中在活动结束后48小时内,处理越快,舆论风险越小。
超卖的三种根因不是孤立的,很多时候是叠加出现,扣减时机设计不合理,加上并发控制选错,再加上分布式事务断裂,三个问题层层嵌套,复盘时要有耐心,像剥洋葱一样一层层拆,先从数据流水对起,再向代码逻辑深入,最后落到架构层面整改,下次大促前,把这些漏洞补上,超卖问题才算是真正处理完了。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/772717.html


