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),可以按顺序逐个重放。关键性质:
- Write-Ahead:数据页刷盘之前,对应的 WAL record 必须已经刷盘。
- 顺序写:WAL 是追加写,没有寻道——即使 HDD 上也能跑到接近磁盘顺序带宽。
- 幂等 REDO:每个 WAL record
携带足够信息(整页镜像或操作描述),恢复时按 LSN
顺序重放,已重放过的页面不会重复重放(通过页面上的
pd_lsn判断)。
二、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 15Block 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 上,fsync 和 fdatasync
的性能差异很小。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_SHUTDOWN 或
XLOG_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.c 的
CheckpointWriteDelay() 中):
// 每刷一定数量的 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:在大批量操作(COPY、CREATE TABLE AS、CREATE 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 为主,说明是正常的写入。如果
Heap 的
FPW(整页镜像)占比过高,说明 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: Heap — heap 表的操作
# rmgr: Btree — B-Tree 索引的操作
# rmgr: Transaction — 事务提交/回滚
# rmgr: XLOG — checkpoint、WAL 切换等
# rmgr: Storage — 文件创建/截断十、关键要点
WAL record 由
XLogRecord头部 + Block Headers + Data 组成。Block Header 中的 FPW(整页镜像)是 WAL 写入量的主要来源,它的存在是为了防止页面撕裂。XLogInsert()通过 8 个 WALInsertLock 实现并发插入。每个 Backend 在独立的 slot 中预留 LSN 区间和拷贝数据。WAL Writer 在刷盘时只刷已完成的连续字节——不等待各 slot 的拷贝,但串行化按 LSN 顺序落盘。WAL Writer 异步刷盘,Backend 在同步提交时自己调用
XLogFlush()。wal_sync_method控制使用fsync/fdatasync/O_DSYNC中的哪一个。Checkpoint 分两阶段:确定 REDO point(找到最老脏页的 LSN)→ 刷脏页 → 写 checkpoint WAL record → 更新
pg_control。REDO point 决定了崩溃恢复的起点。REDO 恢复通过
pd_lsn保证幂等性。每次 REDO 操作前检查页面pd_lsn,如果页面 LSN 已经比当前 record LSN 新,跳过。wal_level的三级差异核心在 FPW 行为和附加信息:minimal在 bulk load 时优化 WAL;logical在每次 UPDATE/DELETE 时记录旧行完整 image。checkpoint_completion_target的陷阱:它摊平的是checkpoint_timeout窗口内的刷脏工作,但如果 checkpoint 由max_wal_size触发且 WAL 写入速率太高,窗口太短,completion_target起不到摊平作用。此时该调的是max_wal_size。max_wal_size太小会导致 WAL 段频繁切换 + checkpoint IO 风暴的恶性循环。正确值应设为正常负载下 checkpoint_timeout 窗口内 WAL 量的 3-5 倍。
上一章:MVCC 实现:CLOG、hint bit 与快照可扩展性 下一章:Buffer Manager
参考资料
源码(PG 17)
src/backend/access/transam/xlog.c:CreateCheckPoint(), StartupXLOG(), XLogFlush(), XLogWrite(), CheckPointGuts()src/backend/access/transam/xloginsert.c:XLogInsert(), XLogInsertRecord(), ReserveXLogInsertLocation(), CopyXLogRecordToWAL()src/include/access/xlogrecord.h:XLogRecord, XLogRecordBlockHeader, XLogRecordBlockImageHeader, XLogRecordBlockCompressHeader 结构体定义src/include/access/xlog_internal.h:RMGR ID 定义(RM_XLOG_ID, RM_HEAP_ID 等), XLogSegSize, WAL segment 相关常量src/backend/access/heap/heapam.c:heap_redo(), heap_xlog_insert/delete/update()src/backend/postmaster/checkpointer.c:CheckpointerMain()src/backend/postmaster/walwriter.c:WalWriterMain()src/include/access/rmgr.h:RmgrData 结构体定义,RmgrTable 数组声明src/include/storage/bufpage.h:PageHeaderData.pd_lsn 字段
官方文档
- PostgreSQL Documentation, Chapter 30: Reliability and the Write-Ahead Log(WAL 配置与 checkpoint)
- PostgreSQL Documentation, Section 19.5: Write Ahead Log(wal_level, wal_sync_method, max_wal_size 等 GUC 详解)
- PostgreSQL Documentation, Section 19.5.2: Checkpoints(checkpoint_timeout, checkpoint_completion_target)
- PostgreSQL Documentation, Section 30.5: WAL Internals(WAL 内部机制的官方描述)
- PostgreSQL Documentation, pg_waldump 参考页
论文
- Mohan, C. et al. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS 1992. — WAL/ARIES 算法的理论基础,PG 的 checkpoint 和 REDO 设计直接来源于此。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界
拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。
【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大
拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。