土法炼钢兴趣小组的算法知识备份

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#mvcc#clog#hint-bit#snapshot#slru#transaction-id#wraparound#procarraylock#innodb#undo-log

目录

MVCC 实现:CLOG、hint bit 与快照可扩展性

数据库 MVCC:快照隔离到底隔离了什么 中,我们拆解了 PG 的版本链(xmin/xmax/ctid)、快照结构(SnapshotData)和可见性判断规则。那篇文章到 HeapTupleSatisfiesMVCC() 为止,留下三个不透明的问题:

  1. xmin/xmax 只给了事务 ID——怎么知道事务 1001 是提交了还是回滚了?
  2. t_infomask 里的 HEAP_XMIN_COMMITTEDHEAP_XMAX_COMMITTED 这些 hint bit 是谁写的?什么时候写的?
  3. GetSnapshotData() 扫描所有活跃 Backend 的 PGPROC 来构建快照——1000 个连接时这个扫描到底有多重?

这三个问题分别指向 CLOG(提交日志)、hint bit(可见性缓存)和 snapshot scalability(快照可扩展性)。本文逐个拆解它们的源码实现,最后与 InnoDB 的 undo log 方案做系统性对比。


一、快照与可见性判定的快速回顾

在展开 CLOG 之前,先回顾可见性判定的完整路径,明确 CLOG 和 hint bit 在哪里介入。

每个 Backend 在执行查询前获取一个快照:

// src/backend/storage/ipc/procarray.c
Snapshot GetSnapshotData(Snapshot snapshot) {
    // 1. 获取 ProcArrayLock 共享锁(PG 14 优化了持锁范围,见第四节)
    LWLockAcquire(ProcArrayLock, LW_SHARED);

    // 2. 确定快照的 xmin / xmax / xip(活跃事务列表)
    snapshot->xmin = GetOldestActiveTransactionId();
    snapshot->xmax = ReadNextFullTransactionId();
    snapshot->xip = 收集所有 xmin < xid < xmax 的活跃事务 ID;

    LWLockRelease(ProcArrayLock);
    return snapshot;
}

拿到快照后,每次读一行时调用可见性判定函数。以 HeapTupleSatisfiesMVCC() 为例:

// src/backend/access/heap/heapam_visibility.c
bool HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) {
    // 1. 如果 xmin 是当前事务 → 可见(本事务的修改自己能看到)
    // 2. 如果 xmin 在快照的 xip 列表中 → 不可见(创建者还没提交)
    // 3. 如果 xmin < snapshot->xmin → 需要查 CLOG 确认是否已提交
    //    → TransactionIdDidCommit(xmin)   ← CLOG 在这里介入
    // 4. 如果 xmin 已提交 → 检查 xmax:是否被删除了?
    //    → 同样需要查 CLOG 确认 xmax 事务的状态
}

流程 3 和 4 中的 TransactionIdDidCommit() 就是 CLOG 的入口。每次判定都要查的话代价很高——这就是 hint bit 存在的理由:把 CLOG 的结果缓存到 tuple header 的 t_infomask 里,后续读取直接看 flag。


二、CLOG:提交日志的内部机制

事务状态位

CLOG(Commit Log)是 PG 在共享内存中维护的事务状态日志,记录每个事务的最终状态。它不是”日志”文件意义上的 append-only log——它在共享内存中是一个 SLRU(Simple Least Recently Used)缓存,对每个事务 ID 存储 2 个 bit:

状态 编码 含义
CLOG_XID_STATUS_IN_PROGRESS 0x00 事务正在进行中(初始状态)
CLOG_XID_STATUS_COMMITTED 0x01 事务已提交
CLOG_XID_STATUS_ABORTED 0x02 事务已回滚
CLOG_XID_STATUS_SUB_COMMITTED 0x03 子事务已提交,但父事务尚未提交(仅子事务使用)

每个事务只占 2 bit。一个 8KB 的 CLOG 页面可以记录 32768 个事务的状态。

// src/include/access/clog.h
#define CLOG_XID_STATUS_IN_PROGRESS     0x00
#define CLOG_XID_STATUS_COMMITTED       0x01
#define CLOG_XID_STATUS_ABORTED         0x02
#define CLOG_XID_STATUS_SUB_COMMITTED   0x03

SLRU 页面管理

CLOG 基于 SLRU(Simple Least Recently Used)框架实现。SLRU 是 PG 内部的通用页面缓存机制——CLOG、pg_subtrans(子事务映射)、pg_multixact(多事务 ID 映射)都用它。SLRU 维护一个固定大小的共享内存缓冲区,当请求的页面不在缓冲区时,从磁盘的 pg_xact/ 目录加载。

// src/backend/access/transam/clog.c
#define CLOG_BLCKSZ            BLCKSZ         // 8KB per page
#define CLOG_BITS_PER_XACT     2              // 2 bits per transaction
#define CLOG_XACTS_PER_BYTE    4              // 4 transactions per byte
#define CLOG_XACTS_PER_PAGE    (CLOG_BLCKSZ * CLOG_XACTS_PER_BYTE)  // 32768

CLOG 页面在 pg_xact/ 目录(PG 10+,之前叫 pg_clog/)下以 000000010002 等命名,每个文件 256KB(32 个页面)。PG 用 TransactionIdToPage(xid)TransactionIdToPgOffset(xid) 两个宏定位:

// src/include/access/clog.h
#define TransactionIdToPage(xid)    ((xid) / (TransactionId) CLOG_XACTS_PER_PAGE)
#define TransactionIdToPgOffset(xid) ((xid) % (TransactionId) CLOG_XACTS_PER_PAGE)

事务提交时,CLOG 的状态在 TransactionIdSetTreeStatus() 中更新:

// src/backend/access/transam/clog.c
void TransactionIdSetTreeStatus(TransactionId xid, int nsubxids,
                                 TransactionId *subxids, XidStatus status, ...) {
    // 1. 更新主事务的 CLOG 状态
    TransactionIdSetPageStatus(xid, nsubxids, subxids, status, ...);
    // 2. 如果 status == COMMITTED,把子事务的状态从 SUB_COMMITTED 推进到 COMMITTED
}

CLOG 的读取路径与 LRU 淘汰

HeapTupleSatisfiesMVCC() 需要判定事务可见性时,调用链为:

HeapTupleSatisfiesMVCC()
  → TransactionIdDidCommit(xid)
    → TransactionLogFetch(xid)
      → SimpleLruReadPage(ClogCtl, pageno, ...)
        → 如果页面在 SLRU buffer 中 → 直接返回
        → 如果不在 → SlruPhysicalReadPage() 从 pg_xact/ 读磁盘
        → 如果缓冲区满 → SlruSelectVictimPage() 挑一个最近最少使用的淘汰

SLRU 的淘汰是纯粹的 LRU——没有 shared_buffers 那种 clock sweep 策略。CLOG 的 SLRU buffer 大小由 clog_buffers 控制(默认给 1 个 buffer,即 8KB。但若 shared_buffers 较大,PG 会自动扩展)。在绝大多数场景下,活跃事务的 CLOG 页面都能命中缓冲区——事务提交后很快就会被查询判定可见性。

读 CLOG 的关键函数:

// src/backend/access/transam/clog.c
XidStatus TransactionLogFetch(TransactionId xid) {
    // 1. 计算 CLOG 页面号
    int pageno = TransactionIdToPage(xid);
    int byteno = TransactionIdToPgOffset(xid) / CLOG_XACTS_PER_BYTE;
    int bshift = TransactionIdToPgOffset(xid) % CLOG_XACTS_PER_BYTE * CLOG_BITS_PER_XACT;

    // 2. 通过 SLRU 读取页面
    int slotno = SimpleLruReadPage(ClogCtl, pageno, true, xid);

    // 3. 从页面中提取这 2 bit
    byte = ((char *) ClogCtl->shared->page_buffer[slotno])[byteno];
    status = (byte >> bshift) & CLOG_XACT_BITMASK;
    return status;
}

CLOG 的磁盘段文件

CLOG 的磁盘存储位于 $PGDATA/pg_xact/(PG 10+,此前为 pg_clog/)。每个段文件 256KB(32 个 8KB 页面):

$PGDATA/pg_xact/
  0000    → 事务 ID 0       ~ 1,048,575    (页面 0-31)
  0001    → 事务 ID 1,048,576 ~ 2,097,151  (页面 32-63)
  0002    → ...

在 checkpoint 时,PG 将 SLRU 缓冲区中的脏 CLOG 页面写出到对应段文件。同时会检查并删除不再需要的旧段文件——判断标准是该段文件覆盖的所有事务 ID 都小于 oldestActiveXid 的 wraparound 裁剪点。如果 freezing 滞后,pg_xact/ 可能堆积大量段文件占用磁盘。


三、hint bit:为什么写、怎么写、写坏了怎么办

为什么需要 hint bit

每次读一行都要调用 TransactionIdDidCommit() 去查 CLOG——即使 xmin 对应的事务一年前就提交了。在 1000 TPS 的 OLTP 系统里,这会产生大量重复的 SLRU 查询,争用 CLOG 的 SlruCtl->shared->buffer_locks

hint bit 的解决方案:第一次判定 xmin 已提交时,把结果写到 tuple header 的 t_infomask。后续再读这行时,看到 HEAP_XMIN_COMMITTED 标志就知道 xmin 已提交——完全跳过 CLOG 查询。

// src/include/access/htup_details.h
#define HEAP_XMIN_COMMITTED     0x0100  // xmin 事务已提交
#define HEAP_XMIN_INVALID       0x0200  // xmin 事务已回滚
#define HEAP_XMAX_COMMITTED     0x0400  // xmax 事务已提交(即这行已被删除/更新)
#define HEAP_XMAX_INVALID       0x0800  // xmax 事务已回滚

HEAP_XMIN_COMMITTED 设置后,判定只是一个位运算:

#define HeapTupleHeaderXminCommitted(tup) \
    ((tup)->t_infomask & HEAP_XMIN_COMMITTED)

写入时机:SetHintBits

hint bit 的写入发生在 HeapTupleSatisfiesMVCC() 的返回值路径上,通过 SetHintBits() 完成:

// src/backend/access/heap/heapam_visibility.c
static void SetHintBits(HeapTupleHeader tuple, Buffer buffer,
                         uint16 infomask, TransactionId xid) {
    // 1. 如果 buffer 可写 → 直接在共享缓冲区中修改 tuple header
    if (!(buf_state & BM_DIRTY)) {
        tuple->t_infomask |= infomask;              // 设置 hint bit
        MarkBufferDirtyHint(buffer, buf_state);     // 标记 buffer 为非关键脏页
    }
    // 2. 如果 buffer 不可写(如从 toast 返回的 tuple)→ 跳过
}

几个要点:

hint bit 的竞争问题

hint bit 写入不是免费的。主要风险有三个:

1. Buffer 内容锁(content lock)的争用。写入 hint bit 需要以 BUFFER_LOCK_SHARE 持有 buffer 内容锁。当多个 Backend 同时读同一行(比如热点行),它们都会尝试写 hint bit——第一个写入后 hint bit 已设置,后续 Backend 跳过写入,但获取内容锁本身有开销。不过 hint bit 的写入只需要共享内容锁(而非排他),所以不会阻塞同页面的其他读取者。

2. 只读查询产生写脏页。pg_stat_statements 中看到 shared_blks_dirtied > 0 的纯 SELECT 查询,几乎都是 hint bit 写入导致的。这些只读查询间接增加了 bgwriter 和 checkpoint 的写盘量——对写入延迟敏感的场景中需要注意。对于只读副本(hot standby),hint bit 写入在 PG 9.4 之前是完全禁止的(standby 上的页面不可写);PG 9.4 后引入了 wal_log_hints = on 选项,允许 standby 写 hint bit,但此时 hint bit 变更为 WAL-logged,增加了流复制的 WAL 流量。

3. 大量初始 hint bit 写入的 IO 压力。大表批量 SELECT 首次接触大量未设置 hint bit 的 tuple(如从 pg_dump 导入的数据、从旧版本 pg_upgrade 迁移后的库),会在短期内产生密集的 hint bit 写入。每个写入调用 MarkBufferDirtyHint(),虽然不立即刷盘,但增加了 shared_buffers 的脏页比例——当 dirty page 比例超过 bgwriter_lru_maxpages 的吸纳能力时,backend 需要自己执行 FlushBuffer(),查询延迟出现尖峰。


四、快照可扩展性:ProcArrayLock 争用与 PG 14 优化

为什么高连接数下取快照会痛

GetSnapshotData()src/backend/storage/ipc/procarray.c)要收集所有已建立连接上可能影响可见性的事务 ID,填进 SnapshotDataxip 等字段。连接数上千时,这件事变成连接可扩展性的主瓶颈——Andres Freund 在分析生产 profile 时看到只读负载把大量 CPU 花在 GetSnapshotData() 上(见其 2020 年 Citus 博文 Improving Postgres Connection Scalability: Snapshots)。

痛点不只是「\(O(\text{连接数})\)」算法,还有缓存线乒乓

  1. 通过 procArray->pgprocnos[] 间接跳到稀疏的 PGPROC / 旧 PGXACT 槽,扫描时碰到大量空闲或无关字段;
  2. 历史上为维护 RecentGlobalXmin 还要读各后端的 xmin,写路径频繁改这些字段,读路径反复拉脏 cache line。

取快照时仍会拿 ProcArrayLock共享锁(与「完全无锁」不是一回事);PG 14 的贡献是把扫描变成对稠密、按字段拆分的数组的顺序读,并砍掉不必要的字段访问,从而缩短持锁窗口、降低跨核流量。

演进两阶段:先 PGXACT,再拆掉 PGXACT

第一阶段(PG 9.4 起):从 PGPROC 拆出 PGXACT
xid / xmin / 子事务计数等「快照关心的字段」从巨大的 PGPROC 里挪到更紧凑的并行数组,避免扫描时拖进锁等待、信号量等无关成员。这是必要但不够:数组仍可能稀疏,且 GetSnapshotData() 曾为 RecentGlobalXmin 去读 xmin,继续制造跨核写读冲突。

第二阶段(PG 14,Freund 系列提交):稠密 xids 数组,并最终移除 PGXACT
关键提交包括(均可在 postgres.git 核对):

Commit 作用
941697c3 引入 ProcGlobal->xids稠密的 in-progress xid 数组,按 pgxactoff 索引,避免经 pgprocnos 间接扫稀疏槽
5788e258 vacuumFlags 挪到 ProcGlobal
73487a60 子事务信息进 ProcGlobal删除 PGXACT

GetSnapshotData() 在持有 ProcArrayLock 共享锁时,核心循环变成对 ProcGlobal->xids[pgxactoff] 的顺序读取(示意,以源码为准):

/* PG 14+ procarray.c — 逻辑示意,非完整函数 */
LWLockAcquire(ProcArrayLock, LW_SHARED);
other_xids = ProcGlobal->xids;
for (pgxactoff = 0; pgxactoff < numProcs; pgxactoff++) {
    TransactionId xid = UINT32_ACCESS_ONCE(other_xids[pgxactoff]);
    /* 仅当 xid 有效时再碰其它稠密数组 / 收集进 snapshot->xip */
}
LWLockRelease(ProcArrayLock);

另外,快照计算不再依赖在 GetSnapshotData() 里维护 RecentGlobalXmin(该职责调整后,读路径可少碰经常被写的 xmin 字段)——这是 Freund 博文里强调的「去掉不必要工作」一步。

性能数字怎么引用

Freund 在 Azure F72s_v2 上用只读 pgbench 对比改动前后,并给出多组「活跃连接 + 大量空闲连接」曲线(博文 Figure 1–3 与文末表格)。结论应写成:高连接、尤其大量空闲连接时,快照路径的可扩展性显著改善;不要笼统写成「1000 连接 TPS 提升 25–40%」这类无硬件/workload 出处的概括。本站写作环境未复跑该实验,故只引用、不冒充自测

仍然昂贵的操作

操作 与 ProcArrayLock 说明
GetSnapshotData 共享锁,持锁时间缩短但仍存在 连接数极大时仍 \(O(n)\)
Backend 进出 ProcArrayAdd/Remove 排他锁 改稠密数组布局时更敏感一点
分配/清理 xid ProcGlobal->xids 与读路径缓存局部性相关

开放改进(Freund 博文亦提到):缓存快照是否仍有效时的锁、以及更根本的 CSN(commit sequence number)快照——社区讨论多年,工程体量大,PG 14 选择了可合入的稠密数组路线而非一次换成 CSN。


五、事务 ID 回卷:PG 的 20 亿上限

32 位 XID 与回卷环

PG 的 TransactionIduint32,最多 \(2^{32} \approx 43\) 亿。由于 PG 对事务 ID 做模比较(区分”过去”和”未来”),有效比较范围是 \(2^{31} \approx 21\) 亿。当一个 PG 集群的事务 ID 接近这个极限时,必须做 freezing——将足够老的 tuple 的 xmin 设为 FrozenTransactionId(= 2),这样比较逻辑会认为它是”比所有事务都老”的。

事务 ID 空间是一个环。PG 将 32-bit 空间分成两半:小于当前事务 ID 约 10 亿的是”过去”区间,大于当前事务 ID 约 10 亿的是”未来”区间。如果存在 xmin < 当前 xid - 10亿 的未被冻结 tuple,PG 无法区分这个 tuple 是”很久以前的”还是”未来的”。

回卷的威胁模型

四个递进阈值的计算在 SetTransactionIdLimit() 中:

// src/backend/access/transam/varsup.c
void SetTransactionIdLimit(TransactionId oldest_datfrozenxid, ...) {
    xidVacLimit  = oldest_datfrozenxid + autovacuum_freeze_max_age;
    xidWarnLimit = xidVacLimit + 5000000;
    xidStopLimit = xidWarnLimit + 5000000;
    xidWrapLimit = oldest_datfrozenxid + 0x7FFFFFFF;  // 21 亿
}

四个阈值的触发顺序:

  1. xidVacLimit:触发 autovacuum 的 anti-wraparound VACUUM,冻结老 tuple。
  2. xidWarnLimit:在日志中输出警告信息,提示数据库即将进入保护模式。
  3. xidStopLimit:拒绝新事务,只允许 autovacuum 运行。pg_stat_activity 中看到连接试图开始事务时收到 “database is not accepting commands to avoid wraparound” 错误。
  4. xidWrapLimit:数据库被迫 shutdown——如果 autovacuum 在 xidStopLimit 之前没能完成 freezing,到达这个阈值时 PG 强制关闭避免数据丢失。

监控手段

在生产中,应该持续监控事务年龄,而不是等告警:

-- 每个数据库的事务年龄(核心指标)
SELECT datname,
       age(datfrozenxid) AS xid_age,
       datfrozenxid,
       2^31 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

age(datfrozenxid) 超过 autovacuum_freeze_max_age(默认 2 亿)时,anti-wraparound VACUUM 被触发。注意这个 2 亿不是安全线——它只是触发阈值。如果 autovacuum 跟不上事务产生速率,2 亿的 buffer 会在几个星期内耗尽(取决于事务速率)。

也可以通过查看 pg_xact/ 的段文件数间接判断冻结是否滞后:

# 段文件数直接反映 CLOG 覆盖的事务范围
ls $PGDATA/pg_xact/ | wc -l
# 每个段文件覆盖约 100 万个事务 ID
# 过多段文件(如 > 1000)意味着 old xid 冻结严重滞后

这个威胁链的完整讨论在第 8 章展开(VACUUM 与 Freezing),第 22 章还会给出 wraparound 危机从预警信号到数据库只读 shutdown 的完整时间线和修复边界。此处先建立威胁模型:CLOG 需要为每个事务 ID 分配存储,而事务 ID 空间是有限的——这是 PG MVCC 实现中最根本的工程约束。


六、与 InnoDB 的 undo log 方案对比

InnoDB 的方案:undo log + rollback segment

InnoDB(MySQL 8.0)的 MVCC 使用 undo log 回溯旧版本:

聚簇索引中只保留最新版本
  + 每行有一个 ROLL_PTR(回滚指针),指向 undo log 中的旧版本
  + undo log 以链表形式链接每个行的历史版本
  + 读旧版本时:从索引读最新版本 → 沿 ROLL_PTR 回溯 → 应用 undo record 重建旧版本

undo log 存放在共享表空间(ibdata1)或独立的 undo tablespace 中,由 purge 线程异步回收不再需要的 undo record。

两套方案的关键差异

维度 PG(heap 版本链) InnoDB(undo log 回溯)
旧版本存储位置 heap 表页面上(多版本 inline) undo log 独立存储,聚簇索引只保留最新版本
版本访问 沿 ctid 链向前遍历(版本都在数据页上) 从索引读最新版本 → ROLL_PTR 回溯 undo log
旧版本膨胀 表膨胀(dead tuple 占据页面空间)→ 必须 VACUUM undo log 膨胀(purge 线程跟不上)
回滚速度 O(1):标记 xmax 即可回滚,不需要重建数据 需要沿 ROLL_PTR 应用 undo,大事务回滚可能很慢
hint bit 等价物 hint bit 在 tuple header 中(HEAP_XMIN_COMMITTED undo record header 中的事务状态标志
长事务影响 阻止 VACUUM 回收 dead tuple → 表膨胀 阻止 purge 回收 undo record → undo 表空间膨胀
事务状态查询 CLOG(SLRU 缓存,固定大小共享内存) 事务系统页(TRX_SYS 页面,在共享表空间中)
崩溃后事务状态恢复 CLOG 从磁盘 pg_xact/ 读取,不需要恢复 undo log 本身就是恢复信息——崩溃时未提交事务通过 undo 回滚

PG 不选 undo log 的原因

PG 的设计决策可以追溯到 Berkeley POSTGRES 项目(Stonebraker & Rowe, SIGMOD 1986)。PG 选择 heap 版本链而非 undo log,有几个历史和技术原因:

1. 回滚简单。PG 只需在 tuple header 上设置 xmax 即为”回滚”——不需要读取和逆操作 undo record。对于大事务的内部回滚(savepoint 级别),PG 也只需清理当前子事务插入的 tuple——不需要逐行 undo。

2. 时间旅行查询。POSTGRES 项目最初支持”时间旅行”(查询任意历史时间点的数据)。如果把所有版本都 inline 存在数据页上,时间旅行只需扫描页面上的旧版本。虽然这个特性在商业化 PG 中未被广泛使用,但其设计影响延续至今。

3. 避免 undo 膨胀和回滚竞争。InnoDB 的 undo log 在高并发更新场景下,purge 线程可能跟不上,导致 undo 表空间膨胀。PG 的 VACUUM 也面临类似挑战,但 VACUUM 的回收粒度是整页扫描,而 purge 是按 undo record 逐条回收——在某些场景下 PG 的回收效率更高。

但代价也很明显:

批量更新场景下的行为差异

用一个典型场景展示差异。假设对一张 100 万行的表执行 UPDATE ... SET col = col + 1(更新所有行):

PG:heap 表从 100 万行变成 200 万行(100 万旧版本 + 100 万新版本),表大小翻倍。如果 autovacuum 还没来得及跑,seqscan 需要扫描 200 万个 tuple(但通过可见性判定跳过 100 万个旧版本)。索引也需要更新——每行的 ctid 变了,索引条目指向新的物理位置。这个 UPDATE 需要更新所有索引。

InnoDB:聚簇索引中仍是 100 万行(新旧版本原地替换),旧版本通过 DB_ROLL_PTR 存储在 undo log 中。索引如果只覆盖了没被修改的列,不需要更新(change buffer 缓冲)。表大小增长主要发生在 undo tablespace。

这个差异在实际运维中的后果:PG 的大批量 UPDATE 后面如果不紧跟 VACUUM,磁盘使用和查询扫描范围都会膨胀。InnoDB 的大批量 UPDATE 后面如果不紧跟 purge,undo tablespace 膨胀——但聚簇索引保持紧凑。


七、实验:观察 CLOG 与 hint bit

观察 hint bit 的写入

CREATE EXTENSION pageinspect;

-- 创建测试表
CREATE TABLE test_hint (id INTEGER, val TEXT);
INSERT INTO test_hint VALUES (1, 'hello');

-- 查询前:观察 t_infomask(还没有 hint bit)
SELECT lp, t_xmin, t_xmax,
       t_infomask::bit(16) AS infomask_bits,
       (t_infomask & x'0100'::int) > 0 AS xmin_committed
FROM heap_page_items(get_raw_page('test_hint', 0));

-- 执行一次 SELECT,触发 hint bit 写入
SELECT * FROM test_hint;

-- 查询后:现在 HEAP_XMIN_COMMITTED (0x0100) 应该被设置了
SELECT lp, t_xmin, t_xmax,
       t_infomask::bit(16) AS infomask_bits,
       (t_infomask & x'0100'::int) > 0 AS xmin_committed
FROM heap_page_items(get_raw_page('test_hint', 0));

验证只读查询产生脏页

-- 启用 pg_stat_statements
CREATE EXTENSION pg_stat_statements;

-- 重置统计
SELECT pg_stat_statements_reset();

-- 对刚导入的大表执行 SELECT(没有 hint bit)
SELECT count(*) FROM large_table;

-- 查看 shared_blks_dirtied — 纯 SELECT 也应该有非零值
SELECT query, shared_blks_dirtied, shared_blks_hit
FROM pg_stat_statements
WHERE query LIKE '%large_table%';

监控事务 ID 回卷

-- 每个数据库的事务年龄
SELECT datname,
       age(datfrozenxid) AS xid_age,
       datfrozenxid,
       current_setting('autovacuum_freeze_max_age')::int AS freeze_max_age,
       2^31 - age(datfrozenxid) AS xid_remaining
FROM pg_database
WHERE datname NOT LIKE 'template%'
ORDER BY age(datfrozenxid) DESC;

-- CLOG 段文件数(需要在 OS 层执行)
-- 段文件过多意味着老事务 ID 对应的 CLOG 还没被回收

观察 UPDATE 后的版本链

-- 插一行,更新两次
INSERT INTO test_chain VALUES (1, 'v1');
UPDATE test_chain SET val = 'v2' WHERE id = 1;
UPDATE test_chain SET val = 'v3' WHERE id = 1;

-- 观察版本链:lp_flags, t_ctid, xmin, xmax
SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid,
       t_infomask::bit(16) AS mask,
       CASE lp_flags
         WHEN 1 THEN 'LP_NORMAL'
         WHEN 2 THEN 'LP_REDIRECT'
         WHEN 3 THEN 'LP_DEAD'
         ELSE 'LP_UNUSED'
       END AS flag_desc
FROM heap_page_items(get_raw_page('test_chain', 0));
-- 输出中能看到旧版本 t_ctid 指向新版本的 (block, offset),
-- 以及 xmax 记录了更新旧版本的事务 ID

八、关键要点

  1. CLOG 是 PG 的事务状态仲裁者——每个事务 ID 对应 2 bit 的状态(IN_PROGRESS / COMMITTED / ABORTED / SUB_COMMITTED),通过 SLRU 框架缓存和管理。CLOG 页面在 pg_xact/ 目录中作为段文件持久化,每次 checkpoint 写出脏页并回收废弃段文件。

  2. hint bit 是 CLOG 的缓存层——第一次判定事务状态后,把结果写入 tuple header 的 t_infomask,后续读取跳过 CLOG 查询。hint bit 写入不生成 WAL 记录,crash recovery 后丢失时需重新查 CLOG,不影响正确性。只读查询通过 hint bit 间接产生脏页。

  3. PG 14 的快照可扩展性优化减轻了高连接下的 cache 争用——稠密 ProcGlobal->xids 等数组取代稀疏 PGXACT 间接扫描;GetSnapshotData() 仍取 ProcArrayLock 共享锁,但持锁路径更短、跨核流量更低。数字以 Freund 2020 博文的 pgbench 图表为准,本文不冒充自测。

  4. 事务 ID 回卷是 PG MVCC 的硬约束——32-bit 事务 ID 的有效比较范围是 21 亿,接近极限时必须通过 freezing 重置老 tuple 的 xminxidVacLimitxidWrapLimit 是四个递进的危机阈值。监控 age(datfrozenxid) 是预防 wraparound 的第一道防线。

  5. PG 的 heap 版本链 vs InnoDB 的 undo log:膨胀落点不同——PG 用表膨胀换回滚简单与堆内版本局部性;InnoDB 用 undo 表空间换聚簇紧凑。详见第六节与 InnoDB Read View


九、争论:zheap 与「要不要 undo」

第六节论证了「为什么历史选择 heap 版本链」。社区并非没有反向试验:

因此「PG 不选 undo」不是永恒教条,而是默认路径的路径依赖;读 InnoDB 对照时,应把 zheap 当成「同一争论在 PG 树内的分叉尝试」,而不是已合并的替代实现。跨引擎变体总览见 MVCC 实现变体


十、开放问题

  1. CSN 快照:用提交序号代替扫描活跃 xid 集合,从算法上打破 \(O(\text{连接数})\)——Freund 博文与邮件列表均指出工程体量大、开放问题多,至今未取代稠密数组路线。
  2. 64-bit XID / 冻结策略:降低 wraparound 运维负担的补丁与设计反复出现;任何方案都要回答「谁为历史版本买单」以及与 logical decoding 的兼容。
  3. 只读备库与 hint bitwal_log_hints 打开后,hint 写入变成复制流量——云上只读副本的最优默认值仍依赖 workload,缺少单一公开基准。

上一章:页面布局与元组格式 下一章:WAL 内部机制


参考资料

前置文章

源码(机制描述以 PG 14+ / 当前主线为准;阅读时核对所装大版本)

官方文档

论文与设计讨论

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】VACUUM 与 Freezing:膨胀的根因和 Wraparound 危机

拆解 PostgreSQL VACUUM 的完整内部流程:heap scan、dead tuple 回收、索引清理、FSM/VM 更新。深入可见性映射和空闲空间映射的结构设计,以及 Index-Only Scan 如何依赖 VM 判断页面全可见。解析 Freezing 机制与事务 ID 回卷防御,Autovacuum 的触发阈值和 cost-based delay。最后用一条从 n_tup_del 增长到数据库强制只读的完整危机时间线,讲清楚 Anti-wraparound VACUUM 的预警信号链、典型陷阱和排查方法。

2026-06-18 · database / kernel

【MySQL InnoDB 内核】InnoDB 存储引擎机制深度拆解

从线程模型到页格式、从 undo log MVCC 到 binlog 两阶段提交——对 MySQL InnoDB 做源码级拆解,并与 PostgreSQL 内核系列逐章对照。20 篇覆盖内核机制与生产运维实战,面向 MySQL DBA、从 PG 转 MySQL 的后端与数据库内核开发者。

2026-06-16 · database / kernel

【PG 内核】事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件

拆解 PostgreSQL 事务系统的三层结构:事务状态机 TransState 的状态转换路径、子事务(savepoint)的 TransactionState 栈与 ResourceOwner 嵌套管理、两阶段提交(2PC)的 WAL 记录与 pg_twophase 状态文件格式、事务 ID 分配的 xidStopLimit/xidWrapLimit 防线。附带 2PC 泄露的排查 SQL 和子事务栈过深的故障案例。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。


By .