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 页内容 ⚠️ 偶尔(初始化等) ✅ 通常(优化后)
相关推荐
无极低码2 小时前
windows 程连接 Oracle 报 ORA-12541
数据库·windows·oracle
Meepo_haha2 小时前
配置MyBatis-Plus打印执行的 SQL 语句到控制台或日志文件中
数据库·sql·mybatis
Carino_U3 小时前
MySQL中Explain详解与索引最佳实践
数据库·mysql
蜡台3 小时前
Mysql 安装使用时常见问题解决记录
数据库·mysql
摸鱼的后端3 小时前
Docker容器中Kingbase数据库授权到期更换解决方案
数据库·docker·容器
极创信息3 小时前
企业信创产品认证全流程:从信创适配到信创认证的实操指南(2026版)
java·数据库·spring boot·mysql·matlab·mybatis·软件工程
onebound_noah3 小时前
【实战解析】如何高效获取京东商品详情数据(含多语言SDK接入)
java·前端·数据库
PD我是你的真爱粉3 小时前
MySQL 高性能实战与底层原理
数据库·mysql·adb
爬山算法3 小时前
MongoDB(73)如何设置用户权限?
数据库·mongodb·oracle