PostgreSQL WAL 与崩溃恢复内部机制

拆解 PostgreSQL 预写日志与崩溃恢复:XLogRecord 与块引用结构、full_page_writes 与 torn page、hint bits、LSN 与 pg_wal 段文件命名、wal_level 三级差异、检查点与 pg_control 记录的 redo 起点、恢复的 analysis 与 redo 流程、pg_waldump 读日志实测、archive_mode 归档与 synchronous_commit 的持久性权衡。

断电后重启,PostgreSQL 凭什么保证已提交的事务不丢、未提交的事务不留痕?答案全部写在 pg_wal/ 目录里。WAL(Write-Ahead Logging,预写式日志)要求数据页的任何修改在落盘前,描述该修改的日志必须先落盘。这条规则把「随机写数据页」变成了「顺序写日志」,既提升了吞吐,也让崩溃恢复成为可能。

核心认知:WAL 是唯一权威的事实来源。数据页只是日志重放后的缓存结果,任何时刻只要日志完整,数据页损坏都可以被重放修复。


一、WAL 的设计目标与基本模型

1.1 先写日志规则

1. 事务修改一个页 -> 生成一条 WAL 记录写入 WAL 缓冲区
2. 事务提交 -> 把 WAL 缓冲区 fsync 到 pg_wal(这一步决定持久性)
3. 数据页留在共享缓冲区,稍后由后台写进程刷盘
4. 崩溃 -> 用 WAL 重放所有「已落盘日志但未落盘数据页」的修改

关键不变量:数据页的 pd_lsn 永远不大于已落盘的 WAL 位置。否则恢复时无法判断该页是否包含未记录的变化。

1.2 顺序 IO 与批量提交

WAL 是追加写,磁盘只需一次寻道,比随机写数据页快几个数量级。更进一步,commit_delay 与 commit_siblings 允许多个并发事务的提交日志合并成一次 fsync,用少量延迟换吞吐。

SHOW commit_delay;      -- 默认 0,单位微秒
SHOW commit_siblings;   -- 默认 5,活跃事务达到此数才启用延迟
SHOW wal_buffers;       -- WAL 缓冲区,默认 -1 表示自动

1.3 为什么不能只写数据页

因为数据页有 8KB,而一次事务可能只改几个字节。直接写页意味着每次小修改都要随机写 8KB,且崩溃时无法知道页内哪些字节是「新」的。WAL 把「改了什么」精确记录下来,重放时可以幂等应用。


二、WAL 记录结构

2.1 XLogRecord 头部

每条 WAL 记录以一个固定头部开头,后跟主数据与若干块引用。

/* src/include/access/xlogrecord.h */
typedef struct XLogRecord
{
    uint32   xl_tot_len;   /* 整条记录总长度 */
    TransactionId xl_xid;  /* 产生此记录的事务 ID */
    XLogRecPtr xl_prev;    /* 上一条记录的 LSN,构成链表 */
    uint8    xl_info;      /* 操作码高位标志 + 低 4 位类型 */
    RmgrId   xl_rmid;      /* 资源管理器 ID(Heap、Btree、Xact 等) */
    uint8    xl_orig_rmid; /* 跨页记录时的原始 rmgr */
    uint16   xl_crc;       /* 头部 CRC32C 校验 */
} XLogRecord;

xl_rmid 决定这条记录交给哪个模块重放:RM_HEAP_ID 处理堆元组插入,RM_BTREE_ID 处理索引页,RM_XACT_ID 记录提交/回滚,RM_XLOG_ID 记录检查点等控制信息,可用 pg_waldump --rmgr=list 列出全部。

2.2 块引用与 XLogRecordBlockHeader

如果记录涉及数据页修改,会附带块引用,说明「改哪个文件的哪一页、页内哪些字节」。

typedef struct XLogRecordBlockHeader
{
    uint8  id;              /* 块编号,从 0 开始 */
    uint8  fork_flags;      /* fork 类型(main/fsm/vm)+ 标志位 */
    uint16 data_length;     /* 紧随其后的块数据长度 */
} XLogRecordBlockHeader;

标志位中最重要的两个:

BKPBLOCK_HAS_IMAGE     块前镜像(全页写)存在
BKPBLOCK_HAS_DATA      块内容(片段)存在

有了块引用,重放时无需解析页内容即可定位目标页;BKPBLOCK_SAME_REL 还能让同一条记录里的多个块共享关系标识,节省空间。

2.3 全页写 full_page_writes 与 torn page

这是 WAL 里最容易被忽略却最关键的设计。磁盘的扇区通常是 512B 或 4KB,而 PostgreSQL 的页是 8KB。若在写页过程中断电,磁盘上可能留下「一半新一半旧」的撕裂页(torn page),其 CRC 无法校验通过。

解决方式是:每次检查点之后,某页的第一次修改记录里附带整页的镜像(backup image)。

SHOW full_page_writes;   -- 默认 on
SHOW wal_compression;    -- 可设为 on/lz4/zstd 压缩全页镜像

代价是 WAL 体积显著增大——检查点后每个被改动的页都会产生一次 8KB 的全页写。wal_compression 能缓解,但压缩与解压消耗 CPU。

-- 估算全页写占 WAL 的比例(PG13+ 可用统计视图)
SELECT * FROM pg_stat_wal;
-- 关注 wal_records / wal_fpi / wal_bytes

2.4 hint bits 与 WAL

元组头里的 t_infomask 会缓存「已提交/已回滚」判断,这些位叫 hint bits。设置 hint bits 本身是页修改,但 PostgreSQL 故意不为它写 WAL——因为它可以从 CLOG(pg_xact)重新推导,函数 SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED, xid, false) 的最后一个参数就是「不记 WAL」的开关。这避免了大量只改标志位的日志膨胀。


三、LSN 与 WAL 文件组织

3.1 LSN 的构成

LSN(Log Sequence Number)是 64 位无符号整数,表示日志中的一个字节位置,格式为 X/Y(两个十六进制 32 位数)。

SELECT pg_current_wal_lsn();          -- 当前写入位置
SELECT pg_current_wal_insert_lsn();   -- 当前插入位置
SELECT pg_current_wal_flush_lsn();    -- 当前已刷盘位置

-- LSN 之间的字节差
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/1A00000') AS bytes;

高 32 位是「逻辑日志段号」,低 32 位是段内偏移。段号乘以段大小(默认 16MB)加偏移即绝对字节位置。

3.2 段文件命名与 pg_wal 目录

ls -lh $PGDATA/pg_wal
# 000000010000000000000001
# 000000010000000000000002
# archive_status/  归档状态标记目录

文件名 24 位十六进制分三段:时间线(8 位)+ 逻辑段号(8 位)+ 段内段号(8 位)。00000001 是时间线,0000000000000001 表示第 1 个 16MB 段。

SHOW wal_segment_size;   -- 默认 16MB,initdb 时确定不可改
SELECT timeline_id FROM pg_control_checkpoint();

3.3 WAL 缓冲区

WAL 先写入共享内存中的 wal_buffers,再由 walwriter 与提交进程刷盘。wal_buffers 默认 -1,自动取 shared_buffers 的 1/32 且上限 16MB;wal_writer_delay 默认 200ms,wal_writer_flush_after 默认 1MB 写满即刷。


四、wal_level 三级差异

wal_level 决定 WAL 中记录多少信息,是复制能力的开关。

级别记录内容支持能力
minimal仅崩溃恢复所需,跳过某些批量操作日志无复制、无归档
replica增加足够信息供只读副本与 PITR流复制、归档、基础备份
logical增加表级变更信息(logical decoding)逻辑复制、CDC、逻辑解码
SHOW wal_level;   -- 默认 replica

关键差异在 minimal:CREATE TABLE AS、COPY、CLUSTER 等在 minimal 下会跳过 WAL(因为新页可以直接落盘而无需重放),这使它们更快,但一旦崩溃这些表的变更无法恢复,且副本无法重放。

-- 修改后必须重启
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 8;
ALTER SYSTEM SET max_wal_senders = 10;
-- pg_ctl restart

五、检查点与恢复起点

5.1 检查点做了什么

检查点把共享缓冲区中所有脏页刷盘,并在 pg_control 中记录一个「重做起点(redo location)」。恢复时只需从该点开始重放,而不必从头重放整个日志历史。

1. 记录检查点开始 LSN
2. 把缓冲区所有脏页刷盘
3. 等待所有进行中的事务结束(或记录它们的快照)
4. 在 pg_control 写入 redo 起点

5.2 checkpoint_timeout 与 max_wal_size

SHOW checkpoint_timeout;      -- 默认 5min,最长间隔
SHOW max_wal_size;            -- 默认 1GB,触发检查点的 WAL 量
SHOW min_wal_size;            -- 默认 80MB,WAL 回收下限
SHOW checkpoint_completion_target;  -- 默认 0.9,检查点在其间隔的 90% 内完成

max_wal_size 与 checkpoint_timeout 谁先达到谁触发。频繁检查点会让 WAL 体积膨胀(更多全页写)且 IO 抖动;检查点过疏则恢复时间变长。

-- 强制立即检查点(谨慎使用,会造成 IO 尖峰)
CHECKPOINT;
-- 查看检查点统计
SELECT * FROM pg_stat_checkpointer;
-- num_timed | num_requested | write_time | sync_time | buffers_written

5.3 pg_control 与 redo 起点

pg_control 是集群的控制文件,记录检查点位置、时间线、wal_level 等关键信息,损坏会导致集群无法启动。

SELECT checkpoint_lsn, redo_lsn, timeline_id, checkpoint_time, wal_level
FROM pg_control_checkpoint();

六、崩溃恢复流程

6.1 恢复的两个阶段

崩溃恢复由 StartupXLOG 驱动,分为两步:

1. 读取 pg_control,定位最后一个检查点与 redo 起点
2. 从 redo 起点开始,顺序读取 WAL 段并逐条重放,直到日志末尾

重放时只应用「页的 pd_lsn 小于记录 LSN」的修改,从而天然幂等——重复重放同一记录不会产生副作用。

6.2 统一 redo

9.x 之前,PostgreSQL 使用 ARIES 风格的 analysis / redo / undo 三阶段,需要撤销未提交事务。现代版本采用统一 redo:不重放未提交事务的修改,也就无需 undo 阶段。

XLOG_HEAP_INSERT 记录不写 xmax,重放时若事务未提交则其元组永不可见
XLOG_HEAP_DELETE / XLOG_HEAP_UPDATE 记录由事务状态决定是否可见
提交记录 XLOG_XACT_COMMIT 一到,之前所有修改立即生效

所以崩溃后未提交事务的元组虽物理存在,但 t_xmin 未提交,任何快照都看不到它们,后续 VACUUM 会清理。

6.3 恢复中的一致性

恢复期间 pd_lsn 是判断依据:如果某页的 pd_lsn 已不小于记录 LSN,说明该修改已落盘,跳过;否则应用。这正是「数据页 LSN 不得超前于 WAL」这条不变量的用处。可用 SELECT pg_last_wal_replay_lsn(), pg_is_in_recovery(); 在恢复中观察进度。


七、pg_waldump 读日志实测

# 列出所有资源管理器
pg_waldump --rmgr=list

# 读取当前 WAL 段(需先找到最早可读的段)
ls $PGDATA/pg_wal
pg_waldump $PGDATA/pg_wal/000000010000000000000001

# 只看 Heap 相关记录,限制 20 条
pg_waldump --rmgr=Heap -n 20 $PGDATA/pg_wal/000000010000000000000001

# 按 LSN 范围过滤
pg_waldump --start=0/1A000028 --end=0/1A001000 \
  $PGDATA/pg_wal/000000010000000000000001

典型输出形如:

rmgr: Heap        len (rec/tot):  59/   59, tx:  743, lsn: 0/1A000028,
  prev 0/1A0000F8, desc: INSERT off 5 flags 0x00, blkref #0: rel 1663/16384/24576 blk 0
rmgr: Transaction len (rec/tot):  34/   34, tx:  743, lsn: 0/1A0000F8,
  desc: COMMIT 2026-10-06 15:00:00.123456+08

fpi: N 表示这条记录包含全页镜像,blkref 说明改的是哪个关系的哪一页。用它可以精确验证某次操作是否落入了 WAL。


八、归档与持久性权衡

8.1 archive_mode 与连续归档

归档把写满的 WAL 段复制到外部存储,是 PITR(时间点恢复)的前提。

ALTER SYSTEM SET archive_mode = 'on';
ALTER SYSTEM SET archive_command = 'test ! -f /backup/wal/%f && cp %p /backup/wal/%f';
-- 需要重启
# 查看归档状态
psql -c "SELECT * FROM pg_stat_archiver;"
# archived_count | failed_count | last_archived_wal | last_failed_wal

8.2 synchronous_commit 的持久性权衡

fsync 是提交路径上最贵的操作。synchronous_commit 允许在「持久性」与「延迟」之间取舍。

取值行为崩溃后可能丢失
off不等 WAL 落盘就返回最近若干事务(通常几百 ms)
local等本地 WAL 落盘无(单机安全)
on等本地与同步副本确认无
remote_write等副本写入操作系统副本崩溃可能丢
remote_apply等副本可见无,但延迟最高
-- 单条语句级别放宽持久性
BEGIN;
SET LOCAL synchronous_commit = off;
-- 批量导入
COMMIT;

-- 会话级
SET synchronous_commit = off;

synchronous_commit = off 保证的是一致性而非持久性:崩溃后数据库仍然一致,只是最近一小段已提交事务可能丢失。对日志、埋点、批量导入这类可容忍少量丢失的场景非常有效。


常见问题(FAQ)

为什么 WAL 会突然暴涨

最常见原因是检查点太少:max_wal_size 过大或 checkpoint_timeout 过长,导致大量脏页在检查点后首次修改,每条记录都带 8KB 全页镜像。其次是长事务或未关闭的复制槽——槽位会阻止 WAL 回收,pg_replication_slots 中 active = false 的槽位是经典元凶。

崩溃恢复要多久

恢复时间约等于「从 redo 起点重放到日志末尾」的耗时,与检查点间隔内的 WAL 量正相关。要缩短恢复时间就缩短 checkpoint_timeout 或降低 max_wal_size,代价是更频繁的刷盘。可用 recovery_min_apply_delay 之外的参数(如 log_min_messages)观察恢复进度日志。

full_page_writes 能关掉吗

技术上可以,但只有在文件系统能保证 8KB 原子写(且底层存储不会撕裂)时才安全。主流文件系统与云盘都不保证这一点,关闭后一旦撕裂页出现,恢复会报 CRC 错误甚至数据损坏。除非有 ZFS 或支持原子写的企业存储,否则保持开启,用 wal_compression 缓解体积。

hint bits 不写 WAL 会不会丢

不会。hint bits 只是 CLOG 查询结果的缓存,丢失后可以从 pg_xact 重新推导,最多多花一次 CLOG 查询。这就是它被设计为「不记录 WAL」的原因——它不携带任何无法重建的信息。

复制槽导致 WAL 堆积怎么办

先用 pg_replication_slots 找出 restart_lsn 落后最多的槽位。若是废弃槽位,SELECT pg_drop_replication_slot('slot_name') 删除;若是活跃副本暂时离线,应尽快恢复副本而非删槽。可以用 max_slot_wal_keep_size 设置上限,超过后自动让槽位失效,防止磁盘被撑爆。


相关阅读

延伸阅读


完整示例(一键复制)

-- ========== 1. 观察 WAL 位置与统计 ==========
SELECT pg_current_wal_lsn()       AS write_lsn,
       pg_current_wal_insert_lsn() AS insert_lsn,
       pg_current_wal_flush_lsn()  AS flush_lsn;
SELECT * FROM pg_stat_wal;
SELECT * FROM pg_stat_checkpointer;
SELECT * FROM pg_stat_archiver;

-- ========== 2. 关键参数确认 ==========
SHOW wal_level;
SHOW wal_segment_size;
SHOW wal_buffers;
SHOW full_page_writes;
SHOW wal_compression;
SHOW synchronous_commit;

-- ========== 3. 复制槽是否阻塞 WAL 回收 ==========
SELECT slot_name, active, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots ORDER BY restart_lsn;
# ========== 4. pg_waldump 读取日志 ==========
pg_waldump --rmgr=list
pg_waldump --rmgr=Heap -n 20 $PGDATA/pg_wal/000000010000000000000001
pg_waldump --start=0/1A000028 --end=0/1A001000 $PGDATA/pg_wal/000000010000000000000001
-- ========== 5. 检查点与恢复起点 ==========
SELECT checkpoint_lsn, redo_lsn, timeline_id, checkpoint_time, wal_level
FROM pg_control_checkpoint();
CHECKPOINT;                       -- 手动触发(会造成 IO 尖峰)
SELECT pg_is_in_recovery(), pg_last_wal_replay_lsn();

-- ========== 6. 按场景调整持久性 ==========
-- 批量导入放宽持久性
SET synchronous_commit = off;
-- 复制与归档
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 8;
ALTER SYSTEM SET archive_mode = 'on';
ALTER SYSTEM SET archive_command = 'test ! -f /backup/wal/%f && cp %p /backup/wal/%f';
-- 之后重启集群使 postmaster 级参数生效

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询