InnoDB、PostgreSQL 与存算分离:刷脏保序的抉择

引言

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 页内容 ⚠️ 偶尔(初始化等) ✅ 通常(优化后)
相关推荐
翻斗包菜9 小时前
【MongoDB 从入门到实战:安装、配置、CRUD、权限、备份恢复全教程】
数据库·mongodb
weixin_424999369 小时前
如何用SQL按条件计算移动求和_结合CASE与窗口函数
jvm·数据库·python
21439659 小时前
持久化存储如何适配不同浏览器?解决隐私模式下存储失败的指南
jvm·数据库·python
justjinji9 小时前
c++怎么读取大端序设备的固件bin文件_字节反转与位移操作【详解】
jvm·数据库·python
m0_515098429 小时前
如何处理视图中的Definer_视图创建者权限变更对视图有效性的影响
jvm·数据库·python
2401_883600259 小时前
如何创建物化视图_CREATE MATERIALIZED VIEW基本语法与数据填充
jvm·数据库·python
xxjj998a9 小时前
MySQL无法连接到本地localhost的解决办法2024.11.8
数据库·mysql·adb
电商API_180079052479 小时前
京东商品详情接口返回数据说明API调用示例
数据库·性能优化·数据挖掘·数据分析·网络爬虫
Elivs.Xiang9 小时前
Redis - Docker环境下的持久化、主从复制、哨兵、集群、淘汰策略
数据库·redis·docker
绩隐金9 小时前
SQL Server 开发系列(第七期):触发器与约束——数据完整性的守护者
数据库