
前阵子帮一个业务库排查启动故障来回翻了半天的pg_waldump输出才看明白同样是 WALheap 模块的日志在 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 之后第一次页面修改就自动附加FPIFull Page Image标志整个记录从“物理偏移型”临时变成“页镜像型”。InnoDB 绝大多数 redo 是物理偏移型但某些特殊操作又会带近整页的信息。SQLite 则“固执”很多WAL 模式不管什么操作都写完整页镜像不做第二种变种。因此在排查故障的时候不能只看这个数据库“用了什么日志模式”还要关注特定时间点、特定记录类型下日志内容是否发生了形态切换。3. 主流系统 WAL 内容的字节级拆解3.1 PostgreSQLresource manager 驱动的记录变种PostgreSQL 的 WAL 设计非常有代表性。每条 WAL 记录在文件里是一个XLogRecord头部字段依次是xl_tot_len记录总长度xl_xid事务 IDxl_info记录类型和标志位其中XLR_FPI标志说明这条记录带了完整页镜像xl_rmid资源管理器 IDxl_prev上一条记录的位置xl_crcCRC 校验值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每帧都是整页镜像的 WALSQLite 的 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_logfile0ib_logfile1改成了#innodb_redo目录下的 32 个文件并配合 32 个线程并发刷新。这个变化没有改变 redo 记录的基本内容变种但改变了 WAL 管理方式在排查 redo 缺失问题时需要知道新老版本的差异。3.4 RocksDB 和 etcd把操作语义写进日志RocksDB 的 WAL 格式也很有意思。它把日志文件分成若干 32KB 的 block每条物理记录有一个 7 字节的 header4 字节 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物理偏移变更小较快弱需配合 doublewriteInnoDB redo逻辑操作中等较慢依赖状态机弱etcd WAL、MongoDB oplog需要注意的是这些特性不是绝对优劣而是设计取向。SQLite 选择整页镜像是因为它目标环境通常是单机嵌入式WAL 文件虽然大一点但恢复逻辑简单可靠。MySQL 选择物理偏移 doublewrite是为了在高并发事务场景下把日志量压到最低。关键要看你的业务负载和可容忍的恢复时间。5. 实操把 WAL 内容拆开看5.1 PostgreSQLpg_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 off1 flags0x00, 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.12345608如果看到FPI记录会变成rmgr: Heap len (rec/tot): 40/ 4136, tx: 529, lsn: 0/16000090, prev 0/16000078, desc: INSERT off2 flags0x00, blkref #0: rel 1663/13426/16385 blk 1, FPWlen (rec/tot)中的rec是实际记录头部数据长度tot是包含页镜像后的总长度。两条记录一对比你就能直观地看到“内容变种”对日志体积的影响普通 INSERT 只有几十字节而 FPW 记录突然就涨到了 4KB 以上。想进一步看细节可以加-b输出 block 内容或者用--rmgrheap只过滤某个资源管理器。5.2 SQLite用 hexdump 看清 frame 变体SQLite 没有命令行 WAL dump 工具但格式很简单适合用xxd直接观察。先让一个库进入 WAL 模式并插入数据sqlite3 test.db PRAGMA journal_modeWAL; 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 InnoDBblock 与 record type 的识别InnoDB redo log 没有官方解析命令行但知道结构就能用xxd做一些基础判断。先定位 redo 文件老版本是ib_logfile0和ib_logfile18.0.30 是#innodb_redo目录下的多个文件。查看第一个 log blockxxd 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 和 etcdldb 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 文件持续暴涨缺少 checkpointFPW 频繁读事务挂起调整 checkpoint 参数排查长事务恢复时报页校验失败torn page 未防住FPW 关闭开启 full_page_writes 或 doublewriteSQLite 读库报损坏WAL 或 shm 异常检查 -wal 完整性不可乱删redo 日志启动失败文件被误删从备份恢复不要手动编造 LSNetcd 数据不一致WAL 与 snapshot 不匹配使用 snapshot restore排障时最大的经验是不要凭现象猜先回答三个问题这个系统的 WAL 记录是哪一种变种为主它依赖什么机制来防 torn page日志文件会不会被外部工具误删把这三个问题想清楚大部分 WAL 问题都能定位到根因。我的个人体会是WAL 的“内容变种”不是死记硬背的概念而是每次故障复盘时用来对照的第一性工具。遇到 WAL 膨胀先想是不是整页镜像过多遇到恢复失败先想日志内容能否在不依赖任何半损坏页面的前提下重建数据。把这个思路理顺了无论是 PostgreSQL、SQLite、InnoDB 还是 RocksDB底层逻辑都是相通的。