PostgreSQL崩溃恢复:CLOG Redo机制源码解析

发布时间:2026/10/9 2:53:09
PostgreSQL崩溃恢复:CLOG Redo机制源码解析 PostgreSQL源码155Redo系列CLOG Redo (RM_CLOG_ID 3)。这个标题抛出来大部分DBA会直接划走但凡是啃过PostgreSQL内核源码的人都知道CLOG Redo是整个崩溃恢复链路上一个“小而关键”的节点。CLOGCommit Log提交日志负责记录每个事务当前处于什么状态而CLOG Redo就是崩溃恢复时把那些还没来得及落盘的CLOG页内容从WAL里重新补写回来的过程。这篇文章适合正在读PG源码的开发者、想搞懂事务提交状态的运维同学以及任何被“数据库启动不起来报transaction status错误”折磨过的人。我会从数据布局、WAL记录结构、redo函数实现、实战推演到故障排查一条线讲完尽量用代码和现场日志说话不堆概念。建议你打开源码对照着看目录在src/backend/access/transam/clog.c核心头文件是src/include/access/clog.h。1. CLOG Redo是什么它在崩溃恢复链路里的位置1.1 CLOG页与事务状态的存储模型CLOG在PostgreSQL里本质上是一张“逻辑表”每个事务IDXID对应2个比特位用来标记事务是进行中、已提交、已中止还是子事务已提交。这张表不是用普通的堆表存的而是挂在SLRUSimple Least Recently Used缓存机制上底层是一个个固定大小的页每个页默认是BLCKSZ通常为8KB。一页8KB每事务占2bit所以一页能记录的事务数是8 * 1024 * 8 / 2 32768 个事务CLOG段文件在数据目录下现代版本叫pg_xact早期版本叫pg_clog。SLRU一个段默认包含32个页所以一个段文件能覆盖32 * 32768 1048576 个事务也就是说大约每100万个XID会滚动出一个新的CLOG段文件。这个换算关系在分析WAL线索、手工检查CLOG文件时非常有用。因为CLOG页是带缓存的事务状态变化会先写内存里的SLRU缓冲页之后才回写到磁盘文件。崩溃发生在这个时间窗口里时内存里的最新状态就丢了磁盘上的CLOG页还是旧的。如果没有redo机制数据库重启后根本不知道某个事务到底提交了没有也就不敢决定哪些数据可见、哪些数据要回滚。CLOG Redo做的事情就是把WAL里记录下来的事务状态逐条重新写回CLOG页相当于把“内存里丢掉的那部分账”补上。1.2 为什么CLOG值得单独占一个rmgrPostgreSQL里每个资源管理器Resource Manager简称rmgr有唯一的IDRM_CLOG_ID的值是3。rmgr表在src/backend/access/transam/rmgr.c里每个rmgr对应一组回调函数包括redo、undo、desc、identify等。RM_CLOG_ID对应的register条目大致是PG_RMGR(RM_CLOG_ID, CLOG, clog_redo, clog_desc, ...)也就是说WAL日志里只要rmid RM_CLOG_ID恢复时就会调用clog_redo来处理这条记录。CLOG单独占一个rmgr而不是合并进事务日志RM_XACT_ID是因为它的数据模型和事务层不一样事务层记录的是“我提交了”“我中止了”这种动作而CLOG记录的是“XID从多少到多少这批事务各自处于什么状态”这是纯状态数据动作语义完全不同。把状态记录和动作记录分开恢复流程也更清晰事务日志重放负责恢复事务在内存中的快照和子事务关系CLOG重放则负责恢复磁盘上的持久状态。两者各干各的互不干扰。1.3 崩溃恢复阶段clog_redo在什么时候被调用数据库启动后进入恢复流程主入口在StartupXLOGsrc/backend/access/transam/xlog.c。恢复线程从最近一次checkpoint的起点开始顺序读取WAL记录每拿到一条记录会根据record-xl_rmid找到对应的rmgr然后调用该rmgr的redo函数。伪代码如下for (;;) { record XLogReadRecord(...); if (record NULL) break; RmgrTable[record-xl_rmid].rm_redo(record); }当record-xl_rmid RM_CLOG_ID时走的就是clog_redo。这个调用时机通常在事务提交/中止记录重放之后或者更准确地说是在WAL日志里CLOG状态记录出现的位置上。PG把CLOG状态写入放在事务提交路径中因此崩溃恢复时也是严格按WAL顺序重放CLOG记录会被精确地在对应时间点执行。2. 记录结构一条CLOG Redo里到底装了啥2.1 xl_clog_redo的内存布局想理解clog_redo先要看WAL记录里携带的数据。CLOG写入时使用的数据结构在src/include/access/clog.h里核心是这样一段typedef struct xl_clog_redo { TransactionId startXid; int num_xacts; char xact_status[FLEXIBLE_ARRAY_MEMBER]; } xl_clog_redo;startXid这批状态记录里第一个事务的XID。num_xacts后面跟着多少个事务的状态。xact_status[]每个事务一个字节的状态码长度就是num_xacts。注意这里不是每一个字节对应一个事务的完整状态虽然定义是char但实际只使用低2个比特位其余比特位保留。如果一组连续XID的状态是{COMMITTED, ABORTED, COMMITTED}那么xact_status就是{0x01, 0x02, 0x01}。这一整块数据通过XLogRegisterData注册进WAL记录恢复时直接就用它。整条WAL记录没有关联任何数据块镜像也就是说redo的时候不需要依赖WAL里的block image而是自己定位CLOG页、自己修改页内容。2.2 四种状态的二进制含义CLOG每个事务2bit因此最多4种状态定义如下状态值含义TRANSACTION_STATUS_IN_PROGRESS0x00事务仍在进行中TRANSACTION_STATUS_COMMITTED0x01事务已提交TRANSACTION_STATUS_ABORTED0x02事务已中止TRANSACTION_STATUS_SUB_COMMITTED0x03子事务已提交父事务状态待定状态写入的时候不是简单置位而是“按位修改、保留高位信息”。比如一个事务从IN_PROGRESS变成COMMITTED需要把对应2个bit置为0b01。真正操作时源码里通常用TransactionIdSetStatusBit来设置状态位它内部会通过CLOG页上计算出的偏移量用掩码方式修改对应bit位。这个设计有两个好处一是单次状态修改只影响2个bit不会破坏同一字节里其他事务的状态二是SLRU页的修改粒度很小WAL日志和页回写之间的一致性更容易保证。2.3 为什么状态要“打包发送”如果你去看WAL日志会发现CLOG记录经常是“一批一批”出现的很少看到单事务一条记录。这是因为同一时刻提交的多个事务XID往往是连续的。比如事务1001、1002、1003同时在提交它们的状态可以打包成一条CLOG记录startXid 1001 num_xacts 3 xact_status {0x01, 0x01, 0x01}这样一条记录就搞定了不需要每条事务单独写一条WAL能明显压缩恢复时的WAL体积和读取次数。源码里对单条CLOG记录能携带的事务数有上限这个限制主要取决于单条WAL记录的尺寸限制。如果某一瞬间需要记录的事务数非常多PG会自动拆成多条记录。理解这个机制对排查“为什么WAL里CLOG记录这么多”很有帮助。3. 源码拆解从clog_redo入口到页面落盘3.1 入口函数逐行解释clog_redo函数在clog.c里逻辑本身并不复杂核心代码如下我做了一点注释void clog_redo(XLogReaderState *record) { xl_clog_redo *xlrec (xl_clog_redo *) XLogRecGetData(record); TransactionId startxid xlrec-startXid; int num_xacts xlrec-num_xacts; char *xact_status xlrec-xact_status; TransactionId xid; int i; for (i 0; i num_xacts; i) { xid startxid i; TransactionIdSetStatusBit(xid, xact_status[i], InvalidXLogRecPtr, false); } }先看XLogRecGetData(record)它拿到的是WAL记录中注册的业务数据区也就是前面说的xl_clog_redo结构。这个结构里没有页号、没有block信息所有信息都靠XID自己算。然后就是一个循环对startxid开始连续的num_xacts个事务逐一设置状态。这里的第四个参数传的是false表示这次调用不需要再写WAL。因为当前本来就在重放阶段如果再写WAL就有可能出现递归写日志的问题。这里有一个关键点TransactionIdSetStatusBit不是即时把页面刷盘它只是把SLRU缓冲页里的对应位置改掉然后标记这个页是脏的。真正的回写由后续的SLRU刷盘逻辑统一处理。所以redo结束时CLOG页不一定已经在磁盘上但一定已经在内存缓冲里了。重启恢复流程继续往下走脏页最终会被刷出去。3.2 真正的状态写入函数做了什么TransactionIdSetStatusBit是CLOG子系统里最底层的状态修改函数。它不直接操作页而是先算出XID对应的CLOG页和页内偏移int pageno xid / CLOG_XACTS_PER_PAGE; // 第几页 int byteno (xid % CLOG_XACTS_PER_PAGE) / CLOG_XACTS_PER_BYTE; // 页内第几个字节 int bshift (xid % CLOG_XACTS_PER_BYTE) * CLOG_BITS_PER_XACT; // 字节内偏移假设CLOG_XACTS_PER_PAGE32768CLOG_XACTS_PER_BYTE4因为一字节有8bit每事务占2bit那么事务XID1001时pageno 0 byteno 1001 / 4 250 bshift (1001 % 4) * 2 1 * 2 2意思是这个事务的状态位在CLOG页第0页的第250字节、第2和第3个比特位。然后函数会调用SLRU接口读取对应页到缓冲区slotno SimpleLruReadPage(CLOGCtl, pageno, false, xid);如果这个页在崩溃前完全没有创建比如数据库是第一次推进到这个XID范围那么需要先调用SimpleLruZeroPage把新页零初始化再设置状态。这个细节很重要redo时不能假设页一定存在尤其是CLOG段经常被清掉、或者事务ID推进越过了原本没有CLOG页的边界。拿到页后就用位掩码把2个bit改写clogpage (char *) CLOGCtl-shared-page_buffer[slotno]; clogpage[byteno] | (status bshift);注意这里不是直接赋值而是用|这个操作是否准确呢严格说它需要先把原来的2个bit清掉再写入新值实际代码里是clogpage[byteno] ~((1 CLOG_BITS_PER_XACT) - 1) bshift; clogpage[byteno] | status bshift;这样能确保不会把同一字节里其他事务的状态弄脏。这种位操作看起来简单但在并发重放恢复时因为恢复进程是单线程的所以不存在竞争问题。在正常运行期间CLOG页面的写入有锁保护所以不能简单照搬redo路径的代码去理解在线事务路径。3.3 重放时的幂等性与安全边界崩溃恢复可能会重放同一条CLOG记录吗实际中不会重复重放同一条记录但一个CLOG页可能被多条记录连续修改比如先有一条记录写入xid 1001已提交接着又有一条记录写入xid 1002已提交它们落在同一页。这两条记录都会被完整重放。这里涉及一个幂等性考量把同一个XID的状态设置两次会不会有问题CLOG只保存最新状态最后一次写入覆盖前一次。但在正常的WAL顺序里同一个XID不太可能产生两条CLOG记录因为一个事务的生命周期只会从IN_PROGRESS变成COMMITTED或ABORTED通常只有一次状态变化。子事务的SUB_COMMITTED状态可能出现额外记录但子事务状态一旦转为SUB_COMMITTED就不会再变回去。因此即使某条CLOG记录被重复执行最多就是把相同的状态再写一遍不会引发逻辑错误。这种幂等性对恢复系统来说是极好的性质。另一个安全边界是TransactionIdSetStatusBit传入的XID必须是有效的不能是0。恢复时如果遇到非法XID函数内部会报错终止恢复。这实际上是一种“宁可启动失败也不给错误状态”的兜底设计。3.4 和正常提交路径的协作关系正常事务提交时流程是RecordTransactionCommit记录事务日志RM_XACT_ID。调用CLOG相关函数把事务状态写到CLOG内存页。在写CLOG页之前CLOG函数会先把状态信息注册成一条WAL记录并调用XLogInsert(RM_CLOG_ID, ...)。XLogInsert返回的EndRecPtr会被记录到事务提交记录里或者用于mark CLOG页的LSN。所以CLOG的WAL记录实际上是在“修改CLOG页之前”就先落盘的。这样能保证如果数据库写完WAL后崩溃恢复时重放这条WAL记录就能重新生成CLOG页的状态如果在写CLOG页之后崩溃WAL记录虽然会重复重放一次但因为幂等性结果不受影响。这个顺序关系和普通数据页的“WAL在前、数据页在后”原则完全一致理解CLOG Redo之前先理解这个日志序是整条链路的基石。4. 现场演练推演一次CLOG重放4.1 用pg_waldump看记录纸上谈兵没意思我建议你实际操作一次。先准备一个测试库连续执行几个事务然后用自带的pg_waldump查看WAL日志pg_waldump -p /path/to/pg_wal -start 0/5000000 -end 0/6000000输出里会看到类似这样的CLOG记录rmgr: CLOG len (rec/tot): 10/ 34, tx: 0, lsn: 0/5012340, prev 0/5012310, desc: invalid: startXid 1005 numXacts 3: 01 01 02desc字段会直接打印出startXid、numXacts以及后面每个事务的状态字节。01代表已提交02代表已中止00代表进行中03代表子事务已提交。这里的invalid是CLOG记录的子类型名因为CLOG在历史版本里只有一个子类型所以名字看起来有点奇怪但它就是标准的CLOG状态刷新记录。4.2 把状态字节写成CLOG页假设WAL记录内容如下startXid 1005 num_xacts 3 xact_status {0x01, 0x01, 0x02}那么重放时会依次把事务1005、1006、1007的状态设为已提交、已提交、已中止。计算页号pageno 1005 / 32768 0三个事务都在第0页。页内字节位置byteno(1005) 1005 / 4 251 bshift(1005) (1005 % 4) * 2 2 byteno(1006) 1006 / 4 251 bshift(1006) (1006 % 4) * 2 4 byteno(1007) 1007 / 4 251 bshift(1007) (1007 % 4) * 2 6所以重放后CLOG页第0页第251字节的bit分布会变为bit 0-1: xid1004如果之前有值 bit 2-3: xid1005 0b01 bit 4-5: xid1006 0b01 bit 6-7: xid1007 0b10你如果用十六进制工具直接看pg_xact/0000文件会发现这个字节的值和重放前的值不一样了。这个过程完全由redo代码完成不需要额外人工干预。4.3 跨页边界怎么处理如果startXid32766num_xacts5那么前3个事务落在CLOG第0页后2个事务落在第1页。TransactionIdSetStatusBit内部每次调用都会重新计算页号一旦页号变化就会重新读取另一个SLRU页。所以clog_redo的循环体不用自己判断是否跨页它只是逐个调用底层函数底层函数天然支持跨页。但有一点要注意如果一条CLOG记录横跨了很多页意味着恢复时要依次把多个CLOG页读入缓冲对恢复性能是有影响的。实际中PG会根据当时的XID连续程度决定怎样分批尽量避免一次性跨太多页。4.4 用gdb验证推演讲一个我自己的调试方法。如果你也在读PG源码可以编译一个带调试符号的版本然后在clog_redo处打断点gdb -p postmaster_pid break clog_redo commands silent printf startXid%u, num_xacts%d\n, xlrec-startXid, xlrec-num_xacts continue end这样每重放一条CLOG记录gdb都会打印一条摘要。配合pg_waldump的输出可以逐条对比确认WAL记录解析没有问题。我在模拟环境里这样验证过很多次有些看起来“诡异”的记录其实只是对子事务状态的补充刷新逻辑上没有任何问题。5. 常见故障与排查建议5.1 启动报“could not access status of transaction”这个错误是CLOG相关故障里最常见的报错形式类似FATAL: could not access status of transaction 123456 DETAIL: Could not open file pg_xact/0001: No such file or directory.多半原因是CLOG段文件缺失。可能是人为删除了pg_xact下的文件也可能是pg_resetwal使用不当导致XID回退让数据库认为某些CLOG段应该存在但找不到。正常情况下不能直接重建pg_xact目录因为里面记录着历史事务的提交状态一旦丢失数据库无法判断哪些事务已经提交全库数据一致性会毁掉。如果只是测试环境、且确定数据不重要可以极端操作备份数据后用pg_resetwal -x 新XID强制重置。但生产环境绝对不要这么做。正确的思路是尽量从备份中恢复缺失的CLOG段或者接受从上次全量备份WAL日志重新恢复数据库。5.2 CLOG页本身损坏但没报缺文件还有一种情况是文件存在但内容不对启动时报FATAL: invalid page closure for transaction ...这类问题通常需要在恢复阶段定位是哪个页面出现校验错误。PG在读取SLRU页时会做CRC校验一旦失败就会终止启动。遇到这种情况如果只是个别页损坏可以考虑把损坏的段文件截断或置零让数据库在恢复时通过WAL重新生成这一段的CLOG内容。前提是你有从最近checkpoint之后的完整WAL并且没有归档缺口。实际操作前先把整个数据目录做快照否则可能越修越坏。5.3 如何从pg_xact十六进制反推事务状态排查问题时你可能会手工检查CLOG文件。CLOG每个事务2bit通过十六进制看文件时会发现每个字节代表4个事务的状态低2个bit事务N的状态第2-3个bit事务N1的状态第4-5个bit事务N2的状态第6-7个bit事务N3的状态比如字节0x1A展开是00011010对应四个事务的状态是xid N: 0b10 ABORTED xid N1: 0b01 COMMITTED xid N2: 0b01 COMMITTED xid N3: 0b00 IN_PROGRESS注意读取时要按文件偏移反推出起始XID千万别搞反了顺序。我踩过的坑是直接把字节从高位到低位解读结果四个事务状态全反了。5.4 调试恢复流程的实用技巧如果你想跟踪CLOG redo在恢复过程中一共处理了多少条记录、多少事务除了gdb打断点还有一个更轻量的办法临时在clog_redo里加elog(LOG, ...)输出重新编译后看启动日志。虽然这个办法简单粗暴但比自己盲猜有效得多。还有一个技巧在恢复启动时利用log_min_messagesdebug1可以看到更多恢复阶段的日志但CLOG相关的细粒度消息不一定全开。更多时候我建议用pg_waldump先离线分析WAL把CLOG记录分布搞清楚再决定在代码里加什么调试输出这样效率最高。6. 真正理解CLOG Redo后再看PG会通透很多写到最后说点个人体会。CLOG Redo的代码量在PG整个恢复框架里占比很小函数体就几十行但它横跨了三个认知层次事务状态模型、SLRU存储机制、WAL恢复框架。搞懂它之后再回去看崩溃恢复里其他rmgr的redo逻辑会感觉套路都差不多先解析记录、再定位页面、最后做幂等状态写入。我建议你按这个顺序深入先读clog.c里的TransactionIdSetStatusBit再读clog_redo最后打开pg_waldump对照实际日志。另外一个小技巧是如果遇到“事务状态不对”的诡异问题先查WAL里CLOG记录的状态字节再用十六进制工具手工解析对应pg_xact文件大多数问题都能定位到是页缺失、段文件被清、还是记录本身就没生成。你要是钻研到这里PostgreSQL的崩溃恢复对你就不再是“黑盒”了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询