Skip to content

商品剩 1 件,两个人都下成功了

商品剩 1 件,两个人同时下单,都下成功了。

锁是加了的。 问题不在锁写得对不对,在于你什么时候扣这一件。

或者反过来:用户下了单没付钱,库存被占着,别人买不了,二十分钟后订单超时取消,库存才放出来——这二十分钟里,这件商品在货架上,但谁也买不走。

踩错的代价

这个选择没有标准答案,但选错了的代价是不对称的

下单扣:不会超卖,但会少卖。恶意下单不付款可以直接把你的库存锁死。

支付扣:不会锁库存,但会超卖。用户付了钱你发不出货,得退款道歉,严重的要赔。

超卖比少卖贵得多。 少卖是损失机会成本,超卖是已经收了钱交不出货——那是信任问题,不是钱的问题。

所以如果你只能选一个:选下单扣

两种做法的真实差别

下单扣支付扣
超卖风险
库存被恶意占用
需要超时释放需要不需要
支付失败要回滚需要不需要
适合大部分电商场景秒杀/预售等特殊场景

下单扣的两个必备配套

一、超时未支付要自动释放。 没有这个,下单扣就是给恶意占用开门。定时任务扫超时订单,取消并回滚库存。

二、回滚必须幂等。 这条最容易漏——后面单独说。

乐观锁挡的不是超卖

这是本篇最想说的一件事。

很多人的库存扣减长这样:

java
// 查出来,判断,再改 —— 中间有窗口
ProductSku sku = mapper.selectById(skuId);
if (sku.getStock() < num) {
    throw new BizException("库存不足");
}
sku.setStock(sku.getStock() - num);
skuMapper.updateById(sku);

然后有人说「加个乐观锁就好了」,于是变成:

java
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 version1~2 单48~490
同上 + 冲突重试 5 次5 单450
UPDATE ... WHERE stock >= 150 单00
库存判断不基于本次读到的值 + 重试152 单−102

第四行才是超卖的真实成因:库存是从缓存拿的、或者是上一步算好的,
跟这条 UPDATE 不是同一次读。version 只管「行没被改过」,
那个陈旧的库存值它一个字都不管,于是一路扣成负数。

所以不是「降低概率」,是压根管的不是这件事。

而第一行那个数字才是纯 version 乐观锁的日常代价:50 件货、200 并发,只卖出 1~2 件
48 件卖不掉,用户全看到「库存不足」——货明明还在。
这不是偶发,是常态,因为大家抢的是同一个 version,抢不到的全部失败。

真正挡超卖的写法

把库存条件放进 SQL:

sql
UPDATE product_sku
   SET stock = stock - #{num},
       sales = sales + #{num}
 WHERE id = #{id}
   -- 这一行才是防超卖的
   AND stock >= #{num}

然后看影响行数

java
int rows = mapper.deductStock(skuId, num);
if (rows == 0) {
    // 没扣到,就是不够
    throw new BizException("库存不足");
}

判断和扣减在同一条 SQL 里完成,中间没有窗口。 数据库的行锁替你保证了原子性。

stock >= numversion = ? 可以同时用,但要清楚它们防的是两件事

  • stock >= num超卖
  • version = ?并发覆盖(比如同时有人在改这行的其他字段)

只加乐观锁不加库存条件,是最常见的误解。

两级库存要一起扣

真实系统里库存通常有两级:

  • 商品级库存product.stock)—— 这个商品总共还有多少
  • SKU 级库存sku.stock)—— 具体规格(红色 XL)还有多少

下单时两级都要扣,而且要在同一个事务里。

漏扣任何一级的后果:

  • 只扣 SKU:商品列表页显示的库存不对,用户看到「有货」点进去发现没货
  • 只扣商品:某个规格能被无限下单

还有一对容易漏的:库存和销量。 扣库存的同时要加销量,回滚时反过来。真实实现里这两个是写在同一条 UPDATE 里的:

sql
SET stock = stock - #{num},
    sales = sales + #{num}

写在一条 SQL 里,而不是两次更新 —— 这样它们永远一致。分成两次,中间挂了就对不上了。

回滚必须幂等

下单扣库存,就必然要处理回滚:订单取消、支付超时、支付失败、退款。

这几个入口可能同时触发。 用户点了取消,同时超时任务也扫到了这单——库存被回滚两次,凭空多出货。

真实系统里的做法是在订单明细上加一个标记:

是否回滚库存:0-未回滚,1-已回滚

回滚前先看这个标记,已回滚就跳过。这个标记的更新和库存回滚要在同一个事务里,否则还是有窗口。

更稳的做法是把标记本身当作幂等锁:

sql
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

这条链路上的其他几篇

  • 《支付回调一定会重复,你的幂等挡不挡得住》
  • 《订单状态机是怎么烂掉的,以及怎么不烂》

签名-B

大粽子