SELECT … FOR UPDATE 在事务中的锁升级与死锁风险

5次阅读

selectfor UPdate 在 WHERE 条件无有效索引时会锁整张表,因全表扫描导致聚簇索引全锁;需用 EXPLaiN 检查执行计划、统一加锁顺序、慎用间隙锁,并优先使用原子操作或唯一约束替代。

SELECT … FOR UPDATE 在事务中的锁升级与死锁风险

WHERE 条件没走索引,SELECT ... FOR UPDATE 会直接锁整张表

这不是“可能”,而是 InnoDB 的确定行为:当 SELECT ... FOR UPDATEWHERE 条件无法命中有效索引(比如字段无索引、用了函数如 DATE(create_time)、或发生隐式类型转换),mysql 就会退化为全表扫描,并对所有扫描过的行加锁——在可重复读(RR)隔离级别下,这往往等价于锁住整个聚簇索引,也就是事实上的表级锁定。

常见错误现象:
并发稍高时大量事务卡在 Waiting for table metadata lockWaiting for global read lock
EXPLAIN format=jsON 显示 key_locks 字段为空或范围极大
SHOW ENGINE INNODB STATUS 中看到 lock_mode X locks rec but not gap 变成 lock_mode X locks table

  • 实操建议:每次上线前用 EXPLAIN FORMAT=json 检查关键 FOR UPDATE 语句,确认 keykey_locks 字段是否符合预期
  • 复合查询务必建联合索引,例如 user_id = ? AND status = ? 就不能只依赖 user_id 单列索引
  • 禁止在事务中执行 SELECT ... FOR UPDATE WHERE name LIKE '%abc' —— 这种前导通配符必然走不了索引

多行更新顺序不一致,是死锁最典型的触发路径

死锁不是“运气差”,而是两个事务以相反顺序访问同一组主键。比如事务 A 先 SELECT ... FOR UPDATE WHERE id IN (5, 2)(实际按索引顺序锁了 2→5),事务 B 同时执行 SELECT ... FOR UPDATE WHERE id IN (2, 5) 却因查询优化器路径不同锁了 5→2,就形成 A 等 B 释放 5、B 等 A 释放 2 的循环等待。

使用场景:
• 批量状态更新(如“把一批订单设为已发货”)
• 分页任务队列消费(SELECT ... FOR UPDATE LIMIT 10

  • 实操建议:强制统一加锁顺序,例如始终加 ORDER BY id ASC,哪怕业务逻辑本身不需要排序
  • 避免在同一个事务里混合操作多张表且顺序随意,比如订单服务先锁 orders 再锁 inventory,支付回调却反过来——这是环形等待的温床
  • SELECT ... FOR UPDATE SKIP LOCKED 替代 LIMIT 分页取数,它能跳过已被锁的行,天然规避部分顺序冲突

间隙锁(Gap Lock)在 RR 隔离级别下悄悄扩大锁范围

在默认的可重复读(RR)下,SELECT ... FOR UPDATE 不仅锁匹配的行,还会锁住“值之间的空隙”。例如 WHERE age BETWEEN 20 AND 30,即使表中没有 age=25 的记录,InnoDB 也会锁住 (20,30) 这个区间,阻止其他事务插入 age=25 的新行——这就是间隙锁,它本意是防幻读,但副作用是大幅提高锁冲突概率。

参数差异:
READ COMMITTED 隔离级别下,间隙锁被禁用,只锁实际存在的行
SERIALIZABLE 下则更激进,会将范围查询升级为临键锁(Next-Key Lock),覆盖行+间隙

  • 实操建议:若业务能接受 RC 级别(比如日志类、统计类场景),可考虑降级隔离级别来关闭间隙锁
  • 高频更新的非唯一字段(如 status),不要单独建索引——它容易引发大面积间隙锁;改用联合索引前置该字段,或改用乐观锁
  • 避免在事务中先 SELECT ... FOR UPDATEINSERT 同一索引范围,比如先查 WHERE status = 0,再插一条 status = 0 的新记录,极易触发间隙锁冲突

别把 SELECT ... FOR UPDATE 当“安全保险丝”,它本身就是锁冲突源头

很多团队误以为“先查再更”比直接 UPDATE 更可控,结果反而更危险:一次 SELECT ... FOR UPDATE 锁的范围,常常比后续 UPDATE 语句本身需要的还大。尤其是当 SELECT 走的是非唯一索引,而 UPDATE 用的是主键时,前者锁了一片,后者只改一行,白白扩大了竞争面。

性能影响:
• 锁持有时间 = 从 SELECT ... FOR UPDATE 执行完到事务 COMMIT 的整个时长
• 一个 200ms 的计算逻辑放在事务内,就等于让锁多持有了 200ms

  • 实操建议:能用原子性 INSERT ON DUPLICATE KEY UPDATEUPDATE ... WHERE 直接完成的,就别拆成两步
  • 事务内只保留真正需要锁的最小操作集,把日志记录、http 调用、缓存更新等非数据库操作移出事务
  • 对幂等写入场景(如防重下单),优先用唯一约束(UNIQUE(user_id, order_date))替代 SELECT ... FOR UPDATE,失败走应用层重试,既免锁又快

最容易被忽略的一点:锁不是加在 SQL 上,而是加在索引结构上;你写的 SQL 看似简单,但执行计划稍有偏差,锁的粒度就可能从“一行”变成“一万个间隙”。所以别信直觉,每个 FOR UPDATE 都得拿 EXPLAININNODB STATUS 对着看。

text=ZqhQzanResources