# 第 24 章 锁、加锁规则与死锁


> 本章解决"这条 SQL 到底锁了哪些行、锁到哪个区间"这一类只能靠推导回答的问题；它是第 22 章一致性读的另一半。

## 学习目标

读完本章，你应当能够：

1. 按作用范围复述 InnoDB 的锁层次（全局 → 表级 → 行级，含自增锁），说清 `FLUSH TABLES WITH READ LOCK` 的用途与危害，以及 `mysqldump --single-transaction` 绕开全局锁的机制与代价；
2. 画出元数据锁（metadata lock, MDL）的排队雪崩链条，并给出 `performance_schema.metadata_locks`、`sys.schema_table_lock_waits`、`lock_wait_timeout` 三个观测/干预入口；
3. 区分 `ALGORITHM=COPY/INPLACE/INSTANT` 各自允许的并发度，判断一条 DDL 会不会阻塞业务；
4. 用给定数据与索引结构逐区间推导 `SELECT ... FOR UPDATE` 的加锁范围，背出并**验证**加锁规则八条；
5. 说明唯一索引与非唯一索引等值查询为什么锁范围不同，以及意向锁为什么必须存在；
6. 解释 `INSERT` 的隐式锁、`INSERT ... ON DUPLICATE KEY UPDATE` 与 `REPLACE INTO` 的加锁差异；
7. 独立读懂 `SHOW ENGINE INNODB STATUS` 的 `LATEST DETECTED DEADLOCK` 段，并给出三条以上可落地的死锁预防手段；
8. 在乐观锁与悲观锁之间做取舍，写出用影响行数判断 CAS 成功与否的正确代码。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 聚簇索引与二级索引的物理差异、B+ 树叶子节点有序性 | 见第 23 章 23.2、23.3 节；本章所有推导都建立在索引有序性上 |
| 快照读与当前读的区别、`trx_id` 与 undo 版本链判断可见性 | 见第 22 章 22.4、22.6 节；本章只处理当前读加锁 |
| REPEATABLE READ 与 READ COMMITTED 的定义、幻读现象 | 见第 22 章 22.2 节；本章只解释"为什么 RR 必须有间隙锁" |
| 行格式中 `trx_id`、`roll_pointer` 的位置 | 见第 20 章 20.5 节；本章用 `trx_id` 解释隐式锁 |

## 24.1 锁的层次：从全局到行

| 锁 | 加锁方式 | 阻塞谁 |
|---|---|---|
| 全局读锁 | `FLUSH TABLES WITH READ LOCK` | 所有 DML（`SELECT` 仍可执行） |
| 表锁（共享/排他） | `LOCK TABLES ... READ/WRITE`、`ALTER TABLE` 部分阶段 | 与该表锁冲突的所有读写 |
| 元数据锁 MDL | 语句开始时**自动**加，事务结束时才释放 | DDL 与后续所有语句（见 24.2 节） |
| 意向锁 IS/IX | 加行锁前**自动**加 | 只与表锁冲突，意向锁之间不冲突（见 24.5 节） |
| 自增锁 AUTO-INC | 插入自增列时按 `innodb_autoinc_lock_mode` 加 | 并发插入获取自增值 |
| 记录锁/间隙锁/临键锁 | 当前读、DML 在索引记录上加 | 其他事务的当前读与写入（见 24.3、24.7 节） |

表级锁与全局锁是"层次"关系而非互斥关系：同一条 `UPDATE` 会**同时**持有 MDL 读锁（保护表结构）、可能一个意向排他锁（intention exclusive lock, IX）、若干行级临键锁。线上事故往往不是行锁造成的，而是最外层那把"看不见的锁"。

### 24.1.1 全局锁：唯一真实用途是取一致性备份点

`FLUSH TABLES WITH READ LOCK`（FTWRL）先关闭所有打开的表（写回脏页、清理表缓存），再对整个实例加一把全局读锁。DML 被挡而 `SELECT` 仍能执行，这常让人误以为"库没问题"，实际写入早已超时。它唯一的正当用途是在**不支持一致性快照的存储引擎**（如 MyISAM）上做逻辑备份，或人为造一个静止点。危害有三层：全实例范围、持有期间连接池被等锁的写请求耗尽、**会话断开不会自动释放**（要靠 `UNLOCK TABLES`）。所以只允许"加锁 → 记录位点 → 立刻解锁"的毫秒级窗口。

`mysqldump --single-transaction` 之所以能不长时间持有全局锁，是因为先开事务并建立一致性快照（`START TRANSACTION WITH CONSISTENT SNAPSHOT`），之后整个导出**读的都是这一份快照**，业务写入可以继续而导出结果依然自洽。注意代价与前提：

```sql
-- mysqldump --single-transaction 在服务端的等价骨架
FLUSH TABLES WITH READ LOCK;                   -- 只为拿一个与快照对齐的 binlog 位点，毫秒级
SHOW MASTER STATUS;                            -- 8.0.26+ 改为 SHOW BINARY LOG STATUS
START TRANSACTION WITH CONSISTENT SNAPSHOT;    -- 快照在此刻建立，之后所有读都看这个版本
UNLOCK TABLES;                                 -- 立刻释放全局锁，业务写入恢复
-- …… 后续所有表的读取都在事务内完成，靠 MVCC 看到同一个一致性版本 ……
COMMIT;
```

三个前提必须同时成立，否则 `--single-transaction` 是假的一致性：**只对事务型表有效**（MyISAM 表在快照外，仍需 `--lock-tables`）、**必须在 REPEATABLE READ 下**（若隔离级别被改成 READ COMMITTED，快照读会在每条语句重新取视图，导出不再自洽）、**导出期间不能有 DDL**（DDL 会因 MDL 被阻塞，同时可能破坏导出映射）。

> [!WARNING]
> `--single-transaction` 并非"完全不加锁"。它在导出期间对每张被读的表持有 MDL 读锁（语句级，但在一个长事务里等价于长持有），所以导出期间的 `ALTER TABLE` 会被卡住——这正是很多"备份任务一跑，发布就卡住"的根因。面试答"单事务备份不加任何锁"是错的。

## 24.2 MDL：线上锁事故的第一名

### 24.2.1 为什么必须有 MDL：兼容矩阵与"一直持有"

MDL 解决的问题是：**正在执行的一条语句，其表结构不能在执行期间被改掉**。没有 MDL 时，会话 A 已按旧表定义开始逐行扫描，会话 B 迅速完成 `ALTER TABLE t DROP COLUMN c`，A 继续按旧定义解析后续记录就会读到错位的字节。它的两个特性决定了杀伤力：**自动加**（DML 加读锁、DDL 加写锁），且**在显式事务里一直持有到事务结束**（不是语句结束）。兼容性只有一句话：读锁与读锁兼容，其余两两互斥。

| 请求方 \ 已持有 | MDL 读锁（SELECT/DML） | MDL 写锁（DDL） |
|---|---|---|
| MDL 读锁（`SELECT`） | 兼容 | **冲突** |
| MDL 写锁（`ALTER`） | **冲突** | 冲突 |

### 24.2.2 排队雪崩：为什么"一个长事务 + 一条 DDL"能把全表打死

MDL 的请求是一个**先来先服务队列**，而队列里的写请求会挡住它后面的读请求——即使读请求彼此兼容，这是最反直觉也最常考的一点：

```
时间 ────────────────────────────────────────────────────────────────────▶
会话 A  BEGIN; SELECT ...;  └──── 持有 MDL 读锁，迟迟不 COMMIT ────┘
会话 B                        └─ ALTER TABLE ... 申请写锁，排在队首，等 A
会话 C/D/E                                   └─ SELECT ... 申请读锁，被 B 挡住（队列持续变长）
结果：A 不提交 ⟹ B 拿不到写锁 ⟹ C/D/E 全部排队 ⟹ 连接池耗尽 ⟹ 整个库不可用

MDL 请求队列（队首先获得锁；写锁是"排头兵"：它没拿到锁，后面兼容的读锁也不放行）
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ A: 读锁(已得)│ B: 写锁(等)  │ C: 读锁(等)  │ D: 读锁(等)  │
└──────────────┴──────────────┴──────────────┴──────────────┘
长事务迟迟不释放，整条队列原地冻结
```

> [!CAUTION]
> 不要说"MDL 读锁之间兼容所以后面的查询不受影响"：它们确实互相兼容，但**队列不允许插队**——写锁一旦排到队首，后续所有请求（包括兼容的读锁）都必须等它。面试官追问"那怎么避免"时，标准回答是"DDL 前先杀掉长事务 / 设置 `lock_wait_timeout` 让 DDL 快速失败并重试"，而不是"DDL 不会阻塞查询"。

### 24.2.3 观测与干预

```sql
-- performance_schema 自 5.7 起记录 MDL 等待（5.7 上可能看不全），8.0 重写哈希结构后可信度提高
SELECT * FROM performance_schema.metadata_locks WHERE LOCK_STATUS = 'PENDING';
SELECT * FROM sys.schema_table_lock_waits;
SELECT trx_id, trx_mysql_thread_id,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;  -- MDL 读锁的持有者一定是未提交事务
-- lock_wait_timeout 只管表锁与 MDL 的等待上限（与行锁超时是两个独立参数），默认一年量级，发布脚本应显式设置
SET SESSION lock_wait_timeout = 5;
```

`wait_timeout` 管的是**空闲连接**存活时长（与应用连接池的 `maxLifetime` 配合），它不会解除一个正在等锁的会话——等锁的会话不是空闲状态。真正能打断"DDL 等 MDL"的是 `lock_wait_timeout`（会话级变量，超时后 DDL 以错误返回而不是无限等待）。两者常被混为一谈，属于必考辨析点。

### 24.2.4 Online DDL 的三种算法与各自锁范围

DDL 究竟阻塞多少业务取决于它用到哪种算法。优化器按 `INSTANT > INPLACE > COPY` 的顺序尝试，也可以用 `ALGORITHM` 与 `LOCK` 子句强制要求（不满足则报错而不是降级，这正是"上线前先验证 DDL 会不会重建表"的手段）：`INSTANT`（8.0.12+ 加列、8.0.29+ 任意位置加列/删列）只改数据字典、秒级完成，加列带默认值、改默认值、重命名列、`ENUM` 追加值都属于它；`INPLACE` 不拷贝整表但可能重建索引，加删二级索引与 `VARCHAR` 扩长（未跨 256 字节边界）属于它，是否阻塞 DML 取决于写成 `LOCK=NONE` 还是 `LOCK=SHARED`；`COPY` 建临时表逐行拷贝，改列类型、缩小 `VARCHAR`、加删主键、加空间索引、改字符集都要它，全程 DML 被阻塞、只能并发查询。

```sql
-- 让 DDL 要么以指定算法成功、要么直接失败暴露风险，
-- 而不是被服务端静默降级成 COPY 后在业务高峰把表锁死
ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT;
ALTER TABLE t ADD INDEX idx_age(age), ALGORITHM=INPLACE, LOCK=NONE;
```

> [!NOTE]
> `INPLACE` 不等于"不锁"：它在**准备阶段**（prepare）与**提交阶段**（commit）各需要一次短暂的 MDL 写锁，只有执行阶段可以并发 DML。所以即便 `LOCK=NONE`，长事务同样会让 DDL 卡在准备阶段——这就是 24.2.2 的雪崩在 online DDL 场景下的再现；大表加列在 8.0.12 前仍需重建整表，这也是业界引入 `gh-ost`、`pt-online-schema-change` 的原因（见第 25 章 25.7 节）。

## 24.3 行锁的三种形态与 RR 为什么必须加间隙锁

InnoDB 的行锁加在**索引记录**上，不是加在"行"上——这是后面所有推导的根基。如果一条 SQL 用不到任何索引，它只能扫描聚簇索引并给扫描过的每条记录加锁，效果接近锁全表。

```
索引记录（按索引键升序）与三种锁的覆盖关系
        id=5            id=10           id=15           id=20
   ────────●───────────────●───────────────●───────────────●────────▶
   (-∞,5)      (5,10)      (10,15)     (15,20)      (20,+∞)    ← 间隙，不含端点

 记录锁 Record Lock：只锁 ● 这一个索引记录，阻止对它做 UPDATE/DELETE
 间隙锁 Gap Lock   ：只锁一个开区间 (a,b)，**只在 RR 下存在**；阻止区间内 INSERT，
                     但不阻止其他事务对区间内的记录加锁（这一点是 24.7 那类死锁的根源）
 临键锁 Next-Key Lock = 记录锁 + 该记录之前的间隙锁 = (a,b]，RR 下加锁的默认单位
 插入意向锁 Insert Intention：INSERT 前对目标间隙加的"我要插这里"的意向，彼此兼容
```

**RR 必须有间隙锁，是因为它要挡住的不是"已有行的修改"，而是"原本不存在的行的出现"**：RR 下的当前读必须让同一事务重复执行同一条范围查询时返回相同结果集，一旦允许别的会话在自己扫描过的区间插入新行，第二次查询就会多出记录（幻读），而当前读不走 MVCC 快照、无法靠版本链屏蔽，只能锁住插入。RC 下每条语句取一次新快照，幻读在语义上被接受，于是 InnoDB 在 RC 下**关闭间隙锁**（只留记录锁），锁范围显著缩小——代价是加锁读不再保证范围稳定，且半一致性读会让 `UPDATE` 参照最新已提交版本（见第 22 章 22.2 节）。

> [!IMPORTANT]
> "间隙锁只在 RR 下存在"是必答点，还要补一句：把隔离级别降到 RC 能消掉大量插入型死锁与锁等待，这是高并发写入系统从 RR 降到 RC 的第一动机（5.7 起 `innodb_locks_unsafe_for_binlog` 已废弃、8.0 移除，降级只能改隔离级别）。但 RC 让基于加锁读的"先查后改"失去范围保护，应用层必须自己补乐观锁或唯一约束。

## 24.4 加锁规则八条：逐条场景与验证

以下规则以 MySQL 5.7/8.0 官方文档的 InnoDB 锁模型为准，适用于 RR 隔离级别下的当前读（`SELECT ... FOR UPDATE` / `LOCK IN SHARE MODE` / `UPDATE` / `DELETE`）。推导共用同一张表与同一批数据：

```sql
CREATE TABLE t (
  id   INT PRIMARY KEY,               -- 聚簇索引，唯一
  age  INT,
  name VARCHAR(16),
  KEY idx_age (age)                   -- 普通二级索引（非唯一），叶子节点按 (age, id) 有序
) ENGINE=InnoDB;

INSERT INTO t VALUES (5,5,'a'),(10,10,'b'),(15,15,'c'),(20,20,'d'),(25,25,'e');
-- 于是 idx_age 的叶子记录序列是：(5,5) (10,10) (15,15) (20,20) (25,25)
```

| # | 规则 | 用上面的数据举例 | 验证 SQL（另一会话执行，观察是否阻塞） |
|---|---|---|---|
| 1 | 加锁的基本单位是**临键锁** `(a, b]`，退化成记录锁或间隙锁只是优化结果 | 任何范围当前读默认锁一整段区间 | `BEGIN; SELECT * FROM t WHERE age=15 FOR UPDATE;` |
| 2 | **查找过程中访问到的对象才会加锁**；优化阶段能确定"等值且唯一命中一行"时会退化 | `id=10` 只走聚簇索引一层，锁 1 条记录 | `SELECT * FROM t WHERE id=10 FOR UPDATE;` 与 `WHERE age=10 FOR UPDATE;` 对比锁数量 |
| 3 | 索引上的**等值查询**，对**唯一索引**加锁时退化为**记录锁** | `id=10` → 只锁 `id=10`，`(5,10)` 间隙不锁 | 另一会话 `INSERT INTO t VALUES (7,7,'x')` 成功 |
| 4 | **唯一索引等值查询但记录不存在**时，退化为**间隙锁** | `id=12` 不存在 → 锁 `(10,15)` | 另一会话插 `id=11`/`id=14` 阻塞，插 `id=7`、`id=16` 成功 |
| 5 | **非唯一索引等值查询**会**向后扫描到第一个不满足条件的记录**，因此锁住的是更宽的临键锁区间 | `age=15` → 范围 `(10,20]`（`idx_age` 上再往后多探一条 `age=20`） | 另一会话插 `(11,10,'x')` 也阻塞，因为 `(age=10,id=11)` 落在 `(10,10]` 与 `(15,15]` 之间 |
| 6 | **范围查询**会一直扫描到**第一个不满足条件的记录**才停止，并把沿途对象全部加锁 | `age >= 15 AND age < 20` → 扫到 `age=20` 才停，锁 `(10,20]` | 另一会话插 `age=17` 阻塞，插 `age=21` 成功 |
| 7 | **唯一索引的范围查询**在**已扫描到的记录**上退化为记录锁，但仍会保留一个"最后一条不满足条件记录之前"的间隙锁 | `id >= 10 AND id < 16` → 锁 `id=10`、`id=15` 记录加 `(5,10)` 间隙锁 | 另一会话插 `id=7` 阻塞、插 `id=11` 成功 |
| 8 | **`LIMIT` 能减小加锁范围**：扫描到足够行数就停，不再往后多探 | `age=15 LIMIT 1` 扫到 `(15,15)` 即满足，无需再探 `age=20` | 另一会话插 `age=17` 阻塞（间隙仍在），但锁的右边界收窄 |

**唯一索引与非唯一索引的差异（规则 3 与规则 5 的对照推导）**——这是本章最值得背下来的对比：

```
【等值查询 id = 10】（唯一索引，命中）
  聚簇索引一趟定位到 id=10 → 记录锁（规则 3）；范围 { id=10 }，可插入 id=7/11/14
【等值查询 age = 10】（非唯一索引，命中）
  定位到第一条 age=10 的记录 (10,10)；但 age 不唯一，必须向右扫到第一条 age≠10 的记录
  (15,15) 才敢停——为确认后面没有第二个 age=10 的行，这一步无法省略
  → (10,10] 临键锁 + (15,15] 临键锁（(5,10) 来自 (10,10] 的前半段），最终范围 (5, 15]（左界是 5 而不是 10）
  可插入 id=1、id=100；但 (age=7,id=1)、(age=10,id=11)、(age=14,id=9) 全部阻塞，区间宽度 3 vs 1
  —— 唯一索引省下的是整个扫描尾部，不是"一点"
```

> [!TIP]
> 现场推导的固定四步：**(1) 判断走哪个索引；(2) 在该索引的有序序列上定位起点；(3) 按"等值是否唯一命中 / 是否范围查询"决定要不要继续向后扫到第一个不满足条件的记录；(4) 把扫过的每条记录都变成它自己的临键锁 `(前一条, 本条]`，再合并重叠区间**。合并后的开区间就是真正的加锁范围，任何 SQL 都能推。

## 24.5 意向锁：让表锁不必逐行检查

意向锁（intention lock）是表级锁，只有一个用途：**在给行加锁之前先在表上留下"这表里已经有行锁了"的标记**，让随后到来的表锁能 O(1) 判断能否获得。没有它时，`LOCK TABLES t WRITE` 为判断能否加锁就必须遍历全表所有记录看有没有行锁——代价与表大小成正比，而表锁本身就是为"快速挡住一批操作"存在的。

| 已持有 \ 新请求 | IS | IX | 表级 S | 表级 X |
|---|---|---|---|---|
| 意向锁 IS / IX | 兼容 | 兼容 | IS 兼容、IX **冲突** | **冲突** |
| 表级共享锁 S | 兼容 | **冲突** | 兼容 | **冲突** |
| 表级排他锁 X | **冲突** | **冲突** | **冲突** | **冲突** |

矩阵里唯一需要背的是**左上角 2×2 全兼容**：意向锁之间从不冲突，因为它们的语义只是"表里有行锁"，而多个行锁并存是正常的。所以意向锁本身几乎不会成为瓶颈，它只在"有人在加表锁"时才有存在感——这也解释了为什么"意向锁会导致死锁"是错误说法。

> [!NOTE]
> 面试官常追问的下一层是：**意向锁的加锁顺序**。InnoDB 给行加锁前先加对应的意向锁（行级 S 前加 IS，行级 X 前加 IX），且意向锁在事务提交/回滚时才释放；这保证了"表锁与行锁之间不存在中间状态"，即表锁绝不会在看到行锁标记之前就拿到锁。`innodb_table_locks=ON`（默认）时 `LOCK TABLES` 才会去检查行锁，把它关掉则 `LOCK TABLES` 不再与行锁交互，这在一些批量导入脚本里被当作"提效"手段，代价是可能读到不一致数据。

## 24.6 INSERT、隐式锁与三条特殊路径

### 24.6.1 隐式锁：插入为什么不需要显式加锁

`INSERT` 不显式加锁，因为**它插入的是尚不存在的新记录，其他事务在它提交前根本看不见它**（一致性读靠版本链屏蔽）；而且为每次插入都建锁结构，纯插入型负载的内存开销会成倍上升。隐式锁（implicit lock）的实现极轻：新记录的记录头写上当前事务的 `trx_id`；当**其他事务**用当前读访问到它、发现这个 `trx_id` 对应未提交事务时，就**替它补一把锁**：

```
时刻 1  事务 T1: INSERT INTO t VALUES (12,12,'x');   -- 记录头 trx_id = T1，无显式锁
时刻 2  事务 T2: SELECT * FROM t WHERE id=12 FOR UPDATE;  └─ 读到 id=12，查记录头 trx_id = T1
                  且 T1 活跃（在 innodb_trx 里）⟹ T2 以 T1 的身份补加 LOCK_REC（即"隐式锁转显式锁"）
时刻 3  其他想锁 id=12 的事务现在会等这把补出来的锁 / 等 T1 提交
```

面试官会追问"判断 `trx_id` 是否活跃要查哪里"：`information_schema.innodb_trx`（内存中的事务表）。若该 `trx_id` 已不在活跃列表里，说明事务早已提交或回滚，无需补锁。

### 24.6.2 INSERT ... SELECT、外键与冲突插入

| 操作 | 加锁行为 | 为什么 |
|---|---|---|
| `INSERT INTO a SELECT ... FROM b` | RC/RR 下对 `b` 加**共享临键锁**（当前读语义） | 保证读出的行在写入 `a` 前不被改动，否则数据不一致；它是**当前读**而非快照读，所以会锁源表——这就是"`INSERT ... SELECT` 锁住整张源表"的原因 |
| `CREATE TABLE a AS SELECT ... FROM b` | 对 `b` 加共享锁（RC 下不加） | 同上 |
| 外键约束：删/改父表记录 | 对**子表**对应记录加**共享记录锁**；插入/更新子表也会锁父表 | 必须确认没有子行引用它才能删父行；锁的是记录而非间隙，所以父表插入新值不被挡 |
| 唯一键冲突的插入 | 对冲突记录加排他记录锁 | 要串行化"谁赢"；`INSERT IGNORE` 与报 1062 的普通 `INSERT` 也会短暂持有 |

### 24.6.3 ON DUPLICATE KEY UPDATE 与 REPLACE INTO

| 维度 | `INSERT ... ON DUPLICATE KEY UPDATE` | `REPLACE INTO` |
|---|---|---|
| 冲突时的动作 | 直接 `UPDATE` 冲突行（原地改），主键不变 | **`DELETE` 冲突行 + `INSERT` 新行**，主键会变 |
| 自增与级联 | 不消耗自增值、不触发级联 | 旧行被删、新行拿**新的自增值**，指向它的外键可能被级联删除/置空 |
| 加锁范围 | 冲突记录上的排他记录锁（唯一索引等值命中） | 冲突记录排他锁 + 删除位置与插入位置涉及的间隙锁，还可能触发索引页分裂 |
| 结论 | 幂等 upsert 的**默认选择** | 只在"必须彻底替换整行且不在乎主键"时使用 |

> [!WARNING]
> `REPLACE INTO` 最常被踩的坑是**触发器与级联**：它具有"删除"语义，因此会触发 `ON DELETE` 级联、让审计触发器记成一次删除加一次插入、并让自增主键跳号。面试题"用 `REPLACE` 做幂等写入有没有问题"，答"没问题，反正是覆盖"就是典型错误回答；正确起点是"它有删除语义，主键与外键都会变，且加锁范围大于 `ON DUPLICATE KEY UPDATE`"。

## 24.7 死锁：四条件在 InnoDB 里的具体形态

操作系统课上的死锁四条件在 InnoDB 里逐一对应：

| 必要条件 | 在 InnoDB 中的具体形态 | 被打破的常见手段 |
|---|---|---|
| 互斥（mutual exclusion） | 行级排他锁天然互斥，无法共享 | 无法打破（锁的定义） |
| 请求并保持（hold and wait） | 事务持有 A 记录的锁，同时申请 B 记录的锁 | 一次性按序获取全部锁（按固定顺序访问） |
| 不可剥夺（no preemption） | InnoDB 不会强行抢走已授予的锁，只能让等待者超时或回滚 | 设置 `innodb_lock_wait_timeout` 让等待失败并重试 |
| 循环等待（circular wait） | 等待图里出现环：T1 等 T2、T2 等 T1 | 全局统一的加锁顺序；`innodb_deadlock_detect` 发现环即回滚代价小的一方 |

**最典型的死锁：两个事务以相反顺序更新两行。**

```
时刻   事务 T1                                    事务 T2
 t1    BEGIN;                                      BEGIN;
 t2    UPDATE ... WHERE id=1;   ── 持有 id=1 的排他记录锁
 t3                                                UPDATE ... WHERE id=2;  ── 持有 id=2 的锁
 t4    UPDATE ... WHERE id=2;   ── 申请 id=2，被 T2 挡住，等待
 t5                                                UPDATE ... WHERE id=1;  ── 申请 id=1，等待
 t6    等待图出现环 T1→T2→T1，死锁检测触发，回滚"代价较小"的那个事务，另一个正常提交
```

RR 下还有一类只有 InnoDB 会有的死锁，它更能体现间隙锁性质：**两个事务先对同一间隙各加一把共享间隙锁（间隙锁之间兼容，所以都成功），随后各自要在该间隙内插入，插入意向锁与对方持有的间隙锁冲突，互相等待**。这解释了为什么"降低隔离级别到 RC"能消掉大量插入型死锁——RC 下根本没有间隙锁。

### 24.7.1 死锁日志怎么读

```
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:  TRANSACTION 4213, ACTIVE 3 sec starting index read   ← 牺牲者，已回滚
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s); thread id 12, query id 88 updating
UPDATE account SET bal = bal - 1 WHERE id = 2      ← 它当时在执行、也因此死锁的语句
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3 page no 4 n bits 72 index PRIMARY of table `test`.`account`
 lock_mode X locks rec but not gap waiting   ← 排他记录锁且不含间隙 ⟹ 争的是唯一索引上的行
*** (2) TRANSACTION:  对称地持有 id=1、在等 id=2
UPDATE account SET bal = bal + 1 WHERE id = 1
*** (2) HOLDS THE LOCK(S): index PRIMARY ... lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... lock_mode X locks rec but not gap waiting
```

读法固定为三步：**先看每个 `TRANSACTION` 块最后一条 SQL**（通常直接暴露顺序不一致）；**再看 `WAITING FOR THIS LOCK TO BE GRANTED` 的 `index` 与 `lock_mode`**（判断争的是主键还是二级索引、是纯记录锁还是含间隙的 `locks gap before rec`）；**最后用 `HOLDS THE LOCK(S)` 与对方的 `WAITING FOR` 对照，闭合等待环**。`index PRIMARY` 与 `index idx_age` 的差别极为关键——同一个业务逻辑走了不同索引，加锁范围完全不同，死锁形态也会不同。

```sql
SHOW ENGINE INNODB STATUS\G   -- 只保留"最近一次"死锁；排查频发死锁必须开下一个
SET GLOBAL innodb_print_all_deadlocks = ON;    -- 把每次死锁写进错误日志，用于统计与聚类
```

### 24.7.2 `innodb_deadlock_detect` 的代价与预防手段

死锁检测的开销容易被低估：**每当一个加锁请求需要等待，InnoDB 就要做一次等待图环检测**，复杂度与此时等待中的事务数同阶（`O(n)` 量级）。在热点行（例如同一商品的库存行）上成千上万个事务同时排队时，这部分 CPU 会急剧膨胀，表现为"CPU 打满但 QPS 不涨"。两种处理方式：**保留检测**（默认，推荐）让冲突立即以 1213 返回、应用捕获后重试；**关闭检测**（`innodb_deadlock_detect=OFF`）把环检测的 CPU 换成"等待 `innodb_lock_wait_timeout`（默认 50 s）的连接占用"，只有在业务能保证不会真正死锁（所有访问严格按主键升序、单语句事务）时才可接受，否则一个真死锁会挂满连接池，比 1213 危险得多。预防手段按性价比排序：

| 手段 | 做法 | 消除的是哪个条件 |
|---|---|---|
| 固定顺序访问 | 批量更新前对主键排序；`WHERE id IN (...)` 隐含按索引序加锁，但仍建议业务侧统一顺序 | 循环等待 |
| 缩短事务 | 把 RPC、消息发送、循环计算移出事务；事务内只留 SQL | 请求并保持 |
| 降隔离级别到 RC | 消除间隙锁，插入型死锁基本消失 | 循环等待（间隙锁引发的） |
| 减少加锁范围 | 走唯一索引而非二级索引；用 `LIMIT`；避免无索引 `UPDATE` | 请求并保持的锁数量 |
| 用唯一索引把并发插入变串行 | 先 `INSERT` 唯一键占位，冲突则走 `UPDATE`；让数据库而非应用判断谁先 | 互斥被复用为"业务串行化" |
| 应用层重试 | 捕获 1213（死锁）与 1205（等锁超时），带退避与幂等重试 | 不消除死锁，但把不可用变成抖动 |

> [!IMPORTANT]
> 死锁不是 bug，是并发系统的正常反馈。面试官真正想听的是"**我如何让死锁可观测、可恢复、可收敛**"：`innodb_print_all_deadlocks` 收集、按 SQL 聚类、统一加锁顺序、1213 自动重试。只答"加锁顺序一致"会被追问"那两个微服务各自的顺序怎么统一"。

## 24.8 乐观锁与悲观锁、锁等待监控

### 24.8.1 悲观锁与"先查后改"的非原子性

`SELECT ... FOR UPDATE` 是悲观锁的标准用法，它把"读到的行"锁到事务结束；**必须走索引**，否则会锁住扫描到的全部记录（退化成近似锁表）。不用锁的"先查后改"则会出现经典的**检查-使用竞态（check-then-act）**：两个线程都读到库存 1、都判断"够扣"、各自扣减，最终超卖——解法不是加长事务，而是**把判断与写入合并成一条原子语句，用影响行数判断结果**：

```sql
-- 乐观锁：把"读-判断-写"压成一条带条件的 UPDATE。
-- 受影响行数是唯一可信的成功标志，比"再查一次版本号"可靠——后者又引入一个读取窗口
UPDATE account
   SET bal = bal - #{n}, version = version + 1
 WHERE id = #{id} AND version = #{oldVersion} AND bal >= #{n};
```

```java
// 必须用影响行数判断；catch 不到异常不代表更新成功，这是最容易漏的一步。
// affectedRows = 0 有三种来源：版本过期、条件不满足（余额不足）、"新旧值相同"（MySQL 不计数），
// 因此要么保证语义上不存在等值更新，要么再查一次区分原因，不要一律当冲突重试
int rows = mapper.casUpdate(id, n, oldVersion);
if (rows == 0) {
    throw new ConcurrentModifyException();
}
```

> [!WARNING]
> 两个高频错误：**先 `SELECT` 出版本号、中间隔着网络 RPC 再回写**（窗口内别人已改过，容易更新丢失）；**只看 `UPDATE` 是否抛异常、不检查 `affectedRows`**（CAS 失败被静默忽略，业务以为扣款成功）。另外 `UPDATE` 前后值恰好相同时 `affectedRows` 也是 0，把 0 一律当冲突会导致误重试。

冲突率决定取舍：冲突率低时乐观锁几乎零等待、吞吐高；冲突率高时它退化为"重试风暴"（大量失败请求反复重试，CPU 花在无用功上），此时悲观锁串行反而更稳。工程上的第三条路是**把串行化下沉到唯一索引**：用 `INSERT` 一条唯一键记录表达"我抢到了"，唯一约束天然原子，既不需要版本号也不需要 `FOR UPDATE`。

| 维度 | 悲观锁（`FOR UPDATE`） | 乐观锁（版本号/CAS） |
|---|---|---|
| 加锁时机 | 读的时候立即加，持有到事务结束 | 不加锁，写的时候用条件判断 |
| 冲突率低时 | 白拿锁，浪费少量开销 | 最优，几乎无等待 |
| 冲突率高时 | 串行等待，但不会做无用功 | 大量重试失败，吞吐可能崩 |
| 长事务风险 | 高（锁一直持有，易引发 MDL/行锁堆积） | 低（事务极短） |
| 跨服务可用性 | 不行（数据库锁只在本实例有效） | 可以（版本号随数据走） |

### 24.8.2 两个超时参数与锁监控

| 参数 | 管什么 | 超时后 | 默认值口径 |
|---|---|---|---|
| `innodb_lock_wait_timeout` | InnoDB **行锁**等待 | 报 1205 `Lock wait timeout exceeded` | 50 s |
| `lock_wait_timeout` | **元数据锁（MDL）**与显式表锁等待 | 语句以错误返回（DDL 失败而非永久等待） | 极大（一年量级），所以 DDL 常被无限期挂着 |
| `wait_timeout` | **空闲连接**存活时长 | 连接被服务端关闭 | 与锁无关，常被误答 |

```sql
-- 8.0 的排障入口：data_locks 是"当前持有/等待的锁"，data_lock_waits 是"谁在等谁"；
-- 二者替代了 5.7 的 information_schema.innodb_locks / innodb_lock_waits（8.0 已移除）
SELECT * FROM performance_schema.data_locks;       -- 逐把锁：索引、lock_mode、LOCK_STATUS
SELECT * FROM performance_schema.data_lock_waits;  -- 等待关系：REQUESTING vs BLOCKING
SELECT * FROM sys.innodb_lock_waits;               -- 上面两张表 join 好的人类可读视图
-- 拿到 blocking_thread 后去 innodb_trx / processlist 看它的 SQL 与事务年龄，定位源头
SELECT * FROM sys.innodb_lock_waits WHERE locked_table = '`test`.`account`';
```

> [!TIP]
> 排"锁等待"类故障的三步：`sys.innodb_lock_waits` 找阻塞者 → `innodb_trx` 看它的事务年龄与当前 SQL → 判断是"长事务"（业务没提交）还是"锁范围过大"（SQL 没走索引）。前者催促提交或杀会话，后者是 SQL 问题。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 加锁单位 | 行锁加在**索引记录**上，推论必须从索引有序性出发 | 以为锁加在"数据行"上，于是无法解释同一条 SQL 走不同索引时锁范围不同 |
| MDL 雪崩 | 长事务持读锁 → DDL 排到队首 → 后续兼容的读请求也不能插队 | 答"MDL 读锁互相兼容，所以后面的查询不受影响" |
| RR 与间隙锁 | 间隙锁只为阻止插入、防幻读，只在 RR 下存在 | 答"间隙锁锁的是记录之间空隙里的数据" |
| 规则 3 与 5 | 唯一索引等值命中退化记录锁；非唯一索引等值查询必须向后多扫一条 | 只背"等值就退化为记录锁"，把非唯一索引也说成记录锁 |
| 死锁与乐观锁 | 死锁是正常反馈，靠顺序统一 / 短事务 / RC / 1213 重试收敛；CAS 用 `affectedRows` 判断 | 答"关掉 `innodb_deadlock_detect` 就不死锁了"、"CAS 只看有没有抛异常" |

## 自测题

**Q1**（推导）给定 24.4 节的表 `t` 与数据 `(5,5),(10,10),(15,15),(20,20),(25,25)`，在 RR 隔离级别下执行 `SELECT * FROM t WHERE age = 15 FOR UPDATE;`，请写出 `idx_age` 上的完整加锁区间，并说明为什么左边界不是 15。
<details><summary>答案与解析</summary>

推导：`age` 是非唯一索引，等值查询先定位到第一条 `age=15` 的记录 `(15,15)`；但 `age` 不唯一，必须继续向右扫到第一条不满足条件的记录 `(20,20)` 才敢停。把扫过的每条记录变成以它为右端点的临键锁，得 `(10,15]` 与 `(15,20]`；再把定位起点时也访问到的 `(10,10)` 的临键锁 `(5,10]` 合并进来，完整集合是 `(5,10] ∪ (10,15] ∪ (15,20] = (5, 20]`。

面试时写 `(10,20]` 或 `(5,20]` 都常见，关键是讲清"多扫一条"是为了确认 `age=15` 没有第二行。追问通常是"`age=11` 的记录能不能插入"——**不能**，它落在 `(10,15)` 内。错误回答有两种：答"只锁住了 `age=15` 这一行"（那意味着 `age=14` 可随便插），以及把左边界说成 15（忽略了 `(15,15]` 的左端点来自上一条记录 `(10,10)`）。
</details>

**Q2**（判断）"在 REPEATABLE READ 下，事务 A 对某个不存在的 `id=12` 执行 `SELECT ... FOR UPDATE` 会给 `(10,15)` 加间隙锁；由于间隙锁之间兼容，事务 B 也对 `id=12` 加间隙锁不会阻塞。因此两个事务可以安全地同时申请同一个间隙并都成功。"这句话哪一部分对、哪一部分会导致问题？
<details><summary>答案与解析</summary>

前半句正确：唯一索引等值查询记录不存在时退化为间隙锁（规则 4），而**间隙锁之间兼容**，B 的加锁请求确实不会被阻塞。错在"因此可以安全地同时申请"——两个事务都以为自己"占住了" `(10,15)`，随后各自 `INSERT INTO t VALUES (12, ...)`，此时要在同一间隙上加**插入意向锁**（insert intention lock），而它与**对方持有的间隙锁冲突**，双方互相等待形成死锁；这正是"间隙锁只抑制插入、不排除其他间隙锁"的后果。追问的下一层是"怎么避免"：用 `INSERT ... ON DUPLICATE KEY UPDATE`/唯一索引把判断下沉、把隔离级别降到 RC（无间隙锁），或让所有插入走同一条串行化路径。答"间隙锁会互相阻塞"属于基础概念错误。
</details>

**Q3**（设计）线上出现大面积查询超时，监控显示大量会话处于 `Waiting for table metadata lock`，且错误日志里有一条十几分钟前开始的 `ALTER TABLE orders ADD COLUMN remark VARCHAR(64)`。请给出排查与处置顺序。
<details><summary>答案与解析</summary>

第一步定性：这是 24.2.2 的 MDL 排队雪崩，不是行锁问题。第二步找持锁者：`sys.schema_table_lock_waits` 或 `performance_schema.metadata_locks WHERE LOCK_STATUS='PENDING'` 拿到阻塞源头，再用 `information_schema.innodb_trx` 按 `trx_started` 排序找**最早的未提交事务**——通常是某个把 RPC 或循环写在事务里的应用长事务。第三步恢复：`KILL` 掉那个长事务会话，DDL 立刻拿到写锁、队列随之释放；**而不是先 KILL 掉 DDL**（长事务还在，下一次 DDL 会重演）。

第四步是根因治理：给所有 DDL 显式设 `SET SESSION lock_wait_timeout = 3~5` 让它快速失败并重试，而不是把整个实例挂起；规定应用侧"事务内不含 RPC"；这张大表的加列改用 `ALGORITHM=INSTANT` 或 `gh-ost`。常见错误回答是"重启数据库"（扩大故障）或"杀掉所有等待的查询"（等待的查询正是业务）。加分点在于指出：**这些查询本身没有任何问题，它们只是排在了 DDL 后面**，所以"优化 SQL"完全无效。
</details>

**Q4**（分析）死锁日志里两个事务的最后一条 SQL 分别是 `UPDATE account SET bal=bal-1 WHERE id=2` 和 `UPDATE account SET bal=bal+1 WHERE id=1`，两处 `WAITING FOR THIS LOCK TO BE GRANTED` 都写着 `index PRIMARY ... lock_mode X locks rec but not gap waiting`。请还原死锁过程，并说明如果这两条 SQL 走的是 `idx_uid` 而不是主键，日志和锁范围会有什么不同。
<details><summary>答案与解析</summary>

还原：T1 先更新 `id=1` 持有其排他记录锁，T2 先更新 `id=2` 持有其排他记录锁；随后 T1 申请 `id=2`、T2 申请 `id=1`，等待图出现环，死锁检测回滚其中一个。`locks rec but not gap` 表示争的是**纯记录锁**（唯一索引/聚簇索引等值命中，规则 3），所以这是纯粹的"顺序相反"型死锁，与间隙锁无关。若走 `idx_uid`（非唯一二级索引），`lock_mode` 仍为 `X` 但**不含 `locks rec but not gap`**（或出现 `locks gap before rec`），因为 RR 下非唯一索引等值查询会加临键锁并向后多扫一条（规则 5），锁范围从"一行"扩到"一个区间加两条记录"，日志里的 `index` 会写成 `idx_uid`。

差别不只是日志措辞：**锁范围更大意味着并发度更低、死锁概率更高**——这正是"同一条业务 SQL 换一个索引就可能从无死锁变成天天死锁"的原因。追问点通常是"那为什么不直接关掉 `innodb_deadlock_detect`"，答案见 24.7.2。
</details>

**Q5**（实现）用 Java 实现"扣减库存并防止超卖"，要求不使用 `SELECT ... FOR UPDATE`，并说明重试策略与边界条件。
<details><summary>答案与解析</summary>

核心是把"检查 + 写入"合并为一条原子语句，用影响行数判断结果：

```java
// 库存判断写在 WHERE 里，由数据库在持锁期间原子完成——应用侧的 if 无法阻止
// 两个线程同时通过检查（check-then-act 竞态）；再用 affectedRows 判断成败
public boolean deduct(Long skuId, int n, int maxRetry) {
    for (int i = 0; i < maxRetry; i++) {
        int rows = mapper.casDeduct(skuId, n);   // UPDATE stock SET qty = qty - #{n}
                                                 //  WHERE sku_id = #{id} AND qty >= #{n}
        if (rows == 1) {
            return true;                         // 原子扣减成功，无需二次读
        }
        // rows == 0 可能是库存不足/行不存在（不可重试），也可能是别人抢先（可重试），
        // 必须回查区分，否则会在无库存时做无意义重试放大压力
        Integer qty = mapper.selectQty(skuId);
        if (qty == null || qty < n) {
            return false;
        }
        sleepBackoff(i);                         // 指数退避 + 抖动，避免同步重试风暴
    }
    return false;
}
```

边界条件与追问：**重试必须幂等**（用业务唯一流水号 + 唯一索引去重）；**必须在自动提交或极短事务内执行**，否则行锁持有时间拉长、竞争加剧；高并发下要加前置的 Redis 预扣减或消息队列削峰（见第 27 章、第 36 章）。错误回答的典型形态：用 `synchronized` 或本地 `ReentrantLock` 保证单机串行（多实例部署下完全失效），或者先 `SELECT` 再 `UPDATE` 而不带条件（只是换了个地方制造竞态）。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么 RR 需要间隙锁，而 RC 不需要？ | 幻读与锁的关系 | 错误回答："RC 也能防幻读，只是性能差一点"——说不清 RC 每条语句重取快照、当前读不再承诺范围稳定。加分点：指出 RC 下间隙锁被关闭是**高并发写入系统主动降级隔离级别**的第一动机 | 24.3 |
| 唯一索引和非唯一索引做等值查询，加锁范围一样吗？ | 规则 3 与规则 5 的推导 | 错误回答："都是等值查询，都退化成记录锁"。正确要点：非唯一索引必须向后扫到第一个不满足条件的记录才敢停，锁范围多出一整段区间（`id=10` 锁一行 vs `age=10` 锁 `(5,15]`） | 24.4 |
| `SELECT ... FOR UPDATE` 锁不住新插入的行，那 RR 怎么防幻读？ | 间隙锁与插入意向锁 | 错误回答："靠 MVCC 快照读防幻读"——当前读不走快照。正确要点：靠间隙锁挡住 `INSERT`，且间隙锁之间兼容、插入意向锁才与它冲突 | 24.3、24.4 |
| 一条 `ALTER TABLE` 为什么能把整张表的查询打死？ | MDL 队列机制 | 错误回答："DDL 加了表锁，把查询锁住了"。真正的因果是 MDL 队列不插队：写锁排到队首后，后面兼容的读锁请求也全部排队，长事务才是持锁者 | 24.2 |
| `INSERT` 为什么不加显式锁，别的会话怎么被它挡住？ | 隐式锁到显式锁的转换 | 错误回答："`INSERT` 加插入意向锁所以能互斥"——插入意向锁彼此兼容，不提供互斥。正确要点：靠记录头 `trx_id`，由后续当前读的会话代为加 `LOCK_REC` | 24.6 |
| `REPLACE INTO` 和 `INSERT ... ON DUPLICATE KEY UPDATE` 该选哪个？ | 语义与加锁范围差异 | 错误回答："`REPLACE` 更简单，反正是覆盖"。正确要点：`REPLACE` 有**删除语义**——主键会变、自增跳号、触发级联删除、加锁范围更大 | 24.6 |
| 死锁了怎么办？把 `innodb_deadlock_detect` 关掉行不行？ | 死锁检测代价与兜底 | 错误回答："关掉就不死锁了"。实际是关掉后死锁仍在，只是靠 `innodb_lock_wait_timeout`（默认 50 s）兜底，会挂满连接池。加分点：环检测每次等锁都要做，热点行上 CPU 会膨胀 | 24.7 |
| 乐观锁和悲观锁你怎么选？ | 冲突率与事务长度 | 错误回答："乐观锁性能更好，一律用乐观锁"。高冲突下乐观锁会退化成重试风暴；加分点：给出"冲突率低用 CAS、高用悲观锁、可用唯一索引把串行化下沉到数据库"的三段式 | 24.8 |

## 延伸阅读

- MySQL 官方手册：`InnoDB Locking`、`InnoDB Locking Model`、`Locks Set by Different SQL Statements in InnoDB`、`Metadata Locking`、`Online DDL Operations`、`Deadlocks in InnoDB`。
- MySQL 官方手册：`performance_schema.data_locks`、`performance_schema.data_lock_waits`、`sys.innodb_lock_waits`；以及 `innodb_lock_wait_timeout`、`lock_wait_timeout`、`innodb_deadlock_detect`、`innodb_print_all_deadlocks`、`innodb_autoinc_lock_mode` 变量条目。
- 《高性能 MySQL》相应章节（锁、事务、schema 变更与在线 DDL 运营实践）。
- 《数据库系统概念》第 15、16 章：两阶段锁、时间戳排序与等待图检测，用于把 InnoDB 的实现放回理论谱系（详见第 6 章 6.4 节的死锁四条件推导）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch24/  

