update ... where stock > 0 不能真正防超卖,因其where条件判断与更新非原子,多个事务可能同时读到stock=1并都执行扣减致为负;真正可靠的是select ... for update显式加锁+事务内校验,确保判断与扣减在同一次加锁操作中完成。

为什么 UPDATE ... WHERE stock > 0 不能真正防超卖
很多人写秒杀扣库存时直接用:
UPDATE items SET stock = stock - 1 WHERE id = 123 AND stock > 0;看起来逻辑正确,但实际在高并发下仍会超卖。根本原因是:这个语句在执行前只做一次
stock > 0 判断,而判断和更新之间存在时间窗口;多个事务可能同时读到 stock = 1,都通过判断,然后都执行减 1,最终变成 -2。真正起作用的是行级锁的加锁时机——必须让“判断是否有库存”和“扣减”落在同一个加锁读+更新原子操作里,而不是靠 WHERE 条件过滤后才加锁。
- MySQL 的
UPDATE在 RR 隔离级别下会对扫描到的**所有满足 WHERE 条件的记录**加 next-key lock(行锁 + 间隙锁),但前提是这些记录真实存在且被引擎选中 - 如果 WHERE 中的条件(如
stock > 0)无法走索引,可能触发全表扫描,锁范围扩大甚至升级为表锁,性能崩盘 - 更危险的是:当
stock = 0时,该语句不匹配任何行,不加任何锁,完全失去并发控制能力
必须用 SELECT ... FOR UPDATE 显式加锁
正确做法是先查再更新,且查的时候就加上排他锁,把判断和扣减逻辑拆成两步,但确保中间不被其他事务干扰:
BEGIN; SELECT stock FROM items WHERE id = 123 FOR UPDATE; -- 检查 stock > 0,若否,ROLLBACK 并返回 UPDATE items SET stock = stock - 1 WHERE id = 123; COMMIT;
关键点在于:SELECT ... FOR UPDATE 会立即对主键(或唯一索引)定位的那行加 X 锁,后续任何其他事务对该行的 SELECT ... FOR UPDATE 或 UPDATE 都会被阻塞,直到当前事务结束。
- 必须在事务内执行,且隔离级别为
REPEATABLE READ(MySQL 默认),否则可能遇到幻读或锁失效 -
FOR UPDATE只对命中行加锁,所以WHERE条件必须能走主键或唯一索引(例如id = ?),不能是status = 'on' AND stock > 0这类非唯一条件,否则可能锁住多行甚至全表 - 不要在
SELECT ... FOR UPDATE后做耗时操作(比如调用外部 HTTP 接口),否则锁持有时间过长,拖垮吞吐量
为什么不能依赖应用层计数器或 Redis 扣减后异步落库
用 Redis 做库存预减(DECR)再异步写 MySQL,看似高性能,但在秒杀场景下极易出问题:
- Redis 扣成功 ≠ MySQL 能扣成功(比如唯一约束冲突、网络失败、事务回滚),导致库存不一致
- 如果 MySQL 扣减失败后没有补偿机制(比如回补 Redis 库存),就会永久少卖
- 用户下单成功但支付超时,需要回滚库存,此时 Redis 和 MySQL 两边都要可靠回滚,分布式事务成本远高于单库行锁
- 某些风控策略(如限购、黑名单)必须依赖 MySQL 中的用户订单历史,绕过 DB 就无法实时校验
除非你有成熟的 TCC 或 Saga 补偿框架,并接受最终一致性,否则别为了“快”牺牲强一致。秒杀的核心不是压测 QPS,而是保证每笔成交真实可履约。
实战中容易被忽略的三个细节
很多团队按上述方案写了代码,上线后还是超卖,往往栽在这几个不起眼的地方:
- 没给
items.id加主键,或者用了 UUID 字符串主键但未设为CHAR(36) PRIMARY KEY,导致SELECT ... FOR UPDATE无法准确定位单行,锁了索引范围甚至整张表 - 事务未设置超时(
innodb_lock_wait_timeout默认 50 秒),一个慢查询卡住锁,后面所有请求排队等锁,接口大面积超时 - 应用连接池配置不合理,比如最大连接数 20,但秒杀瞬间涌入 500 请求,大量请求在获取数据库连接阶段就排队,根本没机会走到
FOR UPDATE,造成假性“没锁住”
行级锁不是银弹,它把并发压力从“数据错乱”转移到了“锁竞争”上。真正压得住的系统,一定在锁粒度、索引设计、连接池、事务边界这四点上反复抠过细节。











