1. CLOG 为什么只需要两种 Redo 记录CLOGCommit Log提交日志在 PostgreSQL 里承担着一个非常朴素但关键的职责记录每个事务的最终状态。事务结束之后代表这个事务是否提交的 2 bit 状态会被写进 CLOG 页其他事务在判断元组可见性时需要根据事务 ID 去 CLOG 里查这个状态。CLOG 的物理存储是 SLRU 页面每个页面固定 32KB保存CLOG_XACTS_PER_PAGE个事务的状态位。这里的CLOG_XACTS_PER_PAGE约等于 32768 个事务所以一个 CLOG 页可以覆盖 3 万多个事务 ID 区间。由于事务 ID 是 32 位无符号整数整个 CLOG 空间在理论上最多需要十几万个页面但实际运行中大部分页面永远不会被触达。真正让 CLOG 在恢复场景里显得特殊的地方在于绝大多数 CLOG 页面上的写入操作本身并不产生独立的 WAL 记录。这一点和很多人直觉相反。通常我们提到数据页修改第一反应都是先写 WAL 再落盘但 CLOG 的事务状态位修改走的是另一套逻辑——它会把状态位更新直接做到共享内存里的 CLOG 页上然后靠CLOGControlLock这种集中锁来做并发保护必要的时候落盘。那崩溃恢复时这些状态位怎么恢复答案是通过事务提交记录XLOG_XACT_COMMIT/XLOG_XACT_ABORT的重放来间接恢复。重放事务提交记录时系统会重新对对应事务 ID 执行TransactionIdSetTreeStatus之类的操作把该事务的状态位重新写进 CLOG 页。所以在重做机制里CLOG 资源管理器不需要逐个记录我这个事务状态位写到了哪一页、置成了什么值那些信息已经被更上层的事务提交记录覆盖了。那 CLOG 自身到底需要什么样的重做其实只有两种物理动作需要 WAL 保护一种是分配一个新 CLOG 页并把它清零也就是CLOG_ZEROPAGE另一种是把最老的、已经不再需要的 CLOG 页截断删掉也就是CLOG_TRUNCATE。分配和截断这两种操作都改变了 CLOG 文件在磁盘上的物理边界如果崩溃后主流程重放时不知道某个页已经被分配或已经被删除SLRU 缓冲区和实际文件就会脱节轻则误读状态重则启动直接报错。所以 CLOG 的 redo 函数clog_redo其实非常短两个分支一个是建页一个是删页仅此而已。从资源管理器维度看RM_CLOG_ID 3在 PostgreSQL 源码里属于一个非常稳定的 ID排在RM_XACT_ID和RM_SMGR_ID之后名称是clog。这个 ID 在 WAL 记录头里出现时代表这条记录属于提交日志子系统。全局的RmgrTable数组通过这个 ID 找到clog_redo、clog_desc、clog_identify等回调。如果你只是想知道重做时 CLOG 干了什么那么把整个clog_redo函数一页纸看完就够了但如果想知道为什么它只需要干这么点事就要把 CLOG 在正常事务处理、检查点和崩溃恢复之间的职责关系理清楚。打个比方来说CLOG 就像一个小区的门禁刷卡记录。正常情况下每天进进出出的刷卡动作会被记录、汇总这些记录本身不需要单独抄一份送物业存档因为物业的系统在每天结算时会重新生成汇总。真正需要在物业备份里体现的只是哪个门禁点哪一天开始启用和哪一段历史记录可以销毁这两个边界操作。CLOG 的 redo 就是这个逻辑它负责维护边界不负责维护内容。2. 定位代码RM_CLOG_ID 在源码里的登记方式先带大家把代码位置捋清楚读源码的时候你最先需要找到的是资源管理器登记表。在 PostgreSQL 源码树的src/include/access/rmgrlist.h文件里有一行看着特别不起眼PG_RMGR(RM_CLOG_ID, clog, clog_redo, clog_desc, clog_identify, clog_startup, clog_cleanup)这行宏展开后会往RmgrTable数组里填一项。数组的下标就是资源管理器 ID所以RmgrTable[3]对应的就是 CLOG。RM_CLOG_ID这个常量定义在src/include/access/rmgr.h里#define RM_CLOG_ID 3PgRmgrName之类的外部符号是由rmgrlist.h宏自动生成的平时排查问题时你更常用的是pg_waldump --rmgrclog这种过滤方式。WAL 记录的头部会包含资源管理器 IDpg_waldump拿这个 ID 去查表然后调用clog_desc把记录内容打印成人能读懂的形式。CLOG 资源管理器总共只有两种 WAL 记录类型宏定义在src/include/access/clog.h里#define CLOG_ZEROPAGE 0x00 #define CLOG_TRUNCATE 0x10注意这里的定义方式0x00和0x10不是随便写的低位 4 bit 作为标志位保留给了 XLR 通用信息比如XLR_INFO_MASK而高位的0x10才能区分不同记录类型。clog_redo函数里也是先用XLogRecGetInfo(record) ~XLR_INFO_MASK把通用标志位去掉再拿剩下的值去匹配CLOG_ZEROPAGE或CLOG_TRUNCATE。这两类记录携带的数据结构同样定义在clog.htypedef struct xl_clog_zeropage { int32 pageno; /* 需要清零并分配的 CLOG 页号 */ } xl_clog_zeropage; typedef struct xl_clog_truncate { TransactionId oldestXact; /* 截断后保留下来的最老事务 ID */ TransactionId oldestXactPerBackup; /* 最近一次物理备份对应的保留事务 ID */ } xl_clog_truncate;clog_desc函数会把这两条记录的格式化文本交回来所以你用pg_waldump看到的输出大体长这样rmgr: clog , rec/logical: 20/20, tx: 0, lsn: 0/16B2, prev 0/16A1, desc: TRUNCATE: oldestXact 726, oldestXactPerBackup 726 rmgr: clog , rec/logical: 20/20, tx: 0, lsn: 0/16B3, prev 0/16B2, desc: ZEROPAGE: pageno 150第一次看到这种输出时你可能觉得记录量怎么这么小实际确实小。CLOG 的 WAL 流量在整个系统里占比很低在一个长时间运行的实例里可能只有少量 ZEROPAGE 和 TRUNCATE 记录。恢复时跑完这些记录CLOG 的物理边界就对了。3. 逐行拆解 clog_redo 的 ZEROPAGE 分支现在正式进入重做函数。clog_redo本体在src/backend/access/transam/clog.c代码量非常紧凑。我用 PostgreSQL 15/16 的分支代码为基础把核心部分拆开讲。void clog_redo(XLogReaderState *record) { uint8 info XLogRecGetInfo(record) ~XLR_INFO_MASK; if (info CLOG_ZEROPAGE) { xl_clog_zeropage *xlrec (xl_clog_zeropage *) XLogRecGetData(record); int pageno xlrec-pageno; int slotno; LWLockAcquire(CLOGControlLock, LW_EXCLUSIVE); slotno SimpleLruZeroPage(ClogCtl, pageno); SimpleLruSetLatestPage(ClogCtl, pageno, slotno); LWLockRelease(CLOGControlLock); } ... }这个分支干的事情就三件拿锁、零页、更新最新页标记。我们一个一个看。3.1 为什么重做时要拿 CLOGControlLockCLOGControlLock是保护 CLOG 共享内存结构的一个集中式 LWLock。正常运行时任何事务在写状态位之前都要以共享方式拿这个锁写完后释放分配新页时则以排他方式拿锁。恢复期间虽然主要是单进程在跑 WAL 重放但热备模式下后端进程可能同时在读 CLOG、甚至执行只读事务如果重放ZEROPAGE时不加锁另一个后端正在TransactionIdGetStatus读同一个 CLOG 页两边就可能发生竞争。所以代码里老老实实拿了LW_EXCLUSIVE这跟正常路径里ExtendCLOG拿锁的方式完全对称。3.2 SimpleLruZeroPage 做了什么SimpleLruZeroPage是 SLRU 层提供的通用函数函数名里的 Zero 才是精髓。它会做以下几件事检查请求的页号是否已经存在于 SLRU buffer 中。如果恰好已经在缓冲里说明这个页此前已经被初始化过那就不需要真正做零页操作直接返回这个页面所在 buffer 的 slot 编号。如果页面不在缓冲里就找一个空闲或可淘汰的 buffer slot。如果所有 slot 都被占用就按 SLRU 的淘汰策略选一个 victim把它刷盘或直接丢弃腾出位置。在拿到的 slot 上把整个 32KB 页面 memset 成 0同时把页面的 LSN 设置为当前重放的 WAL 记录位置这也就是零页与 WAL 对齐的关键点页面内容虽然是空的但它的 LSN 必须在重放位置之后否则后续日志会认为这个页面过期了。把该 slot 标记为 dirty注册到 SLRU 的目录信息里。这里有个值得注意的细节正常路径下分配一个新 CLOG 页时写入方会在把状态位写进去之前对页面做零初始化然后再写状态位。而重放路径拿到的ZEROPAGE记录只负责确保这个页面存在且是干净的后续事务提交记录重放时会在这个干净的页面上写状态位。所以两者配合起来顺序就是先有 ZEROPAGE 垫底再有 commit/abort 状态写入。3.3 SimpleLruSetLatestPage 的意义SimpleLruSetLatestPage通常不会引起太多注意但它解决的问题非常实际。SLRU 的Control-latest_page_number字段保存了当前已知的最大页号。正常运行中这个值会被SimpleLruZeroPage或者扩展逻辑更新。但恢复期间重放进程可能是从任意起点开始的如果某个ZEROPAGE记录的页号比当前latest_page_number还大而代码又不更新这个字段之后下一次拿到更大页号的页面时缓冲区里的淘汰逻辑可能会误判页面范围甚至可能出现明明已经存在却还尝试新建的低级错误。SimpleLruSetLatestPage(ClogCtl, pageno, slotno)这个调用把最新页号字段和实际的 buffer slot 绑定到一起确保重放完成后 SLRU 内部状态和磁盘页边界一致。你要是用源码调试过崩溃恢复就会发现这行如果不加后续在缓冲区里按页号查找时可能出现非常诡异的定位失败定位过程还不好排查因为报错往往不在 CLOG 这边而在后续读取事务状态的调用方。4. ZEROPAGE 重放的边界条件和隐藏细节ZEROPAGE分支虽然短但实际应用中有几个边界条件非常值得注意。4.1 重放时页面已经被占用怎么办SimpleLruZeroPage的语义是如果页面已经在缓冲里就直接返回已有 slot。它并不会再清零一遍。什么意思呢假设恢复过程中某个页此前已经被一个正常运行的提交记录写过状态位此时又遇到一条ZEROPAGE记录SimpleLruZeroPage会发现在缓冲里已经存在该页于是直接返回现有 slot并不会把已写入的状态位覆盖掉。这个行为是有意为之ZEROPAGE记录在正常路径下只在页面第一次分配时产生重放顺序上不会有后写状态、再零页的逆序情况。所以即使你看到重放日志里ZEROPAGE的页号比某个提交事务所在页号小也不会真的把数据冲掉。4.2 ZEROPAGE 和 CLOG 页 LSN 的相互校验CLOG 页属于 SLRU 页但它的 LSN 校验逻辑和其他 SLRU 略有不同。clog_redo里SimpleLruZeroPage返回的 slot 会把页 LSN 设成 WAL 当前位置这样后续如果有人读取这个页面并对照 WAL 判断它是旧的还是新的能有一个明确的分界。恢复完成后CheckPointCLOG会把所有脏页刷掉并在检查点记录里记下clog相关的目录信息。这些孤儿页面之后如果再次被读取会以检查点之后的最新位置为准。4.3 为什么正常路径也会产生 ZEROPAGE 日志正常事务运行时一旦需要往一个新的 CLOG 页写状态位写入方会调用ExtendCLOG然后注册一条CLOG_ZEROPAGE记录。这时候记录里的pageno就是新页号。写到 WAL 里的原因很简单如果这个新页没有在 WAL 里留下痕迹那么崩溃恢复时系统根本不知道有这一页后续重放该页上的事务提交记录时SLRU 会认为页号非法进而报错。你可以把ZEROPAGE理解成文件系统层面的扩展预留。它不携带任何事务状态数据只是告诉恢复进程某页已经在物理上被预留了别再把它当不存在。5. 拆解 TRUNCATE 分支CLOG 截断为什么是重做里最需要谨慎的一步再看clog_redo的另一个分支。TRUNCATE 分支的代码更短但它背后的逻辑比 ZEROPAGE 要复杂得多。else if (info CLOG_TRUNCATE) { xl_clog_truncate *xlrec (xl_clog_truncate *) XLogRecGetData(record); TransactionId oldestXact xlrec-oldestXact; TransactionId oldestXactPerBackup xlrec-oldestXactPerBackup; truncate_clog(oldestXact, oldestXactPerBackup); }5.1 截断动作一旦做错后果很严重CLOG 页是顺序增长的文件每个页对应一段连续事务 ID 区间。事务 ID 在 CLOG 里一旦被截断就没有任何其他机制能恢复那些页里的状态位。如果截断点算得太激进把仍有事务在用的页面删掉重放后续提交记录时就会往一个不存在的页面上写状态如果截断点算得太保守又可能导致磁盘上的 CLOG 文件无限膨胀。重做路径必须确保一件事恢复时遭遇截断记录后删掉的页面一定没人再需要了。实现上CLOG 会计算一个可以安全截断的边界然后调用TruncateCLOG。TruncateCLOG内部会扫描clog目录下的所有文件找出最早仍然活着的 CLOG 页然后据此删掉更早的页面。扫描和删除的动作都会在CLOGControlLock保护下完成避免和并发读取竞争。你在源码里看TruncateCLOG时能看到它调用了SlruScanDirCbReportPresence之类的回调目的就是在所有 CLOG 文件中找出最小有效页号。5.2 两个事务 ID 的差异以及为什么备份也会影响截断xl_clog_truncate里有两个字段oldestXact和oldestXactPerBackup。前者表示当前数据库中仍然可能存在的最早事务 ID后者表示最近一次备份中可能涉及的最早事务 ID。为什么备份和截断会扯上关系因为如果发生了物理备份备份制作期间 WAL 里可能只记录了很少一部分事务恢复备份时pg_wal_replay必须从备份点开始重放一直重放到截断发生之后的 WAL 位置。如果截断时把备份点所依赖的 CLOG 页删掉了那么从备份恢复出来的实例在重放后面 WAL 时可能需要读取已经被删掉的早期事务状态结果就是状态缺失。所以在正常运行时CLOG 截断的边界不是简单取oldestXact而是取Min(oldestXact, oldestXactPerBackup)对应的页面边界。这个保守策略牺牲了一点磁盘空间换取备份恢复的可靠性。重做路径里truncate_clog(oldestXact, oldestXactPerBackup)内部会把这两个值都纳入考虑再执行真正的删页。5.3 重做时截断过程中崩溃了怎么办恢复本身也是可能中途再次崩溃的。假如重放流程先执行了 ZEROPAGE还没来得及执行后面的 TRUNCATE 就崩溃那没关系下次启动会从检查点或redo起点重新开始。假如已经执行了 TRUNCATE但还没执行下一条 WAL 记录就崩溃那已经删掉的页面也是安全的因为截断点本身是从 WAL 记录里解析出来的而且当时已知事务状态。这保证了重做的幂等性反复执行同一条 TRUNCATE 不会破坏数据。需要提醒的是重放 TRUNCATE 时代码并不会每次真正执行物理文件删除而是把截断任务交给SlruScanDirCbPresence和之后可能的SlruDeleteSegment等操作。这些操作的成败不会影响数据页状态位的完整性只影响磁盘空间是否被回收。所以某种场景下你可能看到恢复后 CLOG 目录里还留着一些多余文件这不是 bug只是说明截断动作在重放时选择了保守路径。6. CLOG Redo 在整个崩溃恢复流程里的实际运行节奏理解了clog_redo的代码后再把视野放大看看它在整条恢复链路里是怎么被调起来的。xlogrecovery.c的PerformWalRecovery循环里每读出一条 WAL 记录都会通过RmgrTable[rmid].rm_redo(record)派发。CLOG 记录就是这样进入clog_redo的。6.1 恢复顺序和页面缓存状态的关系恢复过程中CLOG 页面会通过 SLRU buffer 被读进共享内存。这些页面在正常状态下可能已经落盘也可能是恢复过程中临时写入的脏页。重做 ZEROPAGE 时新零页会被放置到 buffer 里并标记为脏重做提交记录时状态位更新同样发生在 buffer 里的 CLOG 页上之后通过CheckpointCLOG一并落盘。也就是说恢复期间 CLOG 并不需要每一条 WAL 都同步刷盘它仍然是缓冲页 延迟落盘的经典 SLRU 模式。这也意味着clog_redo处理完的页面不一定立刻就是磁盘上的真实状态但检查点会保证最终一致性。6.2 热备模式下重做 CLOG 的并发行为热备hot standby模式下后端进程可以同时执行只读查询。事务可见性判断会读取 CLOG 检查历史事务状态。此时如果重放进程正在对某个 CLOG 页做 ZEROPAGE 或 TRUNCATE后端进程读取时需要和重放进程在CLOGControlLock上排队。这不会阻塞太久因为 CLOG 重做记录数量少单个操作也只是内存页面初始化或目录更新开销极低。你如果在热备库上执行pg_stat_database观察锁等待通常会看到这类等待非常短暂几乎可以忽略。6.3 CLOG 记录在检查点之间的排布你可以把 WAL 尾部想象成一条时间线ZEROPAGE记录出现在某个页第一次被分配的时刻TRUNCATE记录出现在最老事务被推进到足够安全之后。检查点本身会把 CLOG 的最新页码和目录信息写入检查点记录但检查点记录并不会替代ZEROPAGE和TRUNCATE它只是给恢复过程一个起点边界。因此恢复时如果从最近一次检查点开始重放中间如果跨越了一个TRUNCATE恢复进程会正确截断 CLOG 文件如果没有TRUNCATE那么即使latest_page_number和实际文件不一致后续的 ZEROPAGE 也会把边界补齐。7. 我实际排查 CLOG 重做问题时的经验与工具前面把原理讲得差不多了最后分享一些实际排查中验证过的方法以及踩过几次坑之后总结的小技巧。7.1 用 pg_waldump 观察 CLOG 记录当你想确认某个崩溃点前后的 CLOG 状态时第一件事永远是开pg_waldump。最直接的命令是pg_waldump --rmgrclog --path/path/to/pg_wal这个命令会把 WAL 里所有 CLOG 相关记录列出来。从中你能看到三类关键信息pageno出现的位置、oldestXact的递进关系、oldestXactPerBackup和oldestXact什么时候出现偏离。如果发现oldestXactPerBackup长期大于oldestXact说明截断动作在等待备份窗口关闭这通常是正常现象如果发现某个TRUNCATE记录里oldestXact突然回退那就要警惕是不是事务 ID 回卷边界处理出了问题。7.2 模拟 CLOG 缺失看恢复反应想验证恢复路径对 CLOG 缺失的容错能力可以在恢复前手动把某个 CLOG 段文件改名或删掉然后启动恢复。通常你会看到类似could not access status of transaction的报错。这个行为能帮你理解 CLOG ZEROPAGE 记录如果不能正确重放会导致哪一层 API 爆出问题。我建议在测试环境里动手试一次比读十遍注释都管用。测试时一定要留好原始文件备份因为这种模拟很容易把实例搞到无法自动恢复需要手工修复。7.3 关于截断的记录保留期虽然clog_redo只处理两条记录但排查问题时不要只看 CLOG 本身的 WAL。截断动作通常发生在vacuum推进datfrozenxid之后或者pg_wraparound相关的预防性维护流程里。所以一条CLOG_TRUNCATE记录旁边往往能找到对应的XLOG_XACT或其他资源管理器记录。把多条 WAL 记录串起来看才能还原出是谁触发了这次截断、事务边界是怎么推进的。7.4 一句常见误区的纠正有人以为 CLOG 资源和提交事务记录是同一条 WAL实际上XLOG_XACT_COMMIT属于事务资源管理器RM_XACT_ID而 CLOG 记录属于独立的RM_CLOG_ID。两者虽然紧挨着出现但派发函数完全不同。你如果用一个过滤 RMID 的工具去只筛 CLOG会看不到提交记录但这不代表提交记录不存在。排查时务必把两个资源管理器分开看避免被误导。CLOG 的重做逻辑在整个 PostgreSQL 恢复框架里算是一个小而美的样本它用最少的记录类型覆盖了最容易出物理问题的两个边界动作。你在源码里看到clog_redo时可能只有几十行代码但这几十行背后承载的是 SLRU、WAL 重放、事务状态推进和备份恢复之间复杂的协同约定。我个人在给同事讲这部分时最喜欢让他们先忽略 redo 本身先去理解 CLOG 为什么不需要逐条记录状态位修改想通了这一点再看ZEROPAGE和TRUNCATE就会觉得一切顺理成章。如果你正在读同一段源码希望在RmgrTable那行宏定义上多停两秒从资源管理器的视角重新看一遍整个恢复流程很多曾经的疑问会一下子解开。