总结RM_CLOG_ID处理的是PG提交日志的修改。CLOG记录每个事务的最终状态(in_progress/committed/aborted/sub_committed)存储在pg_xact/目录下的 SLRU 文件中。CLOG WAL只有两种:初始化页面(ZEROPAGE)和truncate旧页面(TRUNCATE)。事务提交流程是顺序是 先WAL落盘、在CLOG和数据落盘CLOG可以看做数据redo的时候会用wal覆盖CLOG的状态所以CLOG和数据一样需要后写需要永远落后于WAL。CLOG特殊之处: 事务状态的设置(COMMITTED/ABORTED)不通过 CLOG 自己的 WAL而是通过wal的 commit/abort 记录中的TransactionIdSetTreeStatus()函数直接写入CLOG页面。CLOG自己的wal记录只负责页面生命周期管理(创建销毁)。CommitTransaction() │ ▼ XactLogCommitRecord() │ 写一条 XACT 类型的 WAL 记录 (XLOG_XACT_COMMIT) │ 这条记录包含: 事务ID、提交时间戳、子事务列表等 ▼ TransactionIdCommitTree() │ ▼ TransactionIdSetTreeStatus() │ 直接修改 CLOG 共享内存中的页面 │ 把对应事务的 2-bit 状态从 IN_PROGRESS 改为 COMMITTED ▼ CLOG 页面变脏, 后续由 checkpoint 刷盘clog两类redo总结WAL 类型宏值redo 核心动作触发条件ZEROPAGECLOG_ZEROPAGE0x00ZeroCLOGPage()SimpleLruWritePage()nextXid 跨越到新 CLOG 页面(每 32768 个 xid)TRUNCATECLOG_TRUNCATE0x10AdvanceOldestClogXid()SimpleLruTruncate()VACUUM 清理不再需要的旧事务状态1 WAL Record 类型与结构体事务状态定义/* src/include/access/clog.h *//* * 事务的四种可能状态 -- 注意: 全零(0x00)是初始状态(IN_PROGRESS) * 每个事务占用 2 bit, 一个 8KB CLOG 页面可以存储 8192*4 32768 个事务 */typedefintXidStatus;#defineTRANSACTION_STATUS_IN_PROGRESS0x00/* 进行中 */#defineTRANSACTION_STATUS_COMMITTED0x01/* 已提交 */#defineTRANSACTION_STATUS_ABORTED0x02/* 已中止 */#defineTRANSACTION_STATUS_SUB_COMMITTED0x03/* 子事务已提交(父事务尚未提交) */数据结构/* src/include/access/clog.h *//* * xl_clog_truncate: CLOG truncate操作的 WAL 记录数据 * 当 VACUUM 清理旧事务时, 不再需要的 CLOG 页面可以被truncate */typedefstructxl_clog_truncate{intpageno;/* truncate到的页面编号(保留此页面及之后的) */TransactionId oldestXact;/* 最老的仍需保留的事务 ID */Oid oldestXactDb;/* 该最老事务所属的数据库 OID */}xl_clog_truncate;CLOG_ZEROPAGE 的数据: 仅一个int pageno(不需要结构体直接写)。2 GDB触发 SQL 示例CLOG_ZEROPAGE (0x00) – 新 CLOG 页面初始化-- CLOG_ZEROPAGE 在事务 ID 耗尽当前 CLOG 页面范围时自动触发-- 每个 CLOG 页面覆盖 32768 个事务(8KB * 4 xid/byte)-- 当 nextXid 跨越到需要新页面时, ExtendCLOG() 会零化新页面并写入 WAL-- 通常通过大量事务来触发:-- (在测试中难以直接触发, 因为需要恰好跨越 32K xid 边界)BEGIN;INSERTINTOtVALUES(1);COMMIT;-- ... 重复直到 xid 跨越 CLOG 页面边界CLOG_TRUNCATE (0x10) – VACUUM 清理旧 CLOG 页面-- 当 autovacuum 或手动 VACUUM 运行时, 如果发现很老的事务已不再需要,-- 会调用 TruncateCLOG() truncate过时的 CLOG 段文件VACUUM;-- 也可以通过 pg_controldata 查看当前 oldestXid,-- 确认哪些 CLOG 页面可以安全truncate3 Redo 核心代码 (带中文注释)/* src/backend/access/transam/clog.c:984-1019 *//* * CLOG 资源管理器的 redo 入口函数 */voidclog_redo(XLogReaderState*record){uint8 infoXLogRecGetInfo(record)~XLR_INFO_MASK;/* CLOG 记录不使用 backup block(全页写) */Assert(!XLogRecHasAnyBlockRefs(record));if(infoCLOG_ZEROPAGE){/* * 处理 ZEROPAGE: 零化新 CLOG 页面 * 当事务 ID 增长到需要新 CLOG 页面时, * 必须先将该页面清零(因为 0x00 IN_PROGRESS 是正确的初始状态) */intpageno;intslotno;/* 从 WAL data 中取出页面编号 */memcpy(pageno,XLogRecGetData(record),sizeof(int));/* 获取 SLRU 排他锁, 防止并发访问 */LWLockAcquire(XactSLRULock,LW_EXCLUSIVE);/* 零化指定页面: false 表示不写 WAL(因为我们正在回放 WAL) */slotnoZeroCLOGPage(pageno,false);/* 立即将零化后的页面写入磁盘, 确保持久化 */SimpleLruWritePage(XactCtl,slotno);Assert(!XactCtl-shared-page_dirty[slotno]);LWLockRelease(XactSLRULock);}elseif(infoCLOG_TRUNCATE){/* * 处理 TRUNCATE: truncate旧 CLOG 段文件 * 将不再需要的旧 CLOG 页面从磁盘删除 */xl_clog_truncate xlrec;memcpy(xlrec,XLogRecGetData(record),sizeof(xl_clog_truncate));/* 更新全局的 oldestClogXid, 表示更老的事务不再需要查看 CLOG */AdvanceOldestClogXid(xlrec.oldestXact);/* 执行 SLRU truncate: 删除 pageno 之前的所有段文件 */SimpleLruTruncate(XactCtl,xlrec.pageno);}elseelog(PANIC,clog_redo: unknown op code %u,info);}4 GDB 调试在本次测试中CLOG_ZEROPAGE 未被触发。这是正常的因为 ZEROPAGE 只在事务 ID 增长到需要分配新 CLOG 页面时才会产生每 32768 个事务一次。未触发原因分析:CLOG 每页存储 32768 个事务的状态(8KB page, 每个 xid 占 2 bit)只有当 nextXid 跨越到新页面范围时ExtendCLOG()才会调用ZeroCLOGPage()并写入 CLOG_ZEROPAGE WAL 记录在常规小规模测试中事务 ID 通常不会跨越页面边界如果要在 GDB 中捕获 CLOG_ZEROPAGE可以:# 在 clog_redo 设置断点 break clog_redo # 或者在写入端 break clog.c:ZeroCLOGPage5 官方文档src/backend/access/transam/READMEpg_xact and pg_subtrans are permanent (on-disk) storage of transaction relatedinformation. There is a limited number of pages of each kept in memory, soin many cases there is no need to actually read from disk. However, ifthere’s a long running transaction or a backend sitting idle with an opentransaction, it may be necessary to be able to read and write this informationfrom disk.CLOG (pg_xact) 是事务状态的持久化存储使用 SLRU(简单 LRU)机制管理内存中的有限页面。大部分读取来自内存缓存只有长事务或空闲事务才需要磁盘 I/O。异步提交与 CLOG LSNFirst, for each page of CLOG we must remember the LSN of the latest commitaffecting the page, so that we can enforce the same flush-WAL-before-writerule that we do for ordinary relation pages. Otherwise the record of thecommit might reach disk before the WAL record does.CLOG 页面也遵守 WAL-first 规则。每个 CLOG 页面都有关联的 LSN确保提交记录的 WAL 在 CLOG 数据刷盘之前先持久化。子事务与 CLOG 的两阶段协议The main role of marking transactions as sub-committed is to provide anatomic commit protocol when transaction status is spread across multiple clogpages. Whenever transaction status spreads across multiple pages we must usea two-phase commit protocol: the first phase is to mark the subtransactionsas sub-committed, then we mark the top level transaction and all itssubtransactions committed (in that order).当一个事务树的状态跨越多个 CLOG 页面时使用两阶段提交协议:第一阶段: 将子事务标记为 SUB_COMMITTED (0x03)第二阶段: 将顶层事务和所有子事务标记为 COMMITTED (0x01)这确保了即使在跨页面时事务提交的原子性也能得到保证。Recovery 期间的 CLOG 处理Not all transactional behaviour is emulated, for example we do not inserta transaction entry into the lock table, nor do we maintain the transactionstack in memory. Clog, multixact and commit_ts entries are made normally.在 WAL 恢复期间CLOG 条目的写入与正常运行时完全一致。恢复进程会正常维护 CLOG确保 Hot Standby 查询能获取正确的事务可见性信息。附录redo调用的SimpleLruTruncate函数分析注意: 这里的truncate不是清空文件而是删除文件。CLOG 数据存储在pg_xact/目录下的多个段文件中(如0000,0001,0002…),每个段文件包含 32 个页面(32 × 8KB 256KB)。当 VACUUM 判定某些旧事务的状态不再需要时,SimpleLruTruncate()会扫描目录并unlink(删除) 那些完全落在 cutoffPage 之前的段文件。直观理解: pg_xact/ 目录下的段文件: ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ 0000 │ │ 0001 │ │ 0002 │ │ 0003 │ │ 0004 │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ page 0-31 32-63 64-95 96-127 128-159 假设 cutoffPage 70 (即页面70之前的都不需要了) 段 0000 (page 0-31): 31 70 → unlink 删除 ✓ 段 0001 (page 32-63): 63 70 → unlink 删除 ✓ 段 0002 (page 64-95): 64 70 但 95 70 → 保留 ✗ (cutoff 落在段内部) 段 0003 (page 96-127): 96 70 → 保留 ✗ 段 0004 (page 128-159):128 70 → 保留 ✗ 结果: 文件 0000 和 0001 被 unlink 删除, 0002 及之后的文件完整保留(不做 ftruncate)源码/* src/backend/access/transam/slru.c */voidSimpleLruTruncate(SlruCtl ctl,intcutoffPage){SlruShared sharedctl-shared;intslotno;/* 更新 truncate 统计计数器 */pgstat_count_slru_truncate(shared-slru_stats_idx);/* * 第一阶段: 清理共享内存中的 SLRU 缓冲区 * * 扫描共享内存中的所有 SLRU buffer slot, * 将属于 cutoffPage 之前的页面从缓冲区中驱逐出去, * 防止后续意外将旧页面重新写回磁盘。 * * (这通常发生在 checkpoint 之后, 脏页应该已经落盘, * 这里只是做额外的安全保障。) */LWLockAcquire(shared-ControlLock,LW_EXCLUSIVE);restart:/* * 安全检查: 最新页面不能被 truncate。 * 如果出现这种情况, 说明可能发生了 XID 回绕异常。 */if(ctl-PagePrecedes(shared-latest_page_number,cutoffPage)){LWLockRelease(shared-ControlLock);ereport(LOG,(errmsg(could not truncate directory \%s\: apparent wraparound,ctl-Dir)));return;}for(slotno0;slotnoshared-num_slots;slotno){if(shared-page_status[slotno]SLRU_PAGE_EMPTY)continue;/* 只处理 cutoffPage 之前的页面 */if(!ctl-PagePrecedes(shared-page_number[slotno],cutoffPage))continue;/* * 如果页面是干净的(VALID且不脏), 直接标记为 EMPTY。 * 这是最常见的情况。 */if(shared-page_status[slotno]SLRU_PAGE_VALID!shared-page_dirty[slotno]){shared-page_status[slotno]SLRU_PAGE_EMPTY;continue;}/* * 如果页面正在进行 I/O 或者是脏页: * - 脏页: 先写回磁盘再驱逐 * - I/O中: 等待 I/O 完成 * 然后重新开始扫描(因为释放锁期间状态可能变化)。 */if(shared-page_status[slotno]SLRU_PAGE_VALID)SlruInternalWritePage(ctl,slotno,NULL);elseSimpleLruWaitIO(ctl,slotno);gotorestart;}LWLockRelease(shared-ControlLock);/* * 第二阶段: 扫描磁盘目录, 删除旧的段文件 * * 扫描 pg_xact/ 目录下的所有文件, * 对每个文件调用 SlruScanDirCbDeleteCutoff 回调, * 判断该段文件是否完全在 cutoffPage 之前, * 如果是则调用 unlink() 删除。 */(void)SlruScanDirectory(ctl,SlruScanDirCbDeleteCutoff,cutoffPage);}函数调用SimpleLruTruncate(ctl, cutoffPage) │ ├── 第一阶段: 清理共享内存缓冲区 │ └── 遍历 shared-page_status[0..num_slots-1] │ ├── 干净页面 → page_status SLRU_PAGE_EMPTY (驱逐) │ ├── 脏页面 → SlruInternalWritePage() → 写回磁盘后驱逐 │ └── I/O中 → SimpleLruWaitIO() → 等待完成后重试 │ └── 第二阶段: 删除磁盘文件 └── SlruScanDirectory(ctl, SlruScanDirCbDeleteCutoff, cutoffPage) │ ├── AllocateDir(ctl-Dir) // 打开 pg_xact/ 目录 │ ├── 对目录中每个合法文件名 (如 0000, 001F): │ │ segno strtol(filename, 16) // 十六进制解析段号 │ │ segpage segno * 32 // 段的首页编号 │ │ │ └── SlruScanDirCbDeleteCutoff(ctl, filename, segpage, cutoffPage) │ │ │ └── SlruMayDeleteSegment(ctl, segpage, cutoffPage) │ │ // 判断条件: 段的首页和尾页都在 cutoffPage 之前 │ │ seg_last_page segpage 31 │ │ return PagePrecedes(segpage, cutoffPage) │ │ PagePrecedes(seg_last_page, cutoffPage) │ │ │ ├── true → SlruInternalDeleteSegment(ctl, segno) │ │ │ │ │ ├── RegisterSyncRequest(SYNC_FORGET_REQUEST) │ │ │ // 取消该段的待 fsync 请求 │ │ │ │ │ └── unlink(path) ← 真正删除文件! │ │ │ └── false → 跳过, 保留该段文件 │ └── FreeDir()SlruInternalDeleteSegment – 最终unlink删除/* src/backend/access/transam/slru.c */staticvoidSlruInternalDeleteSegment(SlruCtl ctl,intsegno){charpath[MAXPGPATH];/* 取消该段的待 fsync 请求(文件都要删了, 不需要再 fsync) */if(ctl-sync_handler!SYNC_HANDLER_NONE){FileTag tag;INIT_SLRUFILETAG(tag,ctl-sync_handler,segno);RegisterSyncRequest(tag,SYNC_FORGET_REQUEST,true);}/* 构造文件路径并调用 unlink() 删除文件 */SlruFileName(ctl,path,segno);ereport(DEBUG2,(errmsg_internal(removing file \%s\,path)));unlink(path);/* ← 这里! 不是 ftruncate, 是 unlink, 直接删除文件 */}