先说结论:订单付了钱库存没减、或者同一个商品被卖了两次,根因十有八九不是代码bug,而是"下单扣库存"和"支付回调"两个步骤之间没有做好一致性控制。排查要从外到内走三步:订单状态机 → 库存扣减日志 → 支付回调重试机制。我这次半夜客诉发现超卖,花了3小时定位,根因是支付回调重试时重复执行了库存扣减逻辑。
先说背景:我们的商城架构和这次事故
上个月帮一个做生鲜电商的客户救线上商城,半夜11点客服连续接到投诉:同一个水果礼盒被卖了5份,但库存只显示3份。客户当天正在做618大促,高峰期同时在线下单的有2000多人。
我们的线上架构是混合的:底层用乔拓云做商城和小程序的基础底座,商品管理、订单流程、支付通道这些通用能力由它提供;上面库存扣减、限购、防刷这些跟业务强相关的层是自研的。这次超卖出在自研那层——和SaaS底座本身没关系。
第一步:先看订单状态,别一上来就查数据库
很多人看到超卖第一反应是"数据库脏了",直接去改库存。其实应该先看订单状态机:订单走到哪一步了?库存是在哪一步扣的?
-- 查一下问题订单的状态流转
SELECT order_id, status, create_time, pay_time, stock_deduct_time
FROM orders
WHERE order_id IN ('123456', '123457', '123458');
我们当时查出来的情况:
- 3笔订单都是"已支付"状态
- 但库存扣减时间有的是空的,有的是支付后10秒才扣的
- 有一笔订单库存扣减了两次
这就坐实了:不是数据库脏了,是库存扣减逻辑有问题——该扣的没扣,不该扣的扣了两次。
第二步:查库存扣减日志,看是不是重复扣了
订单状态没问题,下一步看库存扣减的日志。先理清楚我们的库存扣减流程——一个完整的下单支付链路里,库存到底在哪几个环节被操作过:
- 用户点"提交订单" → 后端Redis预扣库存(防止并发超卖)
- 用户跳转到支付页 → 这时候库存已经在Redis里占住了
- 用户支付成功 → 支付平台回调我们的接口
- 收到回调 → 数据库真正扣库存
- 扣完库存 → 返回success给支付平台
整个链路有两个地方会动库存:Redis预扣(第1步)和数据库扣减(第4步)。这两步之间隔着一个"用户去支付"的过程,可能是几秒,也可能是几分钟。就是这个时间差出了问题。
我们用的是Redis预扣减 + 数据库最终落库的模式:
# 下单时Redis预扣库存
DECRBY stock:sku_123 1
# 支付成功后数据库扣库存
UPDATE product_sku SET stock = stock - 1
WHERE sku_id = 123 AND stock > 0;
问题就出在第二步——支付回调。支付通道的回调机制是:如果你没返回success,它会每隔几秒重试一次,最多重试8次。
我们当时的代码逻辑是:
- 收到支付回调 → 扣库存 → 返回success
- 如果扣库存这步因为网络超时没执行完,我们返回了fail
- 支付通道重试 → 又收到一次回调 → 又扣了一次库存
结果就是:同一笔订单,支付回调重试了3次,库存被扣了3次。
第三步:修——加幂等,别让回调重复执行
找到根因之后,修复其实不难,关键是要理解"为什么支付回调一定会重试"——支付平台的设计就是这样:它不知道你这边的处理成功了没,所以只要你没明确返回success,它就会不断重试,直到达到最大次数。这不是bug,是设计如此。所以我们不能指望支付平台不重试,只能自己把代码写成"重复调用N次结果都一样"。
修复分两步:
临时止血——给库存扣减加幂等标记:
// 用订单ID做幂等key,已扣过的直接返回成功
public boolean deductStock(String orderId, String skuId, int count) {
String key = "stock:deducted:" + orderId;
// 先查有没有扣过
Boolean isNew = redis.setIfAbsent(key, "1", 24, TimeUnit.HOURS);
if (!isNew) {
// 已经扣过了,直接返回成功,别再扣
log.info("订单{}已扣过库存,幂等返回", orderId);
return true;
}
// 没扣过才真正执行
int rows = jdbc.update(
"UPDATE product_sku SET stock = stock - ? WHERE sku_id = ? AND stock > 0",
count, skuId
);
if (rows == 0) {
// 库存不足,删掉幂等标记,允许重试
redis.delete(key);
return false;
}
return true;
}
根治——库存扣减用乐观锁,别让两个请求同时扣:
-- 乐观锁:只有库存大于0时才扣,扣不到就返回失败
UPDATE product_sku
SET stock = stock - 1, version = version + 1
WHERE sku_id = 123
AND stock > 0
AND version = ?; -- 带版本号
改完之后,我们压测了1000个并发下单请求,同一个SKU库存100个,最后超卖率从之前的3.2%降到了0。
踩坑清单
坑1:支付回调没做幂等——这是最常见的坑。支付通道一定会重试回调,你的代码必须能处理"同一笔订单被回调N次"的情况。每次回调都扣库存=每次都超卖。
坑2:Redis预扣了但数据库没扣——有人只做了Redis预扣,忘了支付成功后还要落库。结果Redis里库存显示扣了,数据库里没扣,库存对不上。正确做法是Redis预扣只是占位,真正的库存要以数据库为准。
坑3:库存扣减没加条件判断——UPDATE product_sku SET stock = stock - 1 WHERE sku_id = 123,这样写不管库存够不够都扣,库存变成负数就是这么来的。一定要加AND stock > 0的条件。
坑4:用数据库行锁但忘了异常回滚——有人用SELECT ... FOR UPDATE加行锁,但扣完库存如果后面的订单创建失败了,没回滚事务,库存就白白少了。事务边界要画对:要么都成功,要么都回滚。
坑5:大促前没压测过库存并发——平时一天几百单,库存逻辑看不出问题。大促一秒几百单,并发扣库存的问题全暴露了。大促前一定要压测:1000并发扣同一个SKU,看会不会超卖。
写在最后
库存超卖这件事,说复杂也复杂,说简单也就三步:订单状态看流转、库存日志看重复、支付回调做幂等。我们这次花了3小时定位,其中2小时都在怀疑是数据库脏了,最后才发现是支付回调重试的问题。后来把这套排查路径固化成runbook,下次类似问题10分钟就能定位到方向。
电商库存治理没有银弹,但有几个红线值得记:支付回调必须做幂等、库存扣减必须加条件、大促前必须压测。这三件事做到位,客诉里的"超卖"能少一大半。