Skip to content

全页写入(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 BlockWAL 记录中带 FPI 的块引用
主块引用Main Block ReferenceWAL 记录中只含增量修改的块引用
恢复重放REDO崩溃恢复时从 redo point 向后重放 WAL
数据校验和Data Checksums页头存储的校验和,用于检测(而非修复)损坏
提示位日志wal_log_hints即使只是修改提示位也记录 FPI,为 pg_rewind 服务

一条 UPDATE 的 WAL 记录长什么样 ​

理解 FPW 的关键,是看清 WAL 记录的块引用(block reference)部分:

text
情况 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 的判定规则 ​

text
每个数据页头上有一个 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 这两个参数其实在三个方向上起作用:

  1. 决定恢复时间(RTO)
  2. 决定检查点的 I/O 尖峰频率
  3. 决定 FPW 的重复次数,从而决定 WAL 总体积

核心机制 ​

撕裂页:FPW 存在的唯一理由 ​

现代存储的最小原子写单位通常是 512 字节或 4KB 的扇区,而 PostgreSQL 的一个数据页是 8KB。一个 8KB 页的写入会被拆成多个扇区写入。

text
断电发生在写一页 8KB 数据的中途:

  磁盘上该页的最终状态
  ┌────────┬────────┬────────┬────────┐
  │ 扇区0  │ 扇区1  │ 扇区2  │ 扇区3  │
  │ 新数据 │ 新数据 │ 旧数据 │ 旧数据 │   ← 撕裂页!
  └────────┴────────┴────────┴────────┘
       ▲ 已写入        ▲ 未写入

这既不是完整的旧版本,也不是完整的新版本,
而是「页头是新的、元组还是旧的」这类畸形混合体。

撕裂页的危害在于:

问题说明
页结构可能自相矛盾页头的元组数指向的偏移在页内不存在,任何扫描都可能崩溃
增量 WAL 无法修复WAL 里记的是「把元组 X 改成 Y」「在偏移 P 插入元组 Z」,前提是页的初始状态正确
校验和只能发现不能修复开启数据校验和后能检测到此页损坏,但检测出来也没有原始数据可恢复

这就是 FPW 的核心逻辑:既然断电时页可能写入不完整,那就在第一次修改该页时,把「这页修改前的完整样子」也记进 WAL。恢复时,用 FPI 把页恢复成一个确定的、完整的状态,再在其上重放增量修改。

text
恢复一个撕裂页的两步:
  ① 用 WAL 中的 FPI 覆盖该页  → 页回到确定状态(检查点后的初始状态)
  ② 重放该页的增量修改记录     → 页回到崩溃前的正确状态

为什么只在「检查点后首次修改」时记录 ​

假如每个 WAL 记录都带 FPI,WAL 体积将膨胀到不可接受。PostgreSQL 用两个观察来压缩这件事:

text
观察 1:恢复总是从检查点开始
    → 检查点之前的页状态由检查点保证(检查点会 fsync 所有数据页)
    → 所以只需要「检查点之后」的 FPI

观察 2:一个页在检查点后被改多次,只需要第一次的 FPI
    → 第一次修改前的页 = 检查点时的页(已被 fsync,是完整且确定的)
    → 第二次修改前,磁盘上已经有「第一次修改后」的完整版本
    → 若第一次修改后到第二次修改前发生了崩溃,第二次的记录不需要 FPI:
      因为磁盘上已经有第一次的完整新版本,且它对应的 WAL 记录里有 FPI 兜底

严格地说,第 2 点需要保证「第一次修改后的页完整落盘后,第二次修改才可能发生」——这由 pd_lsn 判定 + 检查点刷脏保证:

text
页 pd_lsn >= RedoRecPtr  ⟺  该页在「当前检查点」开始后已被修改过
                           ⟺  它的磁盘版本是由一次「有 FPI 兜底的写入」产生的

FPW 的开销有多大 ​

用最直观的方式量化:一张表有 20 万个数据页(约 1.6GB),做一次「每页改一行」的批量更新。

text
场景一:检查点刚做完(每页都要 FPI)
  WAL 体积 ≈ 200000 页 × 8KB  +  200000 × 约 100B 增量
           ≈ 1.6GB + 20MB ≈ 1.62GB

场景二:同一个检查点周期内再改一遍(无 FPI)
  WAL 体积 ≈ 200000 × 约 100B ≈ 20MB

同样的业务改动,WAL 体积相差 80 倍以上。
影响 FPW 开销的因素增大开销的方向减小开销的方向
检查点间隔间隔短 → FPI 重复次数多间隔长 → FPI 重复次数少
被修改的页数随机更新大量页 → 每页一次 FPI集中在少数热点页 → FPI 少
wal_compressionoff → FPI 原样写入lz4 / zstd → 压缩后写入
页内填充度页内数据稀疏 → 压缩率更高页满且随机 → 压缩率低
sql
-- 先看清影响 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;
text
        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 / onPGLZ内置算法,压缩率一般,CPU 开销较高
lz4LZ4(PG 15+)速度快、压缩率优于 PGLZ,推荐
zstdZstandard(PG 15+)压缩率最高,CPU 开销介于两者之间

FPI 的压缩率通常很好,因为页内常有大量空闲空间、零值填充和重复的元组头结构:

text
典型实测压缩率(8KB 数据页,页内填充度约 70%):
  lz4    → 约 2.5 : 1
  zstd   → 约 3.2 : 1
  pglz   → 约 2.0 : 1

收益:WAL 顺序写的 I/O 量减少 → 检查点期间 WAL 归档/复制压力下降
代价:WAL 生成时压缩 + 重放/恢复时解压,消耗 CPU

wal_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 被忽略。

ini
# postgresql.conf:为 pg_rewind 准备的实例(未启用数据校验和时)
wal_log_hints = on

相关参数 ​

参数默认值作用域说明
full_page_writeson需重启是否启用 FPW。生产环境必须保持 on
wal_compressionoff运行时(超级用户)压缩 WAL 中的 FPI,可选 pglz/lz4/zstd
wal_log_hintsoff需重启提示位修改也写 FPI,供 pg_rewind 使用
checkpoint_timeout5min需重启间接决定 FPI 的重复频率
max_wal_size1GB需重启同上;WAL 量触发检查点会频繁重置 FPW 状态
data_checksumson(PG 18 起 initdb 默认)初始化时确定启用后提示位修改总被记录,wal_log_hints 失效
ini
# 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 导出为文件,可用于取证
bash
# 列出当前最新的 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"
text
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。可用它反查是哪张表。
sql
-- 用 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;
text
 nspname | relname  | relfilenode | reltablespace
---------+----------+-------------+---------------
 public  | fpw_demo |       16742 |             0

用 -z 统计 FPI 占比 ​

bash
# 对整个 WAL 段做统计汇总
pg_waldump -p "$PGDATA/pg_wal" -z "$SEG"
text
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,恢复时以此为准(保证「记录时的行为」与「恢复时的行为」一致):

sql
SELECT full_page_writes,
       checkpoint_time,
       redo_lsn,
       next_xid
FROM pg_control_checkpoint();
text
 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 的关系 ​

text
三条独立的机制,容易混淆:

① 全页写入(full_page_writes)
   目的:让撕裂页可被修复
   手段:把完整页镜像写进 WAL

② 数据校验和(data checksums)
   目的:检测损坏(包括撕裂页、静默损坏、存储位翻转)
   手段:页头存校验和,读取时比对
   注意:只检测不修复,必须有 FPW 才能修复
   PG 18 起 initdb 默认开启

③ 提示位日志(wal_log_hints)
   目的:让 pg_rewind 能识别分叉后修改过的页
   手段:提示位修改也记录 FPI
   注意:若②已开启,此机制自动生效,参数被忽略
sql
-- 查看本实例是否开启了数据校验和
SHOW data_checksums;
text
 data_checksums
----------------
 on

结果解读:PG 18 起 initdb 默认开启数据校验和,所以多数新实例这里显示 on。这带来两个后果:① 撕裂页/静默损坏更容易被发现;② 提示位修改自动会被 WAL 记录,wal_log_hints 无需再开。对于从 PG 17 及更早版本升级上来的实例(initdb 时未加 -k),这里可能仍是 off,此时若要用 pg_rewind 就必须显式打开 wal_log_hints。

实战示例 ​

示例 1:制造并观察 FPI ​

sql
-- 步骤 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
sql
-- 步骤 2:强制一次检查点,让下一页写入必然带 FPI
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_after_cp \gset
sql
-- 步骤 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;
text
 wal_generated
---------------
 8280 bytes
bash
# 步骤 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"
text
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 量的影响 ​

sql
-- 步骤 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;
text
 table_size | pages
------------+-------
 44 MB      |  5625
sql
-- 步骤 2:做一次检查点,清空所有页的 FPI 状态
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS lsn_1 \gset
sql
-- 步骤 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;
text
 round1_wal
------------
 44 MB
sql
-- 步骤 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;
text
 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 压缩效果 ​

sql
-- 步骤 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;
text
 mode | wal_size
------+----------
 off  | 44 MB
sql
-- 步骤 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;
text
 mode | wal_size
------+----------
 lz4  | 17 MB
sql
-- 步骤 3:确认当前生效的压缩方式
SHOW wal_compression;
text
 wal_compression
-----------------
 lz4

结果解读:

  • off 模式下同一批更新生成 44 MB WAL,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 相当。只有在「数据可随时从外部重建」的场景(如临时压测实例)才考虑关闭。
ini
# 危险配置:仅在可随时重建的临时实例上使用
# full_page_writes = off
# fsync = off
  • FPW 的成本与「被修改的页数」成正比,而不是与「修改的行数」成正比。优化大批量写入的 WAL 体积时,应该关注「有多少个不同的页被触碰」,而不是行数。
sql
-- 判断一次批量操作会触碰多少页:先看表的物理页数
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');
text
 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 后就可能损坏。
sql
-- 监控归档是否落后,FPI 段丢失会导致 PITR 无法修复撕裂页
SELECT archived_count,
       failed_count,
       last_archived_wal,
       last_failed_wal,
       last_failed_time
FROM pg_stat_archiver;
text
 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。
sql
-- 建议固化为监控查询:估算当前检查点周期内 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。
sql
-- 大批量操作的标准前置动作
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)

参考链接 ​