VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

MySQL 锁全景与死锁排查:行锁之上还有间隙锁——Java 工兵连第 13 篇

「Java 工兵连」第 13 篇。第 11 篇讲 MVCC 时留了个钩子:快照读看不见的行,当前读看得见,幻读靠 Next-Key Lock 补位——这篇就来兑现,把 InnoDB 的锁从表到行盘一遍,最后教死锁排查的固定流程。MySQL 线上事故三巨头:慢查询、锁等待、死锁——本篇承包后两个。

一、先建直觉:医院的号源

  • Record Lock(记录锁):锁住已经放出来的某个号——精确到一条索引记录;
  • Gap Lock(间隙锁):把两个号之间还没放出来的号段封死,不许加号(插入)——它锁的是「不存在的位置」;
  • Next-Key Lock:连号带段一起封,左开右闭区间 (a, b]——RR 级别当前读的默认配置,防的就是「幻影病人」插队(幻读)。

全剧终理解句:行锁锁「有人的行」,间隙锁锁「还没人的位置」,合起来锁「一个范围」。

索引数轴上的锁三兄弟 + 加锁速判 + MDL 堵车 + 死锁现场

二、锁全景:从表到行

往下走之前快速过一下上层(面试送分,实战救命):

  • 全局锁(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 释放,而谁都不放——死锁。这就解释了很多「我明明只插了一行怎么也死锁」的灵异现场。处方在下节。

四、处方:四句话防死锁

  1. 顺序统一(最有效):多行更新永远按同一顺序(比如按主键升序)——循环等待的前提是访问顺序交错,顺序统一则环路不成立;
  2. 事务要短:持锁时间短,撞车窗口就小(第 11、12 篇的长事务,第三次报到);
  3. 索引到位:避免锁范围被扫描放大(坑一);
  4. 重试兜底:捕获 1213 死锁错误,小睡随机毫秒后重试——死锁被自动回滚的事务,语义上等于没执行过,重试是安全的。
catch (DeadlockLoserDataAccessException e) {   // Spring 封装的 1213
    Thread.sleep(50 + ThreadLocalRandom.current().nextInt(100));
    retry();   // 死锁回滚后重试,不算重复执行
}

老兵点评

  1. 锁问题的排查顺序:先问「锁了多大范围」,再问「等了多久」。范围异常大 → 八成是索引缺失或失效把行锁放大了(坑一);等待异常久 → 八成是长事务持锁不放或 MDL 堵车。把这两个「八成」写进肌肉记忆,线上锁问题基本一轮定位。
  2. 间隙锁是 RR 的税。它防住了幻读,代价是并发插入更容易互堵互死。互联网高并发场景不少团队干脆降到 RC(没有 gap,只有行锁),用「读已提交 + binlog ROW」组合换并发——这不是玄学,是显式的取舍:你要隔离性还是要并发度。知道自己为什么选,比选什么重要。
  3. 面试段位差:初级背「共享锁排他锁」;中级讲三兄弟和加锁规则;高级讲 gap 死锁的成因(互相兼容但都挡插入)、死锁检测的回滚选择策略、以及 RC 换并发的取舍——聊到这层,你在聊数据库并发设计。

带走三句话

  1. 锁三兄弟:Record 锁点、Gap 锁缝(RR 特供、互相兼容但都挡插入)、Next-Key 锁 (a,b] 区间;速判口诀「唯一等值锁一行,普通等值锁到缝,范围扫描锁区间」。
  2. 两个大坑:行锁锁的是索引(没索引锁全表);MDL 连环堵车(长事务 → DDL → 全表排队)。
  3. 死锁排查一条命令(SHOW ENGINE INNODB STATUS 读 DEADLOCK 段);处方四句:顺序统一、事务要短、索引到位、重试兜底。

本篇是「Java 工兵连」第 13 篇 · MySQL 三部曲完结。→ 上一篇:第 12 篇 · redo / undo / binlog 三本账 | 继续阅读:第 14 篇 · Redis 数据结构与 bigkey | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →