总结
-
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 核心动作 | 触发条件 |
|---|---|---|---|---|
| ZEROPAGE | CLOG_ZEROPAGE | 0x00 | ZeroCLOGPage() + SimpleLruWritePage() |
nextXid 跨越到新 CLOG 页面(每 32768 个 xid) |
| TRUNCATE | CLOG_TRUNCATE | 0x10 | AdvanceOldestClogXid() + SimpleLruTruncate() |
VACUUM 清理不再需要的旧事务状态 |
1 WAL Record 类型与结构体
事务状态定义
c
/* src/include/access/clog.h */
/*
* 事务的四种可能状态 -- 注意: 全零(0x00)是初始状态(IN_PROGRESS)
* 每个事务占用 2 bit, 一个 8KB CLOG 页面可以存储 8192*4 = 32768 个事务
*/
typedef int XidStatus;
#define TRANSACTION_STATUS_IN_PROGRESS 0x00 /* 进行中 */
#define TRANSACTION_STATUS_COMMITTED 0x01 /* 已提交 */
#define TRANSACTION_STATUS_ABORTED 0x02 /* 已中止 */
#define TRANSACTION_STATUS_SUB_COMMITTED 0x03 /* 子事务已提交(父事务尚未提交) */
数据结构
c
/* src/include/access/clog.h */
/*
* xl_clog_truncate: CLOG truncate操作的 WAL 记录数据
* 当 VACUUM 清理旧事务时, 不再需要的 CLOG 页面可以被truncate
*/
typedef struct xl_clog_truncate
{
int pageno; /* truncate到的页面编号(保留此页面及之后的) */
TransactionId oldestXact; /* 最老的仍需保留的事务 ID */
Oid oldestXactDb; /* 该最老事务所属的数据库 OID */
} xl_clog_truncate;
CLOG_ZEROPAGE 的数据 : 仅一个 int pageno(不需要结构体,直接写)。
2 GDB触发 SQL 示例
CLOG_ZEROPAGE (0x00) -- 新 CLOG 页面初始化
sql
-- CLOG_ZEROPAGE 在事务 ID 耗尽当前 CLOG 页面范围时自动触发
-- 每个 CLOG 页面覆盖 32768 个事务(8KB * 4 xid/byte)
-- 当 nextXid 跨越到需要新页面时, ExtendCLOG() 会零化新页面并写入 WAL
-- 通常通过大量事务来触发:
-- (在测试中难以直接触发, 因为需要恰好跨越 32K xid 边界)
BEGIN; INSERT INTO t VALUES(1); COMMIT;
-- ... 重复直到 xid 跨越 CLOG 页面边界
CLOG_TRUNCATE (0x10) -- VACUUM 清理旧 CLOG 页面
sql
-- 当 autovacuum 或手动 VACUUM 运行时, 如果发现很老的事务已不再需要,
-- 会调用 TruncateCLOG() truncate过时的 CLOG 段文件
VACUUM;
-- 也可以通过 pg_controldata 查看当前 oldestXid,
-- 确认哪些 CLOG 页面可以安全truncate
3 Redo 核心代码 (带中文注释)
c
/* src/backend/access/transam/clog.c:984-1019 */
/*
* CLOG 资源管理器的 redo 入口函数
*/
void
clog_redo(XLogReaderState *record)
{
uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
/* CLOG 记录不使用 backup block(全页写) */
Assert(!XLogRecHasAnyBlockRefs(record));
if (info == CLOG_ZEROPAGE)
{
/*
* ===== 处理 ZEROPAGE: 零化新 CLOG 页面 =====
* 当事务 ID 增长到需要新 CLOG 页面时,
* 必须先将该页面清零(因为 0x00 = IN_PROGRESS 是正确的初始状态)
*/
int pageno;
int slotno;
/* 从 WAL data 中取出页面编号 */
memcpy(&pageno, XLogRecGetData(record), sizeof(int));
/* 获取 SLRU 排他锁, 防止并发访问 */
LWLockAcquire(XactSLRULock, LW_EXCLUSIVE);
/* 零化指定页面: false 表示不写 WAL(因为我们正在回放 WAL) */
slotno = ZeroCLOGPage(pageno, false);
/* 立即将零化后的页面写入磁盘, 确保持久化 */
SimpleLruWritePage(XactCtl, slotno);
Assert(!XactCtl->shared->page_dirty[slotno]);
LWLockRelease(XactSLRULock);
}
else if (info == CLOG_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);
}
else
elog(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,可以:
gdb
# 在 clog_redo 设置断点
break clog_redo
# 或者在写入端
break clog.c:ZeroCLOGPage
5 官方文档
src/backend/access/transam/README
pg_xact and pg_subtrans are permanent (on-disk) storage of transaction related
information. There is a limited number of pages of each kept in memory, so
in many cases there is no need to actually read from disk. However, if
there's a long running transaction or a backend sitting idle with an open
transaction, it may be necessary to be able to read and write this information
from disk.
CLOG (pg_xact) 是事务状态的持久化存储,使用 SLRU(简单 LRU)机制管理内存中的有限页面。大部分读取来自内存缓存,只有长事务或空闲事务才需要磁盘 I/O。
异步提交与 CLOG LSN
First, for each page of CLOG we must remember the LSN of the latest commit
affecting the page, so that we can enforce the same flush-WAL-before-write
rule that we do for ordinary relation pages. Otherwise the record of the
commit 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 an
atomic commit protocol when transaction status is spread across multiple clog
pages. Whenever transaction status spreads across multiple pages we must use
a two-phase commit protocol: the first phase is to mark the subtransactions
as sub-committed, then we mark the top level transaction and all its
subtransactions committed (in that order).
当一个事务树的状态跨越多个 CLOG 页面时,使用两阶段提交协议:
- 第一阶段: 将子事务标记为 SUB_COMMITTED (0x03)
- 第二阶段: 将顶层事务和所有子事务标记为 COMMITTED (0x01)
这确保了即使在跨页面时,事务提交的原子性也能得到保证。
Recovery 期间的 CLOG 处理
Not all transactional behaviour is emulated, for example we do not insert
a transaction entry into the lock table, nor do we maintain the transaction
stack 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)
源码
c
/* src/backend/access/transam/slru.c */
void
SimpleLruTruncate(SlruCtl ctl, int cutoffPage)
{
SlruShared shared = ctl->shared;
int slotno;
/* 更新 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 (slotno = 0; slotno < shared->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);
else
SimpleLruWaitIO(ctl, slotno);
goto restart;
}
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删除
c
/* src/backend/access/transam/slru.c */
static void
SlruInternalDeleteSegment(SlruCtl ctl, int segno)
{
char path[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, 直接删除文件 */
}