前阵子帮一个业务库排查启动故障,来回翻了半天的pg_waldump输出才看明白:同样是 WAL,heap 模块的日志在 checkpoint 之后往往会带一整段完整页图像,而索引模块的记录经常只有几十字节的偏移量。同一个 PostgreSQL 实例里,WAL 记录的内容就可以有这么大差别,更不用说把 PostgreSQL、SQLite、InnoDB、RocksDB 这几个系统的 WAL 摆在一起对比了。这篇就围绕“WAL 记录的内容变种”展开,讲讲这些日志记录到底能长成哪几种形态、每个形态在解决什么问题、怎么用工具把日志拆开确认自己的判断。适合 DBA、存储研发、以及对数据库底层感兴趣的读者。
1. WAL 记录的本质:追加日志与崩溃恢复之间的契约
1.1 为什么数据库离不了 WAL
在讲“变种”之前,先把 WAL 本身说透。WAL 全称是 Write-Ahead Logging,核心动作就一句:数据页可以晚点落盘,但描述这次修改的日志必须先落到稳定的存储上。数据库把随机写堆积到内存里的 buffer pool,脏页攒到一定程度再批量刷盘,这个设计极大地提升了吞吐。但代价是:如果刷盘之前发生断电或者进程崩溃,内存里的数据可能全部丢失。为了保证事务提交后修改不丢,数据库必须把“这次改了什么东西”以追加的方式先写进日志文件。
这个日志是顺序写的,而且只追加,因此磁盘开销远小于随机写数据页。崩溃恢复的时候,数据库从日志里读出所有已提交事务的修改,把缺失的页面重放一遍,数据就回来了。你可以把它理解成记账先记在草稿本上,再抽空誊到正式账本;账本被撕了也不怕,按草稿本重抄一份就行。
1.2 日志里“装什么”,决定了恢复能做到什么程度
那么关键问题来了:“这次改了什么东西”这句话,具体以什么形式写进日志?这就是标题里所说的“内容变种”的根源。不同数据库给出的答案很不一样:
- 可以写修改前或修改后的整页内容,恢复时直接把整页覆盖回去;
- 可以只写页面内的字节偏移和变化后的数据,恢复时把这一段补丁打上去;
- 也可以写一条操作语义,比如“把键 K 的值更新为 V”,恢复时重跑这个操作。
这三种写法都能让数据库从崩溃中恢复,但它们在日志大小、写入放大、恢复速度和抗损坏能力上天差地别。日志记录长成什么样,本质上是数据库在“恢复力”和“性能”之间做的一次取舍。后面几章我会把每种变种拆开,再放到真实系统里看它们是怎么混用的。
2. 三种典型内容变种:页镜像、物理偏移、逻辑操作
2.1 完整页镜像型:最简单、也最贵
完整页镜像型变种很直白:WAL 记录里直接携带某个数据页的完整内容。SQLite 的 WAL 模式就是这种思路的典型代表——每修改一个页面,就往 WAL 文件里追加一整个页面镜像(默认 4096 字节左右)。PostgreSQL 里的 full_page_writes 则是另一种经典场景:当页在 checkpoint 之后第一次被修改,日志里会附加上修改前的完整页内容。
为什么需要附加完整页镜像?因为操作系统写盘不是原子的,一个 8KB 或 16KB 的数据库页在掉电时可能只写了一半,这在业界叫 torn page(部分页写)。如果日志里只有一小段字节变更,恢复时把变更应用到那个已经残缺的页面上,结果一定是一堆乱码。而完整页镜像相当于给”恢复”提供了一个干净起点,即使数据页本身烂了,也可以整页覆盖回去,然后再重放后续差异变更。
这种变种的优点是恢复逻辑简单,尤其适合没有额外写缓冲机制的系统。缺点也摆在明面上:日志体积大、写入放大严重。比如 SQLite 在 WAL 模式下,每次更新哪怕只改一行,也会把包含那行的整个页写进 WAL,事务越密集,WAL 增长越快。PostgreSQL 如果频繁发生 checkpoint 后第一次页面修改,pg_wal目录的膨胀速度同样能让你肉疼。
2.2 物理偏移变更型:只记变化,不记全貌
物理偏移变更型记录的是“哪个页、从哪个偏移开始、变了哪些字节”,通常还带一些元组操作信息。这类变种的代表是 InnoDB 的 redo log。InnoDB 的日志记录类型很多,比如MLOG_1PAGE表示操作只影响一个页,MLOG_REC_INSERT表示插入了一条记录,记录体里会包含表空间 ID、页号、偏移量、记录内容等。它不会把整页复制进去,所以日志体积远小于完整页镜像。
代价就是:如果数据页本身已经因为 torn page 而损坏,仅仅靠 redo 里那点偏移补丁是修不回来的,因为补丁打在错误的地基上。所以 InnoDB 需要使用 doublewrite buffer 来解决部分页写问题:刷脏页前,先把整页写到 doublewrite 区域,保证页的原子性,redo log 只负责记录逻辑/物理层面的变更。把“页安全”这件事交给 doublewrite,把“变更恢复”这件事交给 redo,两个组件分工明确。这就是内容变种和外围机制之间的配合关系。
其实 MySQL 自己的 redo 里也不是完全没有整页记录,比如某些 DDL 操作或者日志类型MLOG_PAGE_CREATE会带较多信息,但整体策略比 PostgreSQL 的全页写方式轻很多。这也解释了为什么同样是大量更新,MySQL 的 redo 日志增长速度通常低于 PostgreSQL 开启full_page_writes时的 WAL 增长速度。
2.3 逻辑操作型:把“做了什么”写进日志
第三种变种更抽象:日志里存的不是字节补丁,而是操作语义。典型例子是 etcd 的 WAL:它记录的是 raft 协议里的 entry,内容其实是一段二进制协议数据,代表一个 put、delete 之类的逻辑操作。MongoDB 的 oplog 虽然不是传统意义上的 WAL,但功能上扮演了类似的角色,里面保存的是 namespace 和具体操作类型(insert、update、delete)以及文档内容,secondary 节点直接播放这些逻辑操作就可以追上主节点的数据。
这种变种的优点是非常灵活,日志天然支持跨系统复制和逻辑解析,逻辑备份、异构同步都能直接消费。缺点也很明显:恢复时需要重放完整操作,如果操作本身很复杂或者依赖上下文(比如索引结构已经变化),重放逻辑就会变得脆弱。另外,逻辑操作通常无法解决数据页物理损坏的问题,因为重放是建立在现有数据状态之上的。
2.4 实际系统大多是混合变种
我上面把它分成三类,是为了方便理解,真实系统里基本都是混着用的。PostgreSQL 平时写资源管理器自己的物理变更记录,但是碰到 checkpoint 之后第一次页面修改就自动附加FPI(Full Page Image)标志,整个记录从“物理偏移型”临时变成“页镜像型”。InnoDB 绝大多数 redo 是物理偏移型,但某些特殊操作又会带近整页的信息。SQLite 则“固执”很多,WAL 模式不管什么操作都写完整页镜像,不做第二种变种。因此在排查故障的时候,不能只看这个数据库“用了什么日志模式”,还要关注特定时间点、特定记录类型下日志内容是否发生了形态切换。
3. 主流系统 WAL 内容的字节级拆解
3.1 PostgreSQL:resource manager 驱动的记录变种
PostgreSQL 的 WAL 设计非常有代表性。每条 WAL 记录在文件里是一个XLogRecord,头部字段依次是:
xl_tot_len:记录总长度xl_xid:事务 IDxl_info:记录类型和标志位,其中XLR_FPI标志说明这条记录带了完整页镜像xl_rmid:资源管理器 IDxl_prev:上一条记录的位置xl_crc:CRC 校验值
xl_rmid决定了后面那段数据该怎么解释。PostgreSQL 内部注册了 heap、btree、gin、gist、hash、sequence、transaction、clog 等一堆资源管理器,每个资源管理器各自定义记录的子类型和结构。比如 heap 模块的XLOG_HEAP_INSERT记录里会包含元组位置、XID 等信息,而 btree 模块的XLOG_BTREE_INSERT则包含要插入的索引元组、页面拆分信息等。这些记录的长相完全不同,但都塞在同一条 WAL 文件里。
前面提到的full_page_writes,在记录结构上就是xl_info里多了一个XLR_FPI标志位,并且整块页内容直接附加在记录体后面。所以你在pg_waldump输出里看到FPI字样时,意味着这条记录不是单纯的逻辑变更,而是一个完整的页快照。别忘了,PostgreSQL 在wal_compression开启时会对这份页镜像做压缩(目前支持 pglz 和 lz4),这个变种又叠加了一层内容压缩变体——日志文件更小,但恢复时需要先解压再应用。
3.2 SQLite:每帧都是整页镜像的 WAL
SQLite 的 WAL 文件和 PostgreSQL 完全是另一套格式。WAL 文件最前面是一个 32 字节的文件头,包含魔数、WAL 版本、页大小、checkpoint 序号等信息。魔数还分两种:主库的 WAL 魔数和热备只读时的 WAL 魔数,两者校验和计算方式不同。接着是一系列的 frame,每个 frame 由 24 字节的 frame 头和完整的数据库页镜像组成。frame 头里包括:
- 页号(数据库中的页码)
- 本框架是否为提交帧(commit flag)
- 盐值一和盐值二
- 校验值一和校验值二
- 总帧数(部分版本)
因为每个 frame 都存的是完整页,所以 SQLite WAL 的恢复逻辑异常简单:只需要从 WAL 中把最新版本的数据页按页号找出来,逐个覆盖到主数据库文件即可。没有复杂的前后依赖,也不需要先应用一大堆差异补丁。但也正因为如此,SQLite 一个 update 语句往往会让 WAL 增加几百字节到几 KB,写频繁时 WAL 膨胀会很明显。
SQLite 还有配套的-shm文件(WAL index),它映射了 WAL 中每个页的位置,是内存中的哈希索引,不属于 WAL 记录本身,但会影响 WAL 内容的读取效率。如果-shm文件损坏,SQLite 有能力重建它,所以实际使用中大家经常忽略这个文件,但它确实是 WAL 工作流程的一部分。
3.3 InnoDB redo log:面向物理块的 mini-transaction 记录
InnoDB 的 redo log 以 512 字节的逻辑块为基本单位,整个 redo 文件由一个个 log block 组成,每个 block 有自己的 header 和 trailer,用来存放块内使用字节数、首个日志记录的组提交信息等。而日志数据主体则是一串 mini-transaction 级别的记录。每条记录的类型决定了解析方式,常见类型包括:
MLOG_1PAGE:只涉及单页的操作MLOG_REC_INSERT:插入一条普通记录MLOG_COMP_REC_INSERT:插入一条使用压缩页格式的记录(COMPACT 行格式)MLOG_COMP_PAGE_CREATE:创建一个压缩格式的页MLOG_UNDO_INSERT:向 undo log 写入内容
你能看到的最小单元是“页码 + 偏移 + 变更字节”,这也是它和 PostgreSQL 风格最不一样的地方。MySQL 崩溃恢复时会顺序读取 redo log,根据这些物理/逻辑操作重放页面变更。注意,InnoDB 的 redo 记录不是 SQL 语句,跨事务、跨 page 的复杂变化细节都以这种物理化格式编码了。直接拿文本编辑器翻 redo 文件基本没戏,二进制结构非常紧凑,必须靠解析工具或源码理解。
MySQL 8.0.30 之后 redo log 从固定的ib_logfile0+ib_logfile1改成了#innodb_redo目录下的 32 个文件,并配合 32 个线程并发刷新。这个变化没有改变 redo 记录的基本内容变种,但改变了 WAL 管理方式,在排查 redo 缺失问题时需要知道新老版本的差异。
3.4 RocksDB 和 etcd:把操作语义写进日志
RocksDB 的 WAL 格式也很有意思。它把日志文件分成若干 32KB 的 block,每条物理记录有一个 7 字节的 header:4 字节 CRC、2 字节长度、1 字节类型。类型可能是kFullType(完整记录)、kFirstType、kMiddleType、kLastType(大于一个 block 时被分段)。header 后面的数据体是 WriteBatch 的序列化结果,包含序列号、操作条数、以及每一个操作的键值类型。
RocksDB 的操作类型里有kTypePut、kTypeDelete、kTypeSingleDelete、kTypeRangeDeletion等,这些都属于逻辑操作型变种。Value 到底存不存、是否压缩,取决于 WriteBatch 的设置和列族配置。RocksDB 还有一个特性:单个 value 很大的时候,默认不会把大 value 写进 WAL,而是只记录引用,具体策略由wal_compression和recycle_log_file_num等参数综合决定。这些细节直接影响日志内容的长相,排查 WAL 占用过大时都要考虑进去。
etcd 的 WAL 底层实现和标准 LSM 日志不太一样,本质是把 raft entry 序列化后追加到日志文件中,还支持写 snapshot 时截断 WAL。日志里既有配置变更,也有用户数据操作,全部是逻辑记录。这种日志天然适合分布式共识,但恢复重放时依赖状态机的幂等性设计。
4. 变种带来的一系列连锁影响
4.1 写入放大与日志峰值
WAL 记录是同步写盘链路的一部分,日志越大,单次事务提交的延迟和 IO 压力就越高。完整页镜像型在写入放大上最吃亏:一个 8KB 数据页更新一两个字节,却要在 WAL 里写 8KB 甚至更多。物理偏移型最省:记录粒度小,通常只有几十到几百字节。逻辑操作型的体积介于两者之间,取决于对象大小和序列化效率。
所以生产环境里经常能看到这样的现象:PostgreSQL 如果full_page_writes一直开着,遇上频繁的 checkpoint 后刷写,pg_wal目录短时间内可能暴涨;MySQL InnoDB 的 redo 则相对稳定,因为大部分记录都是增量字节。SQLite 的 WAL 增长则和写入行数强相关,它不关心你改了页内多少字节,每帧固定整页写入,日志体积大约等于“受影响页数 × 页大小”。
这里有个容易被忽略的细节:full_page_writes只影响 checkpoint 之后每个页面第一次被修改时的日志,后续对同一页的修改记录又回到小体积变更。所以它的“放大”不是全局性的,而是周期性的。理解了这个机制,你再调checkpoint_completion_target或max_wal_size就更胸有成竹了——把 checkpoint 频率调低,等于减少 FPI 出现的次数,但 checkpoint 本身变长,崩溃时恢复日志量变大,这是个此消彼长的关系。
4.2 崩溃恢复速度的差异
理论上,日志内容越完整,恢复时对原始状态的依赖越少,恢复越快。完整页镜像可以直接覆盖,物理偏移型必须确保页面基础数据正确,逻辑操作型还要重新执行状态转换逻辑。实际恢复时间不只取决于日志记录类型,还取决于 WAL 总大小和事件顺序,但变种仍然会影响恢复路径的复杂度。
PostgreSQL 遇到校验和失败或者部分页写时,全页镜像就是救命稻草;InnoDB 则依赖 doublewrite 保证页完整,再用 redo 的物理补丁重放,两条路径各有各的恢复成本。SQLite 的 WAL 恢复本质是页替换,速度非常快,这也是它选择整页镜像的一个隐藏优势:不需要状态机,只需要覆盖。如果你在 SQLite 里见过一天只增加几 MB 的 WAL,崩溃恢复通常一瞬间完成;反倒是那些频繁 checkpoint 或大量随机页修改的数据库,恢复时可能要把海量小记录翻出来逐条应用,耗时就上去了。
4.3 对部分写和文件损坏的抵抗能力
把 WAL 记录内容变种和数据损坏风险联系起来,是最有价值的视角。完整页镜像型:经受得住 torn page 的破坏,但整个 WAL 文件如果损坏,依然会导致恢复失败。物理偏移型:必须搭配 doublewrite 或类似机制,否则 torn page 对恢复的破坏可能是灾难性的。逻辑操作型:对页损坏的容忍度最低,因为重放依赖现有数据结构的正确性。
所以不要轻易断言“WAL 用了就是保险”。你需要确认自己用的是哪一种变种,以及配套机制是否齐全。比如 PostgreSQL 中如果有人把full_page_writes关了,同时又没有存储层面的页保护,崩溃恢复就可能因为一个坏页报could not read block,这是非常真实的坑。下表能帮你快速对照:
| 变种类型 | 日志体积 | 恢复速度 | 抗 torn page | 典型系统 |
|---|---|---|---|---|
| 完整页镜像 | 大 | 快、直接 | 强 | SQLite WAL、PostgreSQL FPI |
| 物理偏移变更 | 小 | 较快 | 弱,需配合 doublewrite | InnoDB redo |
| 逻辑操作 | 中等 | 较慢,依赖状态机 | 弱 | etcd WAL、MongoDB oplog |
需要注意的是,这些特性不是绝对优劣,而是设计取向。SQLite 选择整页镜像,是因为它目标环境通常是单机嵌入式,WAL 文件虽然大一点但恢复逻辑简单可靠。MySQL 选择物理偏移 + doublewrite,是为了在高并发事务场景下把日志量压到最低。关键要看你的业务负载和可容忍的恢复时间。
5. 实操:把 WAL 内容拆开看
5.1 PostgreSQL:pg_waldump 里的 FPI 线索
PostgreSQL 排障时最常用的工具是pg_waldump。它可以列出 WAL 文件里的每一条记录,并且标明资源管理器、操作类型、事务 ID、FPI 标志等。假设 PostgreSQL 数据目录的pg_wal下有一个000000010000000000000001文件,执行:
pg_waldump /var/lib/postgresql/16/main/pg_wal/000000010000000000000001输出长这样:
rmgr: Heap len (rec/tot): 71/ 71, tx: 518, lsn: 0/16000028, prev 0/160000E0, desc: INSERT off=1 flags=0x00, blkref #0: rel 1663/13426/16385 blk 0 rmgr: Transaction len (rec/tot): 30/ 46, tx: 518, lsn: 0/16000078, prev 0/16000028, desc: COMMIT 2024-01-15 10:00:00.123456+08如果看到FPI,记录会变成:
rmgr: Heap len (rec/tot): 40/ 4136, tx: 529, lsn: 0/16000090, prev 0/16000078, desc: INSERT off=2 flags=0x00, blkref #0: rel 1663/13426/16385 blk 1, FPWlen (rec/tot)中的rec是实际记录头部数据长度,tot是包含页镜像后的总长度。两条记录一对比,你就能直观地看到“内容变种”对日志体积的影响:普通 INSERT 只有几十字节,而 FPW 记录突然就涨到了 4KB 以上。想进一步看细节,可以加-b输出 block 内容,或者用--rmgr=heap只过滤某个资源管理器。
5.2 SQLite:用 hexdump 看清 frame 变体
SQLite 没有命令行 WAL dump 工具,但格式很简单,适合用xxd直接观察。先让一个库进入 WAL 模式并插入数据:
sqlite3 test.db 'PRAGMA journal_mode=WAL; CREATE TABLE t1(a); INSERT INTO t1 VALUES(1);'然后看test.db-wal文件的前 64 字节:
xxd test.db-wal | head -4你会看到第一行是 32 字节的 WAL header,后面紧跟着 frame header。frame header 里有页号、提交标志、盐值、校验和,再往后就是整页数据。提交标志为 1 的帧代表一个事务的结尾,恢复时从这个点开始往回应用;页面镜像区域则能直接看到你插入的那一行。SQLite 还有个实用技巧:只读模式下通过PRAGMA wal_checkpoint(TRUNCATE);可以手动把 WAL 内容并回主库并截断文件,这也是验证 WAL 内容完整性的简单方式。
5.3 InnoDB:block 与 record type 的识别
InnoDB redo log 没有官方解析命令行,但知道结构就能用xxd做一些基础判断。先定位 redo 文件,老版本是ib_logfile0和ib_logfile1,8.0.30+ 是#innodb_redo目录下的多个文件。查看第一个 log block:
xxd ib_logfile0 | head -2每个 block 512 字节,前 4 字节是 block number,第 5-6 字节是 block 内数据长度,第 7-8 字节是首个记录偏移。真正的内容是一段段紧凑记录,很难直接阅读,如果你想深入解析,建议看 MySQL 源码storage/innobase/log/log0rec.cc里的recv_parse_log_recs或者找开源解析器。对于排障,重点不是逐字节读懂,而是确认 redo 文件还在、block 校验和是否正确、文件头里的日志序列号(LSN)是否是连续的。
5.4 RocksDB 和 etcd:ldb dump_wal 的实际输出
RocksDB 自带ldb工具可以打印 WAL 内容:
ldb --db=/path/to/rocksdb/data dump_wal --walfile=/path/to/rocksdb/data/000001.log输出类似:
Sequence,Count,Type,Size,Offset 1,1,Put(1),1,0 Put(1): key: foo value: bar每行的Type就是操作类型变种:Put、Delete、SingleDelete、RangeDeletion 等。如果某个 WAL 文件被分成了多个 fragment,你还能看到First、Middle、Last这样的分段类型,这是"一个大写操作超过 32KB block 时自动切割"的物理分段变种。etcd 没有这么直接的命令,但可以用etcdutl snapshot等工具处理快照,WAL 本身更多是给集群内部回放用的。
6. 常见问题与排障笔记
6.1 full_page_writes 被关闭以后
PostgreSQL 官方文档反复强调不要在生产环境把full_page_writes设为 off,除非你确定存储设备能保证扇区原子写。这个参数一旦关闭,崩溃恢复时如果遇到坏页,错误往往不是“日志缺失”,而是读取数据文件时报invalid page header或could not read block N in file ...。我排查过的一个案例:某云环境存储底层做了 RAID 卡电池保护,坚持关了full_page_writes,后来一次宕机恢复失败,最后只能从备份找回那一个脏页。这里我的建议是:云盘 RAID 只能降低概率,不能消除单页撕裂,默认开着才稳妥。如果实在担心日志膨胀,优先调整checkpoint_completion_target和max_wal_size,而不是关 FPW。
6.2 SQLite WAL 文件无限膨胀或损坏
SQLite 的 WAL 膨胀常见于高频写入且 checkpoint 一直无法触发。如果 WAL 漫无目的地增长,先检查是不是有连接长期处于读事务。注意,WAL 模式下读事务会固定某个 WAL 位置,老 frame 不能清理,这就会造成文件膨胀。坏掉的 WAL 文件通常表现为database disk image is malformed,处理时要先备份残留 WAL,再用PRAGMA wal_checkpoint试试,如果不行,只能依赖主库文件加上已有备份做恢复。另外要提一句:-wal和-shm文件最好不要乱删,删除后 SQLite 可能直接认为数据库损坏,经验不足的人很容易在这翻车。
6.3 redo 日志缺失或 WAL 被清空
MySQL 在重启时发现 redo log 不一致常见于日志文件被手动清理,比如有人看到磁盘告急把#innodb_redo目录清了一部分。这时 MySQL 会无法启动,报redo log corrupted或Cannot redo log。InnoDB 的物理偏移型日志本来就是一个环状复用结构,误删任何一个当前 LSN 区间内的文件都会导致恢复链断裂。etcd 也有类似情况:一旦 WAL 目录混乱,etcd 从旧 snapshot + 残缺 WAL 恢复可能起不来,正确的姿势是使用etcdctl snapshot restore从一致快照重建。无论哪个系统,WAL 都不能当作普通日志清理,它本身就是数据的一部分。
6.4 分组排查速查
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| WAL 文件持续暴涨 | 缺少 checkpoint;FPW 频繁;读事务挂起 | 调整 checkpoint 参数;排查长事务 |
| 恢复时报页校验失败 | torn page 未防住;FPW 关闭 | 开启 full_page_writes 或 doublewrite |
| SQLite 读库报损坏 | WAL 或 shm 异常 | 检查 -wal 完整性,不可乱删 |
| redo 日志启动失败 | 文件被误删 | 从备份恢复,不要手动编造 LSN |
| etcd 数据不一致 | WAL 与 snapshot 不匹配 | 使用 snapshot restore |
排障时最大的经验是不要凭现象猜,先回答三个问题:这个系统的 WAL 记录是哪一种变种为主?它依赖什么机制来防 torn page?日志文件会不会被外部工具误删?把这三个问题想清楚,大部分 WAL 问题都能定位到根因。
我的个人体会是:WAL 的“内容变种”不是死记硬背的概念,而是每次故障复盘时用来对照的第一性工具。遇到 WAL 膨胀,先想是不是整页镜像过多;遇到恢复失败,先想日志内容能否在不依赖任何半损坏页面的前提下重建数据。把这个思路理顺了,无论是 PostgreSQL、SQLite、InnoDB 还是 RocksDB,底层逻辑都是相通的。