PostgreSQL 存储引擎内幕(下):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 中"时间"的另一种表示:
- 每个 Page 的
pd_lsn记录了最后一次修改该 Page 的 WAL 记录的 LSN - 恢复时,如果 Page 的
pd_lsn已经 >= WAL 记录的 LSN,说明这条 WAL 已经应用过了,跳过 - 复制时,standby 告诉 primary"我已经重放到 LSN X",primary 据此决定哪些 WAL 可以回收
资源管理器:谁产生了这条日志
不同的子系统产生不同类型的 WAL 记录,每个子系统注册一个"资源管理器"(Resource Manager),提供自己的重做和解码函数:
| RmgrId | 子系统 | 操作类型举例 |
|---|---|---|
| RM_HEAP_ID | 堆表 | INSERT、DELETE、UPDATE、HOT_UPDATE |
| RM_BTREE_ID | B-Tree 索引 | 索引页分裂、插入索引条目 |
| RM_XACT_ID | 事务管理 | COMMIT、ABORT |
| RM_CLOG_ID | 事务提交日志 | CLOG 页面更新 |
| RM_XLOG_ID | WAL 自身 | Checkpoint 记录 |
恢复时,对每条 WAL 记录调用对应资源管理器的 rm_redo 函数。逻辑复制时,调用 rm_decode 函数将物理变更转换为逻辑变更(如 DecodeInsert 将 XLOG_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()
└──────────────────┘
两个关键的进程间通信机制:
- 共享内存:Backend 写入 WAL 记录到共享缓冲区,WAL Writer 从共享缓冲区读取并写入磁盘
- Latch:Backend 通过
SetLatch()唤醒 WAL Writer,WAL Writer 通过WaitLatch()等待唤醒信号。Latch 是 PG 的轻量级同步原语,开销远低于信号量
Write vs Flush:两阶段写入
WAL 写入磁盘分为两个阶段,理解这个区别至关重要:
| 阶段 | 操作 | 数据到达 | 性能 | 持久性 |
|---|---|---|---|---|
| Write | 内存 → OS 文件缓存 | 操作系统内核缓冲区 | 快(内存拷贝) | 断电会丢 |
| Flush | OS 文件缓存 → 磁盘 | 持久存储 | 慢(真实 I/O) | 断电不丢 |
事务 COMMIT 时必须确保 WAL Flush 完成——这是持久性保证的关键。synchronous_commit 参数控制这个行为:
on(默认):COMMIT 等待 WAL Flush 完成才返回off:COMMIT 不等 Flush,可能丢失最近几百毫秒的事务。性能更好,但牺牲持久性
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 是最底层的写入函数。它的核心逻辑:
- 进入临界区(
START_CRIT_SECTION)——在写入过程中不可被中断,防止写到一半被取消导致 WAL 文件损坏 - 等待所有正在进行的 WAL 插入完成(
WaitXLogInsertionsToFinish)——多个 Backend 可能同时在往 WAL 缓冲区写入 - 计算要写的 Page 范围,通过
pg_pwrite批量写入 - 处理跨 WAL Segment 的情况——当前文件写满 16MB 时,切换到下一个文件
// 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 的入口是 CheckpointerMain(B_CHECKPOINTER 进程),触发时机:
- 超时触发:
checkpoint_timeout参数(默认 5 分钟) - WAL 量触发:当 WAL 产生量超过
max_wal_size(默认 1GB) - 手动触发:执行
CHECKPOINT命令 - 关闭触发:数据库正常关闭时
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
数据库崩溃后重启,StartupProcessMain(B_STARTUP 进程)负责恢复:
- 读取
pg_control文件,找到最近的 Checkpoint 位置 - 从 Checkpoint 的 redo LSN 开始读取 WAL
- 对每条 WAL 记录,检查对应 Page 的
pd_lsn:如果pd_lsn >= record_lsn,说明该修改已经持久化,跳过;否则调用rm_redo重放 - 重放到 WAL 末尾后,恢复完成,数据库开始正常服务
// 崩溃恢复的核心逻辑(伪代码)
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) |
| 发送函数 | XLogSendPhysical | XLogSendLogical |
| Standby 要求 | 完全相同的 PG 版本和平台 | 可以不同版本,甚至不同数据库 |
| 粒度 | 整个集群 | 表级别,可选择性复制 |
| 用途 | 高可用、读副本 | 数据集成、跨版本迁移 |
逻辑复制的解码过程:XLogSendLogical → XLogReadRecord 读取 WAL 记录 → LogicalDecodingProcessRecord 调用对应资源管理器的 rm_decode 函数。例如 DecodeInsert 将 XLOG_HEAP_INSERT 类型的 WAL 记录解析成逻辑复制可以理解的行变更事件。
Replication Slot 与 LSN
Replication Slot 跟踪每个订阅者的消费进度:
restart_lsn:该 slot 需要保留 WAL 的起始位置——比这更早的 WAL 不能回收confirmed_flush_lsn:订阅者已经接收并处理的位置
如果 standby 断线太久,restart_lsn 会阻止 WAL 回收,导致 primary 的磁盘被占满。这是生产环境中常见的故障场景。
Timeline:数据库的分支历史
Timeline 是 PG 处理数据库"分支"的机制。每个 Timeline 有唯一的数字 ID,在以下场景创建新 Timeline:
- PITR(Point-in-Time Recovery):从某个历史时间点恢复
- Standby Promotion:standby 被提升为主库
- 从 Backup 恢复
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 通信。优势是隔离性好(一个进程崩溃不会拖垮其他进程)、调试友好;代价是进程间通信比线程间通信更重。
总结
几个值得记住的关键直觉:
- WAL = 顺序写日志 + 延迟写数据页:把昂贵的随机 I/O 转换为便宜的顺序 I/O。事务提交只需确保 WAL 刷盘,数据页可以异步写
- Write ≠ Flush:Write 只到 OS 缓存,Flush 才到磁盘。COMMIT 需要 Flush 才能保证持久性
- WAL Writer 与 Backend 通过共享内存 + Latch 通信:多个 Backend 并发写入 WAL 缓冲区,WAL Writer 负责刷盘。写入失败 → PANIC
- Checkpoint = 脏页全刷 + 创建恢复点:崩溃恢复只需从最近 Checkpoint 重放,不需要从头开始。Checkpoint 之前的 WAL 可以回收
- WAL 同时服务恢复和复制:相同的 WAL 记录,崩溃恢复时由 Startup 进程重放,流复制时由 WAL Sender 发送给 Standby
- 物理 vs 逻辑:物理复制发送原始字节流(简单高效),逻辑复制通过资源管理器解码成行变更事件(灵活但更复杂)
至此,PostgreSQL 存储引擎三部曲完成。从 Heap Page 的 8KB 内部布局,到 Buffer Pool 的页面缓存管理,到索引扫描和 MVCC 的可见性判断,再到 WAL 的持久性保证和 Checkpoint 的恢复机制——这些层层嵌套的抽象,共同构成了一个关系型数据库最核心的骨架。