This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/06-InnoDB 深度解析.md
T
2026-05-20 00:00:48 +08:00

480 lines
18 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]
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
note["注意: 叶子节点之间通过双向链表串联,维持主键有序"]
note ~~~ F
style F fill:#00B6BC,color:#fff
style I fill:#00B6BC,color:#fff
```
> [!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
note["注意: 虚线表示二级索引查找后需通过主键回表获取完整行数据"]
note ~~~ SI1
note ~~~ CI1
```
### 覆盖索引(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 详解
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["INSERT/UPDATE\n→ 放 New 区"]
Note2["读未命中\n→ 从磁盘加载到 New 区"]
Note3["频繁访问\n→ 保持 Young 区\n(防污染旧页)"]
Note4["不再访问\n→ 淘汰到 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(重做日志)
Redo Log 是 InnoDB 特有的**物理日志**,用于保证事务的 Durability。
```mermaid
flowchart LR
A["事务修改数据页\nBuffer Pool 中的脏页"] --> B["同时写入 Redo Log Buffer"]
B --> C{flush_log_at_trx_commit}
C -->|1| D["立即刷盘"]
C -->|2| E["写入 OS Cache,每秒刷盘"]
C -->|0| F["每秒刷盘,commit 也异步"]
D --> G["磁盘持久化 ✅"]
E --> G
F --> G
style D fill:#00D866,color:#fff
```
### Redo Log 的特性
- **循环写入**:大小固定(默认各 48MB),写满后从头覆盖
- **物理日志**:记录「在哪个页的哪个偏移改了什么字节」,而非 SQL
- **Crash Safe**:崩溃恢复时重放 Redo Log 恢复到一致状态
- **两阶段提交(2PC)**:协调 Redo Log 和 Binlog 的一致性
> [!NOTE] flush_log_at_trx_commit 三种模式
>
> | 值 | 行为 | 安全性 | 性能 |
> |----|------|--------|------|
> | 1 | commit 时立即 fsync 磁盘 | 最高(ACID) | 最低 |
> | 2 | commit 时写 OS Cache,每秒 fsync | 偶尔丢失 1s 数据 | 较高 |
> | 0 | 每秒写 OS Cache + fsync,commit 异步 | 可能丢多条事务 | 最高 |
>
> 生产环境强烈推荐 `1`。设为 `2` 能在大部分场景接近相同性能,但在 OS 崩溃时会丢数据。
### 两阶段提交(Two-Phase Commit)
为什么 InnoDB 需要协调 Redo Log 和 Binlog?先想一个问题:
> [!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 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。
---
## Undo Log(回滚日志)
与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它支持两个关键功能:
```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(变更缓冲)
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
有了写入路径全景后,我们来看最后一个关键问题:**内存里的脏页什么时候被写回磁盘?**
### LRU List ↔ Flush List 的双链表协作
Buffer Pool 维护着两条核心链表,它们各自有不同的职责:
```mermaid
flowchart LR
subgraph "LRU List, 访问顺序"
Old["Old Sub-list (最近经常访问)"] --> New["New 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/16-B+Tree 索引原理]] — B+Tree 数据结构基础
- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
### 事务与并发控制
- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
- [[hhs/MySQL/24-隔离级别与可见性]] — 四种隔离级别的实际区别
- [[hhs/MySQL/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
- [[hhs/MySQL/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
- [[hhs/MySQL/28-一致性读与当前读]] — 二次读与快照读的触发条件
### 日志与恢复
- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
- [[hhs/MySQL/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
### 性能调优
- [[hhs/MySQL/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
- [[hhs/MySQL/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
### 整体架构
- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
- [[hhs/MySQL/07-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比