MySQL 锁全景与死锁排查:行锁之上还有间隙锁——Java 工兵连第 13 篇
「Java 工兵连」第 13 篇。第 11 篇讲 MVCC 时留了个钩子:快照读看不见的行,当前读看得见,幻读靠 Next-Key Lock 补位——这篇就来兑现,把 InnoDB 的锁从表到行盘一遍,最后教死锁排查的固定流程。MySQL 线上事故三巨头:慢查询、锁等待、死锁——本篇承包后两个。
一、先建直觉:医院的号源
- Record Lock(记录锁):锁住已经放出来的某个号——精确到一条索引记录;
- Gap Lock(间隙锁):把两个号之间还没放出来的号段封死,不许加号(插入)——它锁的是「不存在的位置」;
- Next-Key Lock:连号带段一起封,左开右闭区间
(a, b]——RR 级别当前读的默认配置,防的就是「幻影病人」插队(幻读)。
全剧终理解句:行锁锁「有人的行」,间隙锁锁「还没人的位置」,合起来锁「一个范围」。
二、锁全景:从表到行
往下走之前快速过一下上层(面试送分,实战救命):
- 全局锁(
FLUSH TABLES WITH READ LOCK):全库只读,逻辑备份用; - 表锁(
LOCK TABLES):显式表锁很少用了,但隐性的 MDL(元数据锁)天天在——下一节单讲; - 意向锁(IS/IX):表级「路标」,告诉别人「这表里有行被锁了」,让表锁不用逐行检查——加行锁前自动加,不用管;
- 行级三兄弟:本篇主角,注意它们都挂在索引上。
加锁速判(RR 级别、当前读)
对着 hero 图的数轴(已有 id = 5, 10, 20),三句口诀:
| 查询 | 加什么锁 | 为什么 |
|---|---|---|
WHERE id=10(唯一索引,命中) |
Record Lock,只锁 id=10 | 唯一了,不会有第二个 10,防插没必要 |
WHERE idx=10(普通索引,命中) |
Next-Key + 后一个间隙 | 值可重复!要防别人再插一个 10 进来 |
WHERE id>10 AND id<20(范围) |
扫过的区间全上 Next-Key | 范围内任何位置都可能长出新行 |
一句话:唯一等值锁一行,普通等值锁到缝,范围扫描锁区间。
两个必踩的坑
坑一:行锁锁的是索引。 事务 A UPDATE t SET ... WHERE name='x',而 name 没有索引——InnoDB 只能全表扫描,扫过的每一行都上锁(实际退化为近似锁全表),B 的任何更新全部排队。所谓「行锁很细」,前提是条件走在索引上——第 6 篇的索引失效四宗罪,每一条都会顺手把锁放大成灾难。
坑二:MDL 连环堵车。 每个 SELECT 自动持有 MDL 读锁,DDL(加字段)要 MDL 写锁。堵车链条:一个长事务持着 MDL 读锁不放 → DDL 排队等写锁 → 之后所有查询排在 DDL 后面——整张表瞬间假死,而元凶只是那个不起眼的长事务(第 11 篇的老熟人)。处方:DDL 前先查 information_schema.innodb_trx 清掉长事务,或用 ALTER TABLE ... ALGORITHM=INSTANT(8.0 加列秒级)。
三、死锁:日志会说话
机理:事务 A 持锁 1 等锁 2,事务 B 持锁 2 等锁 1——环路成立,谁也走不了。OS 课讲过四条件:互斥、持有等待、不可剥夺、循环等待——破掉任何一环即可解。
InnoDB 的处理:死锁检测(wait-for graph,innodb_deadlock_detect 默认开)发现环路 → 回滚代价小的事务(undo 量小的那个)当祭品,另一个继续跑。所以死锁不是事故终点,被回滚的那个收到 ERROR 1213——应用层该做的是重试,而不是骂数据库。
排查就一条命令:
SHOW ENGINE INNODB STATUS\G
-- 看 LATEST DETECTED DEADLOCK 段,逐行读:
-- *** (1) TRANSACTION:事务 1 持什么锁(HOLDS THE LOCK)、等什么锁(WAITING FOR)
-- *** (2) TRANSACTION:事务 2 同上
-- *** WE ROLL BACK TRANSACTION (2):谁当了祭品
-- 配合 SQL 语句看加锁顺序,环路一眼可见
实时看谁堵谁:SELECT * FROM sys.innodb_lock_waits;(阻塞链条直出)。
最阴的一种:间隙锁死锁。 Gap Lock 有个反直觉特性——互相兼容(两个事务可以同时封同一段 gap,因为大家都是「防插队」目的一致)。但等两个事务都持着 gap 再各自 INSERT:插入要等对方的 gap 释放,而谁都不放——死锁。这就解释了很多「我明明只插了一行怎么也死锁」的灵异现场。处方在下节。
四、处方:四句话防死锁
- 顺序统一(最有效):多行更新永远按同一顺序(比如按主键升序)——循环等待的前提是访问顺序交错,顺序统一则环路不成立;
- 事务要短:持锁时间短,撞车窗口就小(第 11、12 篇的长事务,第三次报到);
- 索引到位:避免锁范围被扫描放大(坑一);
- 重试兜底:捕获 1213 死锁错误,小睡随机毫秒后重试——死锁被自动回滚的事务,语义上等于没执行过,重试是安全的。
catch (DeadlockLoserDataAccessException e) { // Spring 封装的 1213
Thread.sleep(50 + ThreadLocalRandom.current().nextInt(100));
retry(); // 死锁回滚后重试,不算重复执行
}
老兵点评
- 锁问题的排查顺序:先问「锁了多大范围」,再问「等了多久」。范围异常大 → 八成是索引缺失或失效把行锁放大了(坑一);等待异常久 → 八成是长事务持锁不放或 MDL 堵车。把这两个「八成」写进肌肉记忆,线上锁问题基本一轮定位。
- 间隙锁是 RR 的税。它防住了幻读,代价是并发插入更容易互堵互死。互联网高并发场景不少团队干脆降到 RC(没有 gap,只有行锁),用「读已提交 + binlog ROW」组合换并发——这不是玄学,是显式的取舍:你要隔离性还是要并发度。知道自己为什么选,比选什么重要。
- 面试段位差:初级背「共享锁排他锁」;中级讲三兄弟和加锁规则;高级讲 gap 死锁的成因(互相兼容但都挡插入)、死锁检测的回滚选择策略、以及 RC 换并发的取舍——聊到这层,你在聊数据库并发设计。
带走三句话
- 锁三兄弟:Record 锁点、Gap 锁缝(RR 特供、互相兼容但都挡插入)、Next-Key 锁 (a,b] 区间;速判口诀「唯一等值锁一行,普通等值锁到缝,范围扫描锁区间」。
- 两个大坑:行锁锁的是索引(没索引锁全表);MDL 连环堵车(长事务 → DDL → 全表排队)。
- 死锁排查一条命令(
SHOW ENGINE INNODB STATUS读 DEADLOCK 段);处方四句:顺序统一、事务要短、索引到位、重试兜底。
本篇是「Java 工兵连」第 13 篇 · MySQL 三部曲完结。→ 上一篇:第 12 篇 · redo / undo / binlog 三本账 | 继续阅读:第 14 篇 · Redis 数据结构与 bigkey | 浏览整个专栏 →。
❦ 终 ❦