# 第 22 章 事务、隔离级别与 MVCC


> 本章把"并发事务互相看不见"这件事拆到引擎内部的字段级：一行数据的多个历史版本怎么存、读的时候凭什么判定"这个版本我能看"。

## 学习目标

读完本章，你应当能够：

1. 把 ACID 四条逐一对应到具体机制（undo log / redo log + fsync / 锁 + MVCC / 约束 + 前三者），并说明"一致性不是独立机制"这句话的确切含义；
2. 推导 SQL 标准的四种隔离级别与脏读、不可重复读、幻读三类异常的对应关系，并辨析 InnoDB 与 PostgreSQL 在默认级别上的差异及其原因；
3. 说出 MVCC 的三要素（隐藏字段、undo 版本链、ReadView），画出 undo 版本链的结构图；
4. 按 `m_ids` / `min_trx_id` / `max_trx_id` / `creator_trx_id` 逐步执行可见性判断算法，并解释"不可见时沿 `roll_pointer` 回溯"的终止条件；
5. 用"ReadView 的创建时机"解释 RC 与 RR 的全部行为差异，指出"两个级别看的是不同快照数据"这一说法错在哪；
6. 列出快照读与当前读的完整语句清单，举出 RR 下仍然出现幻读的两类具体场景；
7. 定位并量化长事务的三类危害，写出基于 `information_schema.innodb_trx` 的监控 SQL。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| InnoDB 页结构、行格式与隐藏列的物理布局 | 见第 20 章 20.4 节；本章只用 `DB_TRX_ID`/`DB_ROLL_PTR` 两个字段 |
| undo log 的逻辑结构与 redo log 的 WAL 顺序、LSN、组提交 | 见第 21 章 21.1～21.3 节；本章把 undo 当作"版本链的存储介质"使用 |
| 间隙锁、临键锁、意向锁的加锁规则 | 见第 24 章 24.2、24.3 节；本章只说明"哪些语句会去加锁" |
| binlog 的 `STATEMENT`/`ROW`/`MIXED` 与两阶段提交 | 见第 21 章 21.4、21.5 节；本章只讨论隔离级别对复制安全性的约束 |

## 22.1 ACID 每一条对应哪个机制

"事务有 ACID 四个特性"是本科内容；面试真正在问的是**这四条各自靠什么实现、少一个会怎样**。把它们对应到机制之后，"一致性（consistency）"的特殊地位就露出来了：它不是某个组件的功能，而是前三者的**共同结果**，再加上数据库自身的完整性约束。

| 特性 | 承载机制 | 精确表述 | 缺了会怎样 |
|---|---|---|---|
| 原子性（atomicity） | undo log | 每条修改在写入前先记一条逻辑反向操作（"插入 → 删除主键"、"更新 A→B → 更新 B→A"）；回滚时逆序执行 | 事务中途失败会留下半截修改；但注意回滚是**逻辑回滚**，不是恢复页的物理镜像 |
| 持久性（durability） | redo log + `fsync` | 提交时把 redo 记录刷到磁盘（`innodb_flush_log_at_trx_commit=1`）；数据页可以晚点再刷，崩溃后用 redo 重放 | 已提交事务在断电后消失。**持久性的边界取决于刷盘策略**，`=0/2` 时"提交成功"不等于"落盘成功" |
| 隔离性（isolation） | 锁 + MVCC | 写—写用锁串行化；读—写用 MVCC 让读不加锁也能拿到一致快照 | 并发事务互相看到中间状态。隔离性**不是**回滚能补的，回滚只解决原子性 |
| 一致性（consistency） | 前三者 + 完整性约束 | 事务把一个满足约束的状态映射到另一个满足约束的状态 | 这是**目标**而非机制。约束（唯一索引、外键、`CHECK`、`NOT NULL`）是数据库替应用守住的底线，业务语义上的一致性只能由应用保证 |

这里有一个反直觉点必须讲清楚：**undo log 同时服务两个完全不同的目的**——回滚（原子性）和 MVCC 版本链（隔离性）。同一条 undo 记录，回滚时被"反向执行"，MVCC 时被当作"历史版本读取"。这解释了一个常见困惑：为什么事务提交后 undo 不能立刻删？因为可能还有别的 ReadView 需要读它，必须等 `purge` 确认没人需要了才回收。purge 的判定条件见 22.7 节。

> [!IMPORTANT]
> 面试标准答法是"原子性靠 undo log、持久性靠 redo log、隔离性靠锁加 MVCC、一致性靠前三者加约束"。只答"ACID 是原子性一致性隔离性持久性"会被立刻打断，追问方向是"你说的一致性，数据库具体做了什么来保证它"。

> [!NOTE]
> 约束只能在**单机单库**范围内保证一致性。一旦分库分表或跨服务，唯一索引不再全局唯一、外键无法跨库，这部分一致性就回到应用层（见第 26 章 26.5 节）。这是"数据库不保证分布式一致性"的准确出处。

## 22.2 四种隔离级别与三类异常：标准与 InnoDB 实际行为的差异

三类异常的定义必须用**同一事务内先后两次读的结果**来描述，否则说不清区别：

- 脏读（dirty read）：读到了别的事务**尚未提交**、且可能回滚的修改；
- 不可重复读（non-repeatable read）：同一事务内两次读**同一行**，值不同（因为中间有别的事务提交了修改）；
- 幻读（phantom read）：同一事务内两次执行**同一个范围查询**，第二次多出了行（因为中间有别的事务提交了插入）。

| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实际行为与标准不同之处 |
|---|---|---|---|---|
| READ UNCOMMITTED（RU） | 可能 | 可能 | 可能 | InnoDB 下快照读**不做可见性判断**，直接读最新版本，因此脏读确实存在（见 22.6 节） |
| READ COMMITTED（RC） | 不可能 | 可能 | 可能 | 与标准一致；但 RC 每次读都新建 ReadView，所以"不可重复读"是必然而不是偶然 |
| REPEATABLE READ（RR） | 不可能 | 不可能 | 标准说可能 | **快照读下基本不出现幻读**：整个事务复用同一个 ReadView，别的事务插入的行对本事务不可见 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | InnoDB 把普通 `SELECT` **隐式升级为 `SELECT ... LOCK IN SHARE MODE`**（快照读全部变成当前读），代价是并发度归零 |

> [!CAUTION]
> "InnoDB 的 RR 完全解决了幻读"是高风险错误。准确表述是"**RR 的快照读不会出现幻读，但当前读会出现**"，两类场景见 22.6 节。面试官问的就是这个"但是"。

标准与实现之所以分叉，原因是 SQL 标准只描述**可观察到的现象**，不规定实现手段。InnoDB 用 MVCC 提供了一致性读（consistent read），这是一个标准里没有的概念，它顺带把 RR 下的幻读也压掉了。

### 22.2.1 为什么 PostgreSQL 默认 RC 而 MySQL 默认 RR

这不是口味问题，而是两种 MVCC 实现路线的历史产物：

- MySQL 的默认值来自早期复制格式的约束。`binlog_format=STATEMENT` 时，从库重放的是 SQL 文本，主库上语句的执行顺序直接决定从库结果，因此主库需要更强的隔离（RR）来压制主从数据不一致；MySQL 8.0 起 `binlog_format` 默认已是 `ROW`，这个约束的限制作用已经消失，但默认级别出于兼容性未改。
- PostgreSQL 从 9.1 起默认 RC，因为它的 RC 在每个语句开始时取一次快照，已经能避免语句内部的"半更新状态"，把大部分不可重复读问题留给应用显式处理；这样长事务不会阻塞 vacuum 回收——PostgreSQL 的 `VACUUM` 与 InnoDB 的 `purge` 面临同一个问题，降低默认隔离级别是缓解手段之一。

工程上的取舍结论：**写多且复制延迟敏感的场景倾向 RC**（锁范围小、无间隙锁、主从一致性对 `ROW` 格式不敏感），**需要"整个事务内多次读结果自洽"的报表类场景倾向 RR**。MySQL 官方手册在"事务隔离级别"章节给出的实践建议也是把 RC 与 `binlog_format=ROW` 配对使用。

## 22.3 MVCC 的三要素：隐藏字段、undo 版本链、ReadView

MVCC（multi-version concurrency control，多版本并发控制）在 InnoDB 里由三个组件协作完成。三者缺一不可：隐藏字段提供**定位信息**，undo 版本链提供**历史数据存储**，ReadView 提供**可见性规则**。

### 22.3.1 隐藏字段

InnoDB 的聚簇索引记录（`COMPACT`/`DYNAMIC` 行格式）除用户列外还有三个隐藏列：

| 隐藏字段 | 长度 | 作用 | 何时不存在 |
|---|---|---|---|
| `DB_TRX_ID` | 6 字节 | 最近一次修改该行的事务 ID，MVCC 可见性判断的输入 | 永远存在 |
| `DB_ROLL_PTR` | 7 字节 | 回滚指针，指向 undo log 中该行的**上一个版本**记录 | 永远存在，无旧版本时为 NULL |
| `DB_ROW_ID` | 6 字节 | 行 ID，仅在表**没有主键也没有唯一非空索引**时由 InnoDB 生成并作为聚簇索引键 | 有主键或有唯一非空索引时不存在 |

> [!TIP]
> 记忆锚点：`DB_TRX_ID` 回答"这个版本是谁改的"，`DB_ROLL_PTR` 回答"上一个版本在哪"，`DB_ROW_ID` 回答"这行凭什么唯一"。三个字段分别对应可见性判断的三个输入、版本链的遍历指针、聚簇索引的兜底键。

### 22.3.2 undo 版本链的结构

每次修改一行，InnoDB 把**旧版本**写进 undo log，并让新记录的 `DB_ROLL_PTR` 指向它。于是同一行在逻辑上形成一条单向链表，表头是最新版本，越往后越旧。`trx_id` 标记每个版本由哪个事务产生：

```
 表 t 中主键 id = 1 的这一行（当前最新版本在聚簇索引叶子上）

   聚簇索引记录
  ┌──────────────────────────────────────┐
  │ id=1  name='C'  age=30               │
  │ DB_TRX_ID  = 300   ← 谁写了这个版本   │
  │ DB_ROLL_PTR ──────┐                  │
  └───────────────────┼──────────────────┘
                      │ ① 沿回滚指针进入 undo
                      ▼
   undo log（版本链，越深越旧）
  ┌──────────────────────────────────────┐
  │ undo 记录 A：name='B'  age=20        │
  │   trx_id = 200     （由事务 200 写入）│
  │   roll_ptr ───────┐                  │
  └───────────────────┼──────────────────┘
                      │ ② 继续回溯
                      ▼
  ┌──────────────────────────────────────┐
  │ undo 记录 B：name='A'  age=10        │
  │   trx_id = 100     （由事务 100 写入）│
  │   roll_ptr = NULL  （更早没有版本了） │
  └──────────────────────────────────────┘

  版本从新到旧：300 → 200 → 100
  一次可见性判断失败就沿指针走一格，最多走到链尾（NULL）为止
```

三个必须记住的细节：

1. **undo 记录存的是"如何回到上一个版本"**，即 update undo 里保存的是被覆盖列的旧值。所以沿链回溯不是"读一份完整快照"，而是"用 undo 逐版本倒推"，这也是长链遍历昂贵的原因。
2. **链的方向与判断顺序相反**。可见性判断总是从最新版本开始，一旦命中可见就立刻停止回溯——这是性能上的关键优化，而不是遍历全链再挑。
3. **链的回收由 purge 线程负责**，条件是"没有任何活跃 ReadView 还需要比它更旧的版本"（见 22.7 节）。

> [!WARNING]
> 常见错误回答：把 undo 版本链说成"每个事务保存一份数据快照"。MVCC 是**按行**保留旧版本，不是按事务拷贝整库；一个事务修改了 5 行就只产生 5 个版本，未被修改的行共享同一个最新版本。把 MVCC 理解成"快照隔离的整库拷贝"会在被追问内存开销时答崩。

## 22.4 ReadView 的四个字段与可见性判断算法

ReadView 是"本事务在创建这一刻，系统里哪些事务还没结束"的名单快照。它只有四个字段：

| 字段 | 含义 | 在判断中的角色 |
|---|---|---|
| `m_ids` | 创建 ReadView 时**当前活跃**（已启动未提交）的事务 ID 列表 | 判定"中间地带"版本的可见性 |
| `min_trx_id` | `m_ids` 中的最小值（无活跃事务时等于下一个待分配 ID） | 第一条分界线 |
| `max_trx_id` | 创建 ReadView 时**下一个将被分配**的事务 ID（即已出现的最大 ID + 1） | 第二条分界线 |
| `creator_trx_id` | 创建这个 ReadView 的事务自身 ID | 自己改的行永远可见 |

判断算法的完整流程（每一步都可直接执行）：

```
 输入：某个版本记录的 DB_TRX_ID（记作 trx_id），当前事务的 ReadView
 输出：该版本对本事务是否可见

 步骤 0  若 trx_id == creator_trx_id
            → 可见（自己改的数据自己一定能看到，与名单无关）
            否则进入步骤 1

 步骤 1  若 trx_id < min_trx_id
            → 可见（这个版本由"名单之前就已结束"的事务产生，一定是已提交的）
            否则进入步骤 2

 步骤 2  若 trx_id ≥ max_trx_id
            → 不可见（这个版本由 ReadView 创建之后才启动的事务产生，必须回滚）
            否则进入步骤 3

 步骤 3  此时 min_trx_id ≤ trx_id < max_trx_id，落在"中间地带"
         在 m_ids 中查找 trx_id：
            找到了   → 不可见（该事务创建 ReadView 时还活跃，可能未提交或已回滚）
            没找到   → 可见（该事务在创建 ReadView 之前已提交，只是 ID 在区间内）

 步骤 4  判为不可见时，沿 DB_ROLL_PTR 取上一个版本，
         把新版本的 trx_id 重新代入步骤 0。
         若指针为 NULL（已到链尾）→ 该行对本事务"不存在"
         （表现为范围查询里这一行不出现，即幻读被压制的来源）
```

做一个完整算例，把四条规则各命中一次。设当前事务 ID = 300，执行第一条 `SELECT` 时创建的 ReadView 为 `m_ids = {200, 250}`，则 `min_trx_id = 200`、`max_trx_id = 301`（下一个待分配的 ID）、`creator_trx_id = 300`。

```
 版本链： 300 → 250 → 200 → 100 → NULL

 判断 300：等于 creator_trx_id                 → 可见（步骤 0 命中）
 判断 250：200 ≤ 250 < 301，且 250 ∈ m_ids     → 不可见（步骤 3 命中"在名单中"）
 判断 200：200 ≤ 200 < 301，且 200 ∈ m_ids     → 不可见（同上，"小于"不含等于）
 判断 100：100 < min_trx_id = 200              → 可见（步骤 1 命中）

 结论：本事务看到的这一行是 trx_id = 100 产生的那个版本。
       事务 200 与 250 的修改虽然已经写进页里，但都在名单内，一律当作不存在。
```

> [!IMPORTANT]
> 边界必须记准：`min_trx_id` 是**活跃列表的最小值**，所以规则是 `trx_id < min_trx_id` 而不是 `≤`；`max_trx_id` 是**下一个待分配 ID**，所以规则是 `trx_id ≥ max_trx_id` 而不是 `>`。把这两处写成 `≤` / `>`，会在"恰好在边界上"的用例里判错——这是面试官最爱设的陷阱。

> [!NOTE]
> 面试官会追问的下一层：**这个判断做几次？** 每读一行做一次。所以一个扫描 100 万行的范围查询，如果 undo 链很长，最坏情况是每行都要沿链回溯若干格，代价与"链长 × 行数"成正比。这正是 22.7 节长事务危害的性能机理。另外 `m_ids` 在实现上是按 ID 排序的数组，判断"是否在名单中"用二分而不是线性扫描。

## 22.5 RC 与 RR 的差异根因：ReadView 的创建时机

这是全章最高频的考点，也是最容易答偏的地方。**RC 与 RR 的差别只有一处：ReadView 在什么时候创建。** 两者的可见性判断步骤 0～4 完全一样。

| 维度 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| ReadView 创建时机 | **每次执行 `SELECT` 都新建一个** | **事务内第一次 `SELECT` 时创建，之后复用** |
| 事务内多次快照读 | 每次都是新的名单，别人已提交的修改立刻可见 | 整个事务使用同一份名单，看到的是同一个快照 |
| 因此不可重复读 | 必然可能（两次读两个 ReadView） | 不可能（名单不变，可见性结论不变） |
| 因此幻读（快照读） | 可能（新插入的行在第二次的名单中已提交） | 基本不可能（第一次的名单里没有那个事务） |
| 间隙锁 | 基本不用（RC 下间隙锁被禁用） | 使用临键锁（见第 24 章 24.3 节） |
| 适用场景 | 写多、需要尽快看到他人提交、对复制延迟敏感 | 需要事务内视图自洽的报表、对账类逻辑 |

必须澄清一个非常普遍的误解：**这不是"两个级别看到的快照数据不同"，而是"取快照名单的时刻不同"。** 存储的数据版本完全一样，undo 链也一样，唯一的区别是"你在什么时刻问系统'现在有哪些事务还活着'"。

```
 时间轴（同一行 x=1，事务 T1 是 RR，T2/T3 是并发写事务）

 T1: BEGIN ──① SELECT x  ────────────────② SELECT x ──③ SELECT x ── COMMIT
                │                              │            │
                │ 创建 ReadView V1             │ 复用 V1    │ 复用 V1
                │ m_ids={T2}                   │            │
                │                              │            │
 T2:      BEGIN ── UPDATE x=2 ── COMMIT
                                      │
                                      └─ 提交时刻：T1 的 V1 里 T2 在名单中
                                         ⇒ T2 的版本对 T1 永远不可见

 T3:                                        BEGIN ── UPDATE x=3 ── COMMIT
                                                                  │
                                                                  └─ T3 的 ID ≥ V1.max_trx_id
                                                                     ⇒ 同样不可见

 RR 下 ①②③ 全部读到 x=1；RC 下 ② 读到 2、③ 读到 3（因为每次重建名单）
```

在实际代码里能观察到的差异：同一个 `@Transactional` 方法内先查一次、业务处理耗时若干秒、再查一次，MySQL 默认 RR 下两次结果一致；如果误配成 RC，第二次可能读到别人刚提交的值，导致"校验通过但更新时数据已经变了"这类偶发故障。这也是**用 `SELECT ... FOR UPDATE` 做悲观锁**（见 22.6 节）而不用"先查再改"的原因。

## 22.6 快照读与当前读：什么时候读的不是历史版本

到 22.5 节为止讲的是**快照读（snapshot read）**：不加锁，读 ReadView 判定后的历史版本。与它相对的是**当前读（current read）**：读最新已提交数据，并且会加锁。

| 语句 | 类别 | 读到什么 | 是否加锁 |
|---|---|---|---|
| `SELECT ...`（普通查询） | 快照读 | ReadView 判定的历史版本 | 不加锁 |
| `SELECT ... LOCK IN SHARE MODE`（8.0 亦可写 `FOR SHARE`） | 当前读 | 最新已提交版本 | 加读锁（共享） |
| `SELECT ... FOR UPDATE` | 当前读 | 最新已提交版本 | 加写锁（排他） |
| `UPDATE ...` / `DELETE ...` | 当前读 | 先当前读定位行，再修改 | 加写锁 |
| `INSERT ...` | 当前读 | 不读旧值，直接写入并做唯一性冲突检测 | 加隐式锁 / 冲突时加锁 |
| `INSERT ... ON DUPLICATE KEY UPDATE`、`REPLACE INTO` | 当前读 | 读取判断冲突 | 加锁 |

`SELECT ... FOR UPDATE` 的执行语义值得单独说：它先做一次当前读拿到最新提交版本（**这一步绕过了 ReadView**），然后对匹配的记录加排他锁，并把加锁结果返回。由于它读的是当前数据，读到的行数与内容和快照读可能不同——这就是 RR 下幻读的来源。具体的加锁范围推导（唯一索引等值、非唯一索引等值、范围查询分别加什么锁）归第 24 章 24.3 节，本章只界定"哪些语句会去加锁"。

### 22.6.1 RR 下仍然出现幻读的两类场景

> [!IMPORTANT]
> 这个"但是"是本章最值钱的一段。答出"RR 快照读不幻读"只能算及格，能主动说出这两个场景才算过关。

**场景一：当前读本身不属于快照读。** 事务 A 中 `SELECT * FROM t WHERE id > 100` 快照读返回 3 行；事务 B 插入 `id = 104` 并提交；事务 A 再执行 `SELECT * FROM t WHERE id > 100 FOR UPDATE`，**读到 4 行**。原因是第二次是当前读，直接看最新已提交数据，ReadView 完全不参与。同时因为 `FOR UPDATE` 在 `id > 100` 的区间上加临键锁（见第 24 章 24.3 节），事务 B 的插入若发生在加锁之后就会被阻塞——**加锁能否挡掉幻读，取决于两个事务的先后顺序**。

**场景二：同一事务内快照读与当前读混用，结果前后矛盾。** 这是更隐蔽的一种：

```sql
-- 事务 A（RR 隔离级别，全程未提交）
BEGIN;
SELECT COUNT(*) FROM t WHERE id > 100;          -- 快照读，返回 3：本事务的快照里没有 id=104
-- 此时事务 B 执行 INSERT INTO t VALUES (104, ...) 并 COMMIT

SELECT * FROM t WHERE id > 100 FOR UPDATE;      -- 当前读，返回 4 行：多出来的 id=104 就是幻影行
SELECT COUNT(*) FROM t WHERE id > 100;          -- 又变回 3：复用同一个 ReadView，幻影行再次"消失"
COMMIT;
```

同一个事务里，同一个条件，一次看到 4 行、一次看到 3 行。**这种前后不一致本身就是 RR 隔离级别下的幻读**，也是"RR 到底能不能防幻读"这个问题的标准答案落点。要靠当前读做唯一性校验的业务（例如"先 `SELECT` 判断不存在再 `INSERT`"），必须意识到两次读的语义不同，正确写法是依赖唯一索引让 `INSERT` 直接报冲突。

## 22.7 长事务的三类危害与监控

长事务在面试里的标准答法是"undo 膨胀、回滚慢、锁持有时间长"，但真正把面试官说服的是**说清危害的作用链条**。这里的机制与 22.4 节的可见性判断直接相连：

1. **undo 版本链无法回收，链变长。** `purge` 线程要回收一个旧版本，前提是"不存在还需要它的 ReadView"。一个开了 10 分钟没提交（甚至只读）的事务，它的 ReadView 一直有效，于是这 10 分钟内产生的所有旧版本都不能删。链一长，22.4 节步骤 4 的回溯次数就线性上涨——**危害不是只作用于这个长事务，而是作用于之后所有事务**，因为每个新 ReadView 都可能要跨过这些陈旧版本。
2. **`m_ids` 变大，判断落进"中间地带"的行变多。** 活跃事务越多，`m_ids` 越长，二分查找本身代价上升；更严重的是更多版本落进步骤 3 的分支，本来一步能判定的现在要多走几次链。
3. **占用连接与锁。** 事务不结束，连接归还不到池子里；写事务持有的行锁要等到提交才释放，`innodb_lock_wait_timeout` 默认 50 s 一旦打满就是接口大面积超时。此外事务期间 undo 无法清理、崩溃恢复时要回滚的数据量也成倍增长（回滚与崩溃恢复见第 21 章 21.2 节）。

> [!WARNING]
> 只读事务同样有害，而且更隐蔽：它不加锁、不报阻塞、`SHOW PROCESSLIST` 里可能只有一条不起眼的 `Sleep`，却能把 `purge` 一直挡在后面。排查"undo 表空间为什么只涨不跌"，第一件事就是找长时间未提交的**只读**事务。

监控 SQL（`innodb_trx` 里的时间字段是**秒**，不是微秒，这点常被记错）：

```sql
-- 定位超过 60 秒仍未结束的事务；按持锁行数降序，优先处理真正在写的那些
SELECT trx_id,
       trx_state,                                  -- RUNNING / LOCK WAIT，LOCK WAIT 说明它自己在等锁
       trx_started,                                -- 事务开始时刻，与 NOW() 相减得到已运行时长
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS elapsed_s,
       trx_rows_locked,                            -- 持有或等待的行锁数，量级大说明加锁范围大
       trx_rows_modified,                          -- 已修改行数，直接对应 undo 体积
       trx_is_read_only,                           -- 只读长事务最容易被漏掉，必须单独看这一列
       trx_mysql_thread_id                         -- 需要 kill 时用这个连接 ID
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_rows_locked DESC, elapsed_s DESC;
```

```sql
-- 从"服务端总览"角度确认是否真的存在陈旧版本积压：History list length 持续上涨即为信号
SHOW ENGINE INNODB STATUS\G
-- 关注输出中的 TRANSACTIONS 段：
--   History list length 12345      ← undo 版本链总长度，长期高位说明 purge 被拖住
--   Purge done for trx's n:o < ... ← purge 推进到的位置，长时间不动就是被长事务卡住
```

配套的运维手段还有两类：一是把跑批、导数据这类长任务拆成**分批提交的小事务**（每批 500～1000 行提交一次），把事务时长从"小时级"降到"秒级"；二是给只读报表设置 `SET SESSION TRANSACTION READ ONLY`（本身不解决 purge 阻塞）与合理的超时，并在应用侧用读超时兜底，而不是让一个 `@Transactional` 方法包住整段远程调用。

## 22.8 隔离级别对复制安全性的两个约束

隔离级别不只影响本机并发行为，还通过 binlog 约束主从一致性。这是把第 22 章与第 21、26 章串起来的两道题。

**第一道：RC 与 `binlog_format = ROW` 的配合。** `STATEMENT` 格式记录的是 SQL 文本，从库重放时会**用自己的数据重新执行一遍**。RC 下同一个事务内两次读到的数据可能不同（见 22.5 节），主库执行 `UPDATE ... WHERE` 时依赖的行集合，到从库重放时可能已经变了，于是主从数据分叉。`ROW` 格式记录的是**每行修改前后的镜像**，从库不再重新求值，执行顺序与隔离级别都不影响结果，所以 RC 配 `ROW` 是安全的。MySQL 官方手册因此建议：使用 RC 时把 `binlog_format` 设为 `ROW`。反过来，`STATEMENT` 格式在 RC 下是明确不安全的组合。

**第二道：`innodb_autoinc_lock_mode` 三种模式与主从一致性。**

| 模式 | 名称 | 加锁行为 | 自增值特点 | 与 STATEMENT 复制的兼容性 |
|---|---|---|---|---|
| 0 | traditional | 语句级 `AUTO-INC` 表锁，**持有到语句结束** | 同一语句内连续、无空洞 | 安全 |
| 1 | consecutive | 轻量互斥量，**分配完立即释放**（批量插入等"不知道行数"的情形仍用表锁） | 简单插入（行数可预知）连续；批量插入可能不连续 | 安全 |
| 2 | interleaved | 轻量互斥量，**并发语句交错分配** | 值不连续，同一语句内也可能有空洞 | **不安全**（从库重放会得到不同的自增值） |

模式 2 与 `ROW` 格式配合才安全，原因与第一道题完全同源：`ROW` 格式里自增列的值是**随行镜像一起记录的**，从库不需要自己再生成，因此不连续也不影响一致性。MySQL 8.0 的默认组合是 `innodb_autoinc_lock_mode=2` 加 `binlog_format=ROW`；5.7 的默认是模式 1。这也解释了一个现象：**同一张表在批量插入后自增 ID 出现空洞**，在模式 2 下是设计使然，不是 bug，也不能靠 `AUTO_INCREMENT` 重置彻底解决。

> [!NOTE]
> 面试官会追问的下一层：自增 ID 空洞除了上面这个原因，还有哪些？——插入失败回滚、`INSERT ... ON DUPLICATE KEY UPDATE` 的预分配、事务回滚、以及 MySQL 8.0 之前 `AUTO_INCREMENT` 计数器**不持久化**导致重启后回退（8.0 起写入 redo 日志，重启不再回退）。能在答完模式差异后主动补这条，说明你不是背表格。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| ACID 的实现依赖 | 原子性→undo log、持久性→redo log+fsync、隔离性→锁+MVCC、一致性→前三者+约束 | 把一致性说成有独立机制，或说"靠两阶段提交保证一致性"（两阶段提交保证的是 redo 与 binlog 的一致） |
| MVCC 三要素 | 隐藏字段提供定位、undo 版本链提供历史版本、ReadView 提供可见性规则，缺一不可 | 只答 ReadView，漏掉 `DB_TRX_ID`/`DB_ROLL_PTR`，或把 MVCC 说成"每个事务一份快照" |
| 可见性判断 | 先比 `creator_trx_id`，再比 `min_trx_id`/`max_trx_id`，落在中间查 `m_ids`；不可见就沿 `DB_ROLL_PTR` 回溯 | 把边界写成 `≤ min_trx_id`、`> max_trx_id`；或说"不可见就返回空"而忘了回溯 |
| RC 与 RR 的根因 | 差别只在 ReadView 的**创建时机**：RC 每次 `SELECT` 新建，RR 复用第一次的 | 说成"RR 用的是快照数据、RC 用的是实时数据"——数据版本相同，名单时刻不同 |
| RR 下的幻读 | 当前读（`FOR UPDATE`、`UPDATE`、`DELETE`）绕过 ReadView 读最新数据，会看到快照读看不到的行 | 断言"RR 完全不会幻读"；说不出"同一事务内前后不一致"这个判据 |
| 长事务危害 | 拖住 purge 导致 undo 链变长、`m_ids` 变大、连接与锁被占，危害波及后续所有事务 | 只答"占内存"，说不出"旧版本无法回收"这一条因果链 |
| 复制安全 | RC 必须配 `binlog_format=ROW`；`innodb_autoinc_lock_mode=2` 只在 `ROW` 下安全 | 答"RC 更安全所以随便配"，或把自增空洞当成故障 |

## 自测题

**Q1**（判断）"InnoDB 的 REPEATABLE READ 通过加间隙锁彻底避免了幻读，所以 RR 是不会有幻读的。"请判断并给出准确表述。

<details><summary>答案与解析</summary>

判断：**错**，而且有两处错。

第一处：**RR 抑制幻读的第一功臣不是锁，是 MVCC**。当前读之外的普通 `SELECT` 完全不加锁，它看不到别的事务插入的行，是因为那些行的 `DB_TRX_ID` 要么落在 `m_ids` 里、要么 `≥ max_trx_id`，在 22.4 节的判断里被判为不可见。间隙锁只在当前读路径上起作用，用来阻止"我加锁的区间里被插入"。

第二处：**RR 下幻读仍会发生**，两条路径见 22.6.1 节：① 当前读绕过 ReadView 读最新数据，会看到快照读看不到的新行；② 同一事务内快照读与当前读混用，出现"一会儿 4 行、一会儿 3 行"的前后不一致。

**错在哪**：把"隔离级别定义"与"实现的效果"混为一谈，并且把 MVCC 的功劳记在间隙锁上。**追问方向**："那你说的这个'当前读导致幻读'，SERIALIZABLE 是怎么避免的？"答案是 SERIALIZABLE 把普通 `SELECT` 隐式升级为 `LOCK IN SHARE MODE`，让所有读都变成当前读并加锁，用并发度换掉了不一致。

</details>

**Q2**（推导）事务 T（ID = 500）第一次 `SELECT` 时系统活跃事务为 `{400, 450}`。某行的版本链为 `520 → 480 → 450 → 400 → 300 → NULL`。请逐步给出每个版本的可见性判定结果，并说明 T 最终读到哪个版本。

<details><summary>答案与解析</summary>

先确定 ReadView：`m_ids = {400, 450}`、`min_trx_id = 400`、`max_trx_id = 501`（下一个待分配 ID）、`creator_trx_id = 500`。

```
 520：520 ≥ 501                      → 不可见（步骤 2：ReadView 创建后才启动的事务）
 480：400 ≤ 480 < 501，480 ∉ m_ids   → 可见  ← 判断终止
 450：400 ≤ 450 < 501，450 ∈ m_ids   → 不可见
 400：400 ≤ 400 < 501，400 ∈ m_ids   → 不可见（"小于"不含等于，这是边界陷阱）
 300：300 < 400                      → 可见
```

T 最终读到 `trx_id = 480` 产生的版本：从 520 回溯一格即命中，**后续的 450/400/300 根本不需要判断**。

**错在哪**：最常见的两个错误是把 520 判成可见（忘了 `max_trx_id` 是"下一个待分配 ID"，`≥` 即不可见）、把 400 判成可见（把规则记成 `≤ min_trx_id`）。另一个错误是"遍历整条链，取最旧的那个可见版本"——正确行为是**从最新往旧找到第一个可见版本就停**。**追问方向**："如果 480 也不可见，链尾是 NULL 呢？"答案是这一行对本事务不存在，范围查询里直接少一行，这正是快照读抑制幻读的具体机制。

</details>

**Q3**（辨析）"RC 和 RR 的区别在于：RR 读的是事务开始时的数据快照，RC 读的是最新已提交的数据。"请指出这句话错在哪里，并给出正确表述。

<details><summary>答案与解析</summary>

错在把差异描述成**数据内容**的不同。数据库里存的版本、undo 链、以及任何时刻"最新已提交版本"都是同一份，两个级别没有读不同的数据源。

正确表述：两个级别**使用完全相同的可见性判断算法**（22.4 节步骤 0～4），差别只在 ReadView 的创建时机——**RC 在每次执行 `SELECT` 时新建 ReadView**，所以每次读都用"此刻的活跃名单"，别人刚提交的事务不在名单里，于是它写的新版本可见；**RR 在事务内第一次 `SELECT` 时创建 ReadView 并在整个事务内复用**，名单固定，后续提交的事务要么在 `m_ids` 里、要么 `≥ max_trx_id`，一律判为不可见。

还有一个细节这个说法也漏了：RR 的 ReadView **不是 `BEGIN` 时创建的，而是第一次快照读时创建的**。所以 `BEGIN` 之后先做一堆与读无关的工作再查，看到的快照是"第一次查的时刻"，不是"BEGIN 的时刻"。这一点直接解释了"为什么 RR 不能保证读到事务开始时刻的数据"。

**追问方向**："那 RR 下我能不能主动读最新数据？"可以，用当前读（`SELECT ... FOR UPDATE` / `LOCK IN SHARE MODE`），但代价是加锁，加锁规则见第 24 章 24.3 节。

</details>

**Q4**（推导/计算）某核心交易库出现"上午开始响应变慢，下午 P99 从 20 ms 涨到 800 ms，CPU 与磁盘 IO 都不高"。请给出一个基于本章机制的假设、验证 SQL，以及"看到什么数字就确认这个假设"的判据。

<details><summary>答案与解析</summary>

假设：存在**长事务**，导致 undo 版本链无法回收，所有查询的可见性判断都要沿链回溯多格，代价随链长线性上升；同时活跃事务多使 `m_ids` 变大，更多版本落进"中间地带"分支。CPU 不高是因为这不是计算密集，而是**内存随机访问与指针追逐**（每次回溯都要读 undo 页，undo 页在 buffer pool 里则约 100 ns 量级，落到 SSD 则约 100 μs 量级，量级一出就是几百倍差异）。

验证 SQL：用 22.7 节的 `information_schema.innodb_trx` 查询按 `elapsed_s` 降序，重点看 `trx_is_read_only = 1` 的长事务（只读长事务不加锁，不会出现在锁等待里，是最容易漏的元凶）；再看 `SHOW ENGINE INNODB STATUS` 的 `TRANSACTIONS` 段。

判据：① 存在 `elapsed_s` 达数百秒甚至上千秒的事务；② `trx_rows_modified` 很大（写长事务）或 `trx_is_read_only = 1`（只读长事务）；③ `History list length` 持续在万级以上且不下降。三条同时成立即可确认。确认后的动作是 `KILL` 该连接（用 `trx_mysql_thread_id`），并检查它是否被长事务拖慢。

**错在哪**："CPU 不高、IO 不高，所以是网络问题"——忽略了"数据库内部的指针追逐与内存放大"这一整类归因；或者只查锁等待，而只读长事务根本不进锁等待视图。**追问方向**："为什么只读长事务也要管？"因为它同样让 ReadView 存活，同样挡住 purge；它不加锁反而更难被常规监控发现。

</details>

**Q5**（设计）一个"账户余额校验 + 扣减"的接口，业务要求：同一账户的并发扣减不能超扣，且需要记录校验时的余额快照。请分别给出在 RC 和 RR 下的正确实现思路，说明各自为什么成立，并指出只写 `SELECT` 再 `UPDATE` 会出什么问题。

<details><summary>答案与解析</summary>

**反面写法**：`@Transactional` 里先 `SELECT balance FROM account WHERE id = 1`（快照读），在应用层判断 `balance >= amount`，再 `UPDATE account SET balance = balance - ? WHERE id = 1`。问题在于：快照读不加锁，两个并发事务可以同时读到 `balance = 100` 并都通过校验，随后各自扣减，总扣减量超过余额（写偏斜，write skew）。RR 并不能阻止，因为快照读之间不存在锁冲突。**这是"RR 下也不会幻读所以很安全"这个直觉最典型的翻车现场。**

**RR 下的正确思路**：把校验和扣减放进同一条语句，让数据库在写锁下完成比较——`UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?`，用受影响行数（`affectedRows = 0` 即余额不足）判断结果。这条语句是当前读 + 排他锁，同一行的并发扣减被串行化。若确实需要"先拿到余额快照再判断"，必须用 `SELECT balance FROM account WHERE id = ? FOR UPDATE` 让快照读变成当前读并加锁，此时 RR 的 ReadView 复用不再影响结果（当前读不参与可见性判断），因为**锁已经保证了串行**。

**RC 下的正确思路**：同样推荐单语句 `UPDATE ... WHERE balance >= ?`，与 RR 无差别。差别在于如果用 `FOR UPDATE` 实现，RC 下每次读都是当前读，RR 下第一次读建立 ReadView 之后当前读仍然是当前读——两者在"加锁读"这一路径上行为一致，**真正需要注意的差别是 RC 下同一事务内非加锁读的结论会变**，因此 RC 下更要避免"读一次、判断、再读一次"的写法。

**加分点**：说明为什么不需要分布式锁——单行更新已经由 InnoDB 的行锁保证原子性（加锁规则见第 24 章 24.3 节）；只有跨库或多资源时才需要外部锁。

**追问方向**："如果账户在分库分表的两个分片上要互相转账呢？"这就超出单机事务范围，需要分布式事务方案，见第 26 章 26.6 节。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 你说事务有 ACID 四个特性，那这四个特性各自是靠什么机制实现的？ | 是否能把特性对应到组件 | 错误回答：把四条再复述一遍，或说"一致性靠两阶段提交保证"。加分：逐条对应 undo / redo+fsync / 锁+MVCC / 约束，并指出一致性是目标而非机制 | 22.1 |
| MVCC 是怎么实现的？请把用到的数据结构说完整。 | 三要素是否齐全 | 错误回答：只答"ReadView 判断可见性"，说不出 `DB_TRX_ID`、`DB_ROLL_PTR` 与 undo 版本链。加分：点出 `DB_ROW_ID` 只在无主键时存在 | 22.3 |
| ReadView 里有哪几个字段？给我讲一下可见性判断的过程。 | 算法是否可执行 | 错误回答：规则边界写成 `≤ min_trx_id` 与 `> max_trx_id`，或说"不可见就返回 NULL"而不知道要沿 `DB_ROLL_PTR` 回溯。加分：主动给出"从最新版本回溯、命中即可见就停"的终止条件 | 22.4 |
| RC 和 RR 的差别到底在哪？ | 是否抓住唯一根因 | 错误回答："RR 读快照数据、RC 读最新数据"——把差异说成数据不同。加分：指出"可见性判断算法完全相同，差别只在 ReadView 的创建时机"，并补一句"RR 的 ReadView 是第一次快照读时建的，不是 BEGIN 时" | 22.5 |
| 那 RR 隔离级别下还会不会出现幻读？ | 是否知道"但是" | 错误回答：直接答"不会，RR 已经解决了幻读"。加分：说明当前读绕过 ReadView，给出"快照读 3 行、`FOR UPDATE` 4 行、再快照读又 3 行"的具体场景 | 22.6.1 |
| 为什么 MySQL 默认是 RR 而 PostgreSQL 默认是 RC？ | 实现路线与历史约束 | 错误回答："RR 更安全所以 MySQL 更好"或"PG 设计得差"。加分：说到 MySQL 早期 `STATEMENT` 复制的约束、PG 的语句级快照与 vacuum 压力 | 22.2.1 |
| 长事务有什么危害？ | 危害链条是否完整 | 错误回答："占内存、占连接、回滚慢"——说不出 undo 旧版本无法回收、purge 被挡住、危害波及后续所有事务这条因果链。加分：补上"只读长事务同样有害且更隐蔽"，并给出 `innodb_trx` 的查询 | 22.7 |
| 为什么 MySQL 建议用 RC 的时候要把 binlog 格式设成 ROW？ | 隔离级别与复制的耦合 | 错误回答："因为 ROW 更省空间/更快"（都不对，ROW 通常更占空间）。加分：说清 `STATEMENT` 记录 SQL 文本、从库重放要重新求值，而 RC 下同一事务两次读结果可能不同 | 22.8 |
| `innodb_autoinc_lock_mode = 2` 会不会导致主从自增 ID 不一致？ | 是否理解模式与格式的绑定关系 | 错误回答："会，所以线上不能开 2"。加分：说明模式 2 只在 `binlog_format=STATEMENT` 下不安全，配 `ROW` 时自增值随行镜像一起记录，并补一句 8.0 默认就是 2 | 22.8 |
| 我把隔离级别设成 READ UNCOMMITTED，为什么脏读好像没生效？ | InnoDB 的实际行为 | 错误回答："因为 InnoDB 不支持 RU"。加分：说明 InnoDB 下 RU 的快照读不做可见性判断直接读最新版本，脏读确实存在；同时指出隔离级别只是**读的规则**，不会取消行锁，写—写仍然互斥 | 22.2、22.6 |
| 同一事务里我先 `SELECT` 再 `UPDATE`，为什么 UPDATE 影响的行数和刚才查到的不一样？ | 快照读与当前读的语义差 | 错误回答："MySQL 有 bug"或"是缓存问题"。加分：指出 `SELECT` 是快照读、`UPDATE` 是当前读，两者看到的数据集天然可以不同，唯一性校验必须靠唯一索引而不是"先查再插" | 22.6 |

## 延伸阅读

- MySQL 官方手册 *InnoDB Locking and Transaction Model* 章节，尤其是 "Consistent Nonlocking Reads" 与 "Transaction Isolation Levels" 两节：ReadView 语义、RR 与 RC 的行为差异、`binlog_format` 与隔离级别的配合建议均以此为准。
- MySQL 官方手册 *InnoDB Multi-Versioning* 一节：`DB_TRX_ID`、`DB_ROLL_PTR`、`DB_ROW_ID` 三个隐藏字段的定义与 undo 版本链的描述。
- MySQL 官方手册 *AUTO_INCREMENT Handling in InnoDB* 一节：`innodb_autoinc_lock_mode` 三种模式的定义与各模式对基于语句的复制的影响。
- MySQL 官方手册 *`information_schema.INNODB_TRX` 表*：`trx_started`、`trx_rows_locked`、`trx_rows_modified`、`trx_is_read_only` 等字段的准确口径（时间字段单位是秒）。
- PostgreSQL 官方文档 *Transaction Isolation* 一章，特别是 "Read Committed Isolation Level" 一节：解释 PG 每个语句取一次快照的语义，可与本章 22.2.1 节对照阅读。
- SQL 标准（ISO/IEC 9075）中的隔离级别与三类现象定义：本章 22.2 节表格中"标准"一列的规范依据，注意标准只描述可观察现象、不规定实现。


---

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

