先说结论CLOG Redo是PostgreSQL崩溃恢复流程里最轻量、却也最容易被忽略的一环。它没有堆表那样的页面恢复逻辑也不像事务日志那样有复杂的record拆解整个clog_redo入口函数短到只有十行左右。但正因为短很多人扫一眼就跳过了结果遇到pg_xact膨胀、恢复时报could not access status of transaction这类问题时又绕回来重新啃代码。这篇就把RM_CLOG_ID 3的Redo逻辑一次讲透顺带把CLOG的页面组织、WAL记录格式、截断时机都串起来适合正在读PG源码的开发者也适合排查崩溃恢复问题的DBA参考。1. 先说清楚CLOG是什么以及它为什么需要REDO1.1 事务状态的两比特秘密CLOG全称Commit Log在PG10之后物理目录从pg_clog改名为pg_xact但内核里的函数名、变量名仍然带着clog字样。这套机制的核心任务非常朴素记录每个事务的最终状态。每个事务只占2个bit状态总共四种0b00IN_PROGRESS事务进行中0b01COMMITTED已提交0b10ABORTED已回滚0b11SUB_COMMITTED子事务已提交但父事务未结束一个字节能装4个事务的2bit状态。按默认8KB的BLCKSZ计算一个CLOG页面可以容纳8192 * 4 32768个事务也就是2的15次方。这也直接决定了后续所有位运算事务号右移15位得到页号低15位是页内偏移。这种紧巴巴的bit布局和Linux文件系统的inode位图有点像都是为了在有限空间里塞下尽可能多的状态记录。1.2 CLOG内容能靠事务日志重建为什么还要单独担保很多人第一次看CLOG源码都会有同一个疑问CLOG页面上那些2bit状态不是可以通过重放XLOG_XACT_COMMIT、XLOG_XACT_ABORT这类事务记录来重建吗那为什么还需要CLOG自己的WAL日志实际上事务状态确实是从事务日志重放的这一点没有错但CLOG文件本身还有一个物理层面的问题pg_xact下的文件必须存在、页面必须被清零事务状态才能写进去。举个实际场景当WAL里某条COMMIT记录被重放恢复进程要在这个事务对应的CLOG页里把状态从IN_PROGRESS改成COMMITTED。如果这个页面所在的CLOG文件还没创建或者页面内容是不可预知的旧数据那恢复就会失败。CLOG的WAL日志正是为这种“物理副作用”兜底每当要启用一个新的CLOG页就写一条CLOG_ZEROPAGE日志保证即使这个页还没落盘恢复时也能按日志把页面重建为零。截断文件同理删除文件这个动作本身也必须进WAL否则恢复后日志说截断了但文件还在或者反过来文件丢失了都没法查。所以CLOG的Redo策略可以概括成一句话不记录派生数据的每次修改只记录页面分配和文件截断这类物理管理动作。这种设计在数据库内核里很典型凡是内容能靠其他日志重建的缓存结构都没有必要为每一个bit的变更写日志。1.3 RM_CLOG_ID3在恢复框架里的角色PostgreSQL把WAL记录按资源管理器Resource Manager分类每种类型的日志有自己的redo函数。RM_CLOG_ID 3对应的就是CLOG这一大类注册在src/backend/access/rmgrdesc和src/backend/access/transam/clog.c之间。恢复时StartupXLOG每读到一条WAL记录就会从Rmgrs[rmid].rm_redo取出对应的处理函数。RM_CLOG_ID的redo回调就是clog_redo。这里有个小细节值得注意CLOG相关WAL记录的rmid是3但这个数字并不是固定不可变的。每往rmgrlist.h里加一种新的资源管理器后面的ID都会后移。读旧版本WAL时如果发现unknown op code错误除了日志损坏也要考虑是不是版本之间RM ID发生了偏移。2. 解开clog_redo入口的两条分支2.1 函数入口和info提取以PG15/16的源码为例clog_redo长这样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); ZeroCLOGPage(xlrec-pageno, false); } else if (info CLOG_TRUNCATE) { xl_clog_truncate *xlrec (xl_clog_truncate *) XLogRecGetData(record); TruncateCLOG(xlrec-startXid, xlrec-endXid, xlrec-truncXid); } else elog(PANIC, clog_redo: unknown op code %u, info); }函数第一行用~XLR_INFO_MASK把info的高位掩掉拿到真正的操作码。这里XLR_INFO_MASK包含了XLR_INFO_MASK位的集合比如是否包含FPI、是否为origin消息等标记位必须剥掉才能得到干净的info。如果走完判断都没有匹配的info直接PANIC因为恢复阶段遇到未知操作码意味着WAL可能损坏不能继续猜着往后走。回调函数里看不到任何遍历页面、修改bit的代码这正是前面说的设计思路普通的事务状态变化不归CLOG自己的redo管而是由事务日志的redoxact_redo负责。CLOG redo只管两个动作页面清零和文件截断。2.2 CLOG_ZEROPAGE为新页面拍一张空白底片当CLOG需要启用一个新页面时正常路径下调用ZeroCLOGPage(pageno, true)第二个参数传true表示要写WAL。而在redo路径里调用ZeroCLOGPage(xlrec-pageno, false)传false表示不要再写WAL否则就会无限递归。static void ZeroCLOGPage(int pageno, bool write_wal) { int slotno SimpleLruZeroPage(ClogCtl, pageno); ... if (write_wal) { xl_clog_zeropage xlrec; xlrec.pageno pageno; XLogBeginInsert(); XLogRegisterData((char *) xlrec, sizeof(xlrec)); XLogInsert(RM_CLOG_ID, CLOG_ZEROPAGE); } }redo时只是把该页在SLRU缓冲区里清零并做必要初始化之后提交记录的redo自然会往这个干净页面上写状态。关键点在于ZEROPAGE日志本身不包含页面内容因为清零的结果是确定的重放时只要把页面置零即可不需要记录整页数据。实际操作中SimpleLruZeroPage会额外检查磁盘上文件是否存在不存在就创建并扩展。恢复到一条很老的ZEROPAGE日志时即使对应文件后来被截断了也会先把文件建出来再清页面。这保证了后续事务状态写入有地方落脚。2.3 CLOG_TRUNCATE把物理删除也记入日志CLOG文件不能无限增长必须有截断机制。检查点之后早于最老活跃事务的CLOG页已经没有保留价值TruncateCLOG会把对应的文件删除。删除文件这个动作从逻辑上无法通过其他日志重建所以必须在删除前写一条CLOG_TRUNCATE记录。redo时调用TruncateCLOG(xlrec-startXid, xlrec-endXid, xlrec-truncXid)它会重新计算需要截断到的页号并调用SimpleLruTruncate。这里最微妙的地方在于截断边界不能把还包含活跃事务的页面删掉但也不能因为边界太保守而让pg_xact无限膨胀。源码里通过三个事务号共同锁定范围来避免误删。3. CLOG页编号换算从XID到pageno的位运算3.1 从XID到页内偏移的位运算CLOG的索引方式是纯粹的位运算。默认参数下每个事务2bit状态一个字节4个事务一页8KB共8192字节每页事务数 8192 * 4 32768 2^15所以给定一个TransactionId xidint pageno xid 15; int byteno (xid 32767) 2; int bshift (xid 3) * 2;byteno是页面内的字节偏移bshift是这个事务在该字节内的bit偏移量。如果只想要页号pageno xid 15就够了。源码里的TransactionIdToPage宏就是这个逻辑。读代码的时候要注意TransactionIdToCTime这类函数返回的是一个CTime结构体里面包含pageno和offset而offset又拆成字节号和bit位移。把这三层拆开再回看TransactionIdSetTreeStatus里的循环写入逻辑就会非常顺畅。3.2 日志记录里到底存了什么字段CLOG_ZEROPAGE的日志记录结构极简typedef struct xl_clog_zeropage { int pageno; } xl_clog_zeropage;只有页号没有别的。因为清零这个动作是幂等的、确定性的拿到页号就能完全重建。CLOG_TRUNCATE的字段就多一些typedef struct xl_clog_truncate { int pageno; TransactionId startXid; TransactionId endXid; TransactionId truncXid; } xl_clog_truncate;pageno是这次截断操作涉及的最高页号truncXid是截断截止点对应的事务号startXid和endXid描述的是截断时扫描的事务范围。多出来的这些字段不是装饰品它们是用来校准SimpleLruTruncate的cutoff边界防止把活跃事务的状态页一并删掉。3.3 为什么TRUNCATE要带上三个XID只看truncXid似乎已经够了凡是小于truncXid对应页的CLOG页都可以删除。但TruncateCLOG内部需要计算真正的cutoff页startXid和endXid在这个计算中起到双重保险的作用。SimpleLruTruncate虽然以cutoff页为界删除文件但它还会检查SLRU缓冲池里每个slot的使用状态如果某个页面的最新版本还在缓冲池里就不能直接物理删除文件至少要把页面内容先写出去或者标记。startXid和endXid让TruncateCLOG能更精确地判断哪些页面范围是这次截断操作真正关心的避免因为共享缓冲池里有旧页面残留导致文件截断时机出现偏差。实际排障时如果看到pg_xact文件数量远超预期通常不是TRUNCATE日志本身的问题而是长事务或复制槽把最老活跃事务卡住了。此时可以顺着TruncateCLOG的参数一路往上查最后都会指向快照计算出的oldestSafeXid。4. 从提交事务到崩溃恢复CLOG REDO的完整链条4.1 正常路径下CLOG页面是如何写入的正常事务提交时TransactionIdCommitTree会调用TransactionIdSetTreeStatus把本事务对应的2bit置为COMMITTED如果涉及子事务还要一并置为SUB_COMMITTED。写入流程大致是根据XID计算pageno通过SimpleLruReadPage或SimpleLruZeroPage拿到CLOG缓冲区slot对页面加写锁修改对应的bit位该页是新建页面时先通过ZeroCLOGPage写ZEROPAGE日志事务提交的完整状态变化则由更高层的事务日志XLOG_XACT_COMMIT负责记录这里有一个初学者容易绕晕的点修改已有的CLOG页面里的bit并不会为这个修改单独写WAL。原因是这个修改可以通过重放XLOG_XACT_COMMIT再次发生CLOG只是一层可派生缓存。但页面分配不行因为如果崩溃前文件里根本没有对应页重放提交记录时没有地方可以写。所以ZEROPAGE正好补上这个缝隙。4.2 日志、共享内存和磁盘文件三者的同步边界理解CLOG的同步边界可以把它想象成一套带写缓存的位图文件。共享内存里的SLRU buffer是第一个缓存层pg_xact目录下的文件是第二层。正常运行时页面先在SLRU buffer里被修改脏页会在后续被刷到磁盘。由于CLOG的bit修改不需要立即落盘崩溃时内存里的最新状态可能丢失但没关系重放事务日志能把状态重新算出来。真正需要严格同步的是文件的存在性和内容可预期性。ZEROPAGE日志保证页面的存在TRUNCATE日志保证文件的删除是可重放的。所以在恢复流程里CLOG redo不需要重放所有bit修改只需维护好文件系统的“形状”剩下的事务状态由xact_redo去填充。这个边界画清楚之后再看崩溃恢复反而觉得CLOG部分很清爽。恢复进程先根据checkpoint确定重放起点然后顺序扫WAL遇到ZEROPAGE就建页清零遇到TRUNCATE就删文件遇到事务提交/回滚记录就写CLOG bit三条线互不干扰。4.3 REDO顺序为什么不会覆盖已恢复的提交状态有个经典疑点假设WAL里先后有COMMIT记录和ZEROPAGE记录后者重放时把整个页面清零会不会把前者刚恢复的COMMIT状态抹掉答案是不会。WAL里的记录有严格的LSN顺序ZEROPAGE日志如果在COMMIT之后说明这个CLOG页是在那个事务提交之后才被新分配的。新分配的页面不可能包含之前事务的状态。反过来如果ZEROPAGE日志在COMMIT之前说明页面在事务提交前就已经存在并清零COMMIT记录重放时又会把对应bit写进去。两者之间的序关系由WAL顺序天然保证。这也提醒了一个实践要点恢复阶段不要手动修改或删除pg_xact下的文件。哪怕只是删了一个看起来很老的CLOG文件也可能让后续重放找不到页面直接抛could not access status of transaction。5. 实操调试亲手观察一次CLOG REDO5.1 用pg_waldump查看RM_CLOG记录要观察RM_CLOG_ID 3的日志pg_waldump是最快的工具。先确认实例的wal_level至少是replica然后找到WAL段文件pg_waldump -p $PGDATA/pg_wal -r 3 000000010000000000000001-r 3表示只输出resource manager id为3的记录也就是CLOG相关日志。正常跑过一段时间的事务后能看到类似这样的输出rmgr: clog len (rec/tot): 8/ 8, tx: 0, lsn: 0/016A6C10, prev 0/016A6BE8, desc: zeropage 00000000 rmgr: clog len (rec/tot): 12/ 12, tx: 0, lsn: 0/016A7B20, prev 0/016A7B08, desc: truncate 00000001zeropage 00000000表示清零的页号truncate 00000001表示截断相关操作。注意这些记录的事务号通常显示为0因为CLOG管理日志不属于任何特定事务。如果一次也没看到truncate记录多半是实例运行时间太短还没触发检查点后的截断逻辑。可以主动执行CHECKPOINT再对比前后的WAL输出。5.2 用gdb在clog_redo上设置断点源码调试最直接的方式就是给clog_redo下断点gdb -p $(pidof postgres) (gdb) b clog_redo (gdb) c另一个常见的断点是ZeroCLOGPage和TruncateCLOG分别对应两类redo分支。如果想确认redo时write_wal参数是否为false在ZeroCLOGPage里打印参数即可(gdb) p pageno (gdb) p write_wal正常恢复流程中write_wal应该是false如果看到true说明某条路径错误地在redo阶段又发起了WAL写入这会导致无限递归或者记录顺序错乱。也可以在clog_redo里打印info再对照CLOG_ZEROPAGE和CLOG_TRUNCATE的枚举值。枚举定义通常在src/include/access/clog.h#define CLOG_ZEROPAGE 0 #define CLOG_TRUNCATE 15.3 模拟一次崩溃恢复并检查pg_xact实操模拟崩溃恢复的完整步骤初始化一个实例创建表并插入若干行多提交几批事务。查看pg_xact目录文件ls -l $PGDATA/pg_xact/执行立即停库模拟崩溃pg_ctl -D $PGDATA stop -m immediate再次启动pg_ctl -D $PGDATA start观察启动日志中恢复的起点和结束时间再用pg_waldump对比恢复前后的WAL记录。正常的崩溃恢复会在日志里输出类似redo starts at的信息随后按顺序应用WAL。如果此时加上了gdb断点就能看到clog_redo在恢复过程中被调用的现场。需要特别指出的是恢复过程中CLOG redo的执行速度通常极快一个只有几万条WAL记录的实例CLOG相关日志可能一瞬就重放完了这也是很多人抓不到断点的原因。6. 常见问题与排查实录6.1 恢复时报clog_redo: unknown op code启动实例时如果得到类似clog_redo: unknown op code %u的PANIC含义是clog_redo里遇到了既不是CLOG_ZEROPAGE也不是CLOG_TRUNCATE的info值。优先排查几点是不是用旧版本二进制在读取新版本生成的WALRM ID或操作码编号发生偏移是不是通过pg_resetwal修复问题时把WAL状态搞乱了是不是不同发行版之间把rmgrlist.h的顺序改过曾经遇到过一个案例运维同学从备用实例直接拷贝了pg_wal目录到主实例且主实例的WAL段名完全一致启动后PANIC在这个位置。原因就是两边表结构不一致导致WAL内容错位clog_redo只是第一个被撞上的回调。解决办法不是绕过检查而是恢复正确的WAL归档。6.2 恢复时could not access status of transaction这个报错翻译过来是“无法获取某个事务的CLOG状态”。恢复过程中xact_redo要写CLOG bit时如果对应页面不存在就会失败。常见成因有三个pg_xact目录下的文件被外部工具误删或改动恢复起点checkpoint的redo位置太早往回重放时遇到了一个在当时还没有分配的CLOG页页面分配操作因为某种原因没写ZEROPAGE日志多发生在手工修改了源码路径下排查时先看报错里的事务号算出页号再检查pg_xact里该页对应文件是否存在。如果文件存在但内容异常可以用dd读取对应偏移确认是否是全零理论上新分配页在写入状态前应该是全零。6.3 pg_xact目录膨胀不回收pg_xact目录文件一直在涨但WAL里truncate记录出现得很少这是生产环境比较常见的现象。核心原因是存在一个非常老的最老活跃事务导致截断截止点无法推进。检查方向pg_stat_activity里有没有长事务特别是空闲事务有没有失效的复制槽pg_replication_slots里active为false但xmin很老的槽vacuum_defer_cleanup_age等参数是否设置得过大有没有PREPARED事务pg_prepared_xacts里残留的二阶段事务在源码层面TruncateCLOG的cutoff页由事务快照的最老事务号决定。只要这个值不前进SimpleLruTruncate就只能按兵不动日志里自然看不到truncate动作。恢复期重放truncate记录时遇到类似情况会按同样规则保守处理宁可多留文件也不碰活跃事务页。以我个人调试这套代码的体会来说CLOG Redo最有意思的地方不在clog_redo本身而在于它逼着你把“哪类数据必须记日志、哪类数据可以从其他日志重建”的边界想清楚。把这条边界吃透再回头看xact_redo、heap_redo和SLRU缓存之间的关系会有一种突然通透的感觉。如果你正在研读这部分的源码建议先按我上面第5小节的步骤完整打一遍断点用实际数据把zeropage、truncate两种记录都触发一次再回到代码里对比日志字段效果比干看代码好得多。