SQL 悲观锁(FOR UPDATE / FOR SHARE)的行锁范围与死锁预防 checklist

2次阅读

selectfor UPDATE 加锁范围由执行计划、隔离级别和索引结构共同决定:唯一索引精确匹配时只加记录锁;范围查询、非唯一索引或无索引时会加间隙锁甚至表锁。

SQL 悲观锁(FOR UPDATE / FOR SHARE)的行锁范围与死锁预防 checklist

FOR UPDATE 加的到底是哪几行锁?

mysqlSELECT ... FOR UPDATE 不是“看到哪行锁哪行”,而是由执行计划 + 隔离级别 + 索引结构共同决定的锁范围。在 REPEAtable READ 下,InnoDB 默认使用 Next-Key Lock(记录锁 + 间隙锁),也就是说:

  • 如果 WHERE 条件命中唯一索引(如主键、唯一二级索引),且能精确匹配单条记录 → 只加 Record Lock(锁住该行)
  • 如果条件是范围查询(id > 10 AND id )、非唯一索引、或查不到数据(id = 999 不存在)→ 会锁住索引区间(含间隙),可能意外锁住“本不该改”的相邻行

常见错误现象:

  • SELECT * FROM order WHERE user_id = 123 FOR UPDATE,但 user_id 没有索引 → 全表扫描,升级为表锁(等价于锁整张表)
  • SELECT * FROM stock WHERE sku = 'A001' FOR UPDATE,而 sku 是普通索引(非唯一)→ 锁住所有 sku = 'A001' 的行 + 前后间隙,其他事务插入同 sku 新记录会被阻塞

实操建议:

  • 执行前先用 EXPLAIN 确认是否走了索引,避免隐式全表扫描
  • 尽量让 FOR UPDATE 的条件走唯一索引,缩小锁粒度
  • 不要依赖“WHERE 条件看起来很精确”就认为只锁一行——得看执行计划和索引类型

FOR SHARE 和 FOR UPDATE 在死锁链里怎么互相咬住?

FOR SHARE(共享锁)和 FOR UPDATE(排他锁)不是“和平共处”的:它们之间互斥,且都与意向锁(IS/IX)联动。一个典型死锁场景是两个事务交叉申请不同资源上的锁:

事务 A:
SELECT FROM account WHERE id = 100 FOR UPDATE; → 锁住 id=100
SELECT FROM account WHERE id = 200 FOR SHARE; → 等待 id=200 的共享锁(但事务 B 已持有)

事务 B:
SELECT FROM account WHERE id = 200 FOR UPDATE; → 锁住 id=200
SELECT FROM account WHERE id = 100 FOR SHARE; → 等待 id=100 的共享锁(但事务 A 已持有排他锁)

结果:互相等待,触发 MySQL 自动检测并回滚其中一个事务。

容易踩的坑:

  • 混用 FOR SHAREFOR UPDATE 时没统一访问顺序(比如一个按 id 升序,另一个按降序)
  • 在同一个事务里对多行加锁,但加锁顺序不固定(例如从缓存随机取 ID 列表后遍历)
  • 忘了 FOR SHARE 也会阻塞 FOR UPDATE(很多人误以为“只读锁不碍事”)

实操建议:

  • 同一业务逻辑中,对多行加锁务必按同一字段升序排序后再执行(如 ORDER BY id ASC
  • 能用 FOR UPDATE 统一解决的场景,别拆成 FOR SHARE + 后续 UPDATE —— 多一次 round-trip 就多一次锁竞争窗口
  • 开启 innodb_print_all_deadlocks = ON,把死锁日志落盘,别只靠应用层报错排查

为什么加了索引还是锁了大范围?间隙锁怎么关不掉?

间隙锁(Gap Lock)不是 bug,是 InnoDB 在 REPEATABLE READ 下防止幻读的必要机制。即使你加了索引、WHERE 精确匹配,只要隔离级别是 RR,且索引不是唯一索引,就仍可能触发间隙锁。

例如:
CREATE TABLE t (a INT, b INT, INDEX idx_b(b));
SELECT * FROM t WHERE b = 5 FOR UPDATE;
→ 若 b=5 对应多行(非唯一),InnoDB 会锁住所有 b=5 的记录,并在 b=5 前后间隙加锁(比如 b=4 和 b=6 之间的空档),阻止其他事务插入 b=5 的新行。

这不是配置能“关掉”的——除非:

  • 改用 READ COMMITTED 隔离级别(此时间隙锁禁用,但会引发幻读)
  • 把索引改成唯一索引(UNIQUE INDEX),让 InnoDB 自动优化为 Record Lock
  • 显式用 SELECT ... LOCK IN SHARE MODEFOR UPDATE 配合 SELECT ... INTO @var 提前判断是否存在,再决定是否 INSERT,绕开范围不确定性

性能影响明显:

  • 间隙锁会让并发 INSERT 变慢,尤其在高写入场景下(如订单号生成、秒杀库存扣减)
  • SHOW ENGINE INNODB STATUS 中的 TRANSACTIONS 段会显示大量 waiting for gap lock

死锁预防 checklist(贴在团队 Wiki 最好不过)

这不是“调参就能好”的问题,而是代码层必须约束的行为模式:

  • 所有 FOR UPDATE / FOR SHARE 查询必须走已存在的索引,禁止 type: ALLtype: index(全索引扫描)
  • 多行更新前,统一用 ORDER BY pk ASC 排序,pk 必须是主键或唯一键
  • 同一事务内,禁止先 SELECT ... FOR SHAREUPDATE,应直接 SELECT ... FOR UPDATE
  • 应用层设置合理超时:innodb_lock_wait_timeout(默认 50s)太长,建议设为 5–10s,配合重试逻辑
  • 检查慢查询日志里带 FOR UPDATE 的语句,确认其 Rows_examined 是否远大于 Rows_sent(说明扫描多、锁得多)

最常被忽略的一点:
FOR UPDATE 的锁持续到事务结束(COMMITROLLBACK),不是语句执行完就释放。所以长事务 + 悲观锁 = 锁持有时间不可控,比锁本身更危险。

text=ZqhQzanResources