土法炼钢兴趣小组的算法知识备份

【PG 内核】WAL 内部机制:从事务提交到磁盘刷写

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#wal#xloginsert#checkpoint#redo#recovery#wal-writer#wal-level#pg-waldump#xlogrecord#checkpoint-completion-target

目录

WAL 内部机制:从事务提交到磁盘刷写

执行 INSERT INTO t VALUES (1, 'hello') 之后,PG 在返回 “INSERT 0 1” 之前做了什么?不是直接把数据写到 t 的 heap 文件里。在数据页落盘之前,PG 先把修改记录写到 WAL(Write-Ahead Log)——只有 WAL 成功刷盘之后,数据页才能安全地写入磁盘。这是崩溃恢复的基础:最坏情况下,PG 丢失所有未刷盘的数据页,但只要 WAL 完整,就能通过 REDO 恢复所有已提交的事务。

但 WAL 不只是”先写日志再写数据”八个字那么简单。一条 INSERT 从调用 XLogInsert() 到 WAL segment 文件落盘,中间经过了 WALInsertLock 分段锁、WAL Buffer 的双活页机制、WalWriter 的异步刷盘以及 wal_sync_method 决定的 OS 同步策略。Checkpoint 也不是”定时写脏页”——它分两个阶段:先确定 REDO 起始点,再写 checkpoint record,而这中间的 IO 摊平算法 checkpoint_completion_target 藏着一个多数人不理解的陷阱。

本文以 PG 17 源码为准,拆解 WAL 的五个核心机制:插入路径、record 物理布局、写入链路、checkpoint 流程和 REDO 恢复。后半部分聚焦生产环境中三个最常见的 WAL 问题:checkpoint IO 风暴、WAL 段疯狂切换,以及 pg_waldump 的实操定位。


一、WAL 的设计约束:为什么不能直接写数据页

如果 PG 每次 COMMIT 都把修改过的数据页刷盘,至少有三个致命问题:

1. 随机写放大。 一个事务可能修改多个页面(heap 页 + 索引页),这些页面分散在文件的不同位置。把每个修改的页都 fsync 一次等于多次随机 I/O。

2. 页面撕裂。 OS 和磁盘的原子写单位是 sector(512B 或 4KB),而 PG 的页面是 8KB。如果系统在写页面中途崩溃,磁盘上留下的是半新半旧的页面——checksum 会对不上,但旧数据也已经不可逆地损坏了。

3. 无法支持复制和 PITR。 没有变更日志,standby 无法重放主库的操作,PITR(Point-in-Time Recovery)也无法做到”恢复到下午 3:12:00 的状态”。

WAL 的解决方案是:所有修改只追加到一个顺序文件中,每个 record 自包含(有 checksum),可以按顺序逐个重放。关键性质:


二、WAL Record 的物理布局

XLogRecord 头部

每个 WAL record 以 XLogRecord 结构开头。PG 17 的定义在 src/include/access/xlogrecord.h

// src/include/access/xlogrecord.h
typedef struct XLogRecord {
    uint32      xl_tot_len;     // 整个 record 的总长度(包括 header)
    TransactionId xl_xid;       // 产生此 record 的事务 ID
    XLogRecPtr  xl_prev;        // 上一个 record 的起始 LSN(形成链)
    uint8       xl_info;        // flag bits(XLR_SPECIAL_REL_UPDATE 等)
    RmgrId      xl_rmid;        // 资源管理器 ID(RM_HEAP, RM_BTREE, RM_XLOG 等)
    pg_crc32c   xl_crc;         // CRC-32C 校验和(覆盖整个 record)
    // 后面紧跟着:
    //   XLogRecordBlockHeader(可能有多个,每个 block 一个)
    //   XLogRecordDataHeader[Short|Long](main data)
} XLogRecord;
```text

`xl_rmid` 是 RMGR ID,决定恢复时由哪个资源管理器处理这个 record:

```c
// src/include/access/xlog_internal.h
#define RM_XLOG_ID          0
#define RM_XACT_ID          1
#define RM_SMGR_ID          2
#define RM_CLOG_ID          3
#define RM_DBASE_ID         4
// ...
#define RM_HEAP_ID          10
#define RM_BTREE_ID         12
#define RM_GIN_ID           15

Block Header:页级操作的描述

如果 WAL record 涉及对某些页面的修改,XLogRecord 之后会跟一个或多个 XLogRecordBlockHeader。每个 block header 描述对 一个页面 的修改:

// src/include/access/xlogrecord.h
typedef struct XLogRecordBlockHeader {
    uint8       block_id;       // 此 block 在 record 中的编号(0-31)
    uint8       fork_flags;     // fork 类型(MAIN_FORKNUM 等)+ flags
    uint16      data_length;    // 紧跟在后面的 block data 的长度
    // 接着是:
    //   BlockIdData  (RelFileNode + BlockNumber) 或 XLogRecordBlockCompressHeader
    //   block data(整页镜像或 diff 数据)
} XLogRecordBlockHeader;
```text

Block 的映像有三种注册方式(通过 `fork_flags` 的高位 bit 区分):

| 注册方式 | fork_flags 标志 | 含义 | 恢复时如何使用 |
|---------|----------------|------|--------------|
| `REG_BUF_STANDARD` | `BKPBLOCK_HAS_IMAGE` | 记录整页镜像(FPW, Full Page Write) | 直接覆盖目标页 |
| `REG_BUF_STANDARD` | 无 image flag | 只记录操作描述(如"在 offset 插入这个 tuple"| 在目标页上重放操作 |
| `REG_BUF_STANDARD` | `BKPBLOCK_HAS_IMAGE \| BKPBLOCK_COMPRESSED` | 整页压缩镜像 | 解压后覆盖目标页 |

**FPW(Full Page Write)的意义**:checkpoint 之后每个页面第一次被修改时,PG 把整页镜像写入 WAL。这听起来浪费,但它是防止页面撕裂的关键。如果不用 FPW,恢复时 REDO 操作必须假设目标页是 checkpoint 时的旧版本——但一次中途崩溃可能让页面处于"旧 header + 新数据"的撕裂状态。FPW 绕过了这个问题:直接用整页镜像覆盖,确保页面的完整性和自一致性。

FPW 的数据量是 WAL 写入量的主要来源。一个 UPDATE 只改了几十个字节,但如果涉及的页面是 checkpoint 后的首次修改,FPW 会把整个 8KB 页面写进 WAL。这也解释了为什么 checkpoint 之后 WAL 写入会出现一个高峰。

### 一个 INSERT 的 WAL Record 示例

执行 `INSERT INTO t VALUES (1, 'hello')` 并提交,产生的 WAL record 链:

Record 1: RM_HEAP, xl_info=XLOG_HEAP_INSERT Block 0 (heap page): 操作描述(offset=N, tuple data) Data: xl_heap_insert structure

Record 2: RM_XACT, xl_info=XLOG_XACT_COMMIT Data: xl_xact_commit structure (xid, timestamp, rels…)


如果 heap 页是 checkpoint 之后首次修改,Record 1 的 Block 0 会包含完整的 8KB FPW 镜像。

---

## 三、XLogInsert 的完整路径

`XLogInsert()` 是 WAL 写入的外部入口(RMGR 调用它),内部通过 `XLogInsertRecord()` 完成实际插入。调用链:

XLogInsert() // src/backend/access/transam/xloginsert.c → XLogInsertRecord() // 组装 XLogRecord, 插入 WAL Buffer → ReserveXLogInsertLocation() // 获取 WAL 中的插入位置 (WALInsertLock) → CopyXLogRecordToWAL() // 逐 block 复制到 WAL Buffer 页 → WALInsertLockRelease() // 释放分段锁


### 3.1 WALInsertLock:分段锁的并发设计

多个 Backend 同时产生 WAL record 时,如果用一个全局锁保护 WAL Buffer 的写入位置,锁竞争会成为瓶颈。PG 的策略是把 WAL 插入位置分成多个 **WALInsertLock** 插槽:

```c
// src/backend/access/transam/xloginsert.c
// PG 17 默认 WALInsertLock 数量 (xlog.h)
#define NUM_XLOGINSERT_LOCKS  8

// 每个 Backend 在插入 WAL 时:
// 1. 持有一个 WALInsertLock(通过 MyProc->lockGroupLeader 或 hash(ProcNumber) 选择)
// 2. 在自己的 slot 中预留空间
// 3. 复制数据到 WAL Buffer

分段锁的关键性质:预留空间时无需等待其他 Backend 完成拷贝。每个 Backend 在自己的 slot 中预留 LSN 区间,然后独立拷贝数据。WAL Writer 在刷盘时按 LSN 顺序写入——它不管各 slot 的拷贝是否完成,只等”WAL flush position 之前的区间全部就位”。

3.2 ReserveXLogInsertLocation:预留 WAL 空间

// src/backend/access/transam/xloginsert.c
// ReserveXLogInsertLocation() 的核心逻辑(简化)
static XLogRecPtr
ReserveXLogInsertLocation(int size)
{
    // 1. 检查 WAL Writer 是否已刷到安全位置,如果 WAL Buffer 满了则自旋等待
    do {
        if (WAL buffer 空间不足) {
            WALInsertLockRelease();
            WaitXLogInsertionsToFinish(RequiredFree);
            WALInsertLockAcquire();
        }

        // 2. 为当前 record 预留空间
        StartPos = XLogBytePosToRecPtr(CurrPos);
        CurrPos += size;

        // 3. 如果跨越 WAL segment 边界
        if (CurrPos / XLOG_SEG_SIZE != PrePos / XLOG_SEG_SIZE) {
            AdvanceXLInsertBuffer(CurrPos);  // 分配新 WAL buffer
        }
    } while (false);

    return StartPos;
}
```text

预留在 WAL Buffer 中的空间是 **Lock 区域的私人空间**,其他 Backend 不能碰,但 WAL Writer 可以安全刷盘——因为它只刷"所有 Lock 都已完成的连续字节"

### 3.3 CopyXLogRecordToWAL:组装并拷贝

```c
// src/backend/access/transam/xloginsert.c
// 拷贝过程(简化)
static void
CopyXLogRecordToWAL(int write_len, XLogRecPtr StartPos)
{
    // 1. 组装 XLogRecord header(填充 xl_prev, xl_rmid, xl_xid 等)
    XLogRecord *rechdr = &hdr_rdt;
    rechdr->xl_tot_len = write_len;
    rechdr->xl_prev = ProcLastRecPtr;
    // ...

    // 2. 计算 CRC(覆盖除 xl_crc 字段外的整个 record)
    INIT_CRC32C(rechdr->xl_crc);
    COMP_CRC32C(rechdr->xl_crc, ...);

    // 3. 按字节拷贝到 WAL Buffer
    memcpy(wal_buffer + offset, rec_data, rec_len);

    // 4. 更新 WAL 插入位置(原子写)
    pg_atomic_write_u64(&WALInsertLocks[l].insertingAt, CurrPos);
}

完成拷贝后,这条 WAL record 就在 WAL Buffer 中了。接下来由 WAL Writer 负责异步刷盘(或者同步提交时 Backend 自己 fsync)。


四、WAL 写入路径:Buffer → WAL Segment 文件

4.1 WAL Writer 的职责

WAL Writer 进程(postgres: walwriter)在 src/backend/postmaster/walwriter.c 中启动,入口是 WalWriterMain()

// src/backend/postmaster/walwriter.c, WalWriterMain() 核心逻辑(简化)
for (;;) {
    // 1. 等待 WAL flush position 落后于 insert position(有数据需要刷)
    WaitXLogActivity(WAL_WRITER_FLUSH_AFTER, ...);

    // 2. 把 WAL Buffer 中已完成的 WAL record 写入 OS page cache
    XLogBackgroundFlush();

    // 3. 如果累积了足够的数据或时间,调用 issue_xlog_fsync()
    if (足够条件) {
        issue_xlog_fsync(openLogFile, openLogSegNo);
    }
}
```text

注意 WAL Writer 做的是**异步刷盘**。它把 WAL 数据从 WAL Buffer 写到 OS page cache,然后定期 fsync。对于普通事务,`COMMIT` 后的数据不保证立即落盘——这取决于 `synchronous_commit` 和 `wal_sync_method` 的设置。

### 4.2 同步提交时 Backend 直接 fsync

当 `synchronous_commit = on`(默认)时,`COMMIT` 的调用路径是:

CommitTransaction() → RecordTransactionCommit() → XLogFlush(XactLastRecEnd) // 阻塞等待 WAL 刷盘到此 LSN


`XLogFlush()` 的逻辑是:

```c
// src/backend/access/transam/xlog.c
void XLogFlush(XLogRecPtr record) {
    if (record <= LogwrtResult.Flush)
        return;  // 已经刷盘了

    // 1. 如果 WAL Buffer 中还有未写入 OS page cache 的数据,先 write()
    XLogWrite(LogwrtResult.Write, record, ...);

    // 2. fsync / fdatasync 确保落盘
    issue_xlog_fsync(openLogFile, openLogSegNo);

    // 3. 更新共享内存中的 flush position
    LogwrtResult.Flush = record;
}

4.3 wal_sync_method 的语义

wal_sync_method 控制 issue_xlog_fsync() 使用哪个系统调用来刷盘:

系统调用 语义
fsync(默认 Linux) fsync() 刷数据 + 元数据(文件大小不会变就不用刷元数据)
fdatasync fdatasync() 只刷数据,不刷元数据(文件大小变化时仍刷元数据)
open_datasync open(O_DSYNC) 每次 write() 自带同步语义
open_sync open(O_SYNC) 每次 write() 自带同步语义(+ 元数据)

在 Linux 上,fsyncfdatasync 的性能差异很小。open_datasync 把同步负担交给每次 write(),在特定文件系统(如 ext4 + data=ordered)上可以减少一次显式的 fsync 调用,但实测差异通常不如 direct I/O 和 wal_block_size 的影响大。

PG 15 引入了实验性支持:wal_sync_method = 'recovery_init_sync_method' 设置允许直接使用 O_DIRECT | O_DSYNC 组合——数据绕过 OS page cache 直接写入磁盘,消除了 double buffering,但需要存储支持 512B 对齐写入。


五、Checkpoint 的两阶段流程

5.1 触发条件

Checkpoint 由 Checkpointer 进程(postgres: checkpointer)执行,触发条件有三个:

触发条件 GUC 参数 默认值
时间到了 checkpoint_timeout 5 min
WAL 段数超限 max_wal_size 1 GB
手动 CHECKPOINT 命令 - -
PG 关闭时 - -

三个触发条件中,任意一个满足就触发一次 checkpoint。

5.2 CreateCheckPoint 的两阶段

CreateCheckPoint()src/backend/access/transam/xlog.c 中定义。它的核心流程分为两个阶段:

Phase 1:确定 REDO point

// src/backend/access/transam/xlog.c, CreateCheckPoint() Phase 1(简化)
// 1. 获取当前 WAL 插入位置作为 checkpoint 结束 LSN
checkPoint.redo = GetRedoRecPtr();  // 获取最老的脏页 LSN
                                    // 即 REDO 必须从这个 LSN 开始

// 2. 让共享内存中的 checkpoint 信息对所有进程可见
SpinLockAcquire(&XLogCtl->info_lck);
XLogCtl->RedoRecPtr = checkPoint.redo;
SpinLockRelease(&XLogCtl->info_lck);
```text

`GetRedoRecPtr()` 的内部逻辑是扫描所有 buffer,找到最老的脏页 LSN。因为脏页在系统崩溃时会丢失,而 WAL 从该 LSN 开始需要重放才能恢复所有已暂存(committed 但未刷盘)的数据。REDO point 越老,下次崩溃恢复需要重放的 WAL 越少——因为更多的脏页已被刷盘,对应的 WAL 段可以被回收。

**Phase 2:刷脏页并写 checkpoint record**

```c
// src/backend/access/transam/xlog.c, CreateCheckPoint() Phase 2(简化)
// 1. 刷所有脏页到磁盘
CheckPointGuts(checkPoint.redo, flags);
//   → CheckPointBuffers()         // 刷 shared_buffers 脏页
//   → CheckPointSnapBuilding()    // 刷 snap 构建相关的状态
//   → CheckPointReplicationSlots()// 刷 replication slot 状态
//   → CheckPointSnapBuild()       // 刷逻辑解码 snapshot builder 状态
//   → CheckPointTwoPhase()        // 刷 2PC 状态文件

// 2. 写 checkpoint WAL record
XLogBeginInsert();
XLogRegisterData(&checkPoint, sizeof(checkPoint));
recptr = XLogInsert(RM_XLOG_ID, XLOG_CHECKPOINT_SHUTDOWN);

// 3. 更新 pg_control 文件
UpdateControlFile();

Phase 2 的核心是 CheckPointGuts():把 shared_buffers 中所有脏页刷盘。刷完之后,REDO point 之前的 WAL 段理论上可以回收(因为所有脏页已经安全落盘)。然后写一条 WAL record(XLOG_CHECKPOINT_SHUTDOWNXLOG_CHECKPOINT_ONLINE)标记 checkpoint 完成,并更新 pg_control 文件中的 checkpoint 信息。

pg_control 文件$PGDATA/global/pg_control)是崩溃恢复的入口。它记录最新 checkpoint 的 LSN、REDO point 的 LSN、timeline、状态(DB_IN_PRODUCTION / DB_IN_ARCHIVE_RECOVERY 等),以及其他全局元数据。pg_controldata 工具可以直接查看它。

5.3 IO 摊平:checkpoint_completion_target 的内部算法

Checkpoint 的 IO 量取决于脏页数量。如果不做任何控制,checkpoint 开始后 PG 会全力刷脏页,导致磁盘 IO 瞬时高峰——这就是 checkpoint IO 风暴。

checkpoint_completion_target 控制 Checkpointer 如何把刷脏页的工作摊平到 checkpoint_timeout 时间窗口内。默认值 0.9 表示:checkpoint 的刷脏工作应该在 checkpoint 间隔的 90% 时间内均匀完成。

内部算法(在 src/backend/access/transam/xlog.cCheckpointWriteDelay() 中):

// 每刷一定数量的 buffer 后调用 CheckpointWriteDelay()
// 计算目标进度:
progress = (当前已刷脏页数) / (预期总脏页数);
elapsed = (当前时间 - checkpoint 开始时间);
target_elapsed = checkpoint_timeout * checkpoint_completion_target * progress;

// 如果刷得太快(progress 超前于 elapsed),sleep
if (elapsed < target_elapsed) {
    sleep_time = target_elapsed - elapsed;
    pg_usleep(sleep_time * 1000000L);
}
```text

核心陷阱:如果 `checkpoint_completion_target = 0.9` 看起来"接近 1,已经够激进了",但实际效果取决于 `checkpoint_timeout` 和 `max_wal_size` 谁先触发 checkpoint:

- 如果 checkpoint 由 `max_wal_size` 触发(WAL 写入速度很高),而 `checkpoint_timeout` 是 5 分钟、`completion_target` 是 0.9,那么 Checkpointer 的任务窗口是 **4.5 分钟**。如果实际脏页量需要 10 分钟才能在不打满 IO 的情况下刷完,那么 4.5 分钟窗口根本不够——Checkpointer 会卡在 sleep 和 write 之间,最终在 checkpoint 结束时仍然全力冲刺刷脏页。
- 实际情况中,如果 WAL 写入速率很高(例如 100MB/s),`max_wal_size = 1GB` 的默认值意味着约 10 秒就触发一次 checkpoint。此时 `completion_target` 根本不起作用——总时间窗口太短,无法摊平。

---

## 六、REDO 恢复:StartupXLOG

当 PG crash 后重启时,`PostmasterMain()` 调用 `StartupXLOG()` 执行 REDO 恢复。`StartupXLOG()` 是 `src/backend/access/transam/xlog.c` 中最长也最复杂的函数(PG 17 中约 2000 行)。

### 6.1 恢复流程

StartupXLOG() 1. 读取 pg_control 文件,确认数据库状态 2. 找到最新 checkpoint record → 从 pg_control 读取 checkpoint LSN → 定位到这个 WAL record → 解析 checkpoint 中的 REDO point LSN 3. 从 REDO point 开始,逐条读取 WAL record → 每读到一条 record,检查 CRC 校验和 → 对于每条 record,调用对应 RMGR 的 redo 函数: RmgrTable[record->xl_rmid].rm_redo(record) 4. 恢复结束,创建 restartpoint(如果此实例是 standby) 5. 启动 WAL Writer, Checkpointer 等辅助进程


### 6.2 RMGR 分发

每个 RMGR(Resource Manager)是一个静态结构,在 `src/include/access/rmgr.h` 中注册:

```c
// 例:Heap RMGR 的 redo 入口
// src/backend/access/heap/heapam.c
const RmgrData RmgrTable[] = {
    // ...
    [RM_HEAP_ID] = {
        .rm_name    = "Heap",
        .rm_redo    = heap_redo,
        .rm_desc    = heap_desc,
        .rm_identify = heap_identify,
    },
};

heap_redo() 根据 WAL record 的 xl_info 字段分发到具体的 redo 函数:

// src/backend/access/heap/heapam.c
void heap_redo(XLogReaderState *record) {
    uint8   info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
    switch (info & XLOG_HEAP_OPMASK) {
        case XLOG_HEAP_INSERT:
            heap_xlog_insert(record);  // 重放 INSERT
            break;
        case XLOG_HEAP_DELETE:
            heap_xlog_delete(record);  // 重放 DELETE
            break;
        case XLOG_HEAP_UPDATE:
            heap_xlog_update(record);  // 重放 UPDATE
            break;
        // ...
    }
}
```bash

### 6.3 REDO 的幂等性

恢复过程中 PG 如何保证不会重复 REDO 一个页面?每个页面上的 `pd_lsn` 字段(`PageHeaderData.pd_lsn`,8 bytes)是关键。REDO 函数在重放每个 record 之前检查:

```c
// REDO 函数中的典型逻辑
if (record_LSN <= page->pd_lsn)
    return;  // 此页面已经 REDO 过了,跳过
// ... 执行 REDO 操作 ...
page->pd_lsn = record_LSN;

这也是为什么 FPW(整页镜像)在恢复时可以直接覆盖页面而不比较 LSN——整页镜像本身就是最终的页面状态。


七、wal_level 的三级差异

wal_level 控制 WAL 中记录多少信息,直接影响复制和恢复能力。

wal_level 记录内容 WAL 量(相对) 支持能力
minimal 只记录崩溃恢复所需的最小信息 最低 崩溃恢复(无复制)
replica(默认) minimal 的所有内容 + 物理复制所需的额外信息 中等 崩溃恢复 + 流复制
logical replica 的所有内容 + 逻辑解码所需的额外信息 最高 崩溃恢复 + 流复制 + 逻辑复制/逻辑解码

三级的核心差异在 FPW 行为附加信息

wal_level = minimal:在大批量操作(COPYCREATE TABLE ASCREATE INDEX)时,如果表在创建或 bulk load 时被标记为 RELATION_IS_LOCAL 或使用了 wal_skip_threshold,PG 会跳过 WAL 写入。这不是跳过所有 WAL——正常 INSERT/UPDATE/DELETE 仍然写 WAL——而是优化了”数据本来就不在 shared_buffers 中(新页面直接被写入新的 relfilenode)“的场景。如果 crash 发生,新创建的表可能丢失,但不影响已存在的表。

wal_level = replica:强制所有操作写完整的 WAL record,包括 FPW。COPY 等大批量操作不能跳过 WAL 写入。这是流复制的基础——standby 需要完整的变更历史来保持和 primary 一致。

wal_level = logical:在 replica 的基础上,额外记录逻辑解码所需的信息:每个 UPDATE 和 DELETE 必须保留旧行的完整 image(不只是操作描述),以便 pgoutput 插件能重建行的前后状态。这导致 logical 级别的 WAL 写入量可能比 replica 高出 2-4 倍,取决于 UPDATE/DELETE 的比例。


八、运维实战

8.1 Checkpoint IO 风暴:根因与调优

症状:系统每 5 分钟出现一次 IO 写入高峰,高峰期间查询延迟显著增加,iostat 显示磁盘 util 接近 100%。

根因:checkpoint 触发后,Checkpointer 需要在固定时间窗口内刷完所有脏页。如果窗口太短或脏页太多,就会形成 IO 风暴。

排查路径

-- 1. 观察 checkpoint 触发频率
SELECT * FROM pg_stat_bgwriter;
-- 关注 checkpoints_timed(时间触发)和 checkpoints_req(WAL 触发)的比值
-- 如果 checkpoints_req >> checkpoints_timed,说明 max_wal_size 太容易触发

-- 2. 观察 checkpoint 写入量
-- buffers_checkpoint:checkpoint 刷了多少 buffer
-- buffers_clean:bgwriter 刷了多少 buffer
-- 如果 buffers_checkpoint >> buffers_clean,说明 bgwriter 没分担足够的脏页刷盘

-- 3. 看实际的 WAL 写入速率
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') / 1024 / 1024 AS wal_mb;
-- 连续两次执行,除以时间差,得到 WAL 写入速率(MB/s)

-- 4. 确认 checkpoint 间隔
SELECT * FROM pg_control_checkpoint();
-- 对比两次查询的 checkpoint_time 字段的间隔
```text

**调优策略**

1. **增大 `max_wal_size`**:如果 `checkpoints_req`(WAL 触发)过多,说明 WAL 段积累太快。把 `max_wal_size` 从 1GB 调到 8-16GB 能显著降低 checkpoint 频率。但代价是:崩溃恢复时需要重放更多的 WAL,恢复时间变长。恢复时间 ≈ WAL 量 / 恢复吞吐(SSD 上约 200-500MB/s)。16GB WAL 在 SSD 上恢复约 30-80 秒。

2. **增加 `checkpoint_timeout`**:如果脏页总量不大但 `checkpoint_timeout` 触发太快(默认 5 分钟),可以调大(如 15-30 分钟)。代价同样是更长的恢复时间。

3. **调整 `checkpoint_completion_target`**:如果 checkpoint 由 `max_wal_size` 触发,并且 WAL 写入速率接近或超过磁盘带宽的一半,`completion_target` 设为 0.9 可能根本不够。此时该调到 0.5-0.7 吗?不是——Cheap 的"摊平窗口" = `min(checkpoint_timeout, max_wal_size / WAL 写入速率) * completion_target`。如果这个窗口太小,该调的其实是 `max_wal_size`。

4. **启用 `wal_compression = zstd`**(PG 15+):压缩 FPW 镜像,减少 WAL 写入量。CPU 开销可控(zstd 压缩比和速度的平衡很好),能显著降低 checkpoint 期间的 WAL 写入压力。

5. **提高 `bgwriter` 的积极性**:bgwriter 能在 checkpoint 之前刷掉一部分脏页,减少 checkpoint 的压力。相关参数:`bgwriter_lru_maxpages`(每次最多刷多少页)、`bgwriter_delay`(多久醒来一次)。bgwriter 按 `bgwriter_lru_multiplier` 计算每次应刷的页数,公式基于最近的平均 buffer 替换需求。

### 8.2 max_wal_size 设小导致 WAL 段疯狂切换

**症状**:`pg_stat_bgwriter` 中 `checkpoints_req` 非常高(每秒多次),`pg_ls_waldir()` 显示 WAL 文件不断创建和删除,但每个文件都很小。系统 CPU sys 高(大量 `walwriter` 的 `open()`/`unlink()` 系统调用)。

**根因**:`max_wal_size` 设得过小,WAL 段积累速度远超设定值。每次 WAL 超过 `max_wal_size`,PG 触发一次 checkpoint,刷脏页后回收 WAL 段。如果 WAL 写入速率很高,这个循环在几秒内完成,然后又触发下一次 checkpoint。每次 checkpoint 带来的 FPW 写入进一步增加了 WAL 量,形成恶性循环。

```bash
# 观察 WAL 段文件大小和创建速率
ls -lh $PGDATA/pg_wal/ | head -20

# 观察 WAL segment 切换频率(PG 日志或 pg_stat_wal)
SELECT * FROM pg_stat_wal;
-- wal_segment_size 默认 16MB,所以:
-- wal_bytes / 16MB = segment 切换次数
-- 如果 wal_bytes 增长 1GB 而 max_wal_size=1GB,每 1GB 触发一次 checkpoint

修复max_wal_size 应该设为约 3-5 倍 于”正常负载下一个 checkpoint_timeout 时间窗口内的 WAL 写入量”。计算:WAL 写入速率 (MB/s) × checkpoint_timeout (s) × 3。例如 WAL 写入速率 20MB/s,checkpoint_timeout=300s,则 max_wal_size ≥ 20 × 300 × 3 = 18GB

8.3 pg_waldump:定位问题 WAL Record

pg_waldump 是 PG 自带的 WAL 解码工具,可以把 WAL segment 中的 record 转成人类可读的格式。

常用命令

# 查看当前 WAL 段中最新 10 条 record
pg_waldump -p $PGDATA/pg_wal -n 10

# 从指定 LSN 开始查看
pg_waldump -p $PGDATA/pg_wal -s 0/01000028 -n 20

# 只看特定 RMGR(如 Heap)的 record
pg_waldump -p $PGDATA/pg_wal -r Heap -n 20

# 只看特定事务 ID
pg_waldump -p $PGDATA/pg_wal -x 12345

# 查看 record 的统计信息(按 RMGR 和 record 类型统计数量和大小)
pg_waldump -p $PGDATA/pg_wal --stats

# 输出格式:
# rmgr: Heap        len (rec/tot):     54/    54, tx:        585,
#     lsn: 0/01000028, prev 0/01000000, desc: INSERT off 2, blkref #0: ...
# rmgr 表示资源管理器,desc 是对操作的描述
```text

**排查场景 1:WAL 写入量异常大**

```bash
# 统计各 RMGR 的 WAL 输出量
pg_waldump -p $PGDATA/pg_wal -z 0/01000000 --stats=record 2>&1 | head -30

如果 Heap 占 80% 以上,且操作类型以 INSERT 为主,说明是正常的写入。如果 HeapFPW(整页镜像)占比过高,说明 checkpoint 频率太高导致大量 FPW——回到 8.1 节调整参数。

排查场景 2:定位某个事务写了多少 WAL

# 查找事务 12345 的开始和结束
pg_waldump -p $PGDATA/pg_wal -x 12345

# 统计此事务的 WAL 量
pg_waldump -p $PGDATA/pg_wal -x 12345 | awk 'BEGIN{sum=0} /len.*tot:/{match($0, /[0-9]+\/[0-9]+/); print $0; sum+=gensub(/.*\/([0-9]+),.*/, "\\1", "g", $0)} END{print "Total bytes:", sum}'
```text

**排查场景 3:确认 WAL 段是否可安全回收**

```bash
# 查看 replication slot 的 restart_lsn
SELECT slot_name, restart_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) / 1024 / 1024 AS lag_mb
FROM pg_replication_slots;

# restart_lsn 之前的 WAL 段不能回收。
# 如果某个 slot 的 lag_mb 很大且 active=false,说明 standby 已经离线
# WAL 一直攒着不被回收——最终可能导致 pg_wal 占满磁盘

九、实验:观察 WAL 写入行为

9.1 测量不同 wal_level 的 WAL 写入量

-- 准备测试表
CREATE TABLE wal_test (id SERIAL PRIMARY KEY, data TEXT);
-- 先跑 4 轮预热
INSERT INTO wal_test (data) SELECT md5(random()::text) FROM generate_series(1, 100000);

-- 在每次测试前重启 PG 并切换 wal_level
-- 然后执行相同负载:
INSERT INTO wal_test (data) SELECT md5(random()::text) FROM generate_series(1, 100000);
DELETE FROM wal_test WHERE id % 10 = 0;
UPDATE wal_test SET data = md5(random()::text) WHERE id % 5 = 0;

-- 测量 WAL 写入量
SELECT pg_current_wal_lsn() AS end_lsn;
-- 实验前后 LSN 差值即 WAL 写入量
```text

对比 `minimal`、`replica`、`logical` 三种 wal_level 下的 WAL 写入量,观察 `logical` 级别下 UPDATE/DELETE 带来的额外 WAL。

### 9.2 观察 checkpoint 对 WAL 写入的影响

```sql
-- 观察 FPW 行为:checkpoint 后第一次 UPDATE 产生 FPW
CHECKPOINT;  -- 强制一次 checkpoint

-- 记录当前 WAL LSN
SELECT pg_current_wal_lsn();

-- 执行一次 UPDATE
UPDATE wal_test SET data = 'x' WHERE id = 1;

-- 记录 WAL LSN 差异
SELECT pg_current_wal_lsn();
-- 这个 UPDATE 对应的 WAL record 包含整页镜像(FPW),WAL 量应该约 8KB

-- 再次 UPDATE 同一行(同一页面)
UPDATE wal_test SET data = 'y' WHERE id = 1;

-- 这次不需要 FPW,WAL 量只有几十字节

9.3 模拟 WAL 段疯狂切换

-- 把 max_wal_size 设得极小(需要重启)
ALTER SYSTEM SET max_wal_size = '64MB';
SELECT pg_reload_conf();

-- 疯狂写入
INSERT INTO wal_test (data) SELECT md5(random()::text) FROM generate_series(1, 500000);

-- 观察 checkpoint 频率
SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter;
-- checkpoints_req 应该涨了好几倍
```text

完成后记得恢复 `max_wal_size`。

### 9.4 用 pg_waldump 解读 WAL

```bash
# 找到当前使用的 WAL segment
SELECT pg_walfile_name(pg_current_wal_lsn());

# 解码此 WAL segment 中的 record
pg_waldump -p $PGDATA/pg_wal -s 0/01000000 -n 30

# 观察输出中的 rmgr 字段:
# rmgr: Heapheap 表的操作
# rmgr: Btree       — B-Tree 索引的操作
# rmgr: Transaction — 事务提交/回滚
# rmgr: XLOG        — checkpoint、WAL 切换等
# rmgr: Storage     — 文件创建/截断

十、关键要点

  1. WAL record 由 XLogRecord 头部 + Block Headers + Data 组成。Block Header 中的 FPW(整页镜像)是 WAL 写入量的主要来源,它的存在是为了防止页面撕裂。

  2. XLogInsert() 通过 8 个 WALInsertLock 实现并发插入。每个 Backend 在独立的 slot 中预留 LSN 区间和拷贝数据。WAL Writer 在刷盘时只刷已完成的连续字节——不等待各 slot 的拷贝,但串行化按 LSN 顺序落盘。

  3. WAL Writer 异步刷盘,Backend 在同步提交时自己调用 XLogFlush()wal_sync_method 控制使用 fsync/fdatasync/O_DSYNC 中的哪一个。

  4. Checkpoint 分两阶段:确定 REDO point(找到最老脏页的 LSN)→ 刷脏页 → 写 checkpoint WAL record → 更新 pg_control。REDO point 决定了崩溃恢复的起点。

  5. REDO 恢复通过 pd_lsn 保证幂等性。每次 REDO 操作前检查页面 pd_lsn,如果页面 LSN 已经比当前 record LSN 新,跳过。

  6. wal_level 的三级差异核心在 FPW 行为和附加信息minimal 在 bulk load 时优化 WAL;logical 在每次 UPDATE/DELETE 时记录旧行完整 image。

  7. checkpoint_completion_target 的陷阱:它摊平的是 checkpoint_timeout 窗口内的刷脏工作,但如果 checkpoint 由 max_wal_size 触发且 WAL 写入速率太高,窗口太短,completion_target 起不到摊平作用。此时该调的是 max_wal_size

  8. max_wal_size 太小会导致 WAL 段频繁切换 + checkpoint IO 风暴的恶性循环。正确值应设为正常负载下 checkpoint_timeout 窗口内 WAL 量的 3-5 倍。

上一章:MVCC 实现:CLOG、hint bit 与快照可扩展性 下一章:Buffer Manager


参考资料

源码(PG 17)

官方文档

论文

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界

拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。

2026-06-16 · database / kernel

【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大

拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。


By .