AI时代首先数据库-PostgreSQL实战笔记 | 24 PostgreSQL WAL日志与崩溃恢复原理

上一篇我们拆解了数据页和 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 这笔账本,从最后一个确认的检查点开始,把没落地的变更重新记到数据页上。

相关推荐
云贝贝贝1 小时前
白天稳如狗半夜就抖:OceanBase 合并转储把读写延迟顶了上去
数据库·sql·oceanbase
JavaPub-rodert1 小时前
王仕宇分享:Oxigraph 是什么?用 Rust + RocksDB 搭一个轻量 RDF/SPARQL 知识图谱数据库
数据库·rust·知识图谱
叼烟扛炮1 小时前
Linux 学习笔记(二十二):深入 Ext 文件系统(上)—— 磁盘、块与神秘的 inode
linux·笔记·学习
ycydynq1 小时前
django利用不涉及数据库form自定义表单 表单元素验证
数据库·django·sqlite
木白CPP1 小时前
Persistence subsystem 笔记
数据库·笔记
广州智造1 小时前
Radioss破坏模型FAILJOHNSON (Johnson-cook) 和 FAILTAB1
运维·数据库·自动化·仿真·建模·后处理·前处理
冰暮流星1 小时前
sql之日期函数
数据库·sql
味悲1 小时前
Nginx笔记
linux·运维·笔记·nginx
时间售卖铺1 小时前
MySQL 库表设计:数据库范式详解
运维·数据库·mysql·云原生