KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · WAL、检查点与崩溃恢复 — keel 龙骨
第 00 章里我留了一句「真正决定改动会不会丢的是刷盘时机」。这一章把它讲完。
第 00 章里我留了一句「真正决定改动会不会丢的是刷盘时机」。这一章把它讲完。
PostgreSQL 的核心承诺只有一条:任何已提交的事务,即使下一秒断电,重启后它的数据也一定在。支撑这条承诺的做法叫 WAL(Write-Ahead Log,预写式日志),规则一句话:先把这次改动写成日志并刷盘,再去改数据页。数据页脏着没关系,反正丢了可以用日志重放出来。
一次 INSERT 产生了多少 WAL
先把「一次写入 = 多少日志」量出来。做之前先切一段 WAL,隔离掉其它活动:
pre_switch
------------
0/5004C10
(1 row)
这段 INSERT 会写进 WAL 段: 000000010000000000000006
INSERT 0 1
lsn1 | bytes_one_insert
-----------+------------------
0/6000198 | 368
第一次插入 368 字节。再插一行(表已经建好、页已经存在):
bytes_second_insert
---------------------
272
272 字节。用 pg_waldump 打开那个 WAL 段,第一次插入的日志记录会长这样:
rmgr: Heap len (rec/tot): 160/ 160, tx: 20781, lsn: 0/06000028, desc: INSERT+INIT off: 1, flags: 0x08, blkref #0: rel 1663/24940/25054 blk 0
rmgr: Btree len (rec/tot): 90/ 90, tx: 20781, lsn: 0/060000C8, desc: NEWROOT level: 0, blkref #0: rel 1663/24940/25059 blk 1, blkref #2: rel 1663/24940/25059 blk 0
rmgr: Btree len (rec/tot): 64/ 64, tx: 20781, lsn: 0/06000128, desc: INSERT_LEAF off: 1, blkref #0: rel 1663/24940/25059 blk 1
rmgr: Transaction len (rec/tot): 46/ 46, tx: 20781, lsn: 0/06000168, prev 0/06000128, desc: COMMIT 2026-10-05 14:10:32.149954 中国标准时间
rmgr: XLOG len (rec/tot): 24/ 24, tx: 0, lsn: 0/06000198, prev 0/06000168, desc: SWITCH
逐条读:160 是往堆页插一行新数据;90 是主键 B-tree 第一次插条目时要建根页(NEWROOT);64 是真正把条目插进叶子页;46 是提交记录。加起来 360,加上对齐凑到 368。第二次插入就没有 NEWROOT 了,只有 160 + 64 + 46 = 270,对齐后 272——和上面实测的 272 对得上,这就是「一行 INSERT 的账」怎么拆的。
几个字段值得记住:
rmgr是资源管理器:Heap管堆页、Btree管索引、Transaction管提交状态。崩溃恢复就是按段重放这些类型的记录。blkref里的rel 1663/24940/25054是「表空间 OID / 数据库 OID / 关系 filenode」,不是表名。日志里不存表名,重放时按物理页定位。INSERT+INIT表示这个页之前是空的,这次插入顺带初始化了它。
再放大一点看总量:1000 行插入实测走了 160528 字节 WAL,约 160 字节/行——和单行的 160 字节堆记录一致,索引部分按页摊薄。
flowchart TD
C["客户端 COMMIT"] --> EXEC["执行器先把改动写成<br/>Heap / Btree WAL 记录"]
EXEC --> WALBUF["WAL 记录先进 pg_wal 的写缓冲"]
WALBUF --> FLUSH{"synchronous_commit?"}
FLUSH -->|"on(默认)"| FSYNC["提交前 fsync 到 pg_wal<br/>返回后事务才算数"]
FLUSH -->|"off"| ASYNC["提交立刻返回<br/>WAL 由后台稍后刷盘"]
FSYNC --> DP["脏数据页仍留在共享缓冲区<br/>由 checkpointer 择机写盘"]
ASYNC --> DP
DP -.->|"此刻崩溃"| CRASH["数据页的改动可能丢<br/>但 WAL 里已有记录"]
CRASH --> REDO["重启:从上一个 checkpoint 开始<br/>重放 WAL,redo 到崩溃前最后一个已提交记录"]
REDO --> OK["已提交事务全部恢复"]
ASYNC -.->|"off 下断电"| LOST["最后几笔已返回成功的提交可能丢<br/>(数据库不失真,但可能少最近的数据)"]
style C fill:#e3f2fd,color:#0d3b66
style EXEC fill:#e8f5e9,color:#1b5e20
style WALBUF fill:#ffe0b2,color:#8a4b00
style FLUSH fill:#fff3e0,color:#8a4b00
style FSYNC fill:#e8f5e9,color:#1b5e20
style ASYNC fill:#fff3e0,color:#8a4b00
style DP fill:#f1f8e9,color:#33691e
style CRASH fill:#ffebee,color:#b71c1c
style REDO fill:#e8f5e9,color:#1b5e20
style OK fill:#e8f5e9,color:#1b5e20
style LOST fill:#ffebee,color:#b71c1c
synchronous_commit 各档的语义
synchronous_commit 控制的是「提交要不要等 WAL 刷盘」。本机默认 on。用 200 次单行提交实测:
=== 200 次单行、单语句提交(autocommit),比较 synchronous_commit ===
synchronous_commit=on n= 200 总耗时= 64.2 ms 每次提交均摊= 0.32 ms
synchronous_commit=off n= 200 总耗时= 26.5 ms 每次提交均摊= 0.13 ms
synchronous_commit=on n= 200 总耗时= 53.0 ms 每次提交均摊= 0.27 ms
synchronous_commit=off n= 200 总耗时= 27.1 ms 每次提交均摊= 0.14 ms
on 大约 0.3 ms/次,off 大约 0.13 ms/次,差出一倍多。这个差距的大小完全由磁盘决定:本机是 NVMe,一次 fsync 也就零点几毫秒;换机械盘或者网络盘,差距会放大很多。这里给的是这台机器的数字,别当成通用阈值。
档位不止 on/off 两档,语义要分清楚:
| 取值 | 语义 | 丢了会怎样 |
|---|---|---|
on |
等本地 WAL 刷盘 | 已提交的不会丢 |
remote_apply |
等备库接收并回放完 | 主备数据一致,延迟最高 |
on/remote_write |
等备库写入 | 主库宕机不丢,主备切换可能少一点点 |
local |
只等本地,不管备库 | 主库不丢,备库可能落后 |
off |
不等刷盘就返回 | 进程崩溃不丢(WAL 已在写缓冲),操作系统崩溃/断电可能丢最近几百毫秒的提交 |
off 是最容易被误用的一个。它不会让数据库损坏——日志记录已经生成,只是没 fsync。它牺牲的是「最近一小段时间已返回成功的提交」。批量导入、大批量写日志表这类场景用 off 是合理的;转账、下单这类场景不要碰。
顺带说一个实测中的观察:本机 Windows 构建下,pg_stat_wal 的 wal_sync / wal_sync_time 计数器始终是 0,所以上面用的是墙钟时间对比,不是靠这个计数器。看别的资料时如果引用了 wal_sync 字段,先在自己环境确认它有数。
checkpoint 在干什么
如果每次提交都要等数据页也刷盘,那 WAL 就没有意义了。实际做法是:提交只保证 WAL 落盘,数据页攒着,由 checkpointer 定期批量刷。checkpoint 的意义是标记「到这里为止的所有改动,数据页都已经写下去了」——恢复时就不用从磁盘上最老的一页开始重放,只需要从最近一次 checkpoint 开始。
本机 checkpoint_timeout=300s、max_wal_size=1024MB,也就是「最多 5 分钟」或「攒够 1 GB WAL」触发一次。checkpoint 太频繁 → IO 抖动;太稀疏 → 崩溃后恢复时间长、磁盘上 WAL 堆积。这两个是同一个旋钮的两端。
崩溃恢复:真跑一次
主库不能停(其它实验还在用),我另起了一个独立实例在 5435 上做这个实验。全部在一个命令里跑完:灌 5000 行提交,pg_ctl -m immediate stop 模拟崩溃(不做干净关库),再启起来。
rows_before_crash
-------------------
5000
checkpoint_lsn | redo_lsn | timeline_id
----------------+-----------+-------------
0/1508870 | 0/1508870 | 1
cur_insert_lsn
----------------
0/1A5CF78
崩溃前的状态:5000 行已提交,最近一次 checkpoint 在 0/1508870,而 WAL 已经写到 0/1A5CF78——中间差着一大段没有 checkpoint 的记录。立即停库后重启:
rows_after_crash
------------------
5000
checkpoint_lsn | redo_lsn
----------------+-----------
0/1A5CF78 | 0/1A5CF78
数据一行不少。关键是日志:
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/15088E8
LOG: redo done at 0/1A5CF50 system usage: CPU: user: 0.00 s, system: 0.43 s, elapsed: 0.93 s
LOG: checkpoint complete: wrote 1038 buffers (6.3%); 0 WAL file(s) added, 0 removed, 0 recycled;
write=0.536 s, sync=0.614 s, total=1.182 s; sync files=306, longest=0.006 s, average=0.003 s;
distance=5457 kB, estimate=5457 kB; lsn=0/1A5CF78, redo lsn=0/1A5CF78
四个点:
redo starts at 0/15088E8就是上面那个 checkpoint 位置——恢复不是从零开始,是从最近一次 checkpoint 开始。redo done at 0/1A5CF50落在 WAL 末尾附近,也就是「崩溃前最后一条已提交记录」。没有提交的记录不会进入可见集,重放时会跳过。wrote 1038 buffers (6.3%)说明 checkpoint 期间把一批脏页刷下去了;distance=5457 kB是这次 checkpoint 覆盖的 WAL 距离,也大致等于「崩溃后需要重放的数据量」。- 整个恢复 0.93 秒。恢复时间正比于「最近一次 checkpoint 之后的 WAL 量」——这就是为什么 checkpoint 间隔不能设得太长。
一个真实的坏消息
对共享主库(5433)做 pg_basebackup 时,服务的 BASE_BACKUP 后端进程异常退出(0xC0000409),主库触发了自动恢复。主库没有停,恢复后继续服务,但这件事本身值得记一笔:物理备份/恢复类工具是直接读 WAL 和表文件的,它会碰到的路径比普通查询多得多,在这台机器上稳定复现了失败(换成独立实例、改用「停机 + 文件级复制」的等价做法才跑通,见第 06 章)。真实环境做备份时,备份工具本身要单独做可用性验证,别默认它一定跑得通。
生产边界
- 教学里的实例是单机单盘,
fsync是真实调用。云盘、分布式块存储的 fsync 语义和延迟差别很大,synchronous_commit的收益必须在目标存储上实测。 synchronous_commit=off唯一的适用面是「能容忍丢掉最近一小段已提交数据」的批量写。不确定就别开。- 上线要盯的:
pg_stat_bgwriter里的 checkpoint 触发原因(checkpoints_timedvscheckpoints_req,后者占比高说明max_wal_size太小)、平均恢复点距离、WAL 目录增长速率、以及任何fsync相关的告警。 - 崩溃恢复能兜住「进程/机器挂了」,兜不住「磁盘写坏了」和「误删数据」。前者要靠副本,后者要靠备份 + PITR(基于 WAL 归档的时间点恢复)。
动手
- 对一张小表插一行,用
pg_wal_lsn_diff算出这次插入用了多少 WAL 字节;再插 1000 行,算每行摊多少。 - 把
synchronous_commit在 on/off 之间切,跑同一批 200 次单行提交,记录两者耗时。 - 起一个独立实例做一次
-m immediate崩溃重启,把redo starts at到redo done at的距离和恢复耗时都记下来;再手动CHECKPOINT一次后重复,比较恢复时间。
可观察结果:你能从日志里读出「从哪开始重放、重放到哪、刷了多少脏页」,并能解释为什么 checkpoint 间隔会影响恢复时间。
自测
- 「先写 WAL 再改数据页」这条规则如果在某处被违反,崩溃后会出什么问题?
- 一次单行 INSERT 的 WAL 记录由哪几条组成?第二次插入为什么比第一次少 96 字节?
synchronous_commit=off到底丢的是什么?进程崩溃和机器断电两种情况下的结果一样吗?redo starts at的 LSN 由谁决定?为什么恢复不用从磁盘上最老的脏页开始?- 为什么说 checkpoint 间隔是「恢复时间」和「IO 抖动」之间的取舍?
↓ 下一步:04 章 · 索引与执行计划 —— 日志保证了正确性,但一条查询快不快,得看计划选了什么。