MVCC 实现:CLOG、hint bit 与快照可扩展性
在 数据库
MVCC:快照隔离到底隔离了什么 中,我们拆解了 PG
的版本链(xmin/xmax/ctid)、快照结构(SnapshotData)和可见性判断规则。那篇文章到
HeapTupleSatisfiesMVCC()
为止,留下三个不透明的问题:
- xmin/xmax 只给了事务 ID——怎么知道事务 1001 是提交了还是回滚了?
t_infomask里的HEAP_XMIN_COMMITTED、HEAP_XMAX_COMMITTED这些 hint bit 是谁写的?什么时候写的?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;
}
```text
拿到快照后,每次读一行时调用可见性判定函数。以 `HeapTupleSatisfiesMVCC()` 为例:
```c
// 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
```bash
### SLRU 页面管理
CLOG 基于 SLRU(Simple Least Recently Used)框架实现。SLRU 是 PG 内部的通用页面缓存机制——CLOG、`pg_subtrans`(子事务映射)、`pg_multixact`(多事务 ID 映射)都用它。SLRU 维护一个固定大小的共享内存缓冲区,当请求的页面不在缓冲区时,从磁盘的 `pg_xact/` 目录加载。
```c
// 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) // 32768CLOG 页面在 pg_xact/ 目录(PG 10+,之前叫
pg_clog/)下以
0000、0001、0002
等命名,每个文件 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)
```text
事务提交时,CLOG 的状态在 `TransactionIdSetTreeStatus()` 中更新:
```c
// 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;
}
```bash
### 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 查询。
```c
// 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)
```bash
### 写入时机:SetHintBits
hint bit 的写入发生在 `HeapTupleSatisfiesMVCC()` 的返回值路径上,通过 `SetHintBits()` 完成:
```c
// 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 写入不是
WAL-logged——
MarkBufferDirtyHint()创建的脏页在 checkpoint 时才会写盘,crash recovery 后 hint bit 丢失。丢失 hint bit 不影响正确性,只影响性能(需要重新查 CLOG)。 - 只在
t_infomask中写四个标志之一,不碰xmin/xmax本身——这些值是 tuple 创建/删除时由 WAL-logged 操作写入的。hint bit 是对已有数据的注释性缓存,因此不需要 WAL 保护。 - 在共享缓冲区中进行修改——修改的是
shared_buffers中的页面。下次 checkpoint 或 bgwriter 写出时,hint bit 一并写入磁盘。
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 优化
为什么 ProcArrayLock 是瓶颈
GetSnapshotData() 需要扫描
ProcArray(所有活跃 Backend 的
PGPROC),获取所有在 [xmin, xmax)
之间活跃的事务 ID。在 PG 14 之前,这次扫描全程持有
ProcArrayLock 的共享锁。
在高连接数场景下(比如 1000+
Backend),ProcArrayLock
成为瓶颈有两个原因:
PGPROC的 cache line 很大:ProcArray是PGPROC的数组,PGPROC结构体约 400+ bytes(包含锁等待、信号量、LWLock 等信息),扫描几百个PGPROC会大量触发 cache miss。- 持锁时间长:扫描
ProcArray+ 确定每个 xid 是否活跃 + 复制到快照的xip数组——这比单个 atomic 操作长得多。任何想获取ProcArrayLock排他锁的操作(如事务提交、Backend 退出)都被阻塞。
Andres Freund 在 pgsql-hackers 邮件列表中对这个问题的总结:
“The problem is that we currently take ProcArrayLock in shared mode for GetSnapshotData(), even though most of the data it needs to read is in PGXACT and could be read without any lock at all.”
PGXACT 的分离
PG 14 之前的 PGPROC
把事务可见性判断所需的字段(xid、xmin、nxids)和锁管理所需的字段混在一起。GetSnapshotData()
扫描时,虽然只需要读 xid 和
xmin,但硬件会预读整个
PGPROC——导致大量不必要的 cache line 被拉入
CPU。
PG 的优化分两层:
第一层:PGXACT
结构分离。这一优化在 PG 9.4 就已引入,供
GetSnapshotData() 只扫描紧凑的
PGXACT 数组而非完整的 PGPROC:
// src/include/storage/proc.h
// PGXACT 只包含 GetSnapshotData() 扫描需要的字段,紧凑对齐
struct PGXACT {
TransactionId xid; // 4 bytes:当前事务 ID(只读事务为 Invalid)
TransactionId xmin; // 4 bytes:本事务快照的最小活跃事务
uint8 nxids; // 1 byte:缓存的子事务数量
uint8 vacuumFlags; // 1 byte:PROC_IN_VACUUM / PROC_IS_AUTOVACUUM
uint8 overflowed; // 1 byte:子事务缓存是否溢出
};
```text
`PGXACT` 只有约 11 bytes(对齐后约 12 bytes),扫描 1000 个 Backend 的 `PGXACT` 只需要触摸约 12KB,而扫描 1000 个 `PGPROC`(约 400KB)触发的 cache miss 多一个数量级。
**第二层:无锁读取 xid/xmin。**这是 PG 14 的核心改动(commit `941697c`)。`xid` 和 `xmin` 改为原子变量,`GetSnapshotData()` 不再全程持有 `ProcArrayLock` 共享锁:
```c
// PG 14 中的 GetSnapshotData() 简化逻辑
Snapshot GetSnapshotData(Snapshot snapshot) {
// xmin 不再需要锁——改用 atomic read
snapshot->xmin = pg_atomic_read_u32(&ProcGlobal->xmin_atomic);
// 扫描 PGXACT——不再全程持有 ProcArrayLock
for (int i = 0; i < ProcGlobal->numProcs; i++) {
// xid 通过 pg_atomic_read_u32 读取,无锁
TransactionId xid = pg_atomic_read_u32(&ProcGlobal->allPgXact[i].xid);
if (TransactionIdIsNormal(xid)) {
snapshot->xip[snapshot->xcnt++] = xid;
}
}
snapshot->xmax = ReadNextFullTransactionId(); // 也改为无锁
// ...
}每个 Backend 的 xid 和 xmin
字段变成了原子变量,其他操作(事务开始/结束)用原子写更新。ProcArrayLock
现在只保护 ProcArray 的结构性变化(如添加/删除
Backend slot),这在正常操作中频率很低。
仍然持有 ProcArrayLock 的操作
优化后的 ProcArrayLock
仍然被下列操作以排他锁持有:
| 操作 | 频率 | 持锁时间 |
|---|---|---|
Backend 启动(ProcArrayAdd) |
低 | 短 |
Backend 退出(ProcArrayRemove) |
低 | 短 |
| 事务提交(更新 PGXACT.xid) | 高 | 极短(一次原子写) |
| 子事务溢出到 pg_subtrans | 低 | 中 |
XLogFlush 等待 xid 对应的 WAL 写盘 |
低 | 取决于 IO |
在 PG 14 之后,ProcArrayLock
的排他持锁路径的实际持锁时间从”扫描整个
ProcArray”缩减到”一次原子写”的程度——它不再是高连接数场景的第一瓶颈。在
pgsql-hackers 的讨论和开发者测试中,1000 连接的 pgbench
只读场景 TPS 提升在 25-40% 量级,具体幅度取决于 CPU
核心数和连接数。
五、事务 ID 回卷:PG 的 20 亿上限
32 位 XID 与回卷环
PG 的 TransactionId 是
uint32,最多 \(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 亿
}
```text
四个阈值的触发顺序:
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 强制关闭避免数据丢失。
### 监控手段
在生产中,应该持续监控事务年龄,而不是等告警:
```sql
-- 每个数据库的事务年龄(核心指标)
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 冻结严重滞后
```text
这个威胁链的完整讨论在第 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 的回收效率更高。
**但代价也很明显:**
- 表膨胀是 PG MVCC 最直接的代价——每个 UPDATE 都在数据页上创建一个新版本,没有独立的 undo 空间做"卸压阀"。VACUUM 是必须的,而不是可选的。PG 把多版本直接存在数据表里,InnoDB 把历史版本存在独立的 undo tablespace 中,聚簇索引保持紧凑。
- 跨页面的版本链让 index scan 付出随机 I/O——当一个 tuple 被多次 UPDATE 且每次新版本落在不同页面时,沿着 `ctid` 链读取需要跳转多个页面。而 InnoDB 的索引始终指向聚簇索引的最新版本,版本链只在 undo log 中,不需要在多个数据页之间跳转。
- 事务 ID 回卷的威胁是 PG 独有的——因为版本都在 heap 页面上,必须通过 freezing 来给 32-bit 事务 ID 做"垃圾回收"。InnoDB 的 undo record 生命周期由 purge 线程管理——一旦所有需要它的快照都关闭了,undo record 被 purge 回收,对应的事务 ID 也就不再被引用。PG 的 tuple 可以数亿个事务不被冻结地存在数据页上,累积成 wraparound 风险。
- 跨页版本链对 seqscan 的影响:PG 的 seqscan 按物理页面顺序扫描,遇到旧版本时需要沿 `ctid` 跳跃到新版本所在页面再做可见性判定。如果一个表上有大量 UPDATE,同一个逻辑行的版本散布在多个页面,seqscan 的缓存局部性被破坏。
### 批量更新场景下的行为差异
用一个典型场景展示差异。假设对一张 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 的写入
```sql
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%';
```bash
### 监控事务 ID 回卷
```sql
-- 每个数据库的事务年龄
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 后的版本链
```sql – 插一行,更新两次 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 ```text
八、关键要点
CLOG 是 PG 的事务状态仲裁者——每个事务 ID 对应 2 bit 的状态(IN_PROGRESS / COMMITTED / ABORTED / SUB_COMMITTED),通过 SLRU 框架缓存和管理。CLOG 页面在
pg_xact/目录中作为段文件持久化,每次 checkpoint 写出脏页并回收废弃段文件。hint bit 是 CLOG 的缓存层——第一次判定事务状态后,把结果写入 tuple header 的
t_infomask,后续读取跳过 CLOG 查询。hint bit 写入不生成 WAL 记录,crash recovery 后丢失时需重新查 CLOG,不影响正确性。只读查询通过 hint bit 间接产生脏页。PG 14 的快照可扩展性优化移除了 ProcArrayLock 的瓶颈——通过
PGXACT结构分离缩小 cache miss 足迹,并通过xid/xmin的原子读写让GetSnapshotData()几乎完全无锁。ProcArrayLock只保护 ProcArray 的结构性变更,不再被每次快照获取所持有。事务 ID 回卷是 PG MVCC 的硬约束——32-bit 事务 ID 的有效比较范围是 21 亿,接近极限时必须通过 freezing 重置老 tuple 的
xmin。xidVacLimit到xidWrapLimit是四个递进的危机阈值。监控age(datfrozenxid)是预防 wraparound 的第一道防线。PG 的 heap 版本链 vs InnoDB 的 undo log:PG 用表膨胀换回滚简单性——PG 的版本 inline 存储简化了回滚逻辑和崩溃恢复,但 VACUUM 是必须的且大批量 UPDATE 直接翻倍表大小;InnoDB 用独立 undo log 回溯避免了表膨胀,但 undo 表空间回收和长事务回滚引入了不同的复杂度。
参考资料
前置文章
- 数据库 MVCC:快照隔离到底隔离了什么 — 本文的前置阅读,覆盖 xmin/xmax/ctid 版本链、快照结构、可见性判断规则和隔离级别
源码(PG 17)
src/backend/access/transam/clog.c:CLOG 的 SLRU 管理,TransactionLogFetch()、TransactionIdSetTreeStatus()src/include/access/clog.h:TransactionIdToPage()、TransactionIdToPgOffset()宏,CLOG 状态常量src/backend/storage/ipc/procarray.c:GetSnapshotData()的完整实现(PG 14+ 无锁版本)src/backend/access/heap/heapam_visibility.c:HeapTupleSatisfiesMVCC()、SetHintBits()src/include/access/htup_details.h:t_infomask的 hint bit 标志位定义src/include/storage/proc.h:PGPROC和PGXACT结构体定义src/backend/access/transam/varsup.c:SetTransactionIdLimit()— 事务 ID 回卷阈值的计算contrib/pageinspect/:heap_page_items()函数,观察 hint bit
官方文档
- PostgreSQL Documentation, Chapter 50: Overview of PostgreSQL Internals — “Transaction Processing”
- PostgreSQL Documentation, Chapter 68: Database Physical Storage
- PostgreSQL Documentation, Section 25.1.5: Preventing Transaction ID Wraparound Failures
论文与设计讨论
- Stonebraker, M. & Rowe, L. A. The Design of POSTGRES. SIGMOD 1986. — PG 不带 undo log 的历史根源
- Berenson, H. et al. A Critique of ANSI SQL Isolation Levels. SIGMOD 1995. — 快照隔离的形式化基础
- Andres Freund, “Snapshot scalability for PostgreSQL”, pgsql-hackers, 2020-2021. — PG 14 ProcArrayLock 优化的设计讨论
- MySQL Reference Manual, Chapter 17.3: InnoDB Multi-Versioning — InnoDB MVCC 的官方描述
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【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 的预警信号链、典型陷阱和排查方法。
【MySQL InnoDB 内核】InnoDB 存储引擎机制深度拆解
从线程模型到页格式、从 undo log MVCC 到 binlog 两阶段提交——对 MySQL InnoDB 做源码级拆解,并与 PostgreSQL 内核系列逐章对照。20 篇覆盖内核机制与生产运维实战,面向 MySQL DBA、从 PG 转 MySQL 的后端与数据库内核开发者。
【PG 内核】事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件
拆解 PostgreSQL 事务系统的三层结构:事务状态机 TransState 的状态转换路径、子事务(savepoint)的 TransactionState 栈与 ResourceOwner 嵌套管理、两阶段提交(2PC)的 WAL 记录与 pg_twophase 状态文件格式、事务 ID 分配的 xidStopLimit/xidWrapLimit 防线。附带 2PC 泄露的排查 SQL 和子事务栈过深的故障案例。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。