news 2026/10/16 13:05:59

PostgreSQL CLOG重做机制解析:为何只需两种WAL记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL CLOG重做机制解析:为何只需两种WAL记录

1. CLOG 为什么只需要两种 Redo 记录

CLOG(Commit 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 3

PgRmgrName之类的外部符号是由rmgrlist.h宏自动生成的,平时排查问题时你更常用的是pg_waldump --rmgr=clog这种过滤方式。WAL 记录的头部会包含资源管理器 ID,pg_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.h:

typedef 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 为什么重做时要拿 CLOGControlLock

CLOGControlLock是保护 CLOG 共享内存结构的一个集中式 LWLock。正常运行时,任何事务在写状态位之前都要以共享方式拿这个锁,写完后释放;分配新页时则以排他方式拿锁。恢复期间虽然主要是单进程在跑 WAL 重放,但热备模式下后端进程可能同时在读 CLOG、甚至执行只读事务,如果重放ZEROPAGE时不加锁,另一个后端正在TransactionIdGetStatus读同一个 CLOG 页,两边就可能发生竞争。所以代码里老老实实拿了LW_EXCLUSIVE,这跟正常路径里ExtendCLOG拿锁的方式完全对称。

3.2 SimpleLruZeroPage 做了什么

SimpleLruZeroPage是 SLRU 层提供的通用函数,函数名里的 Zero 才是精髓。它会做以下几件事:

  1. 检查请求的页号是否已经存在于 SLRU buffer 中。如果恰好已经在缓冲里,说明这个页此前已经被初始化过,那就不需要真正做零页操作,直接返回这个页面所在 buffer 的 slot 编号。
  2. 如果页面不在缓冲里,就找一个空闲或可淘汰的 buffer slot。如果所有 slot 都被占用,就按 SLRU 的淘汰策略选一个 victim,把它刷盘或直接丢弃,腾出位置。
  3. 在拿到的 slot 上把整个 32KB 页面 memset 成 0,同时把页面的 LSN 设置为当前重放的 WAL 记录位置,这也就是零页与 WAL 对齐的关键点:页面内容虽然是空的,但它的 LSN 必须在重放位置之后,否则后续日志会认为这个页面过期了。
  4. 把该 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 --rmgr=clog --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那行宏定义上多停两秒,从资源管理器的视角重新看一遍整个恢复流程,很多曾经的疑问会一下子解开。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 13:05:53

Python网络小说分析系统毕设实战:从爬虫到可视化

最近我把“基于Python的网络小说分析系统”这个毕业设计项目从头到尾完整做了一遍。坦白说,标题听起来像是个普通的爬虫项目,实际动手之后才发现,它把数据采集、文本清洗、分词、情感分析、主题聚类到可视化展示全链路都串在了一起&#xff0…

作者头像 李华
网站建设 2026/10/16 13:05:13

Gemini Agent企业级落地:权限、编排与安全实践指南

1. 这不是又一个聊天机器人:Gemini Agent 的工作场景定位本质 “Google Cloud 正式推出面向工作场景的 Gemini Agent”——这个标题里藏着三个被多数人忽略的关键定语:“Google Cloud”、“工作场景”、“Agent”。它不是在说一个新上线的网页版聊天框&a…

作者头像 李华
网站建设 2026/10/16 13:05:10

SymEngine实战:Python符号计算的高性能替代方案

简介:SymEngine 是一套独立的快速 C 符号数学运算库,面向需要高性能符号计算的开发者与研究者。库本身提供 C、Python、Ruby、Julia 等语言包装器,可与 SymPy、Sage 生态集成;代码采用 MIT 许可证,方便后续合规使用与二…

作者头像 李华
网站建设 2026/10/16 13:02:39

2005-2024省级抚养比面板数据:清洗、可视化与区域人口分析

一直做人口结构、区域经济或者社保测算的人,大概率都攒过一套“抚养比”数据。2005-2024年各省总抚养比、儿童抚养比、老年人抚养比,听起来就是三列数字,但真要自己从头整理一遍,会发现口径、缺失值、可比性全是坑。这篇我把这套省…

作者头像 李华
网站建设 2026/10/16 13:02:14

STM32F0 IAR工程快速启动:从ZIP解压到LED闪烁

简介:本资源是面向嵌入式初学者与STM32F0系列开发者的IAR平台工程实践包,聚焦ARM Cortex-M0内核MCU的底层外设驱动开发与系统级功能整合。压缩包完整覆盖Flash编程、TIM定时器控制(含PWM与中断)、USART串口通信、RTC实时时钟配置及…

作者头像 李华