商品剩 1 件,两个人都下成功了
商品剩 1 件,两个人同时下单,都下成功了。
锁是加了的。 问题不在锁写得对不对,在于你什么时候扣这一件。
或者反过来:用户下了单没付钱,库存被占着,别人买不了,二十分钟后订单超时取消,库存才放出来——这二十分钟里,这件商品在货架上,但谁也买不走。
踩错的代价
这个选择没有标准答案,但选错了的代价是不对称的:
下单扣:不会超卖,但会少卖。恶意下单不付款可以直接把你的库存锁死。
支付扣:不会锁库存,但会超卖。用户付了钱你发不出货,得退款道歉,严重的要赔。
超卖比少卖贵得多。 少卖是损失机会成本,超卖是已经收了钱交不出货——那是信任问题,不是钱的问题。
所以如果你只能选一个:选下单扣。
两种做法的真实差别
| 下单扣 | 支付扣 | |
|---|---|---|
| 超卖风险 | 无 | 有 |
| 库存被恶意占用 | 有 | 无 |
| 需要超时释放 | 需要 | 不需要 |
| 支付失败要回滚 | 需要 | 不需要 |
| 适合 | 大部分电商场景 | 秒杀/预售等特殊场景 |
下单扣的两个必备配套:
一、超时未支付要自动释放。 没有这个,下单扣就是给恶意占用开门。定时任务扫超时订单,取消并回滚库存。
二、回滚必须幂等。 这条最容易漏——后面单独说。
乐观锁挡的不是超卖
这是本篇最想说的一件事。
很多人的库存扣减长这样:
// 查出来,判断,再改 —— 中间有窗口
ProductSku sku = mapper.selectById(skuId);
if (sku.getStock() < num) {
throw new BizException("库存不足");
}
sku.setStock(sku.getStock() - num);
skuMapper.updateById(sku);然后有人说「加个乐观锁就好了」,于是变成:
UPDATE product_sku
SET stock = stock - #{num},
sales = sales + #{num},
version = version + 1
WHERE id = #{id}
AND version = #{version} -- 乐观锁这确实解决了并发覆盖,但没解决超卖。
version 只保证「这行数据自我读到之后没被人改过」。它不检查库存够不够。
库存剩 1,两个请求并发:
- 请求 A 读到 version=5、stock=1,扣成功,version 变 6
- 请求 B 读到 version=5,更新时
version = 5不匹配,扣失败
这次挡住了。但如果 B 是在 A 提交后才读的:
- B 读到 version=6、stock=0
- 应用层判断
stock < num,抛异常
也挡住了——因为应用层还有那个 if 判断。
问题不在「窗口」和「概率」,在库存判断跟这条 UPDATE 有没有绑在同一次读上。
同时进来的请求读到的是同一个 version,所以只有一个能提交成功——
这一点乐观锁做得很好。但它保证的只是「这一行没被别人改过」,
它从来没检查过库存够不够。
拿 MySQL 8 跑了一遍,200 个线程抢 50 件货,四种写法:
| 写法 | 卖出 | 剩余 | 超卖 |
|---|---|---|---|
| 读 stock → 应用层判断 → UPDATE WHERE version | 1~2 单 | 48~49 | 0 |
| 同上 + 冲突重试 5 次 | 5 单 | 45 | 0 |
UPDATE ... WHERE stock >= 1 | 50 单 | 0 | 0 |
| 库存判断不基于本次读到的值 + 重试 | 152 单 | −102 | 超 |
第四行才是超卖的真实成因:库存是从缓存拿的、或者是上一步算好的,
跟这条 UPDATE 不是同一次读。version 只管「行没被改过」,
那个陈旧的库存值它一个字都不管,于是一路扣成负数。
所以不是「降低概率」,是压根管的不是这件事。
而第一行那个数字才是纯 version 乐观锁的日常代价:50 件货、200 并发,只卖出 1~2 件。
48 件卖不掉,用户全看到「库存不足」——货明明还在。
这不是偶发,是常态,因为大家抢的是同一个 version,抢不到的全部失败。
真正挡超卖的写法
把库存条件放进 SQL:
UPDATE product_sku
SET stock = stock - #{num},
sales = sales + #{num}
WHERE id = #{id}
-- 这一行才是防超卖的
AND stock >= #{num}然后看影响行数:
int rows = mapper.deductStock(skuId, num);
if (rows == 0) {
// 没扣到,就是不够
throw new BizException("库存不足");
}判断和扣减在同一条 SQL 里完成,中间没有窗口。 数据库的行锁替你保证了原子性。
stock >= num 和 version = ? 可以同时用,但要清楚它们防的是两件事:
stock >= num防超卖version = ?防并发覆盖(比如同时有人在改这行的其他字段)
只加乐观锁不加库存条件,是最常见的误解。
两级库存要一起扣
真实系统里库存通常有两级:
- 商品级库存(
product.stock)—— 这个商品总共还有多少 - SKU 级库存(
sku.stock)—— 具体规格(红色 XL)还有多少
下单时两级都要扣,而且要在同一个事务里。
漏扣任何一级的后果:
- 只扣 SKU:商品列表页显示的库存不对,用户看到「有货」点进去发现没货
- 只扣商品:某个规格能被无限下单
还有一对容易漏的:库存和销量。 扣库存的同时要加销量,回滚时反过来。真实实现里这两个是写在同一条 UPDATE 里的:
SET stock = stock - #{num},
sales = sales + #{num}写在一条 SQL 里,而不是两次更新 —— 这样它们永远一致。分成两次,中间挂了就对不上了。
回滚必须幂等
下单扣库存,就必然要处理回滚:订单取消、支付超时、支付失败、退款。
这几个入口可能同时触发。 用户点了取消,同时超时任务也扫到了这单——库存被回滚两次,凭空多出货。
真实系统里的做法是在订单明细上加一个标记:
是否回滚库存:0-未回滚,1-已回滚回滚前先看这个标记,已回滚就跳过。这个标记的更新和库存回滚要在同一个事务里,否则还是有窗口。
更稳的做法是把标记本身当作幂等锁:
UPDATE order_item
SET stock_rollback = 1
WHERE id = #{id}
-- 只有从 0 改成 1 才算抢到
AND stock_rollback = 0影响行数为 0 就说明别人已经回滚过了,直接返回。跟支付回调幂等是同一个套路:用一条带条件的 UPDATE 同时完成判断和占位。
我的选择
普通电商场景:下单扣 + stock >= num 的条件更新 + 超时释放 + 幂等回滚。
这四件事是一套,缺一个就有洞:
- 没有条件更新 → 超卖
- 没有超时释放 → 库存被恶意占死
- 没有幂等回滚 → 库存凭空增加
秒杀场景另说。 那种量级下数据库扛不住,要走 Redis 预扣 + 异步落库,是另一套设计。但别为了一年一次的大促,把日常的库存逻辑改成秒杀那套 —— 复杂度天天都在,大促一年就几天。
这条其实是这篇的总结:库存的坑基本都不在并发上,在时间上。
什么时候扣、什么时候放、放的时候那单是不是已经放过一次了。
并发只是把这些时间点挤到了一起,让你看得更清楚而已。
常见问题对照表
| 现象 | 真正的原因 | 解法 |
|---|---|---|
| 加了乐观锁还是超卖 | version 防的是并发覆盖,不是库存不足 | SQL 里加 AND stock >= #{num},看影响行数 |
| 库存变成负数 | 先查后改,中间有窗口 | 判断和扣减合到一条 SQL |
| 商品列表显示有货,点进去没货 | 只扣了 SKU 级,没扣商品级 | 两级库存同一事务一起扣 |
| 库存对得上,销量对不上 | 库存和销量分两次更新,中间失败 | 写在同一条 UPDATE 里 |
| 订单取消后库存多出来了 | 用户取消和超时任务都回滚了一次 | 回滚标记 + 带条件的 UPDATE 做幂等 |
| 大量库存被占着,实际没人付款 | 下单扣但没有超时释放 | 定时任务扫超时订单,取消并回滚 |
| 高并发下大量「库存不足」但实际有货 | 纯 version 乐观锁的常态(实测 200 并发抢 50 件只卖出 1~2 件) | 库存条件写进 SQL:AND stock >= #{num},别只靠 version |
这条链路上的其他几篇
- 《支付回调一定会重复,你的幂等挡不挡得住》
- 《订单状态机是怎么烂掉的,以及怎么不烂》
