1. 当年为什么要重造WAL格式:FPI挤压下的9.5抉择
PostgreSQL 9.5发布时,藏在“性能改进”列表里最不起眼又最影响长期架构的一项,是WAL格式的重新设计。很多人只记得9.5带来了wal_compression这个参数,却不知道它背后牵动的是一整套WAL记录组装与解析逻辑的改动。我当年接手一个写入密集的OLTP系统,WAL目录在高峰期以每小时数GB的速度增长,归档服务器和磁盘都快扛不住。那时试过调大checkpoint间隔、调小shared_buffers、压缩归档文件,效果都有限。后来把9.5的wal_compression打开,WAL占用直接掉了一大截,才明白9.5这次“WAL新格式 + 压缩”的组合拳,本质上是在回答一个困扰PostgreSQL多年的问题:全页映像(Full Page Image,FPI)带来的日志膨胀,到底怎么治。
先说清楚WAL是什么。PostgreSQL在做任何数据页修改之前,会先把“即将对页面做什么”写成日志追加到WAL缓冲区,事务提交时必须保证这些日志已经落盘。崩溃恢复时,系统重放WAL,把数据页重建到崩溃前的一致状态。这套“先写日志、后改数据”的机制,是PostgreSQL可靠性的地基。
但这里有个绕不开的麻烦:磁盘写入是扇区级的,一个数据页(默认8KB)由多个扇区组成。如果系统在写页面写到一半时崩溃,页面上可能是新旧数据混杂的“半页”。重放WAL时,日志里记录的是“把某偏移处的数据改成什么”,它依赖页面原来的基线。如果基线本身是坏的,重放结果就是错的。所以PostgreSQL采用了一个简单粗暴的兜底策略:在checkpoint之后,一个页面第一次被修改时,把整个页面的完整映像(FPI)写进WAL。这样重放时不需要依赖页面的初始状态,直接整页覆盖即可。
代价就是WAL体积急剧膨胀。一个8KB的页面,哪怕只更新一行数据,只要它是checkpoint后首次被修改,WAL里就要塞进8KB的完整页面。一个全表UPDATE,等于把整张表的大小又写一遍到WAL。在实际生产中,随机更新密集的负载下,FPI能占到WAL总写入量的60%到70%,这个比例我测过多次,非常稳定。
9.5之前,社区也想过压缩。比较出名的是pg_compress_log,它在WAL日志写入磁盘前用压缩算法整体压缩,需要给日志层打补丁,还要求数据页在恢复时先解压。问题在于,这种“整段压缩”的方式侵入性太强,PostgreSQL核心开发组一直没让它进主线。另一个更轻量的优化是“空洞消除”:页面中全零的区域不逐字节写入,只记录偏移和长度,9.5之前已经有这套逻辑。
9.5的设计者选择了一条更彻底的路:重新设计WAL记录的组装方式,把“是否压缩”下沉到每条记录内部的块映像级别。WAL记录在内存里先组装成完整形态,再决定每个块映像要不要压缩、怎么压缩,最后统一落盘。这个改动为后来LZ4、ZSTD的接入留好了结构位置,也直接促成了wal_compression这个参数的出现。所以9.5的WAL新格式,真正的价值不在于多了一个开关,而在于把“WAL记录如何构造”这个核心路径重写了一遍。
1.1 全页映像:数据库页面写一半的噩梦
要理解9.5为什么必须动WAL格式,得先理解FPI为什么不能被轻易替代。假设没有FPI,重放日志时如果页面基线因为半页写而损坏,PostgreSQL只能报错,无法自动修复。页面的8KB不是一次原子写,文件系统和磁盘层面对“一次write()调用是否只有部分扇区落盘”并没有强保证。
所以在checkpoint后的首次页面修改时强制记录FPI,是用日志空间换恢复确定性。这个策略从PostgreSQL 8.x时代就存在,full_page_writes参数默认开启。9.5没有取消它,而是在保留它的前提下,用压缩把空间代价降下来。这是非常务实的演进:兜底机制不变,只在表示形式上做优化。
1.2 WAL膨胀的真实账本:一次UPDATE消耗多少日志
我做过一个很直观的实验。建一张10万行的表,平均行宽约200字节,表体量约20MB。全表执行UPDATE t SET c = c || 'x',触发每个页面在checkpoint后的首次修改,每个页面都要记FPI。如果这张表的页面数是2500个,那么FPI写入量就是2500 × 8KB,约20MB,等于整张表又被完整写了一遍WAL。如果加上普通XLOG记录,WAL增量会比表本身还大。
更关键的是,这个开销跟更新行数没有线性关系,而是跟“checkpoint之后首次触碰的页面数”强相关。一个很小的热点表,如果checkpoint很频繁,每次checkpoint后热数据又全部重新修改一遍,FPI就会被反复记录。这解释了“为什么WAL增长剧烈”常常不是业务写入量大,而是checkpoint与热页面反复碰撞的结果。9.5的wal_compression之所以有效,就是因为它直接压缩了这些反复出现的FPI。
1.3 9.5之前的外部压缩与内核派的分歧
在9.5合并之前,外部方案集中在这几种思路:有人做日志流整体压缩,有人建议用文件系统压缩,有人试图减少FPI的产生次数。但这些方案都绕不开一个核心矛盾:压缩是让CPU多干活,换IO少干活,这个trade-off需要用户自己掌控,并且不能破坏WAL记录本身的独立性。
内核派坚持的是:WAL记录应该能在不依赖外部工具的情况下被解析、复制、归档。pg_compress_log把整个日志流压缩后,流复制、归档、pg_waldump这些下游工具全部要跟着改,生态太脆。9.5最终采用的方案,把压缩局限在单个块映像内部,WAL记录的基本结构保持不变,下游工具只需要多认识一个“已压缩”标志位。这个设计取舍,是9.5 WAL新格式能够存活至今的核心原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 源码里的WAL新格式:从XLogRecord字段到压缩块信息
源码级解析不能只看文档,直接把src/include/access/xlogrecord.h打开,9.5的WAL记录格式变化一目了然。
首先是记录头XLogRecord。9.5之前,头部字段相对简单;9.5重排并增加了字段,核心是引入了xl_tot_len,表示整条WAL记录的总长度。这个字段的意义在于,WAL记录可能跨页、跨段,不再局限于单个WAL段文件内部连续存放。解析器先读到一个头,根据xl_tot_len知道整条记录的边界,再去逐块解析内容。这个“自描述长度”的设计,让后来的大记录、压缩块、多块引用都变得可控。
我当时第一次读这段代码时,有个很直观的感受:9.5之前,“组装一条WAL记录”和“写入WAL”是混在一起的,边组装边写;9.5引入XLogRecordAssemble函数,把组装阶段独立出来,记录先在内存里拼好,再一次性安排落盘。这个解耦非常关键。没有这个解耦,压缩就无从谈起——你还没看到完整记录,怎么知道哪些块值得压缩?
2.1 记录头部的位级布局:xl_tot_len与xl_prev的作用
9.5的XLogRecord头结构大致包含这些字段:
xl_tot_len:整条记录的总字节数,包括头部、所有块的引用信息、块映像、以及尾部数据;xl_xid:产生这条记录的事务ID;xl_prev:上一条WAL记录的LSN,用于反向遍历;xl_info+xl_rmid:记录类型和资源管理器ID;xl_crc:记录内容的校验值。
xl_prev不是9.5新引入的,但9.5重新强调了它,因为pg_rewind这个9.5同步引入的工具,需要从目标库的WAL里反向扫描,找到分歧点。xl_prev让反向遍历成为可能。我之前一直以为pg_rewind只是复制文件,后来看源码才发现它极度依赖WAL记录的精确解析能力,而解析能力正是建立在9.5这套新格式之上的。
需要注意:WAL头字段的布局不是一成不变的,后续版本一直在微调。比如某些标志位从xl_info里挪到了块引用头的info字段,因为记录级别需要表达的信息越来越多。所以读源码时,一定要看与你安装版本对应的分支,不要拿9.5的布局直接套18。
2.2 块引用的新面孔:XLogRecordBlockImageHeader与压缩结构
一条WAL记录通常涉及多个数据块。每个被引用的块,在记录里会有一段“块引用头”。其中,如果这个块携带了完整页面映像(FPI),就会有一个XLogRecordBlockImageHeader来记录映像的长度、空洞信息、标志位。
9.5的关键新增,是紧随其后的XLogRecordBlockImageCompressInfo结构。这个结构只记录两个长度:
raw_length:压缩前的映像原始长度;compressed_length:压缩后的实际长度。
为什么要单独拆一个结构出来?因为压缩发生后,XLogRecordBlockImageHeader.length这个字段被重新赋值为压缩后的长度,而原始长度必须另找地方存放,恢复端解压时要靠它分配缓冲区。这个“长度信息拆分”的决策,是9.5 WAL格式新意最集中的体现:不破坏原有解析流程,新增结构向后兼容,识别标志靠BKPBLOCK_COMPRESSED。
2.3 压缩后的FPI如何用BKPBLOCK_COMPRESSED标记
这里要仔细看块引用头的info字段。它由一个低位标识块类型,高位是各种标志位。其中一个标志位就是BKPBLOCK_COMPRESSED。当这个位被置上时,解析器知道:这个块映像经过了压缩,需要在XLogRecordBlockImageHeader之后再读取一个XLogRecordBlockImageCompressInfo,获得原始长度和压缩后长度,然后才能正确跳过或解压压缩数据。
恢复端在读取时先检查这个标志位,再决定走普通路径还是压缩路径。这个设计非常简洁。它没有改变“记录里先有块头、再有块数据”的大结构,只是在块头和数据之间多插了一段固定长度的元信息,并且用标志位告诉解析器“这里有一段额外信息”。下游工具即使暂时不支持压缩,看到未知标志位也可以安全跳过相关数据,或者显式报错,而不是把整个WAL流解析错位。
3. wal_compression的代码执行路径:插入端与恢复端的两次握手
光看结构体定义还不够,wal_compression真正起作用,靠的是插入端和恢复端各走一遍函数调用链。这一节我们把代码路径串起来。
3.1 XLogRecordAssemble:组装时如何判断要不要压缩
9.5中,所有WAL记录在写入前都会经过src/backend/access/transam/xloginsert.c里的XLogRecordAssemble。这个函数接收所有已注册的缓冲区引用和辅助数据,逐块决定“这个块要不要在WAL里放全页映像”。
决定放FPI后,还有一个分支判断:当前wal_compression是否开启。如果开启,就尝试对页面的非空洞区域做压缩。源码里这段逻辑大致是这样的过程:先检测页面中的全零空洞,拿到非空洞区域的起始位置和长度;然后调用压缩函数,把这段数据压一遍;如果压缩后的长度确实小于原始长度,就用压缩后的数据,并在块引用头的info字段里置上BKPBLOCK_COMPRESSED标志;如果压缩没有收益,比如数据本身已经是高熵的,就放弃压缩,按普通FPI记录。
这里有一个让很多人误解的点:wal_compression并不是把整条WAL记录当成一个压缩流来压,它只压缩每一条记录里的FPI块映像。普通CRUD产生的逻辑日志,比如插入行的元组数据,本身已经足够紧凑,再压缩收益不大,反而白白消耗CPU。所以这个参数对WAL体积的改善,完全取决于负载里FPI的占比。
3.2 XLogCompressBackupPage:PGLZ压缩的实际调用点
9.5里,真正的压缩函数是XLogCompressBackupPage,它内部默认调用PGLZ算法。PGLZ是PostgreSQL自带的一套LZ系列压缩实现,实现在src/backend/utils/adt/pg_lzcompress.c,它跟外部通用的zlib不是一回事。PGLZ的目标很明确:数据压缩率不必极致,但解压速度必须快,因为恢复端要在大面积重放时频繁解压。
PGLZ在9.5时代是唯一选择。它的压缩率受页面内容影响很大,数据库页面通常有大量空闲空间和重复的页头结构,所以8KB的页面经常能压到2KB甚至更低。但也存在压不动的情况,比如页面上已经写满随机数据。压缩函数会返回“是否成功缩小”,调用方据此决定是否采用压缩结果。
15版本之后,XLogCompressBackupPage不再是PGLZ专用的了,它内部会根据wal_compression参数值分发到LZ4或ZSTD的实现。但从9.5到14,这段代码的逻辑基本没变过,可见最初这个抽象层设计得比较干净。
3.3 恢复端从RestoreBlockImage到页面回放的解压链
恢复端的路径在src/backend/access/transam/xlogreader.c里。DecodeXLogRecord负责把WAL记录解析成结构化形式,逐个块解析引用信息;如果看到BKPBLOCK_COMPRESSED标志,它不会立刻解压,而是把“已压缩的原始字节”连同原始长度、压缩后长度一起存到XLogBlock结构里。
真正解压发生在RestoreBlockImage函数。这个函数由重放流程调用,它检查块引用是否携带映像,再检查是否带压缩标志。如果带压缩标志,就调用XLogDecompressBackupPage,把压缩字节解压回原始页面,然后根据空洞信息把数据拼回完整页面,最后拷贝到目标buffer页面。
要理解为什么解压不能提前到解析阶段做,可以想一下流复制的场景:备库收到的WAL可能是压缩过的FPI,它必须先解压才能把页面写入磁盘。但解析阶段只需要知道“这一块的边界在哪”,并不需要立刻还原页面内容。把解压推迟到真正需要页面数据时,可以让解析阶段更轻量,也方便一些只做统计、不重放数据的工具直接跳过解压。
4. 用pg_waldump拆开一个真实WAL记录:压缩前后的字节账
源码看再多,不如自己动手拆一条真实WAL。这一节带你把环境跑起来,实际看看wal_compression在WAL文件里的表现。
4.1 构建一个能触发FPI的测试负载
先准备一个PG 15或更新版本的实例,编译时尽量带上--with-lz4。确认方式:
bash复制pg_config --configure
输出里如果包含--with-lz4,就说明当前二进制支持LZ4。然后设置参数:
sql复制ALTER SYSTEM SET wal_compression = lz4;
ALTER SYSTEM SET full_page_writes = on;
ALTER SYSTEM SET min_wal_size = '64MB';
ALTER SYSTEM SET max_wal_size = '128MB';
SELECT pg_reload_conf();
重启实例后,建一张测试表并写入数据:
sql复制CREATE TABLE t_wal_test AS
SELECT g AS id, md5(g::text) AS val
FROM generate_series(1, 500000) AS g;
CHECKPOINT;
执行CHECKPOINT的目的是重置FPI的起点。之后执行全表更新:
sql复制UPDATE t_wal_test SET val = md5(val || 'x');
这条UPDATE会把大量页面在checkpoint后首次标记为脏并写出,是最容易制造FPI的操作。
4.2 观测WAL体积与pg_waldump输出
在关闭wal_compression和开启wal_compression = lz4两种配置下分别做同样的实验,记录pg_wal目录大小。
bash复制du -sh $PGDATA/pg_wal
我实测过的经验值是:随机UPDATE密集负载下,打开wal_compression后WAL体积通常能下降40%到50%,具体取决于页面内容可压缩性。如果表里是大段文本,压缩率会更夸张;如果全是随机字节,收益就小。
再用pg_waldump看看具体记录:
bash复制pg_waldump -p $PGDATA/pg_wal --compress-method=lz4 -n 500 | grep "FPW" | head
输出大致如下:
code复制rmgr: XLOG len (rec/tot): 64/ 6148, tx: 0, lsn: 0/0D123456, prev 0/0D123400, desc: FPI , block 0: rel 1663/16384/16385 blk 123 (FPW compressed)
看到(FPW compressed),说明这条记录走的是压缩路径。如果不加--compress-method=lz4,解析时可能提示无法解析压缩块,这是正常的。
4.3 解读压缩块的长度字段与标志位
用pg_waldump --verbose可以展开更详细的块信息。你会看到类似这样的字段:
code复制block 0: rel 1663/16384/16385 blk 123
block image length: 2048 bytes (raw length 8160)
block image compressed with: lz4
如果输出没有直接打印“compressed with”,可以关注长度差距:块映像长度明显小于页面块大小(8192),基本可以确定压缩生效了。
我用pageinspect扩展验证过压缩细节:页面上有大量空闲空间时,LZ4能把8KB压到2KB左右;如果页面充满数据,压缩率会下降到1.5倍左右。关键是,恢复端解压后得到的页面与原始页面完全一致,CRC校验在解压后不会出现问题,因为WAL记录的CRC计算包含了压缩后的字节,压缩不改变数据内容。
5. 从9.5到18:wal_compression的参数与算法演进
理解了源码路径,再看版本演进就清晰多了。wal_compression从9.5走到18,经历了从布尔值到枚举值、从单一算法到多算法共存的转变。
| 版本 | 参数形态 | 可选值 | 默认值 | 备注 |
|---|---|---|---|---|
| 9.5 - 9.6 | boolean | on / off | off | 仅PGLZ |
| 10 - 14 | boolean | on / off | off | 内部标记为压缩 |
| 15 | enum | off / pglz / lz4 | off | 加入LZ4,编译需--with-lz4 |
| 16 | enum | off / pglz / lz4 / zstd | off | 加入ZSTD,编译需--with-zstd |
| 17 - 18 | enum | off / pglz / lz4 / zstd | off | 多算法框架稳定 |
5.1 9.5:唯一的PGLZ选项,on/off二元时代
9.5刚上线时,文档里对wal_compression的描述很简单:布尔参数,控制FPI是否压缩,压缩算法是PGLZ。当时社区对默认值比较保守,所以默认是off,需要DBA手动开。那时我在测试环境里对比过,PGLZ的开销大约会让TPS下降5%到8%,在机械硬盘时代,这点CPU损耗换来的IO减少非常划算。
5.2 15和16:LZ4、ZSTD入场,参数从布尔变成枚举
到了PG 15,核心开发者把参数从boolean扩展成enum,这是一个语义上的重要变化:不再是“要不要压缩”,
