MySQL库存扣减必须用事务包裹并配合SELECT ... FOR UPDATE加行锁,且UPDATE需内置stock>=1校验,高并发时应结合Redis预减缓存分流。

MySQL 库存扣减必须用事务包裹,否则一定超卖
单条 UPDATE 语句看似原子,但没加事务时,多个并发请求会读到同一份旧库存值,然后各自减一,结果就是扣多了。真实业务里,90% 的超卖都源于漏写 BEGIN 和 COMMIT。
实操建议:
- 务必在应用层显式开启事务:先执行
BEGIN,再查库存、判断是否足够、执行扣减、最后COMMIT或ROLLBACK - 不要依赖框架自动事务(比如 Spring 的
@Transactional),确认它真正生效——加日志或抓包看 SQL 是否被包裹 - 避免在事务里做 HTTP 调用、文件读写等长耗时操作,否则锁持有时间过长,拖垮吞吐
SELECT ... FOR UPDATE 是防超卖的关键动作
光有事务不够,还得让查询“带锁”。SELECT stock FROM goods WHERE id = 123 是快照读,不阻塞别人;换成 SELECT stock FROM goods WHERE id = 123 FOR UPDATE,才会对这行加行级写锁,后续同 ID 的 SELECT ... FOR UPDATE 或 UPDATE 都得排队。
常见错误现象:
- 用了
SELECT ... FOR UPDATE,但WHERE条件没走索引 → 锁升级为表锁,性能断崖下跌 - 在非主键/非唯一索引字段上
FOR UPDATE→ 可能锁住不止一行(间隙锁),引发意外阻塞 - 事务中先
SELECT ... FOR UPDATE,后面又执行了无关的UPDATE→ 锁可能提前释放,导致中间窗口期超卖
UPDATE 语句本身要带库存校验,不能只靠 SELECT
即使加了 FOR UPDATE,如果先查再更新,中间仍有极小窗口(比如事务提交前崩溃),更稳妥的是把判断和扣减合并进一条 UPDATE:
UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
执行后检查 ROW_COUNT()(MySQL 返回影响行数):等于 1 才算扣成功;等于 0 说明库存不足,直接回滚。
为什么这样做:
- 避免应用层判断后、更新前被其他事务抢走最后一份库存
- 减少一次网络往返(查 + 更)→ 降低延迟和锁持有时间
-
AND stock >= 1是硬性兜底,哪怕事务隔离级别降为READ COMMITTED也有效
高并发下别只靠数据库扛,加个缓存预减逻辑
当 QPS 上千时,所有请求都打到 MySQL 行锁上,会出现大量 Lock wait timeout exceeded 错误。这时单纯优化 SQL 没用,得在数据库前加一层缓冲。
可行做法:
- 用 Redis 的
DECR做预扣减:DECR goods:123:stock,返回值 ≥ 0 再进 DB 事务 - Redis 里库存值设为初始值 + 安全余量(比如 DB 是 100,Redis 设 110),防 Redis 和 DB 数据短暂不一致
- DB 扣减失败时,记得回调
INCR把 Redis 库存补回去,否则下次全扣光
这个方案不是替代事务,而是分流——把 80% 的无效请求(比如库存已为 0)挡在数据库外。真正难啃的,还是那几行带 FOR UPDATE 的 SQL。










