PostgreSQL 存储引擎内幕(下):WAL、Checkpoint 与持久性

Saint Jerome in His Study, Albrecht Dürer, 1514
Albrecht Dürer,《书房中的圣哲罗姆》, 1514 — 哲罗姆伏案翻译圣经,每一个字都被忠实地记录下来,墙上的沙漏标记着时间的流逝。WAL 的本质与此相同:在任何改变发生之前,先把它写入日志。这份记录是崩溃恢复的基础,也是流复制的源头
PostgreSQL 存储引擎内幕
上:Heap Page 与 Buffer Pool · 中:索引扫描与 MVCC · 下:WAL、Checkpoint 与持久性
这篇文章的价值:数据库承诺你"事务提交后数据不会丢"。这个承诺是怎么实现的?答案是 WAL(Write-Ahead Log)——在修改数据页之前,先把变更记录写入日志。这篇文章追踪一条 INSERT 语句从产生 WAL 记录、到 WAL Writer 刷盘、到 Checkpoint 创建恢复点的完整路径,并解释这套机制如何同时支撑崩溃恢复和流复制。

WAL 的核心思想

Write-Ahead Logging 的规则只有一条:在修改数据页之前,必须先把描述这次修改的日志记录写入持久存储

为什么?因为把修改后的数据页写入磁盘的代价很高(随机 I/O,8KB 一次),而把日志记录追加到文件末尾很便宜(顺序 I/O,通常几十到几百字节)。WAL 把随机写转换为顺序写,同时保证了持久性。

  没有 WAL 的世界(直接写数据页):

  事务提交 → 必须把所有修改的 Page 刷盘 → 随机 I/O → 慢
  崩溃 → 部分 Page 写了一半 → 数据损坏 → 无法恢复

  有 WAL 的世界:

  事务提交 → 只需把 WAL 记录刷盘 → 顺序 I/O → 快
  数据页可以稍后慢慢写(异步)
  崩溃 → 重放 WAL 日志 → 数据恢复到一致状态

XLogRecord:WAL 记录的结构

每个数据库写操作(INSERT、UPDATE、DELETE、以及各种内部操作)都会产生一个或多个 XLogRecord

typedef struct XLogRecord {
    uint32      xl_tot_len;   // 整条记录的总长度
    TransactionId xl_xid;     // 产生这条记录的事务 ID
    XLogRecPtr  xl_prev;      // 前一条 WAL 记录的位置(形成链表)
    uint8       xl_info;      // 操作类型(如 XLOG_HEAP_INSERT)
    RmgrId      xl_rmid;      // 资源管理器 ID(哪个子系统产生的)
    pg_crc32c   xl_crc;       // CRC 校验
} XLogRecord;

一条 INSERT 产生的 WAL 记录大概长这样:

XLogRecord {
    xl_tot_len = 156,              // 整条记录 156 字节
    xl_xid = 12345,                // 事务 ID
    xl_prev = 0/1A2B3C4D,          // 前一条记录的 LSN
    xl_info = XLOG_HEAP_INSERT,    // 堆插入操作
    xl_rmid = RM_HEAP_ID,          // 堆表资源管理器
    xl_crc = 0x1A2B3C4D            // CRC 校验
}
// 后面跟着:被修改的 Page 的 block reference + 实际插入的数据

LSN:WAL 的时间线

LSN(Log Sequence Number)是 WAL 中每条记录的全局唯一位置标识,格式为 段号/段内偏移(如 0/1A2B3C4D)。它是 PostgreSQL 中"时间"的另一种表示:

资源管理器:谁产生了这条日志

不同的子系统产生不同类型的 WAL 记录,每个子系统注册一个"资源管理器"(Resource Manager),提供自己的重做和解码函数:

RmgrId子系统操作类型举例
RM_HEAP_ID堆表INSERT、DELETE、UPDATE、HOT_UPDATE
RM_BTREE_IDB-Tree 索引索引页分裂、插入索引条目
RM_XACT_ID事务管理COMMIT、ABORT
RM_CLOG_ID事务提交日志CLOG 页面更新
RM_XLOG_IDWAL 自身Checkpoint 记录

恢复时,对每条 WAL 记录调用对应资源管理器的 rm_redo 函数。逻辑复制时,调用 rm_decode 函数将物理变更转换为逻辑变更(如 DecodeInsertXLOG_HEAP_INSERT 解析成可理解的行变更事件)。

WAL 的写入路径:从内存到磁盘

WAL 缓冲区:共享内存中的日志暂存区

  Backend Process A          Backend Process B          WAL Writer
  (INSERT INTO ...)          (UPDATE ...)              (后台进程)
        │                         │                        │
        │ XLogInsert()            │ XLogInsert()           │
        ▼                         ▼                        │
  ┌───────────────────────────────────────────────────┐    │
  │              XLogCtl (共享内存)                     │    │
  │                                                    │    │
  │  WAL 缓冲区 (wal_buffers 参数,默认 16MB)          │    │
  │  ┌────────┬────────┬────────┬────────┬────────┐   │    │
  │  │ Page 0 │ Page 1 │ Page 2 │ Page 3 │  ...   │   │    │
  │  │ 8KB    │ 8KB    │ 8KB    │ 8KB    │        │   │    │
  │  └────────┴────────┴────────┴────────┴────────┘   │    │
  │                                                    │    │
  │  控制信息:                                         │    │
  │  - LogwrtRqst: 请求写入到的位置                     │    │
  │  - LogwrtResult: 已经写入到的位置                   │    │
  │  - xlblocks[]: 每页的结束 LSN                       │    │
  │  - walwriter_latch: 唤醒 WAL Writer                │    │
  └─────────────────────────────────┬─────────────────┘    │
                                    │                       │
                                    │  XLogWrite()          │
                                    ▼                       │
                          ┌──────────────────┐             │
                          │  WAL 文件 (磁盘)  │ ◄───────────┘
                          │  每个 16MB        │  XLogBackgroundFlush()
                          └──────────────────┘

两个关键的进程间通信机制:

Write vs Flush:两阶段写入

WAL 写入磁盘分为两个阶段,理解这个区别至关重要:

阶段操作数据到达性能持久性
Write内存 → OS 文件缓存操作系统内核缓冲区快(内存拷贝)断电会丢
FlushOS 文件缓存 → 磁盘持久存储慢(真实 I/O)断电不丢

事务 COMMIT 时必须确保 WAL Flush 完成——这是持久性保证的关键。synchronous_commit 参数控制这个行为:

WAL Writer:后台刷盘进程

WAL Writer 是 child_process_kinds 表中的 B_WAL_WRITER,入口函数 WalWriterMain

  WalWriterMain 主循环:

  ┌────────────────────────────────────────┐
  │  设置信号处理                           │
  │  SIGHUP → 重新加载配置                  │
  │  SIGTERM → 优雅退出                     │
  └───────────────┬────────────────────────┘
                  │
                  ▼
          ┌───────────────┐
          │  WaitLatch()  │ ← 等待唤醒或超时
          │  (休眠)        │
          └───────┬───────┘
                  │ 被唤醒 / 超时
                  ▼
          ┌───────────────────────┐
          │ XLogBackgroundFlush() │
          │                       │
          │ 1. 获取需要 write 的   │
          │    WAL 位置            │
          │ 2. 申请独占锁          │
          │ 3. 等待并发的 WAL      │
          │    插入完成             │
          │    (多个 Backend 可能   │
          │     同时在写 WAL)       │
          │ 4. XLogWrite() 执行    │
          │    实际的磁盘写入       │
          │ 5. 根据策略决定是否     │
          │    fsync                │
          └───────┬───────────────┘
                  │
                  ▼
          ┌───────────────┐
          │  回到 WaitLatch│
          └───────────────┘

XLogWrite:真正的磁盘写入

XLogWrite 是最底层的写入函数。它的核心逻辑:

// XLogWrite 的核心写入循环(简化)
do {
    written = pg_pwrite(openLogFile, from, nleft, startoffset);
    if (written <= 0) {
        if (errno == EINTR) continue;  // 被信号中断,重试
        ereport(PANIC, ...);           // 写入失败 → PANIC,数据库停止
    }
    nleft -= written;
    from += written;
    startoffset += written;
} while (nleft > 0);

注意写入失败时是 PANIC——WAL 写入失败意味着持久性保证被破坏,数据库必须立即停止并等待人工干预。

WAL 文件的组织

  $PGDATA/pg_wal/

  ┌──────────────────────────────────────────────────────────┐
  │ 000000010000000000000001  (16MB)                          │
  │ ┌──────────┬──────────┬──────────┬─────┬──────────┐      │
  │ │ WAL Page │ WAL Page │ WAL Page │ ... │ WAL Page │      │
  │ │  0 (8KB) │  1 (8KB) │  2 (8KB) │     │ 2047     │      │
  │ │ ┌──────┐ │          │          │     │          │      │
  │ │ │Record│ │          │          │     │          │      │
  │ │ │Record│ │          │          │     │          │      │
  │ │ │Record│ │          │          │     │          │      │
  │ │ └──────┘ │          │          │     │          │      │
  │ └──────────┴──────────┴──────────┴─────┴──────────┘      │
  ├──────────────────────────────────────────────────────────┤
  │ 000000010000000000000002  (16MB)                          │
  ├──────────────────────────────────────────────────────────┤
  │ 000000010000000000000003  (16MB)                          │
  └──────────────────────────────────────────────────────────┘

  2048 个 8KB Page = 16MB = 一个 WAL Segment

  文件名编码: {Timeline}{SegmentHigh}{SegmentLow}
  例: 00000001 00000000 00000001 = Timeline 1, Segment 1

  生命周期:
  1. 创建新文件
  2. 写满 16MB → 切换到下一个
  3. Checkpoint 后 → 旧文件可以回收
  4. 回收 = 重命名复用(避免 create/delete 开销)

Checkpoint:一致性恢复点

如果没有 Checkpoint,崩溃恢复需要从第一条 WAL 记录开始重放——随着运行时间增长,恢复时间无限增长。Checkpoint 解决这个问题:定期把所有脏页刷盘,创建一个"一致性点",恢复时只需从最近的 Checkpoint 开始重放。

  时间 ──────────────────────────────────────────────────────►

  WAL: ═══════════════════════════════════════════════════════
       │              │                    │          │
    Checkpoint 1   Checkpoint 2         Checkpoint 3  崩溃!
       │              │                    │          │
       │              │                    │◄────────►│
       │              │                    恢复只需重放
       │              │                    这一段 WAL
       │              │
       │              过了 Checkpoint 2 以后
       │              Checkpoint 1 之前的 WAL 可以回收

Checkpoint 的入口是 CheckpointerMainB_CHECKPOINTER 进程),触发时机:

Checkpoint 的执行步骤

  CreateCheckPoint:

  ┌─────────────────────────────────┐
  │ 1. 获取当前 WAL 插入位置 (redo LSN) │
  │    "从这个点开始的 WAL 需要重放"      │
  └──────────────┬──────────────────┘
                 ▼
  ┌─────────────────────────────────┐
  │ 2. 刷新所有脏缓冲区到磁盘        │
  │    遍历 Buffer Pool 中每个 dirty  │
  │    buffer,调用 fsync            │
  │    (这是最耗时的步骤)           │
  └──────────────┬──────────────────┘
                 ▼
  ┌─────────────────────────────────┐
  │ 3. 刷新 WAL 到磁盘               │
  │    确保所有 WAL 记录已持久化      │
  └──────────────┬──────────────────┘
                 ▼
  ┌─────────────────────────────────┐
  │ 4. 更新控制文件                   │
  │    $PGDATA/global/pg_control     │
  │    写入 Checkpoint 位置信息       │
  └──────────────┬──────────────────┘
                 ▼
  ┌─────────────────────────────────┐
  │ 5. 回收旧 WAL 文件               │
  │    Checkpoint 之前的 WAL 不再     │
  │    需要用于恢复 → 可以重命名复用  │
  └─────────────────────────────────┘

关键约束:Checkpoint 期间脏页的刷盘是渐进式的(checkpoint_completion_target 参数),避免瞬间产生大量 I/O 影响正常查询。默认设置是在两次 Checkpoint 间隔的 90% 时间内均匀地完成刷盘。

崩溃恢复:重放 WAL

数据库崩溃后重启,StartupProcessMainB_STARTUP 进程)负责恢复:

// 崩溃恢复的核心逻辑(伪代码)
checkpoint = ReadControlFile();       // 读 pg_control
lsn = checkpoint.redo;                // 恢复起始点

while (record = ReadWALRecord(lsn)) {
    page = GetPage(record.target);    // 找到目标 Page

    if (page.pd_lsn >= record.lsn)
        continue;                     // 已经应用过,跳过

    rmgr = GetRmgr(record.rmid);
    rmgr.rm_redo(record);             // 重放这条 WAL
    lsn = NextRecordLSN(record);
}
// 恢复完成

流复制:WAL 的另一个消费者

WAL 不只用于崩溃恢复——它也是流复制的基础。B_WAL_SENDER 进程负责将 WAL 实时发送给 standby:

  Primary                                    Standby
  ┌──────────────────────┐                  ┌──────────────────────┐
  │                      │                  │                      │
  │  Backend Process     │                  │  WAL Receiver        │
  │  INSERT/UPDATE/...   │                  │  (B_WAL_RECEIVER)    │
  │       │              │                  │       │              │
  │       ▼              │                  │       ▼              │
  │  WAL Buffer          │   流复制协议      │  写入 WAL 文件       │
  │       │              │ ◄──────────────► │       │              │
  │       ▼              │                  │       ▼              │
  │  WAL Sender          │                  │  Startup Process     │
  │  (B_WAL_SENDER)      │                  │  持续重放 WAL        │
  │                      │                  │       │              │
  │  XLogSendPhysical()  │ ─── WAL 数据 ──► │       ▼              │
  │  或                   │                  │  数据与 Primary 一致 │
  │  XLogSendLogical()   │                  │                      │
  └──────────────────────┘                  └──────────────────────┘

物理复制 vs 逻辑复制

物理复制逻辑复制
发送什么原始 WAL 字节流解码后的逻辑变更(INSERT row X)
发送函数XLogSendPhysicalXLogSendLogical
Standby 要求完全相同的 PG 版本和平台可以不同版本,甚至不同数据库
粒度整个集群表级别,可选择性复制
用途高可用、读副本数据集成、跨版本迁移

逻辑复制的解码过程:XLogSendLogicalXLogReadRecord 读取 WAL 记录 → LogicalDecodingProcessRecord 调用对应资源管理器的 rm_decode 函数。例如 DecodeInsertXLOG_HEAP_INSERT 类型的 WAL 记录解析成逻辑复制可以理解的行变更事件。

Replication Slot 与 LSN

Replication Slot 跟踪每个订阅者的消费进度:

如果 standby 断线太久,restart_lsn 会阻止 WAL 回收,导致 primary 的磁盘被占满。这是生产环境中常见的故障场景。

Timeline:数据库的分支历史

Timeline 是 PG 处理数据库"分支"的机制。每个 Timeline 有唯一的数字 ID,在以下场景创建新 Timeline:

  Timeline 1: ════════════════════════╗
                                      ║ Standby 提升
  Timeline 2:                         ╚═══════════════════
                                            │
                                    00000002.history 文件
                                    记录 TL2 从 TL1 的哪个 LSN 分支

Timeline 历史文件(如 00000002.history)记录分支点,帮助恢复过程正确选择 WAL 文件。

PG 进程全景

最后,把本系列涉及的所有后台进程汇总:

  postmaster (主进程)
  │
  ├── Backend (B_BACKEND)           处理客户端 SQL 查询
  │   └── WAL Sender (B_WAL_SENDER) 复制连接升级后变成
  │
  ├── Background Writer (B_BG_WRITER)  定期刷脏页,减轻 Checkpoint 压力
  ├── Checkpointer (B_CHECKPOINTER)    定期执行 Checkpoint
  ├── WAL Writer (B_WAL_WRITER)        定期刷 WAL 缓冲区到磁盘
  ├── Autovacuum Launcher              调度 VACUUM 工作
  │   └── Autovacuum Worker            执行具体的 VACUUM
  ├── WAL Receiver (B_WAL_RECEIVER)    Standby 上接收 WAL
  ├── WAL Summarizer                   WAL 摘要(增量备份用)
  ├── Startup (B_STARTUP)              崩溃恢复 / Standby 持续重放
  ├── Archiver (B_ARCHIVER)            归档旧 WAL 文件
  ├── IO Worker (B_IO_WORKER)          异步 I/O 工作线程
  └── Syslogger (B_LOGGER)            日志收集

PG 是多进程架构,每个后台进程独立运行,通过共享内存和 Latch 通信。优势是隔离性好(一个进程崩溃不会拖垮其他进程)、调试友好;代价是进程间通信比线程间通信更重。

总结

几个值得记住的关键直觉:

至此,PostgreSQL 存储引擎三部曲完成。从 Heap Page 的 8KB 内部布局,到 Buffer Pool 的页面缓存管理,到索引扫描和 MVCC 的可见性判断,再到 WAL 的持久性保证和 Checkpoint 的恢复机制——这些层层嵌套的抽象,共同构成了一个关系型数据库最核心的骨架。