Files
cs-note/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md
T
2026-05-24 11:42:38 +08:00

564 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, InnoDB, Clustered Index, Buffer Pool, Redo Log, Undo Log, Change Buffer, MVCC, Binlog, WAL]
create time: 2026-05-16 07:30
---
# InnoDB 深度解析
## 概述
InnoDB 是 MySQL 默认且最广泛使用的存储引擎。理解它的内部机制是优化查询和排查性能问题的基础。本节深入讲解 InnoDB 的五大核心组件及其协作方式。
## 架构总览
```mermaid
graph TB
subgraph "Buffer Pool"
BP["页缓存: 16KB/page"] --> BP1["Data Pages"]
BP --> BP2["Index Pages"]
BP --> BP3["Insert Buffer"]
BP --> BP4["LRU List"]
BP --> BP5["Free List"]
BP --> BP6["Flush List"]
end
subgraph "Redo Log"
RL1["Log Buffer: 内存缓冲区"] --> RL2["物理日志文件: ib_logfile0/1"]
end
subgraph "Undo Log"
UL1["Rollback Segment"] --> UL2["Undo Logs"]
end
subgraph "磁盘数据文件"
DF1["表空间: ibdata1 / .ibd"]
end
subgraph "Change Buffer"
CB["二级索引变更缓存"]
end
Client["SQL 请求"] --> BP
BP --> RL1 -- write path
BP --> UL1 -- transaction isolation
BP --> DF1 -- read path
BP --> CB -- secondary index cache
RL1 --> RL2
```
## 写入路径全景
了解完顶层架构后,让我们跟一次完整的 **写入请求** 走一遍流程。这能帮你理解各个组件如何在同一笔事务中协同工作。
```mermaid
flowchart TD
S["SQL: INSERT/UPDATE/DELETE"] --> C{页在 Buffer Pool?}
C -->|命中| D["直接修改内存页"]
C -->|未命中| E["从磁盘加载页到 Buffer Pool<br/>Page Cache Miss"]
E --> D
D --> F["标记页为 Dirty"]
F --> G["写入 Redo Log Buffer"]
G --> H{是否提交 COMMIT?}
H -->|否| P["Undo Log 记录回滚信息"]
H -->|是| I["两阶段提交 2PC"]
I --> J["先写 Redo Log → fsync 落盘"]
J --> K["再写 Binlog → fsync 落盘"]
K --> L["事务完成 ✅"]
P --> M["后台线程: Page Cleaner<br/>脏页刷入磁盘 .ibd"]
L --> N["后台线程: Checkpoint<br/>推进 Watermark 允许覆盖 Redo Log"]
O["Change Buffer<br/>二级索引变更缓存"] -.-> M
style D fill:#00D866,color:#fff
style G fill:#FF9F43,color:#000
style J fill:#FFD166,color:#000
style K fill:#FFD166,color:#000
style L fill:#00B6BC,color:#fff
```
### 分步拆解
| 步骤 | 操作 | 涉及组件 | 同步/异步 |
|------|------|----------|-----------|
| ① | 检查 Buffer Pool 中是否有目标页 | Buffer Pool | 同步 |
| ② | 若无,从磁盘加载(随机读 I/O) | Buffer Pool + 磁盘 | 同步 |
| ③ | 修改内存页 | Buffer Pool | 同步 |
| ④ | 记录 Redo Log(相同页面偏移的修改字节) | Redo Log | 同步 |
| ⑤ | 若涉及二级索引且页面不在 Buffer Pool 中 | Change Buffer | 同步(只改内存) |
| ⑥ | `COMMIT` → 写 Redo Log Buffer → flush | Redo Log | 同步 |
| ⑦ | `COMMIT` → 写 Binlog → flush | Binary Log | 同步 |
| ⑧ | 后台 Clean Thread 将脏页刷入 .ibd | Buffer Pool + 磁盘 | 异步 |
> [!WARNING] 真正的磁盘 I/O 只有两步
>
> - **步骤 ⑥**: Redo Log fsync(保证 crash safe)
> - **步骤 ⑧**: 脏页刷入 .ibd(后台异步,与事务无关)
>
> 其余步骤全部在内存中完成。**这就是为什么 InnoDB 性能远优于 MyISAM**——MyISAM 每次写都要立即落盘,而 InnoDB 可以把大部分写操作延迟到后台执行。
> [!TIP] 顺序写的秘密
>
> 注意到 **Redo Log 本身是顺序追加写**的!固定大小循环复用,永远往尾部写。这意味着即使磁盘是机械硬盘,Redo Log 的写入也保持极高的吞吐——因为没有任何随机 seek。
>
> 对比之下,数据文件 `.ibd` 的写入是随机的(根据 B+Tree 结构分布),这是 InnoDB 最慢的部分,也是 Buffer Pool 和 Change Buffer 要极力减少它的根本原因。
---
## Clustered Index(聚簇索引)
InnoDB 的**数据行就存储在聚簇索引的叶子节点中**。这是 InnoDB 最关键的概念——没有聚簇索引就没有数据。
```mermaid
graph BT
A["主键: 100"] --> B["非叶子节点"]
C["主键: 50"] --> B
D["主键: 200"] --> E["非叶子节点"]
B --> F["叶子节点, row_id=1, name=Alice"]
B --> G["叶子节点, row_id=2, name=Bob"]
E --> H["叶子节点, row_id=3, name=Charlie"]
E --> I["叶子节点, row_id=4, name=David"]
F -.-> G
G -.-> H
H -.-> I
style F fill:#00B6BC,color:#fff
style I fill:#00B6BC,color:#fff
```
> [!tip] 叶子节点的秘密
> 图中虚线箭头表示叶子节点之间通过 **双向链表** 串联,且按主键顺序排列。这意味着即使你不知道具体主键,也能通过顺序扫描快速获取一段范围数据——这就是 **范围查询高效** 的根本原因。
> [!TIP] 为什么一定要用自增主键?
> 如果选随机值(如 UUID)作为主键,每次插入都可能在索引树的中间位置分叉,导致:
> - **页分裂**:16KB 页面满了要拆成两个,触发大量 I/O
> - **写放大**:相邻页变得不连续,顺序写变成随机写
> - **空间浪费**:页填充率下降(从 100% 降到 ~70%)
>
> 使用自增 INT/BIGINT 时,新记录总是在索引末尾追加——完美的顺序写模式。
## Secondary Index(二级索引)
所有非主键索引都是二级索引。关键点:**二级索引的叶子节点存储的是主键值**,而不是行数据。
```mermaid
flowchart LR
subgraph "聚簇索引, PK=id"
CI1["id=1 → full row data"]
CI2["id=2 → full row data"]
CI3["id=3 → full row data"]
end
subgraph "二级索引, idx_email=email"
SI1["email='a@x.com' → pk=1"]
SI2["email='b@x.com' → pk=2"]
SI3["email='c@x.com' → pk=3"]
end
SI1 -.-> CI1
SI2 -.-> CI2
SI3 -.-> CI3
style SI1 fill:#FF9F43,color:#000
style CI1 fill:#00D866,color:#fff
```
> [!tip] 图中的虚线是什么意思?
> 虚线表示 **回表(Back to Table)**:先通过二级索引找到主键,再用主键去聚簇索引取完整行数据。这就是为什么走二级索引查 `SELECT *` 会比走聚簇索引慢——它实际上查了两棵树。
### 覆盖索引(Covering Index)
当查询需要的列全部在一个二级索引中时,**无需回表**,直接返回结果。这比普通查询快得多。
```sql
-- ❌ 普通查询:走 idx_email,但 SELECT * 需要回表查聚簇索引
SELECT * FROM users WHERE email = 'alice@example.com';
-- ✅ 覆盖索引:email + name 都在索引里,不需要回表
ALTER TABLE users ADD INDEX idx_email_name (email, name);
SELECT name FROM users WHERE email = 'alice@example.com';
-- EXPLAIN 会显示 Extra: Using index
-- ⚠️ 常见误区:查其他不在索引中的列,仍需回表
-- idx_email_name 包含的是 (email, name),不包含 id
SELECT id FROM users WHERE email = 'alice@example.com';
-- 虽然 WHERE 条件匹配了索引,但 SELECT 的 id 不在索引中 → 仍需回表
```
## Buffer Pool 详解
> [!question] 为什么需要 Buffer Pool?
>
> 先看一组数字:内存随机访问延迟约 **100 纳秒**,SSD 约 **100 微秒**,机械硬盘约 **10 毫秒**。从磁盘读比从内存读慢 **100~100,000 倍**。
>
> 如果每次 SQL 都去磁盘读写,性能会非常糟糕。**Buffer Pool 的本质就是一个大缓存**:把最常访问的数据页放在内存里,只有缓存未命中时才去磁盘取。这和浏览器缓存图片、Redis 缓存热点数据是同一个思路。
>
> 可以把 Buffer Pool 想象成你桌面上的「常用文件架」——经常用的资料就放在手边,不常用的才去档案柜(磁盘)里拿。
Buffer Pool 是 InnoDB 最重要的性能组件,它缓存了磁盘上的数据页和索引页。
```mermaid
flowchart TD
subgraph "Buffer Pool, N × 16KB Pages"
LRU["LRU List"] --> New["New Sub-list"]
New --> Young["Young Sub-list"]
Young --> Old["Old Sub-list"]
Old --> Free["Free List"]
Note1["写操作 → 放 New 区"]
Note2["读缓存未命中 → 从磁盘加载到 New 区"]
Note3["频繁访问 → 保持 Young 区"]
Note4["不再访问 → 淘汰到 Free 区"]
end
FlushList["Flush List, 脏页链表"] --> Disk["刷入磁盘 ibd 文件"]
LRU --> FlushList
```
### Buffer Pool 关键参数
```ini
innodb_buffer_pool_size = 1G # 建议设为物理内存 50%~70%
innodb_buffer_pool_instances = 8 # 并发实例数(GB 级配置 > 1GB 时)
innodb_buffer_pool_dump_at_exit = ON # mysqld 关闭时保存缓冲池状态
innodb_buffer_pool_load_at_startup = ON # 启动时恢复上一次状态
```
> [!QUESTION] 如何判断 Buffer Pool 是否够大?
> 查看两个关键状态变量:
> ```sql
> SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
> ```
>
> | 变量 | 含义 |
> |------|------|
> | `Innodb_buffer_pool_read_requests` | Buffer Pool 读取请求总数 |
> | `Innodb_buffer_pool_reads` | 无法命中、必须回磁盘读取的次数 |
>
> 命中率 = `1 - reads / read_requests`,正常应在 **99%+**。持续低于 95% 说明需要增大 `innodb_buffer_pool_size`。
## Redo Log(重做日志)
> [!question] 为什么需要 Redo Log?先看一个问题
>
> 回顾前面写入路径,事务提交时只需要把 Redo Log 刷到磁盘,**不需要立即把脏页写回数据文件(.ibd)**。这带来了一个矛盾:
>
> 如果事务已提交,但脏页还在内存里(尚未刷盘),此时服务器突然断电——数据不就丢了吗?
>
> 答案就在于 **WAL(Write-Ahead Logging,预写日志)** 原则:**在修改数据页之前,必须先把修改内容写到日志里**。日志是顺序追加写,体积小、速度快;数据页是随机写,体积大、速度慢。只要日志先落盘了,即使数据页还没写回磁盘,crash 之后也能「重放日志」恢复到崩溃前的状态。
>
> 一句话总结:**Redo Log 让 InnoDB 用顺序写换取了随机写的安全性——事务提交只 fsync 日志,脏页由后台慢慢刷盘。**
Redo Log 是 InnoDB 特有的**物理日志**,实现了 WAL 原则,保证事务的 Durability(持久性)。它的记录粒度是「在哪个页(page)的哪个偏移(offset)改了哪些字节」,而不是 SQL 语句——这和后面要讲的 Binlog 有本质区别。
```mermaid
flowchart LR
A["事务修改数据页\nBuffer Pool 中的脏页"] --> B["同时写入 Redo Log Buffer"]
B --> C{flush_log_at_trx_commit}
C -->|1| D["立即 fsync 刷盘"]
C -->|2| E["写入 OS Cache,每秒 fsync"]
C -->|0| F["每秒写 OS Cache + fsync,commit 异步"]
D --> G["磁盘持久化 ✅"]
E --> G
F --> G
style D fill:#00D866,color:#fff
```
> [!NOTE] 物理日志 vs 逻辑日志,到底有什么区别?
>
> 假设执行 `UPDATE users SET name = 'Bob' WHERE id = 1;`
>
> | 日志类型 | 记录内容 | 恢复方式 |
> |---------|---------|---------|
> | **物理日志(Redo Log)** | `表空间 N 的第 X 页,偏移 128 字节处,写入 'Bob'` | 直接把字节写回原位置,无需解析 SQL |
> | **逻辑日志(Binlog / Undo Log)** | `UPDATE users SET name='Bob' WHERE id=1` | 重新执行这条 SQL |
>
> 物理日志恢复速度极快(直接写原始字节),但只能用于同一页结构的恢复;逻辑日志跨引擎通用,但恢复时需要经过 SQL 解析层。Redo Log 选物理日志的原因很简单:**崩溃恢复要尽可能快,越快数据库不可用时间越短**。
### Redo Log 的特性
- **WAL 原则**:修改数据页前,日志必须先落盘——这是 crash safe 的根本保证
- **循环写入**:大小固定(默认各 48MB),写满后从头覆盖,不能无限增长
- **物理日志**:记录「在哪个页的哪个偏移改了什么字节」,恢复时直接写回字节
- **Crash Safe**:崩溃恢复时重放 Redo Log 恢复到一致状态
> [!NOTE] flush_log_at_trx_commit 三种模式
>
> | 值 | 行为 | 安全性 | 性能 |
> |----|------|--------|------|
> | 1 | commit 时立即 fsync 磁盘 | 最高(ACID) | 最低 |
> | 2 | commit 时写 OS Cache,每秒 fsync | 偶尔丢失 1s 数据 | 较高 |
> | 0 | 每秒写 OS Cache + fsync,commit 异步 | 可能丢多条事务 | 最高 |
>
> 生产环境强烈推荐 `1`。设为 `2` 能在大部分场景接近相同性能,但在 OS 崩溃时会丢数据。
### 先搞清楚:Binlog 是什么?
在讲两阶段提交之前,必须先理解另一个日志:**Binlog(Binary Log,二进制日志)**。前面一直在讲 Redo Log,但 2PC 涉及两种日志的协调,不认识 Binlog 就无法理解后面的内容。
> [!NOTE] Binlog 一句话定义
> Binlog 是 **MySQL Server 层**(不是 InnoDB 引擎层)维护的一份**逻辑日志**,记录了所有修改数据的操作。它是**主从复制**和**数据恢复(PITR)** 的基石。
把 Redo Log 和 Binlog 放在一起对比,区别一目了然:
| 对比项 | Redo Log | Binlog |
|--------|----------|--------|
| **归属** | InnoDB 引擎层(独占) | MySQL Server 层(所有引擎共享) |
| **日志类型** | 物理日志(页偏移 + 字节) | 逻辑日志(SQL 或行变更) |
| **写入方式** | 固定大小,循环覆盖 | 追加写入,文件持续增长 |
| **主要用途** | 崩溃恢复(Crash Recovery) | 主从复制、时间点恢复 |
| **生命周期** | Checkpoint 后可覆盖 | 按配置保留 N 天后清理 |
> [!TIP] 一个类比帮助记忆
>
> - **Redo Log** 像是你记在便签纸上的「操作步骤」——写完账本(数据页)后便签就可以扔掉。便签是循环使用的(固定大小),只给自己看(InnoDB 专用)。
> - **Binlog** 像是公司存档的「操作记录」——永久保存、持续累积,给审计和分支机构(从库)用。
>
> 这两种日志目的完全不同,**缺一不可**:没有 Redo Log,crash 后数据丢失;没有 Binlog,无法做主从复制和精确时间点恢复。
关于 Binlog 的三种格式(Statement / Row / Mixed)、文件结构、核心参数等详细内容,参见 → [[hhs/MySQL/07-高可用与分布式/29-Binary Log]]
---
### 两阶段提交(Two-Phase Commit)
认识了两种日志之后,问题来了:它们各自独立写入,怎么保证「两边记录的一致性」?先想一个问题:
> [!QUESTION] 如果只写一种日志,会出现什么后果?
>
> - **只写 Redo Log**:crash 恢复没问题,但无法做主从复制(Binlog 是复制的唯一数据源)
> - **只写 Binlog**:crash 后 Binlog 可能包含了一个实际没有落盘的数据页的事务 → 主从不一致
>
> 答案:**两种情况都会导致 crash 恢复后,Redo Log 和 Binlog 之间的数据不一致。**
```mermaid
flowchart LR
subgraph "正常事务流程"
A1["步骤1: 修改 Buffer Pool<br/>标记页为 Dirty"] --> A2["步骤2: 写 redo log prepare"]
A2 --> A3["步骤3: fsync redo log"]
A3 --> A4["步骤4: 写 binlog"]
A4 --> A5["步骤5: fsync binlog"]
A5 --> A6["步骤6: 写 redo log commit"]
A6 --> A7["步骤7: fsync redo log"]
A7 --> T1["事务成功 ✅"]
end
style A3 fill:#FFD166,color:#000
style A5 fill:#FF9F43,color:#000
style A7 fill:#FFD166,color:#000
```
> [!TIP] 核心思想:prepare = "我准备好了但不确定",commit = "确认执行"
>
> | Crash 时间点 | Redo Log 状态 | Binlog 状态 | 恢复行为 |
> |---|---|---|---|
> | A2 ~ A3 之间 | prepare(未刷盘) | 未写 | Redo Log 回滚 → **Binlog 也不会有这条记录** ✅ |
> | A3 ~ A4 之间 | prepare(已刷盘) | 未写 | Redo Log 回滚到 prepare 之前 → **事务被丢弃** ✅ |
> | A5 之后 | commit(已刷盘) | 已写 | Redo Log 完整重放 → **事务完整恢复** ✅ |
>
> **关键结论**:两阶段提交的本质是让 Redo Log 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。
> [!question] 思考:为什么不让 MySQL 先写 Binlog 再写 Redo Log?
>
> 从逻辑上讲,顺序调换似乎也能做"两阶段"。但有一个关键区别:
> - **Redo Log 是 InnoDB 自己的**,由存储引擎完全控制,崩溃恢复逻辑自洽
> - **Binlog 是 Server 层的**,所有引擎共用,它无法感知 InnoDB 事务状态
>
> 如果先写 Binlog 再写 Redo Log,在 Redo Log fsync 之前崩溃了——Binlog 里已经记录了这个事务(用于主从复制),但 InnoDB 自己的数据文件里并没有这个修改,**主从数据直接不一致**。
>
> 所以顺序不能反过来。2PC 的设计让 InnoDB(Redo Log)保持「最终决策者」的角色。
---
## Undo Log(回滚日志)
与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它本质上是 InnoDB 的「后悔药」——在你修改数据之前,先把旧值记下来,万一需要回退或者有别的事务想看旧数据,都能找到。
它主要支撑两个关键功能:**事务回滚** 和 **MVCC 多版本读取**。
```mermaid
flowchart LR
subgraph "事务 A: UPDATE users SET balance = balance - 100 WHERE id = 1"
A1["修改前备份 → Undo Log"] --> A2["修改 Buffer Pool"]
end
subgraph "事务 B: UPDATE users SET balance = balance + 100 WHERE id = 1"
B1["等待锁 / MVCC 隔离"] --> B2["读取历史版本"]
end
A1 -.-> ROLLBACK["ROLLBACK 操作"]
B2 -.-> SNAPSHOT["SNAPSHOT READ"]
style A1 fill:#FF9F43,color:#000
style ROLLBACK fill:#FF6B6B,color:#fff
style SNAPSHOT fill:#00D866,color:#fff
```
### Undo Log 的核心用途
- **事务回滚(ROLLBACK)**:执行相反操作还原原始值,如 UPDATE 的反向是 INSERT(新行)或 UPDATE(旧值)
- **MVCC 多版本并发控制**:通过 Read View + Undo Log Version Chain 实现不同事务看到不同的数据快照
- **灾难恢复**:配合 Redo Log,rollback 未提交的事务
> [!TIP] Undo Log vs Redo Log
>
> | 对比项 | Undo Log | Redo Log |
> |--------|----------|----------|
> | 方向 | 回退(undo) | 重做(redo) |
> | 逻辑/物理 | **逻辑日志**(记录SQL语义) | **物理日志**(记录页面偏移) |
> | 生命周期 | 事务提交后可立即回收(purge) | 必须保留到 checkpoint 之后 |
> | 空间 | 可动态增长(undo tablespace) | 固定大小、循环复用 |
## Change Buffer(变更缓冲)
> [!question] 为什么二级索引的维护会成为性能瓶颈?
>
> 二级索引的叶子节点按索引列有序排列,而不是按主键排列。这意味着对二级索引的插入很可能是**随机 I/O**——新记录可能要插入到索引树的任意位置,如果目标页恰好不在 Buffer Pool 里,就必须先从磁盘读进来。
>
> 想象你有一张千万级的用户表,每次 INSERT 都要维护 3 个二级索引,如果每次都触发随机磁盘读写……性能会严重下降。
>
> **Change Buffer 就是为了解决这个问题**:它把「二级索引页不在内存时的写操作」先缓存起来,等该页被其他查询加载到 Buffer Pool 时,再一并合并写入,从而把多次随机 I/O 合并成一次。
Change Buffer 缓存了对**非唯一二级索引页**的修改操作,等该页被读到时再合并写入磁盘。这对 INSERT-heavy 场景有显著加速效果。
```mermaid
flowchart TD
A["INSERT 到二级索引\n页面不在 Buffer Pool 中"] --> B["写入 Change Buffer"]
B --> C["减少随机磁盘 I/O"]
D["后续读取该页\n页面进入 Buffer Pool"] --> E["合并 Change Buffer 中的操作"]
E --> F["一次性更新磁盘"]
C --> G["性能提升 20%~30%"]
E --> G
style B fill:#FF9F43,color:#000
style G fill:#00D866,color:#fff
```
> [!NOTE] Change Buffer 的限制
> - 只对**非唯一索引**生效(唯一索引需要在插入前检查冲突,必须实时读写磁盘)
> - 对 UPDATE/DELETE 同样有效
> - 可以通过 `innodb_change_buffer_max_size` 控制最大占比(默认 25%)
## Checkpoint & Page Clean / Flush
有了写入路径全景后,我们来看最后一个关键问题:**内存里的脏页什么时候被写回磁盘?**
> [!tip] 用一个类比理解 Checkpoint
>
> 把 Redo Log 想象成一本**固定页数的笔记本**,每次数据修改就往上记一笔。笔记本写满了怎么办?不能直接丢掉,因为里面记录的修改可能还没真正写到账本(磁盘数据文件)里。
>
> **Checkpoint 就是「定期把这些记录落实到账本上」的动作**——一旦某页的修改已经刷到磁盘了,那本笔记本上对应的记录就可以安全擦除了,腾出空间写新记录。
>
> Checkpoint 推进得越快,Redo Log 的空间就越充裕;推进得太慢,Redo Log 可能写满导致写入阻塞。这是 InnoDB 内部最重要的平衡之一。
### LRU List ↔ Flush List 的双链表协作
Buffer Pool 维护着两条核心链表,它们各自有不同的职责:
```mermaid
flowchart LR
subgraph "LRU List, 访问顺序"
New["New Sub-list (新加载的页)"] --> Old["Old Sub-list (热点页)"]
end
subgraph "Flush List, 脏污程度"
FL1["Clean Pages (已刷盘)"] --> FL2["Dirty Pages (未刷盘)"]
end
DirtyInLRU["LRU 中的脏页<br/>同时存在于 Flush List"] --> FL2
CleanedInLRU["已被刷盘的页<br/>仍在 LRU 中等待淘汰"] --> FL1
style DirtyInLRU fill:#FF6B6B,color:#fff
style CleanedInLRU fill:#00D866,color:#fff
```
| 链表 | 排序依据 | 主要用途 |
|------|----------|----------|
| **LRU List** | 访问时间(从旧到新) | 决定哪些页该被淘汰出 Buffer Pool |
| **Flush List** | 是否脏污 + LSN 递增 | 决定哪些页需要刷入磁盘 |
> [!QUESTION] 为什么需要两条独立的链表?
>
> 想象一个场景:Buffer Pool 满了,需要回收一页空间。
> - **只查 LRU**:可能取到 dirty page → 必须先刷盘再回收 → 慢
> - **只查 Flush**:可能取到 clean page 但很久没访问过 → 浪费了干净页的空间
>
> 实际实现中,InnoDB 会从 LRU 的 Old 端找一个 dirty page 刷盘;如果一时找不到,也会刷干净页来救急。**这两条链表的配合是 Buffer Pool 高效运转的关键。**
### Checkpoint: Redo Log 循环复用的水坝
既然 Redo Log 是固定大小、循环重写的,那么问题来了:**旧日志什么时候能被安全覆盖?**
```mermaid
flowchart LR
A["Redo Log 写满尾部"] --> B{"能从头覆盖吗?"}
B -->|"否"| C["Page Cleaner: 刷脏页到磁盘"]
C --> D["Flush List 中的脏页减少"]
D --> E["Checkpoint Thread: 推进 Checkpoint LSN"]
E --> F["Watermark 前进 → 旧日志可覆盖 ✅"]
F --> G["头部开始循环写入"]
B -->|"是"| G
style F fill:#00D866,color:#fff
style G fill:#FFD166,color:#000
```
| 概念 | 含义 | 关键参数 |
|------|------|----------|
| **LSN (Log Sequence Number)** | 单调递增的日志序号,每个页也有当前 LSN | — |
| **Checkpoint LSN** | 到此位置之前的所有页保证已落盘 | — |
| **Redo Log 可覆盖分界** | `Checkpoint LSN < X < Current LSN` 之间的日志不能删 | — |
| **innodb_max_dirty_pages_pct** | 脏页占 Buffer Pool 的上限百分比 | 默认 75% |
> [!WARNING] 脏页过多的危险信号
>
> 当脏页比例超过 `innodb_max_dirty_pages_pct` 时,InnoDB 会触发 **流量控制(Throttling)**:
> 1. 停止接受新的写请求
> 2. 优先执行 Page Clean → 刷脏页
> 3. 等比例回落后才恢复写操作
>
> 这会导致 **突发性写入延迟飙升**。监控 `Innodb_dblwr_pages_written` 和 `Innodb_buffer_pool_dirty_pages` 可以提前发现这个问题。
>
> **常见原因**: Buffer Pool 太小、大量批量更新、事务提交过于密集导致 redo flush 跟不上。
---
## 关联笔记
### 索引相关
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — B+Tree 数据结构基础
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
### 事务与并发控制
- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — 四种隔离级别的实际区别
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
- [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] — 二次读与快照读的触发条件
### 日志与恢复
- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
- [[hhs/MySQL/07-高可用与分布式/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
### 性能调优
- [[hhs/MySQL/08-工程实践/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
### 整体架构
- [[hhs/MySQL/01-入门基础/01-MySQL 架构与入门]] — Server 层与 InnoDB 引擎的分层协作
- [[hhs/MySQL/04-存储引擎/20-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比