Postgresql源码(155)Redo系列CLOG Redo (RM_CLOG_ID = 3)

总结

  • 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 页面时,使用两阶段提交协议:

  1. 第一阶段: 将子事务标记为 SUB_COMMITTED (0x03)
  2. 第二阶段: 将顶层事务和所有子事务标记为 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, 直接删除文件 */
}
相关推荐
原来是猿1 小时前
MySQL【表的约束下】
数据库·mysql
6+h1 小时前
【MySQL】索引原理详解
数据库·mysql
不羁的fang少年2 小时前
2pc和3pc的比较
数据库
小尔¥2 小时前
LNMP环境部署
运维·数据库·nginx·php
深念Y2 小时前
记一次完整的MongoDB环境配置实录
数据库·mongodb
DBA小马哥2 小时前
国产数据库选型实战:MySQL迁移的兼容性、安全与性能落地
数据库·mysql·安全
Lethehong2 小时前
深入浅出:复杂查询中基于代价的连接条件下推优化实战
数据库
郝学胜-神的一滴2 小时前
深度解析:Python元类手撸ORM框架,解锁底层编程魔法
数据结构·数据库·python·算法·职场和发展
李恒-聆机智能专精数采2 小时前
从零开始了解数据采集技术篇(8)——为什么工业数据采集很难用“一站式平台”解决?从设备生态到系统架构的技术分析
运维·网络·数据库·数据分析·数据采集