普通 UPDATE 容易发生库存超卖,根本原因在于“读-判断-写”不是原子操作:两个并发请求同时读取到 stock=1,随后都执行减 1,最终就可能把库存扣成 -1;而 WHERE stock>0 属于快照读,不会加锁,因此无法阻塞其他事务的并发更新。

为什么普通 UPDATE 容易导致库存超卖
在电商系统下单场景中,如果只是简单执行 UPDATE product SET stock = stock - 1 WHERE id = 123 AND stock > 0,表面上看已经做了库存判断,但在高并发条件下,依然可能出现超卖问题。核心原因是:读取库存和更新库存不是一个不可分割的原子过程,两个请求可能同时读到 stock=1,然后先后执行减 1,最终导致库存被扣成 -1。
- MySQL 默认隔离级别为
REPEATABLE READ,无法彻底避免这种“读-改-写”带来的并发竞态问题 WHERE stock > 0属于快照读,不加行锁,不能阻止其他事务同时修改库存- 即使是单条 UPDATE,也是在条件满足后才执行,条件判断与真正更新之间仍然存在并发窗口
用 SELECT ... FOR UPDATE 显式加锁
如果在事务中先查询库存再进行扣减,就必须通过加行锁来保证同一条商品记录不会被多个事务同时修改:
START TRANSACTION; SELECT stock FROM product WHERE id = 123 LOCK IN SHARE MODE; -- ❌ 错误:共享锁不阻止其他事务 UPDATE SELECT stock FROM product WHERE id = 123 FOR UPDATE; -- ✅ 正确:排他锁,阻塞其他事务对这行的读写 -- 检查 stock >= 1,再执行 UPDATE UPDATE product SET stock = stock - 1 WHERE id = 123; COMMIT;
FOR UPDATE必须放在事务中使用,并且 WHERE 条件要命中索引(如主键或唯一索引),否则可能退化为表级锁- 如果 WHERE 条件没有走索引,MySQL 可能锁住整个聚簇索引,严重影响数据库并发性能
- 不要先在应用层判断库存再执行 UPDATE;正确做法是先锁定记录,再判断库存,再扣减,否则加锁没有实际意义
更安全的单 SQL 原子扣减方案
为了避免应用层先查后改带来的并发风险,更推荐把库存校验和库存扣减合并为一条 SQL,由 MySQL 直接保证原子性:
UPDATE product SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
- 执行完成后检查
ROW_COUNT():返回 1 表示扣减成功;返回 0 则说明库存不足或库存已经被其他请求扣完 - 这条 SQL 自身就是原子操作,理论上不一定需要额外事务包裹,但实际业务中仍建议放入事务,便于后续创建订单、写日志等操作统一提交
- 需要注意:
stock >= 1属于当前读,会触发隐式加锁(InnoDB 的 next-key lock),整体效果接近FOR UPDATE,但实现方式更简洁 - 不要写成
stock > 0,对于整数库存来说,最小合法值就是 0,使用>= 1在语义上更明确,也更符合库存扣减场景
事务失败后如何重试与幂等
即便使用了事务加锁或原子更新,实际线上环境中仍然可能因为网络抖动、死锁或连接断开导致事务失败,因此必须设计好重试机制与幂等控制:
- 捕获 MySQL 错误码:
1213(Deadlock)、2006(MySQL server has gone away)、2013(Lost connection)这类错误通常需要重试 - 重试前应加入随机短暂延迟(例如 10–100ms),避免大量请求同时重试造成雪崩
- 订单号必须保证全局唯一,插入订单表时可使用
INSERT ... ON DUPLICATE KEY UPDATE或唯一索引约束,防止重复创建订单 - 不要依赖“先查订单是否存在,再决定是否插入”的方式——查询加插入不是原子操作,正确方式是直接插入,利用唯一键做幂等拦截
真正困难的地方,并不只是把那条 UPDATE 写对,而是要准确理解锁的范围、掌握 FOR UPDATE 与 UPDATE ... WHERE 在不同索引命中情况下的差异,以及在分布式架构中如何与 Redis 库存预扣减协同配合。这些数据库并发控制细节一旦处理不当,往往只有在压测或生产高峰期才会暴露问题。
