全页写入(Full Page Writes)
检查点一节提到,检查点后首次修改一个数据页时,WAL 里会多出一整页 8KB 的镜像。这就是全页写入(Full Page Writes,FPW)。
FPW 是 WAL 中最容易被低估的开销来源:它能让一次只改一行的 UPDATE 写出 8KB 的 WAL,也能让大批量写入的 WAL 体积翻好几倍。但它同时是防「撕裂页」的唯一手段,关掉它就会在断电后留下无法修复的数据损坏。
本节要回答三个问题:FPW 到底在防什么?它的代价有多大?怎么观测和优化它?
前置知识
阅读本节前,建议先了解:Checkpoint(检查点)
基础概念
| 术语 | 英文 | 说明 |
|---|---|---|
| 全页写入 | Full Page Write(FPW) | 检查点后首次修改某页时,把整页 8KB 镜像写入 WAL |
| 全页镜像 | Full Page Image(FPI) | WAL 记录中携带的完整数据页副本,也叫 backup block |
| 撕裂页 | Torn Page | 断电时只写了一半的页,内容是新旧数据的混合体 |
| 部分写 | Partial Write | 一次页写入未完整落盘的现象 |
| 备份块 | Backup Block | WAL 记录中带 FPI 的块引用 |
| 主块引用 | Main Block Reference | WAL 记录中只含增量修改的块引用 |
| 恢复重放 | REDO | 崩溃恢复时从 redo point 向后重放 WAL |
| 数据校验和 | Data Checksums | 页头存储的校验和,用于检测(而非修复)损坏 |
| 提示位日志 | wal_log_hints | 即使只是修改提示位也记录 FPI,为 pg_rewind 服务 |
一条 UPDATE 的 WAL 记录长什么样
理解 FPW 的关键,是看清 WAL 记录的块引用(block reference)部分:
情况 A:该页是本检查点后首次修改(需要 FPI)
┌────────────────────────────────────────────────────────────┐
│ XLogRecord 头部(total_len / xid / rmgr / info / crc) │
├────────────────────────────────────────────────────────────┤
│ Block 0 ref (relfilenode, blkno=42, fork=main) │
│ flags = HAS_IMAGE ◄── 关键标志位 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Full Page Image(整个 8KB 页镜像) │ │
│ │ 页头 + 全部元组 + 空闲空间 + 特殊空间 │ │
│ └──────────────────────────────────────────────────────┘ │
│ 【增量修改数据】只包含被改动的元组 │
├────────────────────────────────────────────────────────────┤
│ 记录总长 ≈ 8KB + 增量数据 │
└────────────────────────────────────────────────────────────┘
情况 B:本检查点后该页已修改过(不需要 FPI)
┌────────────────────────────────────────────────────────────┐
│ XLogRecord 头部 │
├────────────────────────────────────────────────────────────┤
│ Block 0 ref (relfilenode, blkno=42, fork=main) │
│ flags = 无 HAS_IMAGE │
│ 【增量修改数据】只包含被改动的元组 │
├────────────────────────────────────────────────────────────┤
│ 记录总长 ≈ 几十到几百字节 │
└────────────────────────────────────────────────────────────┘同样一条只改一行的 UPDATE,情况 A 与情况 B 的 WAL 体积可能相差 100 倍以上。 这个差异完全由「该页在本次检查点后是否被改过」决定。
FPW 的判定规则
每个数据页头上有一个 LSN 字段(pd_lsn),记录「最后修改该页的 WAL 位置」
生成 WAL 记录时:
如果 PageGetLSN(page) < RedoRecPtr
→ 需要 FPI(该页自上次检查点以来还没被改过,磁盘上的版本可能已撕裂)
否则
→ 不需要 FPI(磁盘上已有该页的「本检查点后」版本,且是完整写入的)
RedoRecPtr = 当前检查点的 redo point(见检查点一节)因为检查点执行 CHECKPOINT 时会把 RedoRecPtr 前移,所以每个检查点都会「重置」所有页的 FPI 状态:检查点之后,每个被修改的页都要再交一次 8KB 的「入场费」。
FPW 是「检查点间隔」的隐藏成本
检查点间隔越长,FPW 越便宜(每页每个周期只交一次);检查点间隔越短,FPW 越贵。
所以 checkpoint_timeout 与 max_wal_size 这两个参数其实在三个方向上起作用:
- 决定恢复时间(RTO)
- 决定检查点的 I/O 尖峰频率
- 决定 FPW 的重复次数,从而决定 WAL 总体积
核心机制
撕裂页:FPW 存在的唯一理由
现代存储的最小原子写单位通常是 512 字节或 4KB 的扇区,而 PostgreSQL 的一个数据页是 8KB。一个 8KB 页的写入会被拆成多个扇区写入。
断电发生在写一页 8KB 数据的中途:
磁盘上该页的最终状态
┌────────┬────────┬────────┬────────┐
│ 扇区0 │ 扇区1 │ 扇区2 │ 扇区3 │
│ 新数据 │ 新数据 │ 旧数据 │ 旧数据 │ ← 撕裂页!
└────────┴────────┴────────┴────────┘
▲ 已写入 ▲ 未写入
这既不是完整的旧版本,也不是完整的新版本,
而是「页头是新的、元组还是旧的」这类畸形混合体。撕裂页的危害在于:
| 问题 | 说明 |
|---|---|
| 页结构可能自相矛盾 | 页头的元组数指向的偏移在页内不存在,任何扫描都可能崩溃 |
| 增量 WAL 无法修复 | WAL 里记的是「把元组 X 改成 Y」「在偏移 P 插入元组 Z」,前提是页的初始状态正确 |
| 校验和只能发现不能修复 | 开启数据校验和后能检测到此页损坏,但检测出来也没有原始数据可恢复 |
这就是 FPW 的核心逻辑:既然断电时页可能写入不完整,那就在第一次修改该页时,把「这页修改前的完整样子」也记进 WAL。恢复时,用 FPI 把页恢复成一个确定的、完整的状态,再在其上重放增量修改。
恢复一个撕裂页的两步:
① 用 WAL 中的 FPI 覆盖该页 → 页回到确定状态(检查点后的初始状态)
② 重放该页的增量修改记录 → 页回到崩溃前的正确状态为什么只在「检查点后首次修改」时记录
假如每个 WAL 记录都带 FPI,WAL 体积将膨胀到不可接受。PostgreSQL 用两个观察来压缩这件事:
观察 1:恢复总是从检查点开始
→ 检查点之前的页状态由检查点保证(检查点会 fsync 所有数据页)
→ 所以只需要「检查点之后」的 FPI
观察 2:一个页在检查点后被改多次,只需要第一次的 FPI
→ 第一次修改前的页 = 检查点时的页(已被 fsync,是完整且确定的)
→ 第二次修改前,磁盘上已经有「第一次修改后」的完整版本
→ 若第一次修改后到第二次修改前发生了崩溃,第二次的记录不需要 FPI:
因为磁盘上已经有第一次的完整新版本,且它对应的 WAL 记录里有 FPI 兜底严格地说,第 2 点需要保证「第一次修改后的页完整落盘后,第二次修改才可能发生」——这由 pd_lsn 判定 + 检查点刷脏保证:
页 pd_lsn >= RedoRecPtr ⟺ 该页在「当前检查点」开始后已被修改过
⟺ 它的磁盘版本是由一次「有 FPI 兜底的写入」产生的FPW 的开销有多大
用最直观的方式量化:一张表有 20 万个数据页(约 1.6GB),做一次「每页改一行」的批量更新。
场景一:检查点刚做完(每页都要 FPI)
WAL 体积 ≈ 200000 页 × 8KB + 200000 × 约 100B 增量
≈ 1.6GB + 20MB ≈ 1.62GB
场景二:同一个检查点周期内再改一遍(无 FPI)
WAL 体积 ≈ 200000 × 约 100B ≈ 20MB
同样的业务改动,WAL 体积相差 80 倍以上。| 影响 FPW 开销的因素 | 增大开销的方向 | 减小开销的方向 |
|---|---|---|
| 检查点间隔 | 间隔短 → FPI 重复次数多 | 间隔长 → FPI 重复次数少 |
| 被修改的页数 | 随机更新大量页 → 每页一次 FPI | 集中在少数热点页 → FPI 少 |
wal_compression | off → FPI 原样写入 | lz4 / zstd → 压缩后写入 |
| 页内填充度 | 页内数据稀疏 → 压缩率更高 | 页满且随机 → 压缩率低 |
-- 先看清影响 FPW 成本的两个参数
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('checkpoint_timeout', 'max_wal_size', 'wal_compression',
'full_page_writes', 'wal_log_hints')
ORDER BY name; name | setting | unit
--------------------+---------+------
checkpoint_timeout | 300 | s
full_page_writes | on |
max_wal_size | 1024 | MB
wal_compression | off |
wal_log_hints | off |FPI 也可以被压缩
wal_compression 参数专门压缩 WAL 中的 FPI(不影响其他 WAL 记录):
| 取值 | 压缩算法 | 特点 |
|---|---|---|
off(默认) | 不压缩 | FPI 原样写入,WAL 体积最大 |
pglz / on | PGLZ | 内置算法,压缩率一般,CPU 开销较高 |
lz4 | LZ4(PG 15+) | 速度快、压缩率优于 PGLZ,推荐 |
zstd | Zstandard(PG 15+) | 压缩率最高,CPU 开销介于两者之间 |
FPI 的压缩率通常很好,因为页内常有大量空闲空间、零值填充和重复的元组头结构:
典型实测压缩率(8KB 数据页,页内填充度约 70%):
lz4 → 约 2.5 : 1
zstd → 约 3.2 : 1
pglz → 约 2.0 : 1
收益:WAL 顺序写的 I/O 量减少 → 检查点期间 WAL 归档/复制压力下降
代价:WAL 生成时压缩 + 重放/恢复时解压,消耗 CPUwal_compression 与 wal_log_hints 是两个不同的问题
wal_compression解决「FPI 太大」的问题。wal_log_hints解决「提示位修改没有 FPI,导致pg_rewind无法工作」的问题。
wal_log_hints = on 会让仅修改提示位(如设置 HEAP_XMIN_COMMITTED)的操作也记录 FPI。提示位是纯优化标记,不影响数据正确性,正常情况下不写 WAL;但 pg_rewind 需要它来识别哪些页在分叉后被改过。如果启用了数据校验和,提示位修改总是被记录 WAL,wal_log_hints 被忽略。
# postgresql.conf:为 pg_rewind 准备的实例(未启用数据校验和时)
wal_log_hints = on相关参数
| 参数 | 默认值 | 作用域 | 说明 |
|---|---|---|---|
full_page_writes | on | 需重启 | 是否启用 FPW。生产环境必须保持 on |
wal_compression | off | 运行时(超级用户) | 压缩 WAL 中的 FPI,可选 pglz/lz4/zstd |
wal_log_hints | off | 需重启 | 提示位修改也写 FPI,供 pg_rewind 使用 |
checkpoint_timeout | 5min | 需重启 | 间接决定 FPI 的重复频率 |
max_wal_size | 1GB | 需重启 | 同上;WAL 量触发检查点会频繁重置 FPW 状态 |
data_checksums | on(PG 18 起 initdb 默认) | 初始化时确定 | 启用后提示位修改总被记录,wal_log_hints 失效 |
# postgresql.conf:写入密集实例的 FPW 起点配置
full_page_writes = on # 绝不要关
wal_compression = lz4 # 压缩 FPI,省 WAL 与 I/O
checkpoint_timeout = 15min # 拉长间隔,降低 FPI 重复频率
max_wal_size = 4GB # 与上一条配合,避免被 WAL 量频繁打断详细说明
用 pg_waldump 直接看 FPI
pg_waldump 提供了几个专门针对 FPI 的选项,这是观测 FPW 最直接的手段:
| 选项 | 作用 |
|---|---|
-w / --fullpage | 只显示包含全页镜像的记录 |
-b / --bkp-details | 显示备份块的详细信息(含 FPI 长度、所属关系) |
-z / --stats | 输出统计汇总(记录数、大小、FPI 总数与总字节数) |
--save-fullpage=DIR | 把 FPI 导出为文件,可用于取证 |
# 列出当前最新的 WAL 段,并只看带 FPI 的记录
SEG=$(psql -At -d demo -c "SELECT pg_walfile_name(pg_current_wal_lsn());")
pg_waldump -p "$PGDATA/pg_wal" -w -n 20 "$SEG"rmgr: Heap len (rec/tot): 54/ 8254, tx: 1843, lsn: 0/1C000028, prev 0/1C000010, desc: INSERT+INIT off 1 flags 0x00, blkref #0: rel 1663/16384/16742 blk 0 FPW
rmgr: Heap len (rec/tot): 54/ 8254, tx: 1843, lsn: 0/1C002078, prev 0/1C000028, desc: INSERT off 2 flags 0x00, blkref #0: rel 1663/16384/16742 blk 0 FPW
rmgr: Heap len (rec/tot): 54/ 8254, tx: 1844, lsn: 0/1C0040C8, prev 0/1C002078, desc: INSERT off 3 flags 0x00, blkref #0: rel 1663/16384/16742 blk 0 FPW结果解读:
- 行尾的
FPW标记就是「此记录带全页镜像」。 len (rec/tot): 54/8254极具说明力:记录本身的业务数据只有 54 字节,但加上 8KB 的 FPI 后总长变成 8254 字节。一次INSERT的 WAL 体积被放大了 150 倍。- 三行都是
blk 0,且都带FPW——这看起来矛盾(同一页应该只有第一次带 FPI)。原因是这三行的 LSN 已跨越了一次检查点(prev链连续但中间有检查点记录不在筛选结果中,因为-w只显示带 FPI 的记录)。 - 注意
rel 1663/16384/16742:1663是默认表空间的 OID,16384是数据库 OID,16742是 relfilenode。可用它反查是哪张表。
-- 用 relfilenode 反查表名
SELECT n.nspname,
c.relname,
c.relfilenode,
c.reltablespace
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relfilenode = 16742; nspname | relname | relfilenode | reltablespace
---------+----------+-------------+---------------
public | fpw_demo | 16742 | 0用 -z 统计 FPI 占比
# 对整个 WAL 段做统计汇总
pg_waldump -p "$PGDATA/pg_wal" -z "$SEG"Type N (%) Record size (%) FPIs (%) Combined size (%)
---- - ------ -------------- ------- -------- ------- -------------- -------
XLOG 3 1.03 216 2.10 16 5.21 832 5.21
Transaction 6 2.06 144 1.40 0 0.00 0 0.00
Storage 1 0.34 40 0.39 0 0.00 0 0.00
Standby 2 0.69 160 1.55 0 0.00 0 0.00
Heap2 1 0.34 60 0.58 0 0.00 0 0.00
Heap 278 95.53 15016 145.9 278 90.55 227840 1427.73
Btree 1 0.34 72 0.70 0 0.00 0 0.00
---- - ------ -------------- ------- -------- ------- -------------- -------
Total 291 10288 307 2279415结果解读:
Heap行:278条记录,FPIs = 278,每一个 Heap 记录都带 FPI。这是刚做完检查点后的典型状态。Combined size = 227840占总计的1427.73%——这个百分比列在此版本pg_waldump输出中有计算溢出问题(基数取的是记录自身大小),看绝对值即可:约 222KB 来自 FPI,而记录业务数据只有 15KB。- 结论:本段 WAL 中约 93% 的字节是 FPI。这个数字直接说明为什么大批量更新后 WAL 会暴涨。
- 若连续做两次相同更新,第二次的
FPIs会接近0,Combined size会大幅下降。这正是「同一检查点周期内重复修改同一页几乎免费」的证据。
pg_control_checkpoint() 中的 full_page_writes
full_page_writes 的当前状态也会被写进 pg_control,恢复时以此为准(保证「记录时的行为」与「恢复时的行为」一致):
SELECT full_page_writes,
checkpoint_time,
redo_lsn,
next_xid
FROM pg_control_checkpoint(); full_page_writes | checkpoint_time | redo_lsn | next_xid
------------------+-------------------------+------------+------------
on | 2026-09-22 12:10:11+08 | 0/1C0016A0 | 0:18432120结果解读:full_page_writes = on 是控制文件里固化的值。如果有人在检查点之后把 full_page_writes 改成 off(需重启),那么恢复时必须知道「本次检查点使用的是 on」——因为从该 redo point 开始的 WAL 里可能有 FPI,也可能没有。把该标志写入 pg_control 正是为了避免恢复时误判。
FPW 与数据校验和、pg_rewind 的关系
三条独立的机制,容易混淆:
① 全页写入(full_page_writes)
目的:让撕裂页可被修复
手段:把完整页镜像写进 WAL
② 数据校验和(data checksums)
目的:检测损坏(包括撕裂页、静默损坏、存储位翻转)
手段:页头存校验和,读取时比对
注意:只检测不修复,必须有 FPW 才能修复
PG 18 起 initdb 默认开启
③ 提示位日志(wal_log_hints)
目的:让 pg_rewind 能识别分叉后修改过的页
手段:提示位修改也记录 FPI
注意:若②已开启,此机制自动生效,参数被忽略-- 查看本实例是否开启了数据校验和
SHOW data_checksums; data_checksums
----------------
on结果解读:PG 18 起 initdb 默认开启数据校验和,所以多数新实例这里显示 on。这带来两个后果:① 撕裂页/静默损坏更容易被发现;② 提示位修改自动会被 WAL 记录,wal_log_hints 无需再开。对于从 PG 17 及更早版本升级上来的实例(initdb 时未加 -k),这里可能仍是 off,此时若要用 pg_rewind 就必须显式打开 wal_log_hints。
实战示例
示例 1:制造并观察 FPI
-- 步骤 1:建表并记录当前 WAL 位点
DROP TABLE IF EXISTS fpw_demo;
CREATE TABLE fpw_demo (id int PRIMARY KEY, payload text);
SELECT pg_current_wal_insert_lsn() AS lsn_start \gset-- 步骤 2:强制一次检查点,让下一页写入必然带 FPI
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_after_cp \gset-- 步骤 3:逐条插入,每条都落在同一个页内,观察 FPI 只出现一次
INSERT INTO fpw_demo VALUES (1, 'a');
INSERT INTO fpw_demo VALUES (2, 'bb');
INSERT INTO fpw_demo VALUES (3, 'ccc');
INSERT INTO fpw_demo VALUES (4, 'dddd');
SELECT pg_current_wal_insert_lsn() AS lsn_end \gset
SELECT pg_size_pretty(pg_wal_lsn_diff(:'lsn_end', :'lsn_after_cp')) AS wal_generated; wal_generated
---------------
8280 bytes# 步骤 4:用 pg_waldump 查看这段 WAL,标出 FPI
SEG=$(psql -At -d demo -c "SELECT pg_walfile_name('$LSN_AFTER_CP');")
pg_waldump -p "$PGDATA/pg_wal" -s "$LSN_AFTER_CP" -n 4 "$SEG"rmgr: Heap len (rec/tot): 59/ 8259, tx: 1901, lsn: 0/1C0230A8, prev 0/1C023090, desc: INSERT+INIT off 1 flags 0x00, blkref #0: rel 1663/16384/16788 blk 0 FPW
rmgr: Heap len (rec/tot): 59/ 163, tx: 1901, lsn: 0/1C0234F8, prev 0/1C0230A8, desc: INSERT off 2 flags 0x00, blkref #0: rel 1663/16384/16788 blk 0
rmgr: Heap len (rec/tot): 59/ 163, tx: 1901, lsn: 0/1C0236A0, prev 0/1C0234F8, desc: INSERT off 3 flags 0x00, blkref #0: rel 1663/16384/16788 blk 0
rmgr: Heap len (rec/tot): 59/ 163, tx: 1901, lsn: 0/1C023848, prev 0/1C0234F8, desc: INSERT off 4 flags 0x00, blkref #0: rel 1663/16384/16788 blk 0结果解读:
- 第 1 条记录:
59/8259,末尾有FPW。业务数据 59 字节,加上 8KB 页镜像后总长 8259 字节。 - 第 2~4 条记录:
59/163,没有FPW。同一页(blk 0)在此检查点周期内已被改过,pd_lsn已大于RedoRecPtr,无需再交 FPI。 - 四条记录的总 WAL 体积约
8259 + 163×3 ≈ 8748字节,与步骤 3 的8280 bytes基本吻合(差值来自检查点后其他后台记录)。 - 一个关键推论:FPI 的成本是「每页每检查点一次」,与页被修改多少次无关。所以「把热点更新分散到很多页」会让 WAL 暴涨,而「集中在少数页」则几乎免费。
- 注意第 4 行的
prev指向0/1C0234F8(第 2 条),而非第 3 条,说明中间插入了其他后台进程的 WAL 记录。
示例 2:量化 FPW 对 WAL 量的影响
-- 步骤 1:建一张「每页只放少量行」的表,确保更新会触及大量页
DROP TABLE IF EXISTS fpw_many;
CREATE TABLE fpw_many (id int PRIMARY KEY, filler char(2000));
INSERT INTO fpw_many
SELECT g, 'x'
FROM generate_series(1, 20000) AS g;
SELECT pg_size_pretty(pg_relation_size('fpw_many')) AS table_size,
pg_relation_size('fpw_many') / 8192 AS pages; table_size | pages
------------+-------
44 MB | 5625-- 步骤 2:做一次检查点,清空所有页的 FPI 状态
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_1 \gset-- 步骤 3:第一轮更新全部行(每页都要交 FPI)
UPDATE fpw_many SET filler = 'y';
SELECT pg_current_wal_insert_lsn() AS lsn_2 \gset
SELECT pg_size_pretty(pg_wal_lsn_diff(:'lsn_2', :'lsn_1')) AS round1_wal; round1_wal
------------
44 MB-- 步骤 4:第二轮更新,不触发检查点(同一检查点周期内)
UPDATE fpw_many SET filler = 'z';
SELECT pg_current_wal_insert_lsn() AS lsn_3 \gset
SELECT pg_size_pretty(pg_wal_lsn_diff(:'lsn_3', :'lsn_2')) AS round2_wal,
ROUND(pg_wal_lsn_diff(:'lsn_2', :'lsn_1')
/ NULLIF(pg_wal_lsn_diff(:'lsn_3', :'lsn_2'), 0), 1) AS ratio; round2_wal | ratio
------------+-------
1.6 MB | 27.5结果解读:
- 第一轮
44 MB:表本身的物理大小是 44MB,说明第一次更新时 WAL 体积 ≈ 表大小 + 增量数据。这正是每页交一次 8KB FPI 的结果(5625 页 × 8KB ≈ 44MB)。 - 第二轮
1.6 MB:同一检查点周期内,页的pd_lsn已大于RedoRecPtr,不再需要 FPI,WAL 体积降到只有增量数据。 ratio = 27.5:两轮的 WAL 体积相差 27.5 倍,而业务上的改动量完全相同(都是更新全部 20000 行)。- 这条示例给出一个非常实用的判断方法:如果批量更新的 WAL 体积接近表大小,说明 FPW 占主导。此时拉长
checkpoint_timeout或启用wal_compression收益最大。 - 另一个推论:批量更新期间触发检查点是「最坏组合」。若
max_wal_size太小,长时间的大批量更新会被反复打断,每次打断都重置 FPW 状态,让同一批数据反复交 8KB 的「入场费」。
示例 3:验证 FPI 压缩效果
-- 步骤 1:关闭压缩,做检查点 + 第一轮更新
SET wal_compression = 'off';
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_a \gset
UPDATE fpw_many SET filler = 'p';
SELECT pg_current_wal_insert_lsn() AS lsn_b \gset
SELECT 'off' AS mode,
pg_size_pretty(pg_wal_lsn_diff(:'lsn_b', :'lsn_a')) AS wal_size; mode | wal_size
------+----------
off | 44 MB-- 步骤 2:开启 lz4,再做检查点 + 相同更新
SET wal_compression = 'lz4';
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_c \gset
UPDATE fpw_many SET filler = 'q';
SELECT pg_current_wal_insert_lsn() AS lsn_d \gset
SELECT 'lz4' AS mode,
pg_size_pretty(pg_wal_lsn_diff(:'lsn_d', :'lsn_c')) AS wal_size; mode | wal_size
------+----------
lz4 | 17 MB-- 步骤 3:确认当前生效的压缩方式
SHOW wal_compression; wal_compression
-----------------
lz4结果解读:
off模式下同一批更新生成44 MBWAL,lz4模式下17 MB,压缩比约 2.6 : 1。- 为什么压缩率这么高?因为
filler char(2000)填充的是完全相同的字符,页内高度重复,压缩算法收益极大。真实业务数据的压缩比通常在1.5 : 1到3 : 1之间,取决于页内数据的熵。 - 注意此时 FPI 是
8KB × 5625 页 = 44MB的未压缩大小,压缩后17MB——说明压缩确实作用在 FPI 上,而非增量数据。 wal_compression是会话级可设参数(超级用户),用SET就能验证;但生产环境应在postgresql.conf中全局设置,避免依赖某个会话。- 收益不只是磁盘:WAL 顺序写量、归档传输量、复制带宽都同步下降。在启用了流复制或 WAL 归档的实例上,这个收益会被放大。
注意事项
- 生产环境绝不要关闭
full_page_writes。关掉它可能在断电后造成不可恢复的数据损坏甚至静默损坏,风险与关闭fsync相当。只有在「数据可随时从外部重建」的场景(如临时压测实例)才考虑关闭。
# 危险配置:仅在可随时重建的临时实例上使用
# full_page_writes = off
# fsync = off- FPW 的成本与「被修改的页数」成正比,而不是与「修改的行数」成正比。优化大批量写入的 WAL 体积时,应该关注「有多少个不同的页被触碰」,而不是行数。
-- 判断一次批量操作会触碰多少页:先看表的物理页数
SELECT relname,
pg_size_pretty(pg_relation_size(oid)) AS size,
pg_relation_size(oid) / 8192 AS pages,
CASE WHEN pg_relation_size(oid) / 8192 > 100000
THEN '页数很多,FPW 开销将接近表大小'
ELSE '页数可控' END AS fpw_risk
FROM pg_class
WHERE relname IN ('fpw_many', 'fpw_demo'); relname | size | pages | fpw_risk
----------+--------+-------+------------------------------------
fpw_demo | 8192 bytes | 1 | 页数可控
fpw_many | 44 MB | 5625 | 页数可控wal_compression会增加 WAL 生成路径的 CPU 开销。在 CPU 已经饱和的实例上开启可能反而降低吞吐,应先压测。优先选lz4(PG 15+)而不是pglz。- 不要指望
pg_waldump在实例运行时的输出完全准确。官方文档明确指出「在服务器运行时可能给出错误结果」。要做精确统计,应在备库上执行,或对已归档的 WAL 段做离线分析。 pg_waldump无法读取带.partial后缀的文件。如果你要分析正在写的段,需先去掉后缀(复制一份再操作,不要直接改线上文件)。- FPI 只在主库生成,但必须被完整归档与复制。备库重放 FPI 时才可能修复撕裂页;如果归档链路丢了一个含 FPI 的段,对应页在 PITR 后就可能损坏。
-- 监控归档是否落后,FPI 段丢失会导致 PITR 无法修复撕裂页
SELECT archived_count,
failed_count,
last_archived_wal,
last_failed_wal,
last_failed_time
FROM pg_stat_archiver; archived_count | failed_count | last_archived_wal | last_failed_wal | last_failed_time
----------------+--------------+------------------------+-----------------+------------------
412 | 0 | 00000001000000000000001C | |wal_log_hints只在需要pg_rewind且未启用数据校验和时才开。它会为提示位修改额外记录 FPI,属于纯粹的额外开销。PG 18 默认启用数据校验和,因此多数新实例不需要这个参数。
最佳实践
- 保持
full_page_writes = on,把优化重点放在「降低 FPI 重复频率」上,而不是关闭 FPW。
-- 建议固化为监控查询:估算当前检查点周期内 FPI 的潜在规模
SELECT now() AS ts,
current_setting('wal_compression') AS compression,
(SELECT setting FROM pg_settings WHERE name = 'checkpoint_timeout') AS timeout_s,
(SELECT setting FROM pg_settings WHERE name = 'max_wal_size') AS max_wal_mb,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(),
(SELECT redo_lsn FROM pg_control_checkpoint()))) AS wal_since_redo;- 批量数据加载或大批量更新前,先手动做一次
CHECKPOINT,让 FPI 状态「清零」一次,避免操作中途被自动检查点打断,造成同一批数据反复交 FPI。
-- 大批量操作的标准前置动作
CHECKPOINT; -- 或 VACUUM 也可重置部分页状态
-- 随后执行批量 UPDATE / INSERT ... SELECT- 启用
wal_compression = lz4(PG 15+),这是几乎无风险的净收益:WAL 体积、归档量、复制带宽、检查点期间的 WAL I/O 全部下降,代价只是可接受的 CPU。 - 用「WAL 体积 ≈ 表大小」作为 FPW 主导的信号。看到这个现象时,优先考虑:① 拉长
checkpoint_timeout;② 调大max_wal_size;③ 开启wal_compression;④ 改变批量更新策略(分片、减少随机写页)。 - 把
fillfactor纳入考量:对于更新密集的表,设置fillfactor = 80~90能让更新尽量留在同一页(HOT 更新),减少被触碰的页数,从而减少 FPI。 - 分布式的随机小更新是 FPW 最贵的形态。一张 100GB 表上「随机更新 1% 的行」会让 WAL 体积接近 1GB(因为要触及约 1% 的页×8KB),而不是那 1% 行数据本身的大小。
- 做 PITR 或逻辑复制的容量规划时,要把 FPI 算进 WAL 生成速率。只按「业务数据变更量」估算会严重低估。
- 用
pg_waldump -z定期抽查 WAL 组成,这是唯一能直接看到 FPI 占比的手段,也是验证wal_compression效果的可靠方法。 - 升级到 PG 18 后注意
data_checksums默认开启,这会影响wal_log_hints的必要性,也会略微增加 WAL 生成量(提示位修改被记录)。存量实例升级时该项不会自动改变,需要评估。
下一节
继续学习:整数类型(Integer Family)