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;
}拿到快照后,每次读一行时调用可见性判定函数。以
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 0x03SLRU 页面管理
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) // 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)事务提交时,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 写入不是
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 优化
为什么高连接数下取快照会痛
GetSnapshotData()(src/backend/storage/ipc/procarray.c)要收集所有已建立连接上可能影响可见性的事务
ID,填进 SnapshotData 的 xip
等字段。连接数上千时,这件事变成连接可扩展性的主瓶颈——Andres
Freund 在分析生产 profile 时看到只读负载把大量 CPU 花在
GetSnapshotData() 上(见其 2020 年 Citus 博文
Improving Postgres Connection Scalability:
Snapshots)。
痛点不只是「\(O(\text{连接数})\)」算法,还有缓存线乒乓:
- 通过
procArray->pgprocnos[]间接跳到稀疏的PGPROC/ 旧PGXACT槽,扫描时碰到大量空闲或无关字段; - 历史上为维护
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 的 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 亿
}四个阈值的触发顺序:
xidVacLimit:触发 autovacuum 的 anti-wraparound VACUUM,冻结老 tuple。xidWarnLimit:在日志中输出警告信息,提示数据库即将进入保护模式。xidStopLimit:拒绝新事务,只允许 autovacuum 运行。pg_stat_activity中看到连接试图开始事务时收到 “database is not accepting commands to avoid wraparound” 错误。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 的回收效率更高。
但代价也很明显:
表膨胀是 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 的写入
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八、关键要点
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 的快照可扩展性优化减轻了高连接下的 cache 争用——稠密
ProcGlobal->xids等数组取代稀疏PGXACT间接扫描;GetSnapshotData()仍取ProcArrayLock共享锁,但持锁路径更短、跨核流量更低。数字以 Freund 2020 博文的 pgbench 图表为准,本文不冒充自测。事务 ID 回卷是 PG MVCC 的硬约束——32-bit 事务 ID 的有效比较范围是 21 亿,接近极限时必须通过 freezing 重置老 tuple 的
xmin。xidVacLimit到xidWrapLimit是四个递进的危机阈值。监控age(datfrozenxid)是预防 wraparound 的第一道防线。PG 的 heap 版本链 vs InnoDB 的 undo log:膨胀落点不同——PG 用表膨胀换回滚简单与堆内版本局部性;InnoDB 用 undo 表空间换聚簇紧凑。详见第六节与 InnoDB Read View。
九、争论:zheap 与「要不要 undo」
第六节论证了「为什么历史选择 heap 版本链」。社区并非没有反向试验:
- zheap(EnterpriseDB 等推动的 undo-based heap 原型):目标是把旧版本挪出主堆页,减轻表膨胀与 HOT/VACUUM 压力,语义上更接近 InnoDB 的「最新行 + undo」。
- 停滞原因(工程判断,需对照当时 pgsql-hackers / 项目状态):与现有索引、HOT、逻辑解码、复制、扩展的耦合面极大;收益依赖特定更新密集负载,而默认 heap 的运维工具链已经极其成熟。
因此「PG 不选 undo」不是永恒教条,而是默认路径的路径依赖;读 InnoDB 对照时,应把 zheap 当成「同一争论在 PG 树内的分叉尝试」,而不是已合并的替代实现。跨引擎变体总览见 MVCC 实现变体。
十、开放问题
- CSN 快照:用提交序号代替扫描活跃 xid 集合,从算法上打破 \(O(\text{连接数})\)——Freund 博文与邮件列表均指出工程体量大、开放问题多,至今未取代稠密数组路线。
- 64-bit XID / 冻结策略:降低 wraparound 运维负担的补丁与设计反复出现;任何方案都要回答「谁为历史版本买单」以及与 logical decoding 的兼容。
- 只读备库与 hint
bit:
wal_log_hints打开后,hint 写入变成复制流量——云上只读副本的最优默认值仍依赖 workload,缺少单一公开基准。
参考资料
前置文章
- 数据库 MVCC:快照隔离到底隔离了什么 — 本文的前置阅读,覆盖 xmin/xmax/ctid 版本链、快照结构、可见性判断规则和隔离级别
- MySQL InnoDB · MVCC 与 Read View — undo 链对照
源码(机制描述以 PG 14+ / 当前主线为准;阅读时核对所装大版本)
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+ 稠密ProcGlobal->xids路径)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/PROC_HDR(ProcGlobal)相关定义;PG 14 后勿再假设存在独立PGXACTsrc/backend/access/transam/varsup.c:SetTransactionIdLimit()— 事务 ID 回卷阈值的计算contrib/pageinspect/:heap_page_items()函数,观察 hint bit- Commits:
941697c3、5788e258、73487a60(snapshot scalability 系列)
官方文档
- 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. Improving Postgres Connection Scalability: Snapshots. Citus Data Blog, 2020-10-25. — PG 14 快照可扩展性动机、设计与 pgbench 图表(引用数据)
- Andres Freund. pgsql-hackers: Improving connection scalability: GetSnapshotData(), 2020. — 设计讨论
- MySQL Reference Manual, 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 贡献代码的工程师。