引言
MySQL与PG数据完整性机制对比:DWB vs FPW 这篇文章有提到MySQL需要有flush list来保证刷脏时保证LSN全局有序。但明显PG和存算分离之后,是没有flush list的,本文来分析一下背后的原因。
flush list 本身的作用
核心功能:维护脏页的LSN全局有序
┌─────────────────────────────────────────┐
│ https://zhida.zhihu.com/search?content_id=272261616&content_type=Article&match_order=1&q=Buffer+Pool&zhida_source=entity │
│ │
│ LRU List(淘汰管理): │
│ - 所有Page按访问时间排序 │
│ - 用于淘汰冷数据 │
│ - 不关心脏页、不关心LSN │
│ │
│ Flush List(刷脏管理): │
│ - 仅脏页(有未刷盘修改) │
│ - 严格按 oldest_modification LSN 排序 │
│ - tail = 最老LSN(优先刷) │
│ - head = 最新LSN(最后刷) │
│ │
└─────────────────────────────────────────┘
↓
page_cleaner 线程从 tail 开始刷盘
↓
保证:先刷 LSN=100 的页,再刷 LSN=200 的页
为什么必须有序?
https://zhida.zhihu.com/search?content_id=272261616&content_type=Article&match_order=1&q=B-tree+%E7%BB%93%E6%9E%84&zhida_source=entity变化引入的跨Page依赖:
页分裂操作:
Step 1: 分配新页 P_new @ LSN=100
Step 2: 初始化 P_new @ LSN=110
Step 3: 更新父页 P_parent @ LSN=120(添加指向P_new的指针)
依赖链:P_parent 的内容依赖 P_new 的存在
刷盘顺序要求:
必须先刷 P_new (LSN=110),再刷 P_parent (LSN=120)
否则崩溃后:P_parent 指向不存在的页 → 数据损坏
flush list 保证:
LSN=110 在链表中位于 LSN=120 之前
page_cleaner 必然先刷 110,再刷 120
为什么 PG 不需要
这点其实MySQL与PG数据完整性机制对比:DWB vs FPW 文中已经说了很多了,这里再额外总结一下。
核心原因:操作语义 vs 字节修改,对比一下同样的页分裂操作:
InnoDB redo(字节修改,有依赖)
场景:页分裂,创建新页 P_new,父页 P_parent 添加指针
redo[1] @ LSN=100: MLOG_PAGE_CREATE
page_id: P_new
// 只记录"创建页",页内容还是空的!
redo[2] @ LSN=110: MLOG_WRITE_STRING
page_id: P_new, offset: 0, len: 100, data: "页头+部分记录"
// 依赖:P_new 必须已存在且已初始化
redo[3] @ LSN=120: MLOG_COMP_REC_INSERT
page_id: P_parent, offset: 200, rec: {key=50, ptr=P_new}
// 依赖:P_new 必须已有有效内容,否则指针指向垃圾
关键特性:
- 每条 redo 是"字节级补丁"
- redo[3] 的物理内容包含指针值(P_new 的地址)
- 如果先 apply redo[3],P_new 还未初始化 → 崩溃后数据损坏
PG WAL(操作语义,无依赖)
场景:同样的页分裂
WAL[1] @ LSN=100: XLOG_BTREE_SPLIT_L
内容:{
node: "左页分裂",
left_blk: 1234, // 原页
right_blk: 5678, // 新分配的右页(仅记录块号)
firstright: 50, // 分裂点 key
newitem: {key=50, ptr=5678} // 插入父项的信息
}
// 注意:不包含 5678 页的物理内容!
WAL[2] @ LSN=110: (可能是其他操作,或同一事务的后续)
关键特性:
- 每条 WAL 是"操作描述",不是"字节补丁"
- 恢复时:PG 从 WAL 重新执行"分裂操作"
- 执行时会:分配 5678 → 初始化内容 → 更新父页
- 不依赖磁盘上 5678 的当前状态!
依赖差异的本质
| 维度 | InnoDB redo | PG WAL |
|---|---|---|
| 记录内容 | "在偏移 X 处写入字节 Y" | "执行分裂操作,参数如下" |
| 恢复行为 | 直接修改磁盘页的指定字节 | 重新执行操作逻辑 |
| 上下文依赖 | 必须基于正确的当前页状态 | 自带完整操作语义 |
| 顺序敏感 | 乱序 apply 导致字节级错误 | 乱序重放仍能得到正确结构 |
为什么存算分离之后不需要
主要讨论的是log is database之后大部分情况下的做法。
核心转变:redo 从"操作指令"变为"状态快照"
┌─────────────────────────────────────────┐
│ 传统 InnoDB(需要 flush list) │
│ │
│ redo 格式:生理日志 │
│ "在 Page P 偏移 200 处插入记录" │
│ │
│ 特点: │
│ - 非自包含,依赖执行上下文 │
│ - 恢复时必须按序 apply │
│ - 刷盘必须保证 LSN 顺序,确保依赖满足 │
│ │
│ 需要 flush list:强制全局有序刷脏 │
└─────────────────────────────────────────┘
↓ 存算分离
┌─────────────────────────────────────────┐
│ 存算分离架构(无需 flush list) │
│ │
│ redo 格式:自包含的页镜像 │
│ "Page P 的完整内容变为 [16KB 数据]" │
│ │
│ 特点: │
│ - 完全自包含,无执行依赖 │
│ - 直接覆盖即可,无需 apply 过程 │
│ - 每个 Page 的 redo 独立,无跨页依赖 │
│ │
│ 无需 flush list:Per-Page 独立刷盘/恢复 │
└─────────────────────────────────────────┘
存算分离的具体实现
计算节点:
MTR 提交时:
- 生成该 MTR 涉及的所有 Page 的完整镜像
- LSN 仅用于标记版本,不用于排序依赖
- 发送到存储节点(任意顺序,并行发送)
存储节点:
接收 Page A @ LSN=1000:
→ 直接写入存储:key=(A, 1000), value=完整镜像
→ 无需关心其他 Page
接收 Page B @ LSN=800:
→ 直接写入存储:key=(B, 800), value=完整镜像
→ 与 Page A 完全独立
读取 Page A:
→ 返回 LSN 最大的版本即可
→ 无需 apply 任何 redo
关键消解
| 传统InnoDB需要全局顺序的原因 | 存算分离如何消解 |
|---|---|
| B-tree 页分裂的跨页指针依赖 | redo 包含完整页,指针是结果的一部分 |
| 页分配依赖空间管理页 | 页分配也记录为完整状态 |
| redo 是增量,必须基于正确基础 apply | redo 是快照,直接覆盖 |
| 恢复时需要按序重放 | 存储层直接保存各版本,无需重放 |
总结
flush list 是 InnoDB 物理 redo 架构下,为保证 B-tree 结构变化的刷盘时序依赖而设计的。InnoDB 的 redo 记录的是"物理步骤",步骤之间有字节级依赖,必须按序 apply;PG 的 WAL 记录的是"操作结果",恢复时重新执行完整操作,不依赖磁盘页的中间状态,因此可独立重放。
PostgreSQL 的 WAL 记录操作语义而非字节修改,恢复时重新执行操作不依赖磁盘页状态;配合 Full Page Write 处理部分写损坏,从而无需保证刷盘全局顺序,无需 flush list。
存算分离通过 redo 自包含(完整页镜像)彻底消解了结构依赖,从而也无需 flush list。
关键区分:redo 自包含 vs 完整页
| 概念 | 含义 | InnoDB | 存算分离 |
|---|---|---|---|
| redo 自包含 | 不依赖上下文即可应用 | ❌ 否(需磁盘页+apply) | ✅ 是(直接覆盖) |
| redo 有完整页 | 包含 16KB 页内容 | ⚠️ 偶尔(初始化等) | ✅ 通常(优化后) |