2026-05-17 00:06:11 +08:00
|
|
|
|
---
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
## 写入路径全景
|
|
|
|
|
|
|
|
|
|
|
|
了解完顶层架构后,让我们跟一次完整的 **写入请求** 走一遍流程。这能帮你理解各个组件如何在同一笔事务中协同工作。
|
|
|
|
|
|
|
|
|
|
|
|
```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 要极力减少它的根本原因。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-17 00:06:11 +08:00
|
|
|
|
## 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"]
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
F -.-> G
|
|
|
|
|
|
G -.-> H
|
|
|
|
|
|
H -.-> I
|
|
|
|
|
|
|
|
|
|
|
|
note["注意: 叶子节点之间通过双向链表串联,维持主键有序"]
|
|
|
|
|
|
note ~~~ F
|
2026-05-17 00:06:11 +08:00
|
|
|
|
|
|
|
|
|
|
style F fill:#00B6BC,color:#fff
|
|
|
|
|
|
style I fill:#00B6BC,color:#fff
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!TIP] 为什么一定要用自增主键?
|
|
|
|
|
|
> 如果选随机值(如 UUID)作为主键,每次插入都可能在索引树的中间位置分叉,导致:
|
|
|
|
|
|
> - **页分裂**:16KB 页面满了要拆成两个,触发大量 I/O
|
|
|
|
|
|
> - **写放大**:相邻页变得不连续,顺序写变成随机写
|
|
|
|
|
|
> - **空间浪费**:页填充率下降(从 100% 降到 ~70%)
|
|
|
|
|
|
>
|
|
|
|
|
|
> 使用自增 INT/BIGINT 时,新记录总是在索引末尾追加——完美的顺序写模式。
|
|
|
|
|
|
|
|
|
|
|
|
## Secondary Index(二级索引)
|
|
|
|
|
|
|
|
|
|
|
|
所有非主键索引都是二级索引。关键点:**二级索引的叶子节点存储的是主键值**,而不是行数据。
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
2026-05-20 00:00:48 +08:00
|
|
|
|
flowchart LR
|
2026-05-17 00:06:11 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
SI1 -.-> CI1
|
|
|
|
|
|
SI2 -.-> CI2
|
|
|
|
|
|
SI3 -.-> CI3
|
2026-05-17 00:06:11 +08:00
|
|
|
|
|
|
|
|
|
|
style SI1 fill:#FF9F43,color:#000
|
|
|
|
|
|
style CI1 fill:#00D866,color:#fff
|
2026-05-20 00:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
note["注意: 虚线表示二级索引查找后需通过主键回表获取完整行数据"]
|
|
|
|
|
|
note ~~~ SI1
|
|
|
|
|
|
note ~~~ CI1
|
2026-05-17 00:06:11 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 覆盖索引(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 崩溃时会丢数据。
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
### 两阶段提交(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 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-17 00:06:11 +08:00
|
|
|
|
## 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
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
A1 -.-> ROLLBACK["ROLLBACK 操作"]
|
|
|
|
|
|
B2 -.-> SNAPSHOT["SNAPSHOT READ"]
|
2026-05-17 00:06:11 +08:00
|
|
|
|
|
|
|
|
|
|
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%)
|
|
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
## 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 跟不上。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-05-17 00:06:11 +08:00
|
|
|
|
## 关联笔记
|
|
|
|
|
|
|
|
|
|
|
|
### 索引相关
|
2026-05-20 00:00:48 +08:00
|
|
|
|
- [[hhs/MySQL/16-B+Tree 索引原理]] — B+Tree 数据结构基础
|
|
|
|
|
|
- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
|
2026-05-17 00:06:11 +08:00
|
|
|
|
- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
|
|
|
|
|
|
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
|
|
|
|
|
|
|
|
|
|
|
|
### 事务与并发控制
|
|
|
|
|
|
- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
|
2026-05-20 00:00:48 +08:00
|
|
|
|
- [[hhs/MySQL/24-隔离级别与可见性]] — 四种隔离级别的实际区别
|
|
|
|
|
|
- [[hhs/MySQL/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
|
2026-05-17 00:06:11 +08:00
|
|
|
|
- [[hhs/MySQL/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
|
2026-05-20 00:00:48 +08:00
|
|
|
|
- [[hhs/MySQL/28-一致性读与当前读]] — 二次读与快照读的触发条件
|
2026-05-17 00:06:11 +08:00
|
|
|
|
|
2026-05-20 00:00:48 +08:00
|
|
|
|
### 日志与恢复
|
2026-05-17 00:06:11 +08:00
|
|
|
|
- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
|
2026-05-20 00:00:48 +08:00
|
|
|
|
- [[hhs/MySQL/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
|
|
|
|
|
|
|
|
|
|
|
|
### 性能调优
|
|
|
|
|
|
- [[hhs/MySQL/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
|
|
|
|
|
|
- [[hhs/MySQL/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
|
2026-05-17 00:06:11 +08:00
|
|
|
|
|
|
|
|
|
|
### 整体架构
|
|
|
|
|
|
- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
|
2026-05-20 00:00:48 +08:00
|
|
|
|
- [[hhs/MySQL/07-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比
|