# 第 21 章 Redo Log、undo Log 与 Binlog


> 三种日志是 InnoDB 的一切承诺（不丢、可回滚、可复制）的物质基础；两阶段提交则是它们之间唯一的粘合剂。

## 学习目标

读完本章，你应当能够：

1. 从"顺序写 vs 随机写"与"日志是紧凑表示"两条理由推导出 WAL 的收益，并据此判断 `innodb_flush_log_at_trx_commit` 三种取值在进程崩溃与机器断电下各自的丢失窗口；
2. 说清 redo log 为什么是物理逻辑日志，画出 `write pos`/`checkpoint` 的环形结构，推导"日志写满"时为什么会导致写入停顿；
3. 画出组提交（group commit）三个阶段的队列模型，解释 `binlog_group_commit_sync_delay` 如何用延迟换吞吐，以及它与 `sync_binlog`/`innodb_flush_log_at_trx_commit` 的耦合关系；
4. 区分 insert undo 与 update undo 在提交后能否立即删除，说明 purge 线程、长事务与 undo 表空间膨胀的因果链；
5. 对两阶段提交的三种崩溃时机逐个给出恢复结果，解释为什么决断依据必须是"redo 是 prepare 状态且 binlog 完整"，并按 checkpoint → 重放 redo → 处理 prepare → 用 binlog 决断 → 回滚未提交 的顺序复述崩溃恢复流程，说出 `innodb_force_recovery` 各级的含义。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 一条 `UPDATE` 在 buffer pool、undo、redo、binlog 之间的完整流转 | 见第 20 章 20.4、20.5 节；本章在其基础上深化"落盘顺序" |
| 表空间、段、区、页（16 KB）与行格式 | 见第 20 章 20.4 节；本章只用到"页号 + 页内偏移"这一层抽象 |
| 事务语法与 `BEGIN`/`COMMIT`/`ROLLBACK` 的语义 | 见第 22 章 22.1 节；本章只讲这三条语句在日志层的动作 |
| 磁盘顺序读写与随机读写的量级差异 | 见第 13 章 13.4 节；本章用 `fio` 口径的数字做对比 |

## 21.1 WAL：为什么"先写日志再写数据"更快也更安全

### 21.1.1 两条理由：顺序写与紧凑表示

WAL（Write-Ahead Logging）的规则只有一句：**修改数据页之前，必须先把这次修改的日志刷到持久存储**。它同时解决两个独立问题。

第一是**性能**。数据页散落在表空间的各个位置，一次事务可能改 5 个页，这 5 个页在磁盘上的物理位置毫无规律；而 redo log 是**追加写（append-only）**，从头顺到尾。机械盘上一次随机写要等寻道 + 旋转（约 5~10 ms），一次顺序写只需付出传输时间；即使在 SSD 上，随机写 4 KB 约 100 μs，而顺序写带宽可以跑满接口。更关键的是**合并**：同一页被改 100 次，redo 里就有 100 条记录，而刷脏页只写 1 次磁盘——把"随机写"转成"顺序写 + 批量合并"。

第二是**安全**。提交时若必须把数据页本身刷盘，一次事务要等待所有涉及页的随机写完成，且**部分页写成功、部分失败**会留下物理上不一致的页（这也是 `innodb_doublewrite` 存在的原因之一）。而只要日志落盘，内存里的数据页可以随便延迟写——崩溃后用日志重放即可重建。

还有第三个常被忽略的点：**日志是紧凑表示**。redo 记录的是"页号 5、偏移 0x40 起 8 字节改成 X"这样的增量，而不是整页 16 KB。一条改单列 `INT` 的记录通常只有几十字节，比页小两三个数量级。

```
 不用 WAL（假设）：                       使用 WAL（InnoDB 实际做法）：
 事务 T 提交时必须把 5 个脏页写回磁盘     提交时只需把 redo 追加到 log file
   5 次随机写，位置毫无规律                 1 次顺序写，几十~几百字节
   page#17 page#3 page#88 page#41 page#9    log file: [rec][rec][rec]...
   ~100 μs 各一次（SSD 口径）               ~一次 fsync 的量级
   最坏：部分页写成功 → 页内结构损坏         脏页可以慢慢刷；崩溃后重放 redo
```

### 21.1.2 `innodb_flush_log_at_trx_commit` 的三种取值与丢失窗口

这个参数的名字常常误导人：它管的是**提交时 redo log 如何落盘**，而不是"多久刷一次脏页"。

| 取值 | 提交时动作 | 谁负责落盘 | 进程崩溃（mysqld crash） | 机器断电 / OS 崩溃 | 典型场景 |
|---|---|---|---|---|---|
| `0` | 不写不刷 | 后台线程每秒刷一次 | 丢最近约 1 s | 丢最近约 1 s | 可重建的日志表、批量导入 |
| `1`（默认） | 写入并 `fsync` | 提交线程自己 | 不丢 | 不丢（假设磁盘缓存诚实） | 金融、订单等一切不能丢的场景 |
| `2` | 写入 OS page cache，每秒 `fsync` | OS 每秒刷 | 不丢（进程挂了，内核缓冲区还在） | 丢最近约 1 s | 允许断电丢 1 s 的高吞吐场景 |

> [!IMPORTANT]
> `1` 与 `2` 的差别不在"是否写了文件"，而在"是否越过了 OS page cache 这道墙"。进程崩溃时 page cache 由内核持有，数据不会丢；掉电时 page cache 里的脏页就没了。所以"`2` 比 `1` 快但掉电会丢 1 秒"必须补上"丢的是 OS 缓存里还没刷的那部分"。`2` 与 `0` 的对比同样易错：`0` 连 `write` 都不做，**进程崩溃也可能丢**。

### 21.1.3 为什么不让日志直接替代数据页

常见追问："既然 redo 记了所有修改，为什么不只留 redo、不要数据页？"答案是**恢复时间必须有上界**：checkpoint 不断推进，把"该 LSN 之前的脏页都已刷盘"记下来，崩溃后只需从最近一次 checkpoint 重放。若不刷脏页，checkpoint 永远停在开头，恢复时长随运行时间线性增长。**刷脏页不是为了省日志，而是为了让恢复时间有界**。

## 21.2 redo log：物理逻辑日志与环形结构

### 21.2.1 `物理逻辑` 的含义

redo log 的记录分两种粒度：**物理层**记录"哪个表空间的第几号页、页内哪个偏移、改成什么字节"，靠 `(space_id, page_no, offset, len, data)` 定位；**逻辑层**面对"插入一行导致页分裂"这类操作，不记录分裂后每个字节的最终形态，而是记录"在某索引上插入主键为 K 的记录"，由重放线程重新执行、得到确定结果。

所以准确说法是**物理逻辑日志（physiological logging）**：页内改动的重放是幂等的、不依赖 B+ 树当前结构，同时又能把页分裂这种逻辑操作表达得足够短。

> [!WARNING]
> 答"redo log 是物理日志，记录数据页的修改"会被追问："页分裂怎么记？"坚持纯物理就必须记录分裂后两个页的全部内容，日志膨胀且重放不幂等。正确答案是"页内物理 + 操作逻辑"。

### 21.2.2 LSN：三个位置记录的同一个数
LSN（Log Sequence Number）是**单调递增的字节偏移量**，8 字节整数，表示"日志已经写到哪儿了"。它出现在三处，混淆这三处是高频扣分点：

| 记录位置 | 名称 | 含义（谁写） |
|---|---|---|
| 每个数据页的页头 | `FIL_PAGE_LSN` | 该页最后一次被修改时对应的 LSN（由修改该页的事务写） |
| redo log 全局 | `Log Sequence Number` | 当前已写入日志的末尾位置（日志写入线程） |
| redo log 全局 / 页 | `checkpoint LSN` | 该 LSN 之前的所有脏页都已刷盘（checkpoint 线程） |

判断一个数据页是否需要重放，靠的是**页头 LSN 与 checkpoint LSN 比较**：页的 `FIL_PAGE_LSN` 小于 checkpoint LSN，说明它的修改早已刷盘，无需重放；反之才需要。这样恢复时不必扫描所有页。

### 21.2.3 环形结构与"日志写满"的后果

redo log 在 8.0.30 之前是**固定大小的循环文件组**（`innodb_log_file_size × innodb_log_files_in_group`），8.0.30 起改为 `innodb_redo_log_capacity`（默认 100 MB）统一控制，但**环形语义不变**：

```
     innodb_redo_log_capacity（循环使用的总空间）
  ┌──────────────────────────────────────────────────────┐
  │  ┌──────────┐  ← 可被覆盖 →   ┌──────────┐           │
  │  │ 已刷盘区域 │                │ 待刷盘区域 │           │
  │  └──────────┘                └──────────┘           │
  │       ▲                            ▲                 │
  │  checkpoint LSN                write pos             │
  │ （脏页已刷到的位置）        （日志已写到哪）            │
  └──────────────────────────────────────────────────────┘
   checkpoint 与 write pos 之间 = 尚未刷盘的脏页所依赖的日志
   write pos 追上 checkpoint  ⟹ 环形缓冲区写满
```

**写满时发生什么**：新事务要写 redo，但 `write pos` 已追到 `checkpoint`，环形空间没有空闲。此时 InnoDB 必须**强制推进 checkpoint**——把一批脏页刷盘（`innodb_page_cleaners` 与刷脏线程参与），刷完才能腾出日志空间。这期间**新写入被阻塞**，表现为"刷脏抖动"、写入 RT 尖刺；若刷脏速度跟不上日志产生速度，就是持续停顿。由此得两条工程结论：容量不宜过小（否则刷脏被迫提前，失去"把随机写攒成顺序写"的合并收益），经验口径是能容纳 **1~2 小时**的写入量；刷脏成为瓶颈时症状是**写入周期性卡顿而 CPU 不高**，观测点是 `SHOW ENGINE INNODB STATUS` 里 `Log sequence number` 与 `Last checkpoint at` 的差距。

### 21.2.4 组提交：三个阶段各排一次队

一次提交只要 `innodb_flush_log_at_trx_commit = 1`，就必须 `fsync`。而 `fsync` 是整条链路上最贵的单点操作（机械盘约 5~10 ms，SSD 约 0.1~1 ms 量级）。如果每个事务各自 `fsync`，QPS 会被硬压到每秒几百。组提交（group commit）的做法是**把并发事务分成三阶段、每阶段排队，由 leader 代大家做一次真正的刷盘/落盘**：

```
  阶段 1 flush        阶段 2 sync              阶段 3 commit
  ────────────        ────────────             ────────────
  T1..T5 各自写        T1..T5 排成一队            T1..T5 按序写
  redo/binlog 缓存     一次 fsync 覆盖全队        引擎层 commit 标记
                       （leader 执行）            （顺序不能乱，否则
                                                  binlog 顺序与提交顺序不符）
```

三个阶段必须拆开，是因为**每个阶段能合并的对象不同**：flush 合并"从用户态缓冲区写进文件"，sync 合并 `fsync`（收益最大），commit 合并"引擎层提交"（本身无 I/O，但不排队就会退化成每个事务各自拿锁，把前面的收益吃掉）。

`binlog_group_commit_sync_delay`（微秒）与 `binlog_group_commit_sync_no_delay_count` 是"用延迟换吞吐"的旋钮：`sync_delay = 1000` 表示 leader 在 sync 阶段**故意等最多 1 ms**，把这段时间到达的事务攒成更大一批，一次 `fsync` 覆盖更多事务；`no_delay_count` 规定"攒够 N 个就不再等"。代价是每个事务至少多等这段时间：它换来的是吞吐与 P99 稳定性，牺牲的是平均延迟——高并发批量场景收益明显，低并发时纯粹自伤。

> [!TIP]
> 面试话术："组提交不优化 `fsync` 本身，而是把 N 个事务的 N 次 `fsync` 摊薄成 1 次，所以吞吐随并发度上升，而单事务延迟不降反升。"这句话能区分"背过概念"和"调过参数"。

### 21.2.5 `innodb_flush_method` 与 `O_DIRECT`

`innodb_flush_method` 决定数据文件与日志文件的打开方式，核心分歧是**要不要绕过 OS page cache**：默认 `fsync` 走 buffered I/O，同一页在 buffer pool 与 page cache 各存一份，但保留内核的写合并与预读；`O_DIRECT` 绕过 page cache 直接写盘，省内存但失去预读与写合并；`O_DIRECT_NO_FSYNC` 在 `O_DIRECT` 基础上不再为元数据额外 `fsync`，只在文件系统（如 xfs）已保证元数据时可用。

关键点：**`O_DIRECT` 不会让 redo 的 `fsync` 语义消失**。它只改变脏页回写路径，提交时的持久化仍由 `innodb_flush_log_at_trx_commit` 决定。它要求按扇区对齐（512 B 或 4 KB），未对齐的 I/O 会被内核退回 buffered 路径。

## 21.3 undo log：逻辑日志与两个作用

### 21.3.1 记录的是反向操作

undo log 是**逻辑日志**：不记录"页 5 的偏移 0x40 原来是 X"，而记录"把插入的行删掉"或"把 `age` 从 24 改回 23"这样的反向操作。回滚时由引擎**重新执行**这些反向操作，而不是把字节倒回去。原因是同一个逻辑修改可能落在不同的物理位置（页分裂、记录迁移），只有逻辑层才能保证回滚到正确的语义状态。

undo 有且只有**两个作用**：

1. **事务回滚**：显式 `ROLLBACK`，以及崩溃恢复时对未提交事务的回滚；
2. **MVCC 快照读**：为一致性读提供历史版本，版本链的构建与可见性判断细节见第 22 章 22.3 节。

> [!CAUTION]
> 答"undo log 用来回滚"只对一半，会被追问"还有别的作用吗"。漏掉"给 MVCC 提供历史版本"是本章最典型的丢分点，而这个作用恰恰解释了后面所有关于"长事务为什么危险"的结论。

### 21.3.2 insert undo 与 update undo：提交后能否立即删除

undo 页的回收规则取决于**提交后还有没有人需要这个版本**。

| 类型 | 对应操作 | 提交后其他人的需要 | 提交后能否立即删除 |
|---|---|---|---|
| insert undo | `INSERT` | 无：其他事务看不到未提交的插入，提交后更不需要旧版本 | **可以**，提交即回收 |
| update undo | `UPDATE` / `DELETE` | 有：活跃的 ReadView 可能仍要读旧版本 | **不可以**，必须等 purge |

update undo 的回收由 **purge 线程**完成。它判断"这个版本还有没有人需要"的依据是**最老的活跃 ReadView**：如果一个 undo 记录对应的 `trx_id` 已经不早于任何活跃事务可见需要的时间点，它就可以被清理。`innodb_purge_threads`（默认 4）控制 purge 线程数，undo 表空间由 `innodb_undo_tablespaces` 控制（8.0 默认 2 个，可增长）。

**长事务的危害链**在这里闭环：

```
 长事务长期不提交
      │  该事务的 ReadView 一直活跃
      ▼
 purge 无法越过"最老活跃 ReadView"推进 → 历史版本一个都不能删
      │
      ▼
 undo 表空间持续膨胀 + 版本链越来越长 → 每次快照读都要沿链回溯比较 trx_id
      │
      ▼
 查询变慢、undo 页 I/O 变多、purge 追不上 → 回滚段耗尽风险
```

### 21.3.3 回滚与崩溃恢复中的 undo

崩溃恢复的重放阶段会把**所有已落盘的 redo 都重新应用**，包括尚未提交、甚至刚写入 prepare 的事务。所以重放结束后，库里必然存在一批"物理上已改、逻辑上不该存在"的事务，**必须靠 undo 把它们回滚掉**：redo 让数据前进到崩溃前一刻，undo 再让未提交的事务后退。这也解释了为什么 undo 必须在数据修改**之前**写入——若 undo 后写而崩溃在中间，恢复时既不知道原值是什么、也无法回滚。

## 21.4 binlog：Server 层的逻辑日志

### 21.4.1 与存储引擎无关

binlog 由 **Server 层**写入，记录"逻辑上发生了什么"，与用哪个引擎无关。它有三个用途：**主从复制**（从库重放，机制见第 26 章 26.2 节）、**归档**、**按时间点恢复（point-in-time recovery, PITR）**——先用全量备份恢复，再重放备份点之后的 binlog。三种格式的对比是必考项（`binlog_format` 是**会话级**变量，可运行时切换，但链路上每一段——主库、从库 SQL 线程、级联从库——都必须能正确处理收到的格式，所以生产上是"统一用 ROW"的运维纪律）：

| 维度 | `STATEMENT` | `ROW` | `MIXED` |
|---|---|---|---|
| 记录内容 | 原始 SQL 语句 | 每行修改前像/后像 | 默认 STATEMENT，遇不确定函数自动切 ROW |
| 日志量 | 最小（一条 SQL 可能改百万行） | 最大（每行都要记） | 介于两者之间 |
| 可读性 | 高，直接看懂 SQL | 低，需 `mysqlbinlog -v` 解码 | 混合 |
| `NOW()`/`UUID()`/`RAND()` | **会不一致**（从库执行时刻/内容与主库不同） | 安全（值已固化在行像里） | 通常安全，但判定本身不完美 |
| `LIMIT` 无 `ORDER BY` | **会不一致**（两库返回行序可能不同） | 安全 | 通常安全 |
| 数据恢复能力 | 弱（无法精确还原行） | 强（可反向生成补偿 SQL） | 中 |
| 主库额外开销 | 需为安全重放加更多锁 | 行像序列化开销 + 日志量带来的 I/O | 中 |

为什么互联网公司普遍选择 `ROW`：不是因为它"更先进"，而是因为它在**语义上排除了非确定性**——从库不再"重新执行逻辑"，而是"照着行像改"，因此主从数据在行级别强一致。代价是日志量大，`DELETE`/`UPDATE` 大范围操作时可能比 `STATEMENT` 大几个数量级，需要用 `binlog_row_image = MINIMAL` 压缩：只记录主键（前像）与实际改变的列（后像）。

### 21.4.2 写入时机与 `sync_binlog`

binlog 的写入分两步，**都发生在事务提交阶段**：事务执行期间，SQL 语句（或行像）先写进每个会话私有的 **binlog cache**（超出 `binlog_cache_size` 会溢出到临时文件）；**提交时**把 cache 内容 `write` 进 binlog 文件，再按 `sync_binlog` 决定是否 `fsync`。

| `sync_binlog` | 提交时动作 | 掉电丢失窗口 | 说明 |
|---|---|---|---|
| `0` | 只 `write`，不 `fsync` | 由 OS 决定（可能丢多个事务） | 吞吐最高，风险最高 |
| `1` | 每次提交 `fsync` | 不丢 | 双一配置的一半，最安全 |
| `N`（>1） | 累积 N 个事务后 `fsync` | 最多丢 N−1 个事务 | 折中；末尾可能不完整，靠 `binlog_checksum` 检测 |

**双一配置**指 `sync_binlog = 1` 且 `innodb_flush_log_at_trx_commit = 1`，即"每个事务至少两次 `fsync`（binlog 一次、redo 一次）"。这两次 `fsync` 不丢，靠的正是组提交把并发事务合并——`sync_binlog = 1` 并不意味着"一次提交一次 `fsync`"，而是"每个提交批次至少一次 `fsync`"。

binlog 的滚动由 `max_binlog_size`（默认 1 GB）触发，但**单个事务超过该值时不会在事务中间切开**（切开会让从库读到半个事务），所以实际文件可能超出。过期删除由 `binlog_expire_logs_seconds`（8.0.11 起，默认 2592000 秒即 30 天）控制，早期版本是 `expire_logs_days`。手工清理用 `PURGE BINARY LOGS TO 'mysql-bin.000123'`，但**从库还没消费到该位置就不能删**，否则复制直接中断。

## 21.5 两阶段提交：崩溃点分析

### 21.5.1 时序图

两阶段提交（two-phase commit, 2PC）在这里不是分布式事务协议，而是 **InnoDB 与 Server 层 binlog 之间的内部一致性协议**：redo 与 binlog 分属两个写入者，**没法放进同一个原子操作**，一旦中间崩溃两者就会各说各话。注意要点：**redo 先落盘、binlog 后落盘**，顺序不能反；这也是为什么参数动词是 `flush_log_at_trx_commit`——它管的是 redo，而不是 binlog。

```
 事务执行              【阶段一 prepare】              【阶段二 commit】
 ─────────              ──────────────────             ──────────────────
 改 buffer pool  ──▶  redo 写盘（带 prepare 标记）──▶
 写 undo         ──▶  事务进入 prepare 状态      ──▶  binlog 写盘（write + fsync）
                                                         │
                                                         ▼
                                                     redo 写 commit 标记
                                                         │
                                                         ▼
                                                     引擎层提交、锁释放、事务可见
```

### 21.5.2 三种崩溃时机各自恢复成什么

崩溃点分析要盯住一个不变式：**binlog 一旦完整落盘，就一定会被从库消费**。所以恢复的目标是让主库与从库对齐，而不是让主库"尽量保留"。

**时机 ①：写完 redo prepare 之后、写 binlog 之前崩溃**

```
 redo 有 prepare、无 commit；binlog 中没有该事务
 恢复判断：prepare + binlog 不存在 → 回滚
 主库被回滚、数据不存在；从库没有收到它 ⟹ 一致（都"没做"）
```

**时机 ②：写完 binlog 之后、写 redo commit 之前崩溃**

```
 redo 有 prepare、无 commit；binlog 中该事务完整
 恢复判断：prepare + binlog 完整 → 提交
 主库被提交、数据存在；从库重放后数据存在 ⟹ 一致（都"做了"）
```

**时机 ③：binlog 写了一半（或校验失败）崩溃**

```
 redo prepare；binlog 末尾存在截断或校验不通过的 event
 恢复判断：binlog 不算"完整" → 回滚，尾部截断到最后一个完整 event
 从库只重放了已收到的完整 event ⟹ 一致（都"没做"）
```

| 崩溃时机 | redo 状态 | binlog 状态 | 恢复动作 | 主库最终 | 从库最终 |
|---|---|---|---|---|---|
| ① prepare 后、binlog 前 | prepare | 无该事务 | 回滚 | 无 | 无 |
| ② binlog 后、commit 前 | prepare | 完整 | 提交 | 有 | 有 |
| ③ binlog 写到一半 | prepare | 截断/校验失败 | 回滚 + 截断尾部 | 无 | 无 |

### 21.5.3 为什么决断依据必须是"binlog 是否完整"

因为**binlog 是"对外已经生效"的凭据**——从库只认 binlog。恢复时主库只能"跟着 binlog 走"：binlog 说做了，主库就必须做（否则主库比从库少数据，后续复制会报错或静默分叉）；binlog 说没做，主库就必须回滚。**用 redo 的状态决断是不行的**：时机 ② 里 redo 只有 prepare，若按"没 commit 就回滚"处理，主库回滚而 binlog 已发给从库，从库执行了——主从立刻不一致。

`binlog_checksum`（5.6.2 起支持，5.6.6+ 默认 `CRC32`）是"完整性"落在字节上的实现：每个 event 尾部带 4 字节校验和，写入中断会留下校验失败的尾部事件，恢复据此认定 binlog 不完整、选择回滚并截断。注意它**只保证单个 event 的字节完整性，不保证事务边界完整**——后者靠 event 组末尾的 `XID` 事件是否存在。

### 21.5.4 不做 2PC 会怎样

| 做法 | 崩溃/失败后的主库 | 从库 | 后果 |
|---|---|---|---|
| 先写 binlog，再写 redo commit（无 2PC） | redo 未提交 → 主库回滚 | binlog 已发 → 从库执行 | **从库多数据**，且无法用主库数据修复 |
| 先写 redo commit，再写 binlog（无 2PC） | 已提交，数据存在 | binlog 没发 → 从库缺数据 | **从库少数据**，主从数据分叉且静默 |
| 两者都写但不管顺序（并行） | 随机 | 随机 | 灾难性：备份（PITR 靠 binlog）与数据可能互相矛盾 |

第一种更危险：它破坏了"从库可以被主库重建"这条底线；第二种虽也错，但至少能用主库重做从库。2PC 的代价是每个事务多一次 `fsync` 与一次状态写，收益是"binlog 与数据文件永远可以互相推导"。

## 21.6 redo log 与 binlog 的分工

| 维度 | redo log | binlog |
|---|---|---|
| 层次 | InnoDB 存储引擎层 | MySQL Server 层（与引擎无关） |
| 内容 | 物理逻辑日志：页号 + 页内偏移 + 修改（含逻辑操作） | 逻辑日志：SQL 语句或行前像/后像 |
| 格式 | 二进制、循环覆盖，不可人工解析 | 二进制 + event 头，可用 `mysqlbinlog` 解析 |
| 写入时机 | 事务执行中持续写，提交时按 `innodb_flush_log_at_trx_commit` 刷盘 | 事务执行中写 binlog cache，提交时写文件 |
| 是否循环 | 是，环形结构，空间固定 | 否，追加增长，按大小滚动、按时间过期 |
| 唯一用途 | **崩溃恢复**（crash recovery） | **复制、归档、PITR** |
| 谁需要它 | 只服务本实例 | 服务本实例 + 所有从库 + 备份体系 |
| 丢失后果 | 已提交事务丢失（违反 D） | 从库数据落后、备份无法补齐 |

> [!IMPORTANT]
> 一句话记住分工：**redo 让本机"不丢"，binlog 让别人"跟上"**。所以两者都必须在提交前落盘，且顺序必须是"先 redo prepare、再 binlog、最后 redo commit"。

## 21.7 崩溃恢复的完整流程

```
 ① 找最近一次 checkpoint LSN（SHOW ENGINE INNODB STATUS 或 redo 文件头）
 ② 从 checkpoint 起前向重放 redo（redo apply / 前滚）
      ├─ 比对页头 FIL_PAGE_LSN，跳过"修改已刷盘"的页
      ├─ 重放范围包含未提交事务、含 prepare 事务的修改
      └─ 结束后：数据页 = 崩溃前最后一刻的物理状态
 ③ 扫描事务状态，挑出仍处于 prepare 的事务
 ④ 用 binlog 决断：完整 → 补写 redo commit 标记并提交；不完整/不存在 → 回滚
 ⑤ 回滚其余未提交事务（靠 undo 反向操作）
 ⑥ binlog 尾部若校验失败 → 截断到最后一个完整 event 组
 ⑦ 开始接受连接；undo 的后续清理交给 purge 线程
```

第 ③④ 步的实现细节值得记住：**InnoDB 在 prepare 阶段会把事务的 XID 一并写进 redo**，恢复时用这个 XID 去 binlog 里查找对应的事件组。找到且完整 → 提交；找不到或尾部不完整 → 回滚。这就是"用 binlog 决断"在代码层面的落地方式。

### 21.7.1 `innodb_force_recovery` 的分级

当 redo 或数据页本身损坏（重放到坏页导致启动失败）时，需要人工降级启动：

| 级别 | 含义 | 风险 |
|---|---|---|
| `0` | 默认，正常启动并做完整恢复 | 无 |
| `1` | `SRV_FORCE_IGNORE_CORRUPT`：忽略损坏页继续启动，可 `SELECT`/`mysqldump` 抢救 | 查询到损坏页会报错 |
| `2` | 不运行后台刷脏与 checkpoint | 日志空间很快写满 |
| `3` | 不对未提交事务做回滚 | 数据含未提交内容，**逻辑不一致** |
| `4` | 不执行 insert buffer 合并（实例近似只读） | 索引可能不完整，需 `REBUILD` |
| `5` | 不检查 undo log 一致性 | 可能读到垃圾版本 |
| `6` | 不重放 redo（跳过前滚） | **数据缺失且无法确定缺多少** |

> [!WARNING]
> `innodb_force_recovery ≥ 4` 时实例实质上是**只读**的（官方限制），只能用于把数据导出来重建，绝不能当成"修好了"继续跑业务。级别越高越"能启动"，也越高概率拿到一份逻辑上不成立的数据。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| WAL 的两条收益 | 顺序写 + 批量合并解决性能，日志落盘即安全解决持久性 | 只答"日志快"，说不出随机写与合并 |
| 刷脏页的真正目的 | 让 checkpoint 前进，使崩溃恢复的**重放长度有界** | 答"刷脏页是为了省内存" |
| `flush_log_at_trx_commit` | `1` 不丢；`0` 连 `write` 都不做（进程崩溃也丢）；`2` 进程崩溃不丢、掉电丢约 1 s | 把 `0` 与 `2` 混为一谈，或说 `2` 一定会丢 |
| LSN 的三处记录 | 页头 `FIL_PAGE_LSN`、全局写入位置、checkpoint LSN | 只答"LSN 是日志序号"，说不出页头与 checkpoint 的用途 |
| 日志写满 | `write pos` 追上 `checkpoint` → 强制刷脏、期间写入停顿 | 答"日志写满就报错退出"或"自动扩容" |
| 组提交 | flush/sync/commit 三阶段各排队，用一次 `fsync` 覆盖一批事务 | 答"组提交让单个事务变快" |
| insert vs update undo | insert undo 提交即回收；update undo 必须等 purge | 答"undo 提交后都立即删除" |
| binlog 格式选择 | `ROW` 用确定性换日志量；`MINIMAL` 是压缩手段 | 答"`Statement` 更省所以更好" |
| 2PC 的决断依据 | prepare + binlog 完整 → 提交；否则回滚 | 用"redo 有没有 commit"来决断 |
| 崩溃恢复顺序 | checkpoint → 重放 redo → 处理 prepare → binlog 决断 → undo 回滚 | 把回滚放在重放之前 |

## 自测题

**Q1**（推导）一台机器上的 MySQL 配置为 `innodb_flush_log_at_trx_commit = 2`、`sync_binlog = 1`。请分别说明：mysqld 进程被 `kill -9`、操作系统 panic、机房整体掉电三种情况下，一个刚刚 `COMMIT` 返回成功的事务会不会丢，为什么。

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

分两种情况看两个日志各自的状态：

**(1) mysqld 进程被 `kill -9`**：不丢。`2` 表示提交时 redo 已用 `write` 交给内核，数据在 OS page cache 里，进程死亡不会带走 page cache；`sync_binlog = 1` 更是在提交时就完成了 `fsync`。两者都在，事务不丢。

**(2) 操作系统 panic**：仍不丢。panic 时内核会尝试把脏页（含文件系统元数据）交给存储，但这是"通常能成功、不能保证"的动作；同时 `sync_binlog = 1` 保证 binlog 已在盘上，所以准确表述是"大概率不丢，且即使 redo 丢了，恢复时也能靠完整 binlog 把该事务提交回来"——这正是 2PC 的价值。

**(3) 机房整体掉电**：会丢。掉电后 OS page cache 中的脏页全部消失，`2` 的语义就是"每秒刷一次、掉电最多丢约 1 s"，最近约 1 s 内提交的事务其 redo 已丢。但是否彻底丢取决于 binlog：binlog 因 `sync_binlog = 1` 还在，恢复时该事务处于 prepare 且 binlog 完整，于是被提交——**redo 丢了但 binlog 在，事务被救回**。所以真正会丢的是"binlog 也还没 `fsync` 的那批"，在此配置下理论上是零。

**错在哪**：把所有崩溃类型混成一句"`2` 会丢 1 秒"。这题考的是你是否区分**进程崩溃**与**掉电**，以及是否知道两个日志的持久化是**独立**的、可以互相补救。**追问方向**：若把 `sync_binlog` 也改成 `0`，掉电时 redo 与 binlog 同时处于"未刷"状态，最近约 1 s 的事务彻底消失。

</details>

**Q2**（判断）有同事主张："`innodb_redo_log_capacity` 设大一点没有坏处，反正日志是循环使用的，占的磁盘也不会变多。"请判断并说明调大与调小的各自代价，以及"日志写满"时的具体行为。

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

判断：**部分正确但结论错**。容量确实只占用固定磁盘（循环复用），但它不是"越大越好"，它影响**刷脏节奏**与**崩溃恢复的重放长度**。

调大的收益：日志不易写满，checkpoint 可以推进得更慢，刷脏更从容，能把随机写攒成顺序写，写入尖刺变少。代价：崩溃后从 checkpoint 起的重放范围更大（恢复更慢）、固定占盘更多。调小的利弊正好相反。

**"日志写满"的确切行为**：`write pos` 追上 `checkpoint` 时环形缓冲没有空闲，InnoDB 必须**推进 checkpoint**——把一批脏页刷盘以腾出可复用的日志空间，期间需要新日志空间的事务被阻塞等待。刷脏长期跟不上日志生成速度就是持续停顿（不是报错，是等待）。经验口径是让容量容纳 1~2 小时的写入量。

**错在哪**：把"日志循环"理解成"容量与性能无关"，忽略"写满 → 强制刷脏 → 写入停顿"这条链；或者答"日志满了会自动扩容"——8.0.30 起的 `innodb_redo_log_capacity` 也是**预留**空间，不会按需增长。**追问方向**：看 `Log sequence number` 与 `Last checkpoint at` 的差值是否长期贴近 capacity，且写入 RT 呈周期性尖刺而 CPU 不高。

</details>

**Q3**（分析）一次事务提交过程中，实例在写完 redo 的 prepare 标记、写完完整的 binlog 事务组、**还没来得及写 redo commit 标记**时掉电。请说明重启后该事务的最终状态、从库的状态，并解释为什么不能用"redo 里没有 commit 标记"作为回滚依据。

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

**主库最终状态：提交**。恢复从 checkpoint 重放 redo，把这个事务的修改全部应用（含 prepare 阶段已落盘的内容），此时事务处于 `prepare`；恢复逻辑检测到它处于 prepare，就按 XID 去 binlog 查对应事件组，发现**完整存在**，于是补写 commit 标记完成提交。

**从库状态：也会执行**。binlog 已完整落盘，从库（或级联从库）正常收到并重放它。两边都"做了"，一致。

**为什么不能用"redo 里没有 commit 标记"回滚**：从库的唯一依据是 binlog，**它不管主库 redo 长什么样**。若主库因"没有 commit 标记"回滚，主库没有这个事务而从库已经有——主从立刻不一致，且是**静默**的（复制线程不报错，因为从库执行的是合法事务）。所以决断必须站在"binlog 是否完整"这一侧。

**错在哪**：把 2PC 理解成"只看 redo 的两阶段"，忽略决断权在 binlog。典型错误是"没有 commit 标记就回滚，保证一致性"——这说法在**单机无复制**语境下听着对，一有从库就制造不一致。**追问方向**：若 binlog 写了一半呢？binlog 不完整 → 回滚并把尾部截断到最后一个完整事件组，从库从未收到半截事务，仍一致；第二层追问是 `binlog_checksum` 提供了**单 event 的字节完整性**判据。

</details>

**Q4**（设计）线上一个报表任务在事务里跑了 40 分钟才提交，期间磁盘上 `undo` 目录持续增长，并且其他会话的快照读逐渐变慢。请解释这条因果链，并给出至少三条处置措施（含具体参数或 SQL）。

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

**因果链**：长事务持有自己的 ReadView 且长期活跃 → purge 无法越过"最老活跃 ReadView"推进 → **所有 update undo 版本都不能回收** → undo 表空间持续膨胀、版本链越来越长 → 其他会话快照读要沿链回溯比较 `trx_id`，CPU 与 undo 页 I/O 上升（见第 22 章 22.3 节）→ 表现为"查询变慢"，并有回滚段耗尽风险。

```sql
-- 找出运行时间最长且未提交的事务：元凶是"最老"的那个，不是"改行最多"的那个
SELECT trx_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_rows_modified, trx_state
FROM information_schema.INNODB_TRX ORDER BY trx_started ASC LIMIT 10;
-- History list length 长期只增不减，就说明 purge 被最老的活跃 ReadView 钉住了
SHOW ENGINE INNODB STATUS\G
```

措施一：**缩短事务**。改成"分批小事务 + 走只读从库"，或报表读用一致性快照 `START TRANSACTION WITH CONSISTENT SNAPSHOT` 读完立即提交——这是根因修复。

措施二：**给长事务设闸门**。应用侧设事务超时，配合 `max_execution_time` 与中间件超时，对 `History list length` 与最长事务时长告警。

措施三：**清理与回收**。先 `KILL` 最老的那个事务让 purge 得以推进，观察 `History list length` 是否回落；undo 涨得过大时用 `innodb_undo_log_truncate = ON` 自动截断（需 purge 完成），必要时按官方流程重建 undo 表空间。

**错在哪**：只答"undo 表空间会自动清理，等一会儿就好"，说不出"purge 被最老活跃 ReadView 钉住"这个根因；或者把问题归因到"磁盘满了"。**追问方向**：为什么"提交后 undo 也不能立刻删"？因为 update undo 要给 MVCC 用，必须等 purge 判定没有活跃 ReadView 需要它——这条回答把 21.3 与 22.3 串起来了。

</details>

**Q5**（设计）你的团队要在 MySQL 8.0 上扛住每秒上万次的单行 `INSERT`，且不能丢已提交数据。请给出从参数到应用的完整优化清单，说明每一项为什么有效，以及它的代价。

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

目标是"不丢 + 高吞吐"，所以两个 `fsync` 都不能取消，只能**摊薄**和**减少**。

| 参数 | 建议值 | 为什么有效 | 代价 |
|---|---|---|---|
| `innodb_flush_log_at_trx_commit` | `1` | D 要求不丢，必须保持 | 每提交批次一次 `fsync`（靠组提交摊薄） |
| `sync_binlog` | `1` | binlog 是复制与 PITR 的凭据，不能丢 | 又一次 `fsync`（同样靠组提交摊薄） |
| `binlog_group_commit_sync_delay` / `..._no_delay_count` | 数百微秒到 1 ms / 几百 | 故意等待攒批，一次 `fsync` 覆盖更多事务，同时给延迟设上界 | 单事务平均延迟上升，低并发时纯粹变慢 |
| `binlog_format` + `binlog_row_image` | `ROW` + `MINIMAL` | 减少 binlog 字节量 → 减少写盘与网络传输 | `MINIMAL` 下部分行像回滚工具需额外配置才能还原完整行 |
| `innodb_redo_log_capacity` | 能容纳 1~2 小时写入量 | 降低"日志写满 → 强制刷脏 → 写入停顿"的频次 | 恢复时间变长、固定占盘更多 |
| `innodb_flush_method` | `O_DIRECT` | 避免同一页在 buffer pool 与 page cache 双份缓存 | 失去内核写合并与预读，需扇区对齐 |
| `innodb_io_capacity` / `..._max` | 按设备真实 IOPS 设 | 刷脏速度与设备匹配，避免日志写满时刷不动 | 设太低拖慢 checkpoint，设太高挤占正常 I/O |

应用层：**用批量插入把 N 次提交合并成 1 次**（`INSERT ... VALUES (...),(...),(...)` 或 `rewriteBatchedStatements=true`），这是收益最大的一项——`fsync` 次数从 N 降到 1，而参数层只是把 N 次摊薄成若干次；同时**提高并发连接数**，让组提交有"批"可攒（单线程串行提交时组提交只能攒到 1，所有延迟优化都无效）。

**错在哪**：一上来就答"把 `flush_log_at_trx_commit` 改成 `0`、`sync_binlog` 改成 `0`"——这直接违反题目的"不能丢数据"约束；另一类错误是只在参数上打转，忽略了"组提交需要并发才有效"这一前提，于是把参数调完发现单连接压测毫无提升。**追问方向**：为什么单连接压测看不到组提交的收益？因为组提交攒的是**并发到达的事务**，队列里永远只有 1 个事务时，三阶段排队退化成串行，每次提交仍然各自 `fsync`。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么数据库要先写日志再写数据？直接写数据不行吗？ | 是否理解 WAL 的两条独立理由 | 错误回答："因为日志小"——只说了一半，且说不出 5 个脏页 5 次随机写 vs 1 次顺序写、以及部分页写成功造成的物理不一致；加分：点出"日志落盘后脏页可以随便延迟刷"才是 D 的来源 | 21.1.1 |
| `innodb_flush_log_at_trx_commit` 设成 0、1、2 分别会丢多少数据？ | 崩溃类型与持久化层次 | 错误回答："0 和 2 都丢 1 秒，没区别"——说不出 `0` 连 `write` 都不做（进程崩溃也丢），而 `2` 进了 OS page cache（进程崩溃不丢、掉电丢）；加分：说明 redo 与 binlog 的持久化互相独立、可以互相补救 | 21.1.2 |
| redo log 到底是物理日志还是逻辑日志？ | 是否知道 physiological logging | 错误回答："物理日志，记录数据页的修改"——被追问"页分裂怎么记"就答不上；加分：说清"页内物理 + 操作逻辑"，并解释为什么必须这样（重放幂等 + 日志短） | 21.2.1 |
| LSN 有什么用？它在哪些地方出现？ | 对页、日志、checkpoint 三者关系的掌握 | 错误回答："LSN 就是日志的序号"——说不出页头 `FIL_PAGE_LSN` 用于判断该页是否已被刷盘、checkpoint LSN 用于界定重放起点；加分：画出 `write pos`/`checkpoint` 环形图 | 21.2.2、21.2.3 |
| redo log 写满了会发生什么？ | 对 checkpoint 与刷脏耦合的理解 | 错误回答："报错停止写入"或"自动扩容"——真实行为是推进 checkpoint、强制刷脏，期间新写入阻塞等待；加分：给出"日志容量应容纳 1~2 小时写入量"与卡顿的观测方法 | 21.2.3 |
| 组提交是怎么提升性能的？为什么三个阶段要分开？ | 是否理解 flush/sync/commit 各自能合并什么 | 错误回答："组提交让一个事务变快"——恰恰相反，单事务延迟不降反升；加分：指出 commit 阶段若不排队会让前两阶段的收益被吃掉，并解释 `binlog_group_commit_sync_delay` 是拿延迟换吞吐 | 21.2.4 |
| undo log 有什么作用？ | 是否漏掉 MVCC 这一半 | 错误回答："用来回滚事务"——只答一半；加分：补上"为 MVCC 快照读提供历史版本"，并由此推出长事务导致 undo 膨胀与版本链变长的因果链 | 21.3.1、21.3.2 |
| 为什么 insert undo 提交后能立刻删，update undo 不行？ | 对可见性与 purge 判定的理解 | 错误回答："因为 update 的 undo 更大"——与大小无关，区别在于**活跃 ReadView 是否可能还需要旧版本**；加分：说出 purge 以"最老活跃 ReadView"为推进依据 | 21.3.2 |
| binlog 的三种格式怎么选？为什么互联网公司都用 ROW？ | 主从一致性与日志量的权衡 | 错误回答："Statement 日志量小所以更好"——说不出 `NOW()`/`UUID()`/`RAND()`/无 `ORDER BY` 的 `LIMIT` 在从库重放会不一致；加分：提到 `binlog_row_image = MINIMAL` 作为日志量压缩手段 | 21.4.1 |
| 两阶段提交过程中崩溃，MySQL 怎么知道该提交还是回滚？ | 本章核心难点 | 错误回答："看 redo 有没有 commit 标记，没有就回滚"——在 `binlog 已完整落盘` 的时机里这么做会让主从静默不一致；加分：说清决断依据是 prepare 状态 + binlog 完整性，以及 `binlog_checksum` 提供的校验判据 | 21.5.2、21.5.3 |
| 为什么必须做两阶段提交？不做会怎样？ | 能否给出两种不一致的具体形态 | 错误回答："为了保证原子性"——太笼统；加分：分别说明"先 binlog 后 commit → 从库多数据"与"先 commit 后 binlog → 从库少数据"，并指出前者更危险（破坏"从库可由主库重建"的底线） | 21.5.4 |
| 崩溃恢复的完整流程是什么？ | 步骤顺序与 2PC 的衔接 | 错误回答：把"回滚未提交事务"放在重放 redo 之前，或漏掉"处理 prepare 事务"这一步；加分：按 checkpoint → 前滚 → 处理 prepare → binlog 决断 → undo 回滚的顺序复述，并说明第 ③④ 步靠 prepare 阶段写入 redo 的 XID 去 binlog 查事件组 | 21.7 |
| `innodb_force_recovery` 设成 6 是不是就能修好数据？ | 对"能启动"与"数据正确"的区分 | 错误回答："级别越高恢复得越彻底"——恰恰相反，级别越高丢的数据越多（`6` 直接跳过 redo 前滚）；加分：说明 `≥4` 时实例近似只读，只能用于导出重建 | 21.7.1 |

## 延伸阅读

- MySQL 官方手册 "InnoDB On-Disk Structures → Redo Log" 与 "Undo Logs"：`innodb_redo_log_capacity` 与 `innodb_undo_tablespaces` 的行为口径、8.0.30 前后的参数差异以它为准。
- MySQL 官方手册 "Server Administration → The Binary Log"：`binlog_format`、`binlog_row_image`、`sync_binlog`、`binlog_expire_logs_seconds` 的定义与默认值来源。
- MySQL 官方手册 "InnoDB Startup Options and System Variables" 中 `innodb_flush_log_at_trx_commit`、`innodb_flush_method`、`innodb_force_recovery` 三节：本章 21.1.2、21.2.5、21.7.1 节的参数语义依据。
- MySQL 官方手册 "MySQL Server Logs → Binary Log → Binary Logging Formats"：三种格式差异与"不确定函数"清单的规范出处。
- MySQL 官方博客 "MySQL 8.0.30: New redo log capacity" 与 "Binary Log Group Commit"：环形容量与组提交三阶段队列的第一手说明。
- MySQL 官方手册 "InnoDB Recovery" 与 `innodb_force_recovery` 的分级表：各级允许的操作与限制。
- `man 2 fsync`、`man 2 open`：`O_DIRECT` 的对齐要求与 `fsync` 的持久化语义，21.2.5 节讨论的基础。
- 第 20 章 20.4、20.5 节（页结构与一条 `UPDATE` 的生命周期）、第 22 章 22.3 节（版本链与可见性判断）、第 26 章 26.2 节（binlog 如何被从库消费）：本章的前置与后继。


---

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

