
PostgreSQL WAL 预写日志
很多人第一次接触 PostgreSQL WAL,会把它记成一句话:PostgreSQL 的 WAL 就是 MySQL 的 binlog。
这句话很好理解,可惜不是一个正确的说法。要真正理解 WAL,不能从文件名、参数或复制命令开始。应该先问一个更朴素的问题:一笔事务已经向客户端返回 COMMIT 成功,但它修改过的数据页还没有写入磁盘;此时突然断电,数据库凭什么保证这笔数据不会消失?
WAL 的原始目的就是回答这个问题。流复制、持续归档、PITR 和逻辑复制,都是从这条可靠的变更历史上自然长出来的能力。理解这个顺序之后,PostgreSQL 的设计会很连贯;再回头看 MySQL 为什么同时有 redo log、undo log、binlog,也就不会只停在「一个日志少,一个日志多」这种没什么解释力的比较上。
先给一张最小术语表,对数据库很陌生的话,需要记住这些。
| 术语 | 含义 | 释义 |
|---|---|---|
| Page | 页,是 PostgreSQL 在内存和磁盘之间读写的基本单位,通常为 8 KB | 表、索引和 WAL 都以页组织,但三者不是一回事 |
| Data Page | 数据页,保存表、索引等实际数据 | 事务会把它改脏,之后再写回数据文件 |
| WAL Page | WAL 页,保存 WAL 记录 | 它只是日志容器,不是表数据 |
| Segment | 段文件。WAL 不写成一个无限大的文件,而是切成固定大小的文件 | 默认每个 WAL 段文件为 16 MB |
| Tuple | 元组,可理解为表中一行数据的某个版本 | PostgreSQL 的 MVCC 会暂时保留新旧元组版本 |
| LSN | 日志序列号,是 WAL 中单调递增的位置编号 | 用来描述提交、复制和恢复进行到哪里 |
| redo | 重做,根据日志重新应用已经发生过的物理修改 | 用于崩溃恢复和物理备库重放 |
| flush | 刷盘,将数据从缓存强制同步到稳定存储 | COMMIT 的持久化边界由它决定 |
一、WAL 最初要解决的不是复制
先假设没有 WAL 的情况,执行下面这笔转账:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;这两条 UPDATE 不会只改一个地方。它们至少可能改到保存元组的 heap 数据页、相关的 B-tree 索引页和事务状态数据;如果某个索引页放满了,还可能触发页分裂,也就是把一个索引页拆成两个页。它们分散在不同的数据文件位置。最直观的安全方案是:在 COMMIT 返回前,把所有改过的页都写出并 fsync 到磁盘。
这样当然安全。问题是它慢得没法看。
磁盘喜欢连续写,不喜欢每个小事务都逼它同步许多分散的页。一个只改两行的事务,可能要等待多次随机写;并发事务越多,I/O 越容易互相踩脚。若每次提交都把脏页全部刷完,数据库的吞吐量会直接被存储延迟锁死。
WAL(Write-Ahead Log,预写日志)的解法是把「提交时必须持久化什么」和「数据文件何时写回」拆开:
- 事务先修改内存中的页,并生成描述该修改的 WAL 记录。
COMMIT前,确保 WAL 记录已按顺序写入稳定存储。- 数据页不必立刻写盘;只要对应 WAL 已先落稳,之后哪怕机器断电,也可以从 WAL 把缺失修改重新做一遍。
这条约束叫 WAL rule:数据文件的变更必须在「描述这些变更的 WAL 记录已写入到持久存储」之后写入。
假如一个新页先写到一半就断电,旧页已被覆盖一半,而 WAL 又没落盘,恢复器既看见损坏的数据,也没有足够证据修复它。WAL 不是普通的操作日志;它是让延迟写数据页仍然可靠的先决条件。
1.1 为什么它快:把很多随机同步写换成一条顺序事实流
WAL 采用追加写。新的记录不断写向 WAL 尾部,适合顺序 I/O;大量事务在相近时刻提交时,还可以共享一次同步动作,这就是 组提交。相反,真正的数据页可以由后台进程在更适合的时机批量写出、排序写出,并在检查点时受控地推进。
于是,一笔已经提交的事务可能处在这样的状态:WAL 已经安全,heap 与索引的数据页仍然只存在于 shared_buffers 或操作系统缓存。这不是「数据还没写完」,恰恰是 WAL 正在发挥作用。
COMMIT成功不等于所有数据页已落盘。 它意味着 PostgreSQL 已持有一份足以在崩溃后重建这次提交结果的、已持久化的证据。
这也给持久性一个准确边界。write() 成功通常只表示字节已经交给操作系统的页缓存,并不表示断电后仍然存在;面对断电的关键动作是 fsync、fdatasync,或者由 wal_sync_method 选择的等价同步方式。更底层的 RAID Controller、虚拟磁盘和设备缓存也必须真的遵守「已刷盘」的承诺。设备若谎报完成,数据库的协议再严谨也无能为力。
二、先别把 WAL 和 MVCC、复制混在一起
WAL 出现在很多能力背后,但它不是所有能力的同义词。先画清边界,后面的细节才不会打架。这里的 MVCC(多版本并发控制)指的是:同一条记录可以在一段时间内保留多个版本,读事务依照自己的快照选择可见版本,因此读写不必彼此长期阻塞。
| 问题 | PostgreSQL 中真正负责的机制 | WAL 在其中扮演什么角色 |
|---|---|---|
| 已确认事务断电后不丢 | WAL rule、fsync、崩溃恢复 | 核心事实来源;用于 redo |
| 读写并发时读到一致版本 | MVCC、快照、元组可见性规则 | 持久化相关页与事务状态变化,但不提供快照语义 |
事务中途失败或 ROLLBACK | MVCC 的可见性、事务状态、后续 VACUUM | 记录必要的低层状态;不是一份对等的 undo 文件 |
| 备库跟上主库 | 物理流复制 | 直接把 WAL 流发送并重放 |
| 回到误删前一刻 | 基础备份 + 已归档 WAL | 提供可连续重放的历史 |
| 只同步部分表或做 CDC | 逻辑解码 / 逻辑复制 | 作为解码输入,输出逻辑变更 |
可能有人会说「WAL 支持 MVCC」,但 MVCC 的核心是版本与可见性,不是 WAL。PostgreSQL 的 UPDATE 通常创建一个新的元组版本,旧版本暂时仍留在 heap;每个事务根据快照和 xmin、xmax 等元数据判断某个版本对自己是否可见。事务没有 COMMIT,即使它留下过页变化,其他事务也不会将其判定为已提交,之后再由 VACUUM 清理这次事务产生的死元组。
WAL 的职责在于持久化这些存储变更,使其具备应对崩溃恢复的能力。但把 WAL 关掉并换一个别的日志,不会自动变出 MVCC;反过来,理解 MVCC 也不等于理解 WAL。二者在写入路径相遇,却解决不同问题。
MySQL/InnoDB 的 undo log 在这里很容易制造错觉。InnoDB 通过 undo log 记录旧版本,既支持 ROLLBACK,也让一致性读可以沿版本链构造历史视图;PostgreSQL 则通常把旧元组版本放在表中,由 VACUUM 回收。所以 PostgreSQL 没有一个可以直接叫作 undo log 的对等文件。两者都实现 MVCC,但历史版本放在哪里、谁来回收、长事务会拖住什么,完全不是同一个设计。
三、WAL 的设计:一条以 LSN 排序的物理变更流
现在才适合打开 pg_wal。WAL 的本质并非「一个文本日志文件」,而是一条可寻址、可连续重放的物理变更轨迹。
3.1 WAL 目录、段文件、WAL 页与 LSN
WAL 文件位于 $PGDATA/pg_wal/。它由固定大小(默认 16 MB)的段文件 (Segment File) 组成,每个段文件内部又被划分为多个 WAL 页 (Page,默认 8 KB) 。需要注意的是,段文件大小在执行 initdb --wal-segsize 时确定,而 WAL 页大小则由编译期参数决定,这里的 WAL 页仅仅是日志的存储容器,切勿将其与 Heap 或索引的数据页混为一谈。
在这条全局日志流中,每一个位置都由 LSN (Log Sequence Number) 唯一标识。LSN 是一个单调递增的字节偏移量,数据类型为 pg_lsn,通常以 0/16B6C50 这样的十六进制格式显示。它既不是时间戳,也不是表的行号,而是表示变更在全局 WAL 流中的字节位置。
LSN 单调递增的 WAL 流
0/00000000 ───────── 0/01000000 ───────── 0/02000000
│ │ │
└── 段文件 A ─────────┴── 段文件 B ─────────┴── 段文件 C
默认 16 MB 默认 16 MB 默认 16 MB
一个段文件:WAL 页 → WAL 页 → WAL 记录
└─ 记录可以跨页 / 段文件这套坐标系贯穿全文:提交要等 WAL 刷盘到某个 LSN;数据页会记住自己已经包含到哪个 LSN;备库报告自己接收、刷盘、重放到哪个 LSN;归档和复制槽则围绕「最早仍需要哪个 LSN」决定哪些段文件不能回收。
SELECT pg_current_wal_lsn();
CREATE TABLE wal_demo (id integer PRIMARY KEY, note text);
INSERT INTO wal_demo VALUES (1, 'hello WAL');
SELECT pg_current_wal_lsn(),
pg_walfile_name(pg_current_wal_lsn());两次 pg_current_wal_lsn() 的差值代表期间整个数据库集群产生的 WAL 字节,不代表某一行的大小。B-tree 维护、全页镜像、并发事务甚至其他数据库的写入都可能包含其中。可以用 pg_wal_lsn_diff() 计算距离。
3.2 WAL 记录了什么:不是 SQL,也不是整行复制
一条 WAL 记录包含通用的记录头,但具体内容由不同的资源管理器 (Resource Manager) 决定。heap、B-tree 和事务等模块各自负责记录并恢复对应的数据结构。由于 heap 插入、B-tree 页分裂、事务提交和检查点需要的信息不同,因此不存在一套适用于所有 WAL 记录的固定字段。
| 类别 | 例子 | 它解决什么 |
|---|---|---|
| 定位与连续性 | 记录长度、前一条位置、LSN、校验 | 找到一条连续且可信的历史 |
| 操作类型 | heap、B-tree、Transaction、XLOG 的操作码 | 把记录交给正确的 Resource Manager 解释 |
| 块引用 | Tablespace、Relation、fork、Block Number | 找到受影响的数据页 |
| redo 数据 | 新元组、Item Pointer、Index Item、页结构变化 | 让恢复器把变化再做一次 |
| 事务记录 | COMMIT、ABORT、PREPARE | 判定事务最终结局 |
| 恢复记录 | 检查点、时间线相关信息 | 建立可恢复边界与历史分叉 |
| 全页镜像(可选) | 一整张修改后的数据页 | 修复 torn page 风险 |
因此,物理 WAL 不会原样保存 UPDATE account SET balance = balance - 100 WHERE id = 7。执行器、触发器、约束和索引维护会将它转换为更底层的页操作。物理恢复关心的是如何恢复正确的页状态,而不是重新执行原始 SQL,所以 WAL 不适合作为审计日志。
3.3 全页写入与页 LSN
full_page_writes = on 是默认值。每次检查点之后,一张数据页第一次被修改时,PostgreSQL 会把该页的完整镜像放进 WAL;同一个检查点周期内再改这张页,通常无需重复写整页。
它用于防止页撕裂 (torn page) 。当系统正在把一个 8 KB 的页写入磁盘时突然断电,磁盘上可能留下新旧数据混合的内容。仅靠增量 redo 无法保证还原整个页面;有了全页镜像,恢复器可以先覆盖损坏的页,再重放后续记录。
全页写入也会增加 WAL 体积。检查点越频繁,同一批热页越容易再次成为「本周期首次修改」,产生的全页镜像也越多。关闭 full_page_writes 会把防止部分写入的责任交给底层存储,只有在确认设备具备写原子性和可靠的断电保护时才应考虑。
每个数据页都带有 pd_lsn,表示该页已经包含到哪个 WAL 位置。崩溃恢复或备库重放时,如果页 LSN 不小于当前记录的 LSN,说明对应变更已经写入,无需重复执行。这保证了 redo 可以安全处理部分页已写回、部分页尚未写回的状态。
PostgreSQL 自带 pg_waldump 可以显示 WAL 记录的可读摘要:
pg_waldump -p "$PGDATA/pg_wal" --rmgr=Transaction
pg_waldump -p "$PGDATA/pg_wal" --start=0/16B6C50它适合学习或由熟悉内核的人做专项诊断,不适合拿来当日常监控。生产上应优先观察 pg_stat_wal、pg_stat_io、pg_stat_replication、pg_stat_archiver 和 pg_replication_slots。
3.4 并不是所有东西都会进入 WAL
WAL 作为 PostgreSQL 的物理变更流,还有两条边界需要说明。
第一条是 wal_level。minimal 只保留崩溃恢复所需的信息;replica 在此基础上支持 WAL 归档与物理复制;logical 再增加逻辑解码所需的信息。更高的级别可能产生更多日志,但它表示 WAL 支持哪些用途,并不是单纯的性能档位。修改该参数需要重启,因此接入 CDC 前应提前确认配置。
第二条是 UNLOGGED 表。它的数据修改通常不会写入常规 WAL,因此写入更快。代价是发生崩溃或非正常关机后,PostgreSQL 会清空这类表,其内容也不会通过物理复制同步到备库。它适合缓存或可重建的中间结果,不适合订单、余额和任务状态等关键数据。
临时表和部分批量加载操作也存在特殊路径,但原则不变:减少 WAL 写入,是用恢复能力、复制能力或使用限制换取性能。
四、WAL 在运行时怎样工作:从提交到崩溃恢复
下面以一笔普通写事务为例,略去锁、触发器和网络协议,只保留与 WAL 有关的过程。
内部的 XLogInsertRecord 将记录写入 WAL 缓冲区并分配 LSN,XLogFlush 则确保目标 LSN 已写入稳定存储。提交阶段等待的是后者。当多个后端进程同时提交时,一次 WAL 刷盘可以包含多笔事务,这就是组提交的基础。
commit_delay 可以短暂延迟提交,让更多事务组成一组,但这是以提交延迟换取吞吐量。高并发场景下,即使设为 0 也可能自然形成组提交;在低延迟 NVMe 上主动等待,反而可能增加 p99 延迟。调整前应先用 pg_test_fsync 测量同步开销,再通过真实负载验证。
4.1 同步提交:客户端何时收到成功
默认设置 synchronous_commit = on 要求客户端等待本地 WAL 持久化,但不要求所有数据页写出,也不要求备库收到数据。远端确认需要另外配置同步复制。
| 设置 | COMMIT 返回前的最低保证 | 代价与风险 |
|---|---|---|
on | 本地 WAL 已持久化;若配置同步备库,再等其刷盘 | 默认可靠的本地提交语义 |
local | 本地 WAL 已持久化,不等远端 | 主库自身安全,故障切换仍可能丢未复制事务 |
remote_write | 同步备库已写至操作系统缓存 | 比远端刷盘快;备库 OS 崩溃仍有窗口 |
remote_apply | 同步备库已重放,查询可见 | 可获得更强读后写可见性,延迟最高 |
off | 允许在本地 WAL 实际刷盘前返回 | 数据库进程、OS 或断电故障可丢最近已确认事务 |
synchronous_commit = off 仍会生成并写入 WAL,只是允许在刷盘前返回成功。异步提交的数据丢失窗口最多约为 3 倍 wal_writer_delay,不会破坏数据库一致性,但可能丢失最近已确认的事务。对于可重放、可补偿的数据,可以按事务单独设置。
它与 fsync = off 有本质区别。前者只缩小事务的持久性承诺;后者会破坏数据库对写入顺序和稳定存储的基本假设,系统崩溃后可能产生无法自动恢复的数据损坏。
4.2 检查点与崩溃恢复
如果每次启动都从第一条 WAL 开始重放,恢复时间会无限增长。检查点用于建立恢复边界:检查点完成时,此前修改过的 heap 和索引数据页已经写入磁盘。PostgreSQL 随后写入检查点记录,并将其位置保存到 pg_control。
检查点不是备份,也不会直接清空 WAL。它只表示 redo LSN 之前的变更已经写入数据文件。旧段文件能否回收,还取决于归档状态、wal_keep_size、复制槽和备库进度等条件。
缩短 checkpoint_timeout 或减小 max_wal_size 可以减少崩溃恢复时的 WAL 重放量,但会增加脏页写入和全页镜像;延长检查点间隔可以降低日常写入压力,但会增加最坏情况下的恢复时间。checkpoint_completion_target 用于分散检查点期间的写入,避免产生集中的 I/O 压力。
一次非正常宕机后,PostgreSQL 读取 pg_control,找到最近的检查点记录,从其中的 redo LSN 向前扫描 WAL。不同 Resource Manager 重新应用对应的页操作;页 LSN 已表明改动已存在时,恢复器会跳过它。
假设事务 T1 已经 COMMIT,但数据页尚未写盘。由于提交记录已存在于 WAL,恢复时可以重放必要的修改。事务 T2 修改了数据页但没有 COMMIT,恢复过程可能仍会重放部分底层记录以修复物理页,但 MVCC 不会将这些版本视为已提交数据,之后再由 VACUUM 清理。
这里需要区分「物理一致性」与「事务可见性」。崩溃恢复负责将文件恢复到结构一致的状态,事务系统则决定哪些版本在逻辑上已经提交。redo 不是重新执行已提交的 SQL,也不是简单撤销未提交事务。
五、原始目的之外:WAL 为什么自然长出了复制、备份与逻辑变更流
WAL 的原始任务是保证数据页延迟写回时,数据库仍能完成崩溃恢复。由于它是一条连续、有序且可重放的变更历史,PostgreSQL 又在此基础上实现了复制、归档、时间线和逻辑解码。
5.1 物理流复制:让另一台机器持续做 redo
备库不需要重新执行 SQL 就能成为主库的物理副本。它先从基础备份获得起点,再接收并持续重放主库生成的 WAL。主库的 walsender 进程发送 WAL,备库的 walreceiver 进程接收并写入本地 WAL,恢复进程再应用其中的变更。
物理复制适合构建同构的高可用副本,但主库与备库通常需要保持相同的大版本、兼容的体系结构和表空间路径。因为备库重放的不是「订单状态改为 paid」这样的逻辑事件,而是关系文件中数据块的物理变更。
流复制默认采用异步模式。客户端在主库本地 WAL 刷盘后即可收到成功,WAL 随后才通过网络到达备库。如果主库整体丢失,尚未传输或尚未持久化的事务可能丢失。它可以缩短 RTO(恢复时间目标),但不会自动将 RPO(恢复点目标)降为零。
5.2 四个 LSN:复制不是一个进度条
| LSN | 含义 | 落后通常说明什么 |
|---|---|---|
sent_lsn | 主库已经发送到这里 | 主库 WAL 发送进程或网络出口有压力 |
write_lsn | 备库已写入本地操作系统缓存 | 网络或备库写入跟不上 |
flush_lsn | 备库 WAL 已同步至稳定存储 | 备库的 sync I/O 慢 |
replay_lsn | 备库已重放,数据对只读查询可见 | 备库 CPU、I/O 或恢复冲突 |
因此,「收到」不代表「落盘」,「落盘」也不代表「已经重放」。可以在主库上将各阶段的 LSN 差转换为字节数:
SELECT application_name,
state,
sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)) AS unsent,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, write_lsn)) AS network_or_write_gap,
pg_size_pretty(pg_wal_lsn_diff(write_lsn, flush_lsn)) AS flush_gap,
pg_size_pretty(pg_wal_lsn_diff(flush_lsn, replay_lsn)) AS replay_gap
FROM pg_stat_replication;pg_last_xact_replay_timestamp() 的时间差也常用于复制延迟告警,但空闲主库没有新事务时,该时间戳不会更新,时间差会持续增大。这不代表备库异常,因此需要结合 LSN 差和当前 WAL 生成速率判断。
5.3 同步复制:把提交确认范围扩展到远端
如果业务不能接受异步复制的数据丢失窗口,可以配置 synchronous_standby_names,让提交等待一个或多个备库确认。默认 synchronous_commit = on 时,需要等待备库将 WAL 刷入稳定存储;remote_write 只等待写入操作系统缓存,remote_apply 则等待备库完成重放。
# 主库的 postgresql.conf
synchronous_standby_names = 'ANY 1 (az_a, az_b)'ANY 1 表示 az_a、az_b 中任意一个确认即可完成提交。同步复制通过增加网络 RTT、远端存储延迟和写阻塞风险来降低 RPO。如果唯一的同步备库离线,写事务可能持续等待。因此,它是在可用性与数据持久性之间作出的明确取舍。
5.4 持续归档与 PITR:把短期循环日志变成长期恢复链
崩溃恢复只需保留最近检查点之后的一段 WAL;pg_wal 中旧段文件会循环复用,它不是永久备份。若要从周日的基础备份恢复到周二误删前的 14:29:59,就需要持续把完整 WAL 段文件归档到独立位置。
PITR(Point-in-Time Recovery,时间点恢复)的过程是:恢复基础备份,按顺序重放归档 WAL,并在 recovery_target_time、目标 LSN、目标事务 ID 或还原点停止。文件级基础备份不必是某一时刻的完整快照,重放对应 WAL 可以修复备份期间产生的内部不一致。
archive_mode = on 只表示 PostgreSQL 会调用 archive_command 或 archive_library,不代表备份方案已经完成。恢复能力还取决于归档是否位于独立故障域、是否经过校验、保留期是否足够、基础备份与 WAL 是否配套,以及归档失败是否及时告警。
PITR 应在新的数据目录或隔离实例中演练,不要直接回退仍在服务的主库。应先保留现场并恢复副本,验证目标时间点和业务数据,再决定导出数据或进行切换。只有在 RTO 内恢复了正确数据,恢复流程才算有效。
5.5 时间线:故障切换后,历史会分叉
备库提升为新主库后,旧主库可能仍保留原来的 WAL 尾部。两边从同一个 LSN 继续写入,却代表不同的历史,仅靠 LSN 无法区分。PostgreSQL 使用 时间线 (Timeline) 解决这个问题:恢复到目标位置后继续写入,或将备库提升为主库,都会产生新的时间线。时间线历史文件记录它从哪条旧时间线、在哪个位置分叉。
Timeline 1: A ── B ── C ── D (旧主库失联后仍可能继续写)
└── Timeline 2: E ── F (备库提升后的新历史)时间线可以防止恢复工具将两段互相矛盾的 WAL 拼接在一起。归档仓库除了保存 WAL 段文件,还必须保存时间线历史文件。故障切换后,旧主库也不能只修改配置便重新加入,通常需要使用 pg_rewind 对齐新时间线,或重新制作基础备份。
时间线只能记录历史分叉,无法阻止两个主库同时接受写入。避免 split brain(脑裂)仍然需要外部仲裁、fencing(隔离旧主库)和严格的切换流程。
5.6 逻辑解码:从物理事实流提炼逻辑变更
物理 WAL 与数据页、关系文件和存储版本绑定,不适合作为外部系统接口。逻辑解码 (Logical Decoding) 从一致的 LSN 开始解析 WAL,将内部记录转换为表级 INSERT、UPDATE 和 DELETE 变更,逻辑复制和 CDC 连接器再消费这条逻辑流。CDC(Change Data Capture,变更数据捕获)是指持续捕获数据变更并交给外部系统。
逻辑解码可以选择部分表、支持跨大版本迁移,也可以向搜索或分析系统同步数据。它不会复制所有 DDL、权限、序列和物理对象,因此 Schema 演进需要单独处理。逻辑复制槽还会保留 WAL,并可能通过 xmin 或 catalog_xmin 影响 VACUUM。失效的 CDC 消费者既可能占满 pg_wal,也可能阻止旧元组回收,这是为慢消费者可靠保留历史所付出的存储成本。
六、WAL 何时可以回收:为什么 WAL 目录会写满
即使检查点已经不再需要某个 WAL 段文件进行崩溃恢复,它仍可能因以下条件被保留:
- 归档尚未成功保存它;
wal_keep_size要求保留最近的一段;- 物理备库的复制槽还需要它;
- 逻辑复制槽的消费者尚未确认到更后面的 LSN;
- 恢复、时间线或其他功能仍依赖它。
max_wal_size 不是硬上限。 它主要影响检查点的触发时机和正常情况下的 WAL 回收目标。归档失败或复制槽长期滞后时,PostgreSQL 会继续保留 WAL,以免中断恢复链。此时 pg_wal 会持续增长,文件系统写满后,主库将无法继续写入。
SELECT slot_name,
slot_type,
active,
restart_lsn,
confirmed_flush_lsn,
wal_status,
safe_wal_size,
inactive_since
FROM pg_replication_slots
ORDER BY active, slot_name;restart_lsn 表示复制槽最早可能需要的位置,safe_wal_size 表示距离进入 lost 状态还可以写入多少 WAL。max_slot_wal_keep_size 可以限制复制槽保留的 WAL 大小,但超过上限后,消费者可能需要重新初始化。处理故障时,应先确认哪个消费者正在保留 WAL,再决定修复或重建消费者,同时保证归档链完整,并根据需要扩容或限流。不要直接删除 pg_wal 中的文件。
容量至少应按峰值估算:
所需 WAL 空间 ≈ 峰值 WAL 生成率 × 最大允许离线时间 × 安全系数例如峰值生成速率为 80 MB/s,允许消费者离线 2 小时,安全系数为 1.5,仅这一项就需要约 844 GB。该公式不能代替容量演练,但可以用于初步估算。
真遇到 pg_wal 告急,可以按下面的顺序排查:
- 先确认文件系统剩余空间和 WAL 生成速率,判断还能撑多久;必要时先限流或扩容,给排查争取时间。
- 查看
pg_stat_archiver,确认归档是否连续失败,以及失败发生在数据库、网络还是归档目标。 - 查看
pg_replication_slots和pg_stat_replication,找出最老的restart_lsn、失活复制槽和落后备库。 - 决定消费者是继续追赶还是重新初始化。设置了
max_slot_wal_keep_size时,还要检查复制槽是否已进入unreserved或lost。 - 最后才调整保留策略。删除复制槽意味着消费者可能失去续传位置,必须先确认影响;直接删除
pg_wal文件则根本不在处理选项里。
最常见的错误是在磁盘空间不足时将 WAL 当作普通日志删除。缺失任何一段 WAL,都可能同时中断崩溃恢复、备库追赶和 PITR,因此不能使用普通日志轮转的方式处理。
七、再看 MySQL:为什么它不能也只用一条 WAL
下面比较 PostgreSQL 与 MySQL Server + InnoDB。MySQL 的 binlog 属于服务器层,redo log、undo log 和 doublewrite 则由 InnoDB 实现。区分这两个层次,才能准确讨论 MySQL 的日志设计。
7.1 先建立对应关系:相似功能,不等于同一个东西
| PostgreSQL | MySQL / InnoDB | 相同点 | 关键不同 |
|---|---|---|---|
| WAL | redo log | 都是 write-ahead 的 redo 历史,保护未写回数据页的已提交变化 | PostgreSQL WAL 还直接服务物理复制与归档;redo 主要服务 InnoDB 恢复 |
| 逻辑解码输出 | row-based binlog | 都可提供表级行变更给复制与 CDC | MySQL binlog 是独立的服务器层事实流;PG 需要从 WAL 解码 |
| MVCC 元组版本 + VACUUM | undo log + purge | 都支持一致性读与回滚 | 历史版本的存放和清理策略不同 |
| 全页镜像 | doublewrite buffer | 都试图防 torn page | 一个把页镜像写进 WAL,一个把页先写到受保护区域 |
| WAL 流 | binlog → relay log → applier | 都让副本追随主库 | PG 物理副本重放页级事实;MySQL 副本通常应用 binlog 事件 |
可以将对应关系概括为:PostgreSQL WAL 的崩溃恢复职责最接近 InnoDB redo log;PostgreSQL 的逻辑变更输出最接近 MySQL row-based binlog。不同之处在于,PostgreSQL 让这些能力以同一条物理 WAL 为基础,而 MySQL 将它们放在不同层。
7.2 InnoDB 的 redo log、undo log 与 binlog
InnoDB 同样不会在每次事务提交时同步写出所有数据页。它先将 redo 信息顺序写入 redo log,之后再将缓冲池中的数据页写盘;崩溃后通过 redo 恢复尚未写出的已提交变更。检查点推进后,旧的 redo 空间才能复用。这与 PostgreSQL WAL 最初解决的问题基本相同。
InnoDB 更新行时,会将旧值等信息写入 undo 记录。读事务沿 undo 版本链查找符合读视图的版本,ROLLBACK 也依赖 undo。只有不再有活跃事务需要这些历史时,purge 线程才能清理它们。PostgreSQL 则将新旧元组版本保存在 heap 中,由快照判断可见性,再由 VACUUM 回收死元组。因此,长事务在 InnoDB 中可能造成 undo 历史膨胀和 purge 滞后,在 PostgreSQL 中则可能阻止 VACUUM 回收旧版本并导致表膨胀。
MySQL binlog 记录 MySQL 服务器层的数据修改事件。MySQL 8.4 默认启用 binary logging,默认 ROW 格式:主库写出行变化事件,副本拉取这些事件,先写入 relay log,再由 applier 执行。PITR 则通常是恢复全量备份后,用 mysqlbinlog 回放之后的 binlog;CDC 工具也常直接订阅 row-based binlog。
这种设计的逻辑边界很清晰:副本不需要与主库使用完全相同的 InnoDB 页布局,外部消费者也不必解析存储引擎的内部操作。STATEMENT 格式可以减少部分事件的体积,但会受到非确定性函数、触发器和执行环境差异的影响;MySQL 8.4 默认使用 ROW 格式。
7.3 既有 redo 又有 binlog,如何保证提交一致
redo log 与 binlog 必须保持一致。如果 InnoDB 已确认 redo,而 binlog 在断电前丢失,主库恢复后会保留该事务,但副本与 PITR 无法得知;反过来,如果 binlog 已写入而 redo 未提交,副本可能拥有主库不存在的数据。因此,MySQL 使用内部两阶段提交 (2PC) 协调启用 binlog 的 InnoDB 事务。
实际实现还包含组提交的 flush、sync 和 commit 阶段,并不是图中的单线程流程。这里需要强调的是:redo 与 binlog 的消费者、格式和所有者不同,因此必须在提交边界进行原子协调。
PostgreSQL 的物理恢复与物理复制使用同一条 WAL,因此普通提交无需协调存储引擎 redo 与服务器层 binlog。它不需要这一层跨层 2PC,但物理 WAL 也承担了更多职责;要获得可供外部消费的逻辑变更,仍需经过逻辑解码。这是两种不同的分层设计。
八、两种设计为什么会分叉:不是文件数量,而是抽象层次
只比较「PostgreSQL 一条 WAL,MySQL 三条日志」没有意义。真正的差别在于两者选择了不同的抽象层作为系统的中心。
PostgreSQL 以 物理 WAL 流 为中心。同构实例可以从某个 LSN 开始 redo,并恢复到相同的数据文件状态,因此崩溃恢复、物理复制和归档恢复使用同一套机制。它的模型集中,物理高可用的复制路径也更直接;但由于 WAL 与存储格式绑定,不适合直接作为跨版本、跨系统的业务事件协议。
MySQL 分为两层:InnoDB 使用 redo 保证物理数据正确,MySQL Server 使用 binlog 保存可复制、可回放的逻辑变更。binlog 可以作为跨引擎、副本和 CDC 工具的统一接口;相应地,同一事务必须在两份持久化历史中保持原子一致,2PC 和耐久性参数也更复杂。
| 维度 | PostgreSQL WAL-first | MySQL redo + undo + binlog |
|---|---|---|
| 原始目标 | 让延迟写数据页仍可崩溃恢复 | redo 解决同一问题;其他日志分别承担其他职责 |
| 复制默认输入 | 物理 WAL 流 | binlog 事件 |
| 备库的工作 | 重放物理 WAL | 接收 relay log 后应用逻辑事件 |
| CDC 的入口 | 从 WAL 做逻辑解码 | row-based binlog 天然是常见入口 |
| MVCC 历史 | heap 中的元组版本,VACUUM 回收 | undo 版本链,purge 回收 |
| torn page 典型保护 | WAL 全页镜像 | doublewrite buffer |
| 提交一致性难点 | WAL 本地与可选远端确认 | redo 与 binlog 的内部 2PC |
| 主要运维债务 | 检查点、WAL 归档、复制槽、pg_wal | redo 容量、binlog 保留、undo/purge、2PC 耐久性组合 |
因此,不能根据日志数量判断写放大。PostgreSQL WAL 可能因全页镜像、索引维护、频繁检查点和宽元组产生大量数据;MySQL 的 row-based binlog、redo、doublewrite 和 undo 也各有写入成本。性能比较必须基于具体的工作负载、提交策略、存储设备和复制目标。
九、从事故角度选择,而不是从参数名字选择
假设支付系统有主库、异步备库、独立 WAL 归档和一个逻辑复制槽。14:30:00,支付事务已经在主库返回成功;14:30:01,主库所在可用区整体失联。
| 这笔事务到达的位置 | 它说明什么 | 故障切换后是否一定存在 |
|---|---|---|
| 主库本地 WAL 已刷盘 | 主库的磁盘仍可用时可崩溃恢复 | 整个可用区丢失时不能依赖 |
| 主库数据页未写出 | 正常状态,不影响本地持久性 | WAL 可 redo,但前提是主库存储还在 |
备库已 write_lsn | 已进入备库操作系统缓存 | 不保证断电后仍在 |
备库已 flush_lsn | 已到备库稳定存储 | 可在之后重放 |
备库已 replay_lsn | 已对只读查询可见 | 提升后存在 |
| 归档已成功 | 已有独立的长期历史副本 | 未来 PITR 可使用 |
| 逻辑消费者已确认 | 外部系统已经处理 | 外部系统仍需自己的幂等与一致性设计 |
在异步复制中,本地 WAL 刷盘足以让主库向客户端返回成功,但不能保证 B 可用区接管后仍然拥有该事务。如果支付数据不能丢,应使用跨故障域的同步复制,并接受网络 RTT 和远端刷盘延迟。如果业务优先保证可用性,并允许较小的 RPO,可以使用异步复制,同时在业务层实现对账、幂等和补偿。任何参数都无法同时保证跨地域零丢失、零延迟和网络中断后持续写入,这是故障域与网络本身的限制。
MySQL 也需要分别设置两种日志的持久性。innodb_flush_log_at_trx_commit = 1 控制 redo 在提交时刷盘,sync_binlog = 1 控制 binlog 同步。要获得最保守的 InnoDB 与 binlog 一致性,两者都应设为 1。降低持久性要求可以提高吞吐量,但会增加断电时丢失已确认事务或日志不一致的风险。
十、最小可行的运维观察面
理解机制的目的不是直接读取 WAL 文件,而是在故障发生前发现异常的生成和保留情况。
SELECT wal_records,
wal_fpi,
pg_size_pretty(wal_bytes) AS wal_generated,
wal_buffers_full,
stats_reset
FROM pg_stat_wal;
SELECT backend_type,
context,
writes,
pg_size_pretty(write_bytes) AS write_bytes,
write_time,
fsyncs,
fsync_time,
stats_reset
FROM pg_stat_io
WHERE object = 'wal'
ORDER BY backend_type, context;wal_fpi 突增通常与检查点周期或写入模式有关,wal_buffers_full 持续增加则表示 WAL 缓冲区频繁写满。只有启用 track_wal_io_timing 后,write_time 和 fsync_time 才有参考价值。监控时应建立相同工作负载下的基线,并关注指标的异常变化。
SELECT num_timed,
num_requested,
num_done,
write_time,
sync_time,
buffers_written,
stats_reset
FROM pg_stat_checkpointer;
SELECT archived_count,
last_archived_wal,
last_archived_time,
failed_count,
last_failed_wal,
last_failed_time
FROM pg_stat_archiver;当 num_requested 长期高于定时检查点数量,并伴随写延迟尖峰时,通常说明 max_wal_size 与实际 WAL 生成速率不匹配,或批量任务正在产生大量 WAL。归档监控需要覆盖连续失败次数、最后成功时间、归档目标容量和 pg_wal 增长率,并定期验证归档文件能否在恢复环境中读取。
- 默认保持
fsync = on、full_page_writes = on、synchronous_commit = on;局部弱化耐久性必须有清楚的业务理由。 - 为
pg_wal预留能覆盖峰值 WAL 生成速率、最长消费者离线和安全系数的空间;不要把max_wal_size当容量上限。 - 每一个复制槽都要有负责人、告警和退役流程,避免闲置复制槽长期占用磁盘,也不要在未确认影响前删除活跃复制槽。
- 归档、基础备份和时间线历史文件必须成套保留,并在隔离环境中演练 PITR。
- 故障切换需要外部健康检查、仲裁和隔离机制,PostgreSQL 的复制功能本身不能避免脑裂。
结语:先理解数据库如何记住过去,再谈它如何复制未来
WAL 最初解决的问题是:数据页可以延迟写入,但已经向客户端确认的事务不能因断电而丢失。PostgreSQL 为此将足以完成 redo 的物理变更写入一条按 LSN 排序、可持久化的 WAL 流。
崩溃恢复从检查点开始重放 WAL,备库持续重放同一条 WAL,归档保存 WAL 以支持 PITR,逻辑解码则从中提取表级变更。这些能力都建立在同一条变更历史之上。
MySQL 将 InnoDB 的物理恢复、MVCC 历史和服务器层的逻辑复制分别放在 redo、undo 和 binlog 中,获得了独立的逻辑变更接口,也增加了 redo/binlog 2PC、容量规划和参数配置的复杂度。PostgreSQL 以物理 WAL 为统一基础,模型更集中;MySQL 将不同抽象层分开,边界更明确。两者选择了不同的变更层作为系统的时间轴。
真正需要理解的是每个 COMMIT 承诺了什么:本地 WAL 是否已经刷盘,远端 WAL 是写入缓存、持久化还是已经重放,归档是否连续,以及哪个消费者仍在保留 WAL。明确这些边界之后,才能正确配置参数并制定故障恢复方案。
