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 的账」怎么拆的。

几个字段值得记住:

再放大一点看总量: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

四个点:

一个真实的坏消息

对共享主库(5433)做 pg_basebackup 时,服务的 BASE_BACKUP 后端进程异常退出(0xC0000409),主库触发了自动恢复。主库没有停,恢复后继续服务,但这件事本身值得记一笔:物理备份/恢复类工具是直接读 WAL 和表文件的,它会碰到的路径比普通查询多得多,在这台机器上稳定复现了失败(换成独立实例、改用「停机 + 文件级复制」的等价做法才跑通,见第 06 章)。真实环境做备份时,备份工具本身要单独做可用性验证,别默认它一定跑得通。

生产边界

动手

  1. 对一张小表插一行,用 pg_wal_lsn_diff 算出这次插入用了多少 WAL 字节;再插 1000 行,算每行摊多少。
  2. 把 synchronous_commit 在 on/off 之间切,跑同一批 200 次单行提交,记录两者耗时。
  3. 起一个独立实例做一次 -m immediate 崩溃重启,把 redo starts at 到 redo done at 的距离和恢复耗时都记下来;再手动 CHECKPOINT 一次后重复,比较恢复时间。

可观察结果:你能从日志里读出「从哪开始重放、重放到哪、刷了多少脏页」,并能解释为什么 checkpoint 间隔会影响恢复时间。

自测

  1. 「先写 WAL 再改数据页」这条规则如果在某处被违反,崩溃后会出什么问题?
  2. 一次单行 INSERT 的 WAL 记录由哪几条组成?第二次插入为什么比第一次少 96 字节?
  3. synchronous_commit=off 到底丢的是什么?进程崩溃和机器断电两种情况下的结果一样吗?
  4. redo starts at 的 LSN 由谁决定?为什么恢复不用从磁盘上最老的脏页开始?
  5. 为什么说 checkpoint 间隔是「恢复时间」和「IO 抖动」之间的取舍?

↓ 下一步:04 章 · 索引与执行计划 —— 日志保证了正确性,但一条查询快不快,得看计划选了什么。

进入 keel 阅读