我最近在帮一个团队做数据库选型和架构评审,业务系统既要保留 InnoDB 的生态,又想把 PostgreSQL 的一些能力用起来,底座还是标准的存算分离。讨论到一半,团队负责存储的同事问了一句:“刷脏保序这个事情,两个引擎在存储分离以后到底谁更省心?”这个问题看起来不大,但再往下聊,几乎把两个引擎的底层哲学都翻了出来。这篇文章就是我那一次的整理:从崩溃恢复最基本的规则讲起,把 InnoDB 和 PostgreSQL 各自的选择拆开,再落到存算分离的工程实现。
先说结论:在传统本地磁盘上,两个引擎都能把刷脏安排得明明白白,谈不上谁比谁强多少;但是架构一旦变成“计算节点和存储节点分开”,InnoDB 那种把脏页按 LSN 严格排序的思路,和 PostgreSQL 那种几乎不看页间顺序、全靠 WAL 兜底的思路,会走向两条完全不同的工程路线。理解这两条路线,比单纯记住几个参数有用得多。
1. 刷脏为什么必须先讲“保序”:崩溃恢复的最底层规则
1.1 没有日志,数据库就不敢把脏页写盘
先回到最基础的问题:数据库为什么要刷脏?因为内存有限,数据页总得写回磁盘;因为崩溃恢复,数据页又不能随便写回磁盘。
假设一个数据页在内存里被修改了两次,第一次修改写进了一条日志,第二次修改又写进了一条日志。如果这个数据页在两条日志都还没落盘的情况下就被写回了磁盘,然后机器断电,磁盘上就有了“包含两次修改内容的数据页”,而日志文件里什么都没有。恢复时数据库不知道该页发生过什么,更不知道两次修改分别是什么,只能当作一个未提交的中间状态处理,这就会产生不可恢复的损坏。
所以数据库有一条铁律:数据页落盘之前,修改它的日志必须先落盘。这就是 WAL(Write-Ahead Logging)的本质,InnoDB 里的 redo log、PostgreSQL 里的 WAL,都是为了这条铁律服务的。
光有铁律还不够,数据库还得知道“这条日志对应哪个页、页上已经有哪些修改”。于是每个数据页的页头里都存了一个日志位置,InnoDB 是 LSN,PostgreSQL 是 pd_lsn。恢复时扫描日志,看到某条日志对应的 LSN 比页面上记录的 LSN 旧,说明这个页已经包含这次修改,跳过;反过来,如果页面上记录的 LSN 比日志新,说明这个页缺了这次修改,必须重放。
这个机制对所有人来说都不陌生,但它在存算分离环境下会遇到新的挑战:本地磁盘上,fsync 的语义相对简单,日志先落盘、数据后落盘,顺序基本由发出调用的先后决定;网络存储上,“先发出去的请求”和“先落盘的请求”完全可能是两码事,甚至请求发到了不同副本,各自落盘时间也不同。看似简单的一条铁律,在分布式存储里变得格外脆弱。
1.2 页与页之间,真的需要严格顺序吗?
另一个经常被误解的问题是:脏页 A 和脏页 B 之间,存不存在谁必须先落盘的约束?
答案是:一般情况下没有。如果页 A 和页 B 分别对应两条日志,那么无论 A、B 谁先落盘,恢复时都只需要按照日志顺序重放即可,最终都能得到一致状态。除非两个页之间存在跨页的数据结构约束,比如 B-tree 分裂时父节点页和子节点页同时变化,但这种约束也已经被日志记录好了,重放时同样能恢复,并不依赖物理落盘顺序。
那为什么 InnoDB 还要严格按 LSN 排序刷脏?这就要说到 checkpoint 了。InnoDB 的 redo log 是环形空间,日志文件不可能无限增长,必须通过 checkpoint 定期把“某个 LSN 之前的日志”标记为可回收。而 checkpoint 能推进的前提是:这个 LSN 之前的所有脏页都已经落盘。如果脏页落盘顺序是乱的,系统就无法快速判断“哪些 LSN 之前的页全部干净了”,要么做全量扫描,要么维护额外的元数据,代价极大。
所以 InnoDB 维持 LSN 有序刷脏,更多是为了 checkpoint 和日志回收的高效性,而不是崩溃恢复本身的需要。这个细节很关键,因为它决定了两个引擎在存算分离架构下会做出完全不同的设计选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. InnoDB 的执着:用一条 LSN 有序链表管死刷脏
2.1 flush list 到底是什么
InnoDB 的 Buffer Pool 里有两张重要的链表:LRU list 和 flush list。LRU list 管的是页面热度,决定哪些页优先被淘汰;flush list 管的是脏页顺序,决定哪些页优先被刷盘。
flush list 上每个脏页都带两个 LSN 字段:oldest_modification 和 newest_modification,分别记录这个页第一次和最后一次被修改时对应的 LSN。整条链表按照 oldest_modification 从旧到新排序。也就是说,链表头部永远是最早被改脏的页,链表尾部永远是最新被改脏的页。
这种排序不是装饰。当后台刷脏线程(page cleaner)决定动手时,它只需要从链表头部取一批最老的脏页,按顺序刷盘,就能保证刷盘序列大致符合 LSN 演进顺序。配合 redo log 的推进,系统随时可以回答一个问题:“当前有多少脏页的 LSN 小于某个阈值,它们是否都已经落盘了?”
2.2 page cleaner 是怎么工作的
InnoDB 的 page cleaner 不是只由一个线程完成。从 MySQL 5.7 开始,默认就有多个 page cleaner 线程,每个 Buffer Pool instance 可以独立刷自己的脏页。刷脏触发条件主要看三类:脏页比例超过阈值、LRU list 尾部需要腾出空闲页、redo log 的 checkpoint 推进需求。
实际操作中,page cleaner 每次会取一批脏页,先写入 doublewrite buffer,再从 doublewrite buffer 刷入真正的数据文件。doublewrite 解决的是“单页写一半就断电”的问题——SSD 和文件系统并不能保证一个 16KB 页面在断电时被原子地写入,doublewrite 相当于给数据页加了一层保险:先把整页完整写到一块独立区域,再把它写到目标位置。如果目标位置的页写坏了,恢复时从 doublewrite 区域找回完整版本。
2.3 checkpoint 是 LSN 有序刷脏最大的受益者
InnoDB 的 redo log 是固定大小环形复用的,checkpoint 负责推进“可复用起点”。推进条件我刚才说了:某个 LSN 之前的脏页必须全部落盘。
因为有 flush list 按 LSN 排序,checkpoint 推进从链表头部开始,一批一批地刷,每刷完一批,就能明确知道 checkpoint LSN 可以推进到哪里。如果刷脏是乱序的,系统就得额外维护一张“哪些 LSN 区间已确认落盘”的图,每次推进都要查图,复杂度完全不同。这就是 InnoDB 宁可承受更多锁竞争,也要维持 flush list 有序的根本原因。
2.4 有序带来的代价:锁与并发
任何严格顺序在并发系统里都是有代价的。InnoDB 的 flush list 是全局共享结构,虽然每个 Buffer Pool instance 有独立的 flush list,但 instance 内部依然需要锁来保护链表插入、删除和遍历。当脏页数量极大、刷盘频率极高时,这把锁可能成为瓶颈。
另外,有序刷脏还意味着刷盘行为更偏向顺序写而不是随机写。这在一台本地 NVMe SSD 上问题不大,但在网络存储上,顺序写与随机写在延迟上的差距会被放大。很多团队在存算分离环境里发现 InnoDB 刷脏性能下降,一部分原因就出在这里。
3. PostgreSQL 的另一套逻辑:我不要求页与页之间有顺序
3.1 PostgreSQL 靠什么兜底
PostgreSQL 的机制与 In
