上一篇我们拆解了数据页和 TOAST 的物理结构。这一篇,我们回答一个更底层的问题:当数据库在写入中途突然断电,为什么已经提交的数据不会丢,而写到一半的数据页也不会把数据库"写坏"?答案藏在 PostgreSQL 最核心的机制里:WAL(Write-Ahead Logging) 。理解 WAL,你才能理解"持久性"和"崩溃一致性"在 PostgreSQL 中到底是怎么被兑现的。

一、先回答核心问题:为什么必须"先写日志,再写数据"?
WAL 的中心原则只有一句话:对数据文件(表和索引)的修改,必须等描述这些修改的日志记录被刷到永久存储之后,才能写入数据文件。
这个原则同时解决两个问题。
第一,持久性的性能问题。 如果每次事务提交都必须把修改过的数据页刷到磁盘,一个事务碰巧改了 100 个分散的页,就要做 100 次随机 I/O。WAL 把"刷数据页"替换成"刷日志文件":日志是顺序写入 的,同步顺序日志的成本远低于刷新分散的数据页。更关键的是,在并发环境下,多个事务的提交可以共享一次 fsync()------日志文件的一次刷盘,同时保证了多个事务的持久性。
第二,部分写损坏。 即使不考虑性能,直接把脏页刷盘也有风险。操作系统在崩溃时,一个 8KB 的数据页可能只写了一部分,磁盘上留下一个"半新半旧"的页。这种页无法通过任何日志修复,因为日志里只有"变更",没有完整的页面内容。WAL 的应对方式是全页写(full_page_writes) :每次检查点之后,对某个数据页的第一次修改 ,WAL 会记录下整个页面内容,而不只是变更。这样崩溃恢复时,可以直接用 WAL 中的完整页面覆盖磁盘上可能损坏的页,从一个已知良好的起点开始重放。
一句话:WAL 用"顺序写日志"换掉了"随机刷数据页",同时用"全页写"兜住了部分写损坏的底。
二、LSN:WAL 的"时间坐标"
WAL 记录被追加到日志中,每条记录的位置由一个 LSN(Log Sequence Number) 描述。LSN 是日志中的字节偏移量 ,随每条新记录单调递增 ,数据类型为 pg_lsn。
LSN 的核心用途不是"标识身份",而是比较进度:
- 两个 LSN 相减,可以得到它们之间相隔多少字节的 WAL;
- 崩溃恢复时,从哪个 LSN 开始重放,由检查点记录决定;
- 复制时,备库向主库报告"我已经重放到哪个 LSN",主库据此判断复制进度。
你可以随时查看当前的 WAL 写入位置:
sql
SELECT pg_current_wal_lsn();
三、一次 COMMIT 的完整路径:WAL 到底经过了哪些手
理解 WAL 的写入流程,需要区分三层:WAL 记录的产生、WAL 缓冲区到内核缓存、内核缓存到磁盘。
第一层:记录进入 WAL 缓冲区
事务修改数据时,变更不会立刻写到磁盘上的 WAL 文件 ,而是先写入内存中的 WAL 缓冲区(wal_buffers) 。后台的 WAL Writer 进程会定期把缓冲区中的数据刷出,但事务提交并不等待这个定期刷出。
第二层:XLogWrite 与 issue_xlog_fsync 的分工
当需要把 WAL 数据真正写盘时,PostgreSQL 内部有两个关键函数:
XLogWrite:负责把 WAL 缓冲区中的数据写到内核缓存;issue_xlog_fsync:负责调用fsync类系统调用,把内核缓存中的 WAL 数据刷到磁盘。
这两个步骤是否合并,取决于 wal_sync_method 的设置。如果使用 open_datasync 或 open_sync,XLogWrite 的写入操作本身就保证数据到达磁盘,issue_xlog_fsync 不需要额外工作;如果使用 fdatasync、fsync 或 fsync_writethrough,XLogWrite 只是把数据交给内核,真正的持久化由 issue_xlog_fsync 完成。
第三层:COMMIT 时的等待边界
一次 COMMIT 是否返回"成功",取决于 synchronous_commit 的设置。默认情况下,PostgreSQL 会等待该事务的 WAL 记录被刷到磁盘,然后才向客户端返回提交成功。这意味着,在返回成功的那一刻,即使数据页还没有任何一页被刷盘,这个事务的变更也已经可以通过 WAL 恢复了。
synchronous_commit = off 把等待边界后移:提交立刻返回,WAL 刷盘由后台异步完成。风险是数据丢失,而不是数据损坏 ------如果崩溃发生在 WAL 刷盘之前,最近一小段时间内"已经返回成功"的事务可能丢失,但数据库本身仍然是一致的。这与 fsync = off 有本质区别:后者可能破坏数据库的一致性,前者只影响持久性的"时间窗口"。
四、崩溃恢复:从检查点开始"重播"
崩溃恢复的逻辑,可以用一句话概括:从最近的检查点记录出发,找到重放起点,然后按 LSN 顺序重放 WAL,直到日志末尾。
第一步:定位检查点和 redo 记录
检查点(checkpoint)是一个"已知良好"的状态:在检查点时刻,所有脏数据页都已经被刷到磁盘 ,同时一条特殊的检查点记录被写入 WAL。崩溃恢复启动时,会读取控制文件中的最新检查点信息,确定 redo 记录的位置------这就是 WAL 重放的起点。
为什么可以从检查点开始,而不是从头重放?因为在检查点之前的所有修改,其对应的数据页已经保证在磁盘上了。那些更早的 WAL 段不再需要用于崩溃恢复,可以被回收或删除(如果开启了归档,必须先归档)。
第二步:按 LSN 顺序重放
从 redo 记录开始,PostgreSQL 的 Startup 进程 按 LSN 顺序读取 WAL,对每条记录调用对应资源管理器(rmgr)的 redo 例程:堆表记录由 heap_redo 重放,B-tree 索引记录由 btree_redo 重放,以此类推。
重放的过程是前滚(roll-forward / REDO) :把 WAL 中记录的所有变更重新应用到数据页上。PostgreSQL 当前没有实现 UNDO------未提交事务的变更不会在恢复阶段被"撤销",而是留在页上,由后续的 VACUUM 清理。这就是为什么恢复后的数据库可能"看起来"包含未提交的数据,但从事务可见性角度它们是不可见的。
第三步:到达一致状态
重放会持续到 WAL 的可用末尾。到达一致状态后,数据库完成恢复,开始接受连接。如果启用了归档或 PITR,重放还可以在用户指定的 recovery_target 处停止,这就是上一篇讨论的时间点恢复。
五、检查点:恢复速度与写入成本的调节旋钮
检查点不只是"刷脏页"那么简单,它直接决定了崩溃恢复需要重放多少 WAL。
检查点越频繁 ,redo 点离崩溃点越近,恢复需要重放的工作量越少,恢复越快。但频繁检查点意味着更频繁地把脏页刷盘,带来持续的 I/O 压力;同时,由于 full_page_writes 的存在,每次检查点之后第一次修改一个页时,整个 8KB 页都会被写入 WAL,检查点越密集,WAL 中全页写的比例越高,WAL 体积也越大。
检查点越稀疏,WAL 中全页写的比例越低,日常写入的 WAL 量更小;但崩溃后需要重放的 WAL 更多,恢复时间更长。
PostgreSQL 用两个主要参数控制这个权衡:checkpoint_timeout(默认 5 分钟)和 max_wal_size(默认 1GB),以先到者为准 触发检查点。如果检查点发生得过于频繁,checkpoint_warning 会在日志中给出提示,建议调大 max_wal_size。
六、WAL 段文件:环形复用的"日志环"
WAL 在磁盘上以段文件(segment) 的形式存放在 pg_wal 目录下,每个段文件通常 16MB,段内再按 8KB 页组织。段文件名以不断递增的编号命名,编号不会回绕。
但磁盘上的段文件数量并不是无限增长的。当旧的 WAL 段不再被崩溃恢复需要(即位于 redo 记录之前),PostgreSQL 会回收 它们:不是删除,而是重命名为未来要使用的高编号段文件 ,放入循环中复用。如果启用了 WAL 归档,段文件必须先归档成功,才能被回收,否则归档链会断裂,PITR 将失效。
一个常见的运维误解是"pg_wal 目录里的文件按文件名排序就是时间顺序"。由于回收机制的存在,一个文件名较大的段文件,其修改时间可能反而更早------它已经被回收并重命名,等待未来被使用,而不是最近写入的日志。
七、总结
| 主题 | 核心结论 |
|---|---|
| WAL 原则 | 数据页的修改,必须等描述它的 WAL 记录先持久化 |
| 为什么先写日志 | 顺序写日志远快于随机刷数据页;一次 fsync 可覆盖多个事务提交;全页写兜住部分写损坏 |
| LSN | WAL 中的字节偏移,单调递增,用于比较进度和定位重放起点 |
| 写入路径 | WAL 缓冲区 → XLogWrite(到内核)→ issue_xlog_fsync(到磁盘);COMMIT 是否等待由 synchronous_commit 决定 |
| 崩溃恢复 | 从最近检查点的 redo 记录开始,按 LSN 顺序前滚重放,直到 WAL 末尾 |
| 检查点 | 决定恢复需要重放多少 WAL;频繁则恢复快但 WAL 量大,稀疏则反之 |
| 段文件 | 16MB 一段,旧段在 redo 之前可回收复用;归档模式下必须先归档再回收 |
一句话:WAL 的本质是一笔"用顺序写换随机写"的交易,检查点决定这笔交易的"分期频率",而崩溃恢复就是沿着 WAL 这笔账本,从最后一个确认的检查点开始,把没落地的变更重新记到数据页上。