WAL、检查点与崩溃恢复
到目前为止我们一直在谈「页面」,但没谈一件事:页面是在内存里改的,怎么保证断电时数据不丢?
答案是预写式日志(WAL, Write-Ahead Logging)。它是 Postgres 持久性、崩溃恢复、复制、时间点恢复的共同基础。
一次 UPDATE 的完整路径
Section titled “一次 UPDATE 的完整路径”1. 从磁盘把页面读进 shared_buffers(如果还没在里面)2. 在内存里修改页面,标记为「脏页」3. 把「这次修改」写成一条 WAL 记录,追加到 WAL 缓冲区4. COMMIT 时:把 WAL 缓冲区 fsync 到磁盘 ← 事务在这一刻真正持久化5. 脏页什么时候写回数据文件?—— 以后再说(由检查点或后台写进程负责)flowchart TB
stmt(["UPDATE 语句"]) -- "① 读入并改脏" --> page
subgraph mem["内存"]
direction LR
page["shared_buffers<br/>页面在这里被改成脏页"]
walbuf["WAL 缓冲区"]
page -- "② 生成 WAL 记录" --> walbuf
end
walbuf == "③ COMMIT 时 fsync · 顺序写<br/>事务在这一刻真正持久化" ==> walfile[["pg_wal/ 文件"]]
page -. "④ 检查点 / 后台写 · 随机写<br/>可以很晚才发生" .-> datafile[("数据文件")]
walfile -. "崩溃后 REDO 重放" .-> datafile
style stmt fill:#8a8f9c,color:#fff,stroke:none
style page fill:#2a6796,color:#fff,stroke:none
style walbuf fill:#4d9bd4,color:#fff,stroke:none
style walfile fill:#3b8f6d,color:#fff,stroke:none
style datafile fill:#d8933a,color:#fff,stroke:none
关键点在第 4 和第 5 步的分离:提交只需要把 WAL 落盘,不需要把数据页落盘。图里那条实线(③)是提交的必经之路,虚线(④)想什么时候走都行。
这是个巨大的优化。数据页散布在磁盘各处,写回是随机 I/O;WAL 是一个只追加的文件,写它是顺序 I/O。用一次顺序写换掉多次随机写,这就是 WAL 存在的全部理由。
SELECT pg_current_wal_lsn()::text AS 当前WAL位置,
pg_walfile_name(pg_current_wal_lsn()) AS 当前WAL文件,
pg_size_pretty((pg_current_wal_lsn() - '0/0'::pg_lsn)) AS 启动以来产生的WAL;跑几条更新再看一次,LSN 会向前推进:
UPDATE orders SET amount = amount WHERE id <= 1000;
SELECT pg_current_wal_lsn()::text AS 更新之后的WAL位置;
数据库崩溃时,内存里的脏页全丢了,数据文件处于「有些修改写回了、有些没写」的不一致状态。
重启时 Postgres 做的事叫 REDO:
- 找到最后一个完成的检查点,从它记录的 WAL 位置开始;
- 顺序重放之后的每一条 WAL 记录;
- 对每条记录,检查目标页的 LSN——如果页 LSN ≥ 记录 LSN,说明这个修改已经在磁盘上了,跳过;否则重放它。
重放完毕,数据库回到崩溃前最后一个已提交事务的状态。未提交的事务不需要撤销——阶段三讲过,它们的事务状态不是 COMMITTED,MVCC 的可见性规则自动让它们不可见。
Postgres 没有 UNDO 日志,只有 REDO。 这是 MVCC 架构的直接红利。
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('full_page_writes', 'wal_compression', 'wal_level', 'fsync', 'synchronous_commit')
ORDER BY name;full_page_writes 解决的是页面撕裂(torn page)问题:操作系统写一个 8KB 页时,底层可能按 4KB 扇区分两次写。断电正好卡在中间,磁盘上就留下一个半新半旧的页——这种页无法用 WAL 修复,因为 WAL 记录的是「增量修改」,前提是基础页面是完好的。
解法是:每个检查点之后,第一次修改某个页时,把整个页面的完整副本写进 WAL。 恢复时直接用这个完整副本覆盖,不管磁盘上那页坏成什么样。
检查点做的事:把当前所有脏页写回数据文件,然后在 WAL 里记一个「这个位置之前的修改都已落盘」的标记。
它的意义是给崩溃恢复划定起点——没有检查点,恢复就得从 WAL 的最开头重放。
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('checkpoint_timeout', 'checkpoint_completion_target',
'max_wal_size', 'min_wal_size', 'wal_buffers')
ORDER BY name;触发条件有两个,先到先算:
- 时间:距上次检查点超过
checkpoint_timeout(默认 5 分钟); - WAL 量:自上次检查点以来产生的 WAL 超过
max_wal_size(默认 1GB)。
调优的核心权衡
Section titled “调优的核心权衡”检查点稀疏(max_wal_size 调大) |
检查点密集 | |
|---|---|---|
| WAL 量 | 少(全页写少) | 多 |
| I/O 尖峰 | 平缓(脏页被反复覆盖,只写最后一次) | 频繁 |
| 崩溃恢复时间 | 长(要重放更多 WAL) | 短 |
| 磁盘占用 | 大 | 小 |
实践中的默认建议是把 max_wal_size 调大(写入密集的库调到 8~32GB),用磁盘空间和恢复时间换取更低的 WAL 量和更平缓的 I/O。恢复时间对大多数系统不是首要指标——毕竟崩溃是罕见事件,而 I/O 压力是每天都在的。
真实环境里用这个视图观察检查点是否过于频繁:
SELECT * FROM pg_stat_checkpointer; -- PG 17+,旧版本在 pg_stat_bgwriternum_requested(因 WAL 量触发)远多于 num_timed(因超时触发),说明 max_wal_size 太小了。
synchronous_commit:拿持久性换延迟
Section titled “synchronous_commit:拿持久性换延迟”COMMIT 默认要等 WAL fsync 完成才返回。对小事务,这个 fsync 往往就是整个事务耗时的大头。
synchronous_commit = off 让 COMMIT 不等 fsync 直接返回,WAL 由后台每 wal_writer_delay(默认 200ms)刷一次。
代价非常明确:崩溃时可能丢失最后 200ms 左右已经返回成功的事务。但——
先自己回答,再点开对照。
为什么「提交只需 WAL 落盘」比「提交时把数据页落盘」快得多?
因为两者的 I/O 形态完全不同。数据页散布在磁盘各处,写回是随机 I/O;WAL 是一个只追加的文件,写它是顺序 I/O。一个事务改了 N 个页,提交时也只需要一次 fsync,而不是 N 次随机写。
更重要的是解耦:提交只要求 WAL 落盘,脏页什么时候写回完全自由——可以攒着让检查点或后台写进程慢慢做,同一个页被反复修改时还能合并成一次写。用一次顺序写换掉多次随机写,这就是 WAL 存在的全部理由。
常见错误:以为 WAL 的好处是「写的数据量更少」。不一定更少——全页写打开时,改一个 int 就可能产生 8KB 的 WAL,比直接写那个 8KB 的页还多。WAL 赢的从来不是字节数,而是顺序、一次 fsync、且允许延迟数据页写回这三件事。
LSN 是什么?「Write-Ahead」这个名字的字面规则是什么?
LSN(Log Sequence Number)本质是一条 WAL 记录在 WAL 文件流里的字节偏移。每个数据页的页头也记着「最后修改我的那条 WAL 的 LSN」——就是 page_header.lsn。
字面规则:把某个页面写回磁盘之前,必须确保 LSN ≤ 该页 LSN 的所有 WAL 都已经落盘。 日志「先行」于数据。
这条规则也让 REDO 变成幂等的:重放时对比页 LSN 和记录 LSN,页 LSN 更大就说明这个修改已经在磁盘上了,跳过即可。
常见错误:把 “Write-Ahead” 理解成「WAL 要在事务提交之前写」。规则约束的对象不是提交,而是数据页的写回。这个区别正是崩溃恢复能成立的原因:磁盘上任何一个数据页,它的内容一定能被已经落盘的 WAL 解释——不会出现「页上有个修改,而对应的 WAL 还没写」的情况。
Postgres 为什么只有 REDO 没有 UNDO?
因为 MVCC 已经把回滚做掉了。未提交事务写下的行版本带着自己的 xid,只要事务状态不是 COMMITTED,可见性规则就让这些版本对谁都不可见——不需要任何物理撤销动作。崩溃恢复只要一路 REDO 到最后,未提交的事务自动作废。
常见错误:把「只有 REDO」当成纯粹的架构优势记下来。它和「必须有 VACUUM」是同一个设计的两面:正因为没有 UNDO 把旧值搬走,那些永远不会被任何人看到的死版本就原地留在页面上,只能靠 VACUUM 事后清理。用 undo 的数据库(MySQL、Oracle)省掉了 VACUUM,代价是回滚要付出真实的代价,长事务会撑爆 undo 空间。两种方案各自的运维痛点,都是这一个选择的连锁后果。
全页写解决什么问题?为什么它会让「检查点越频繁 WAL 越多」?
解决页面撕裂(torn page)。操作系统写一个 8KB 页时,底层可能按 4KB 扇区分两次写;断电正好卡在中间,磁盘上就留下一个半新半旧的页。这种页无法用 WAL 修复——WAL 记的是增量修改,前提是基础页面完好。
解法:每个检查点之后,第一次修改某个页时,把整个页面的完整副本写进 WAL,恢复时直接覆盖。这也解释了「我只改了一个 int,为什么产生了 8KB 的 WAL」。
检查点越频繁,WAL 越多,是因为每个检查点都会重置「谁是本轮第一次修改」的标记。同一个热点页在 5 分钟里被改 100 次,一个检查点周期内只付一次全页写;把周期缩到 30 秒,同样这 100 次修改就要付 10 次全页写。
常见错误:「检查点越频繁越安全」。频繁检查点确实缩短崩溃恢复时间,但代价是 WAL 量暴涨、I/O 尖峰次数变多——而崩溃是罕见事件,I/O 压力是每天都在的。实践中的默认建议正相反:把 max_wal_size 调大。
常见错误二:想省 WAL 就关掉 full_page_writes。这是数据安全底线,只有存储层本身保证原子写(带电池的 RAID 卡、ZFS)才能关,不确定就不要关。想省 WAL 该开 wal_compression——它专门压缩全页镜像,通常减少 50~75% 的 WAL 量,CPU 代价很小。
checkpoint_completion_target 解决什么症状?现在还需要手工调它吗?
症状是「每隔几分钟数据库卡一下」——检查点把所有脏页一口气刷下去,产生一个 I/O 尖峰,期间所有查询都变慢。
checkpoint_completion_target = 0.9 的意思是把写盘工作摊平到检查点间隔的 90% 时间里慢慢做:5 分钟的间隔就用 4.5 分钟匀速刷完。
不需要手工调了。 它在老版本里默认是 0.5,是老教程里最常被建议调整的参数之一;PG 14 起默认就是 0.9。
常见错误:以为它能减少检查点的 I/O 总量。它一个字节都不减,只是把同样多的写摊到更长的时间里——它治的是延迟毛刺,不是吞吐。想真正减少写入量,得靠调大 max_wal_size 让检查点变稀疏,这样脏页被反复覆盖后只需要写最后一次。
synchronous_commit = off 和 fsync = off 的风险有什么本质区别?
synchronous_commit = off 丢的是最近约 200ms 内已经返回成功的事务,但数据库仍然是一致的——不会出现半个事务。fsync = off 则意味着崩溃后数据库大概率损坏且不可恢复。
所以前者可以在生产环境按需使用,后者只在「数据可以随时重建」的场景(跑测试、批量导入后立即备份)里才有意义。
常见错误:把两者看成同一个滑块上的两个刻度,「都是拿安全换性能,只是程度不同」。它们根本不在一个维度上:
synchronous_commit = off只是不等 WAL 落盘就返回,Write-Ahead 规则本身完好——数据页写回之前它的 WAL 一定先落盘。丢的是尾巴上那几百毫秒。fsync = off是整条 Write-Ahead 规则失效。数据页可能先于它的 WAL 落到磁盘,崩溃后 REDO 面对的是一个 WAL 无法解释的数据文件。
一句话:前者丢数据,后者丢数据库。
常见错误二:以为 synchronous_commit 只能全局设。它可以按事务放宽——全局保持 on,只对日志、埋点、缓存刷新这类能容忍丢失的写用 SET LOCAL synchronous_commit = off。这才是它的正确用法。