SELECT FOR UPDATE必须在显式事务中使用,否则autocommit=1导致锁瞬时释放;其行锁生效依赖WHERE命中索引,否则升级表锁;UPDATE须带stock>0原子条件并校验影响行数以防超卖。

SELECT FOR UPDATE 必须包在显式事务里才有效
单独执行 SELECT stock FROM goods WHERE id = 123 FOR UPDATE 不会锁住数据——MySQL 默认 autocommit=1,语句一结束锁就释放,等于没锁。真正起作用的前提是:用 BEGIN 或 START TRANSACTION 显式开启事务,并把“查库存→判断→扣减”三步全包进去。
常见错误包括:
- MyBatis 中把
@Select和@Update拆成两个带@Transactional的方法,导致两次独立事务,锁根本跨不过去 - 应用层在
SELECT FOR UPDATE后误加了COMMIT或提前关闭连接 - Spring 的
@Transactional注解没加在完整服务方法上,或传播行为被改成REQUIRES_NEW,造成事务嵌套失效
WHERE 条件必须走索引,否则锁表不是锁行
SELECT ... FOR UPDATE 能否命中行锁,完全取决于 WHERE 是否使用主键或唯一索引。如果条件不走索引(比如 WHERE status = 1 但 status 没建索引),InnoDB 会升级为锁表甚至锁全表,高并发下直接拖垮数据库。
验证方式很简单:执行 EXPLAIN SELECT ... FOR UPDATE,确认 type 是 const 或 ref,且 key 显示用了实际索引。还要注意字段类型匹配问题,比如传字符串 "123" 给 INT 类型的 id 字段,也会导致索引失效。
避免踩坑的实操建议:
- 商品 ID 必须是主键,或至少有唯一索引
- 别在
FOR UPDATE查询里JOIN多张表,尤其关联字段无索引时 - 模糊查询如
LIKE '%xxx%'无法走索引,禁止用于加锁场景
UPDATE 必须带 stock > 0 原子条件,不能只靠锁
光靠 SELECT FOR UPDATE 锁住一行,不等于防住了超卖。两个事务都能成功拿到锁,然后都在应用层读到 stock = 1,都判断为“可扣减”,接着各自执行 UPDATE goods SET stock = stock - 1 WHERE id = 123,结果库存变成 -1。
正确做法是把判断逻辑下沉到 SQL 层,让 UPDATE 本身具备原子性:
UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock > 0;
然后严格检查返回的受影响行数(JDBC 的 executeUpdate() 返回值、MyBatis 的 int 结果)。只有影响行数为 1 才算扣减成功;为 0 表示库存不足或已被抢光,应直接拒绝下单。
死锁和性能瓶颈比想象中更早出现
悲观锁不是“加了就万事大吉”。事务持有锁的时间越长,阻塞越严重,死锁概率越高。比如在事务里调用外部 HTTP 接口、做复杂计算、或写日志慢,都会延长锁持有时间。
容易被忽略的关键点:
- 锁的获取顺序必须一致,否则 A 先锁 X 再锁 Y、B 先锁 Y 再锁 X,立刻死锁
- 高并发下连接池可能被耗尽,表现为大量请求卡在
waiting for table metadata lock - 即使单行锁,若热点商品 ID 集中(比如秒杀 1 号商品),所有请求仍串行排队,吞吐量断崖下跌
真正在生产扛高并发的系统,很少纯靠 MySQL 悲观锁兜底——它适合中低并发、强一致性优先的场景;超过几百 QPS 就得考虑 Redis 分布式锁 + 库存预热,或直接切到最终一致性模型。


















