引言:PostgreSQL 事务并发控制的核心地位
PostgreSQL 作为全球最先进的开源关系型数据库之一,在企业级应用中占据核心地位。从金融交易系统到高并发 SaaS 平台,从地理时空数据到 AI 向量数据库,PostgreSQL 都能凭借其强大的事务能力和高度可扩展性胜任关键业务场景。在 PostgreSQL 内部,并发控制的实现直接影响系统的吞吐量、延迟和稳定性,而 MVCC(Multi-Version Concurrency Control,多版本并发控制) 正是这套机制的基石。
与传统的基于锁(Lock-based)的并发控制方案不同,PostgreSQL 的 MVCC 通过维护数据的多个版本来实现读写互不阻塞——读不加锁、写不阻塞读。这种设计让 PostgreSQL 在读写混合工作负载下都能保持稳定的性能,而不像 MySQL InnoDB 在极端高并发写入场景下会遇到严重的锁竞争问题。
本文将从源码级别的实现原理出发,系统拆解 PostgreSQL MVCC 的六大核心维度:版本元组(Heap Tuple)存储结构、快照机制与事务隔离级别、快照隔离(SI)下的写倾斜问题、VACUUM 机制与事务 ID 回卷(XID Wraparound)、HOT 更新优化与 Index-Only Scan、以及生产级调优与监控实践。目标是让读者不仅"知其然",更"知其所以然",能够独立分析和解决生产中的 MVCC 相关问题。
第一章 Heap Tuple 存储结构:PostgreSQL 如何在数据页中表达"版本"
理解 MVCC 的第一步是理解 PostgreSQL 如何在磁盘和内存中表示一条数据记录的多个版本。PostgreSQL 将其称为 Heap Tuple(堆元组),存储在固定大小(默认 8KB)的数据页(Page)中,每页通过 Line Pointer Array 索引到具体的元组位置。
1.1 HeapTupleHeaderData 关键字段
每条元组头部包含一个固定长度的 HeapTupleHeaderData 结构体(源码位于 src/include/access/htup_details.h),其中与 MVCC 直接相关的关键字段如下:
typedef struct HeapTupleHeaderData
{
union
{
HeapTupleFields t_heap;
DatumTupleFields t_datum;
} t_infomask2; // 低 16 位存储列数,高 16 位为标志位
ItemIdData t_ctid; // 当前元组的 ItemId 或 CTID(行定位器)
/* 以下三个字段是 MVCC 的核心 */
TransactionId t_xmin; // 创建此版本的事务 ID(插入它的事务)
TransactionId t_xmax; // 删除此版本的事务 ID(删除它的事务 0 表示未删除)
CommandId t_cid; // 命令 ID(同一事务内第几条命令)
/* ... */
} HeapTupleHeaderData;
可以看出,每一个元组都自带"版本标签"——t_xmin 是创建版本的事务 ID,t_xmax 是删除/更新该版本的事务 ID。当执行 UPDATE 操作时,PostgreSQL 并不会原地修改原有行,而是:
- 将新行作为新版本插入到同一个表(称为 heap)中
- 在新版本上标记
t_xmin为当前事务 ID - 在旧版本上标记
t_xmax为当前事务 ID(在旧版本所在页加LP_NORMAL标记) - 通过
t_ctid字段将旧版本链接到新版本(TID 链)
这种"追加写"语义是 MVCC 版本链的物理基础。值得注意的是,DELETE 操作也仅是将 t_xmax 设置为当前 xid,而非立即删除数据;真正的空间回收由 VACUUM 完成。
1.2 数据页内部布局
一个 PostgreSQL 数据页(Page)的内存布局如下(src/include/storage/bufpage.h):
typedef struct PageHeaderData
{
PageXLogRecPtr pd_lsn; // 最后修改此页的 WAL 记录位置
uint16 pd_checksum; // 页校验和
uint16 pd_flags; // 标志位
LocationIndex pd_lower; // 空闲空间起始偏移
LocationIndex pd_upper; // 空闲空间结束偏移
LocationIndex pd_special; // 特殊空间起始偏移
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; // 用于判断是否需要 prune
ItemIdData pd_linp[]; // Line Pointer 数组(索引到具体元组)
} PageHeaderData;
页中的元组从 pd_upper 向低地址增长,Line Pointer 数组从 pd_lower 向高地址增长。新元组追加在页的尾部,Line Pointer 数组随之扩展;删除或更新后的旧元组会形成"碎片"(Dead Tuple),等待 VACUUM 回收。
1.3 TID 链与更新传播
当同一行被连续更新多次时,PostgreSQL 维护一条 TID 链:
t_xmin=100, t_xmax=101 → t_xmin=101, t_xmax=102 → t_xmin=102, t_xmax=0 (当前有效)
旧版本 中间版本 最新版本
ctid=(0,3) ctid=(0,4) ctid=(0,5)
每个元组的 t_ctid 指向更新的下一版本。如果一个事务需要看到特定快照下的正确版本,它需要沿着 TID 链遍历,结合快照规则判断每个版本的可见性。这一判断由 HeapTupleSatisfiesMVCC() 等函数(src/backend/utils/time/tqual.c)实现,我们将在下一章详细拆解。
第二章 快照机制与事务隔离级别
上一章我们知道了每个元组都有版本标签(t_xmin/t_xmax),那么谁来判定某一事务应该看到哪个版本?答案是 快照(Snapshot)——PostgreSQL 在事务内部通过快照实现时间点一致性视图。
2.1 SnapshotData 结构
PostgreSQL 定义了多种快照类型,其核心结构 SnapshotData(src/include/utils/snapshot.h)的关键字段如下:
typedef struct SnapshotData
{
SnapshotSource snapshot_source; // 快照来源
TransactionId xmin; // 小于 xmin 的事务均可见(已提交)
TransactionId xmax; // 大于等于 xmax 的事务均未开始(不可见)
TransactionId *xip; // 活跃事务 ID 数组
uint32 xcnt; // xip 数组大小
CommandId curcid; // 当前 CID
/* ... */
} SnapshotData;
快照的可见性判断规则(简化版):对于任意元组版本,事务 T_i 可见当且仅当:
t_xmin所属事务已提交且t_xmin < xmin> 或t_xmin不在快照的活跃事务列表中(即快照时刻之前已提交的事务)t_xmax为 0(未删除),或者t_xmax大于等于xmax(删除事务在快照开始前已存在),或者t_xmax仍在活跃事务列表中(删除事务尚未提交)
这套规则的实现位于 HeapTupleSatisfiesMVCC() 和 HeapTupleSatisfiesUpdate() 等函数,完整逻辑可在 tqual.c 中查阅。
2.2 四种隔离级别的实现
PostgreSQL 支持四种隔离级别,但实际内部只实现了三种(读未提交与提交读等价):
| 隔离级别 | 实现方式 | 幻读 | 不可重复读 | 写倾斜 |
|---|---|---|---|---|
| Read Uncommitted | 等同于 Read Committed | 可能 | 可能 | 可能 |
| Read Committed(默认) | 每条语句前获取新快照 | 可能 | 可能(单语句内不会) | 可能 |
| Repeatable Read | 事务开启时获取快照,整个事务复用 | 不会 | 不会 | 可能 |
| Serializable | 基于 SERIALIZABLE 事务的 SSI(Serializable Snapshot Isolation) | 不会 | 不会 | 不会 |
Read Committed 模式下,PostgreSQL 在每条 SQL 语句执行前重新获取快照。这意味着:同一事务中先后执行两次 SELECT,如果期间有其他事务提交了对目标行的修改,第二次查询结果就会与第一次不同。
Repeatable Read 是 PostgreSQL 默认情况下"最有吸引力"的隔离级别(许多 ORM 框架默认使用此级别):事务在启动时(第一条语句)获取快照,后续所有查询复用此快照,因此事务内保证读取一致性。但它不保证写入冲突检测——两个并发事务可以同时更新同一行,后提交的事务会覆盖前一个事务的更新(而非等待或报错),这就是著名的 Write Skew(写倾斜) 问题。
2.3 Serializable Snapshot Isolation (SSI)
PostgreSQL 9.1 引入了 SERIALIZABLE 隔离级别,基于 Serializable Snapshot Isolation(SSI) 算法(最初由 Cahill 等人在 2008 年提出)。SSI 的核心思想是:在快照隔离的基础上,跟踪读写依赖,当检测到潜在的序列化异常(如 rw-conflict 构成的危险结构)时主动中止其中一个事务。
SERIALIZABLE 的关键机制:
- SIREAD Lock:事务对读取的每个表/页/行获取一个"读锁",标记其读取范围
- RW Dependency Tracking:每次对已被其他事务读取的数据进行写操作时建立 RW-Dependency 边
- 危险结构检测: 出现两条连续 RW-Dependency 边(形成 "pivot" 结构),则中止后一个事务以避免序列化异常
- PREDICATE LOCK:用于范围扫描的谓词锁检测
这意味着 SERIALIZABLE 事务可能在提交时收到错误 40001 (serialization_failure),应用层需要实现重试逻辑。因此,选择隔离级别是"正确性"与"性能/复杂度"之间的权衡,不要盲目使用 SERIALIZABLE。
第三章 可见性判断详解:HeapTupleSatisfies* 家族
MVCC 的核心逻辑集中在 src/backend/utils/time/tqual.c 文件,PostgreSQL 提供了一系列 HeapTupleSatisfiesXXX 函数来判定元组在特定快照下的可见性。主要版本包括:
HeapTupleSatisfiesMVCC():标准 MVCC 可见性判定HeapTupleSatisfiesUpdate():用于 UPDATE/DELETE 操作时的可见性判定(还需要检查行锁)HeapTupleSatisfiesSelf():只看 xmin/xmax 是否为当前事务(用于 VACUUM)HeapTupleSatisfiesVacuum():判定元组对 VACUUM 是否可回收HeapTupleSatisfiesHistoricMVCC(): 用于时间点恢复(PITR)
3.1 HeapTupleSatisfiesMVCC 核心逻辑
bool
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer)
{
/* 1. 判断 t_xmin 的提交状态 */
if (TransactionIdIsCurrentTransactionId(htup->t_xmin))
{
/* 由当前事务创建的版本 */
if (HeapTupleHeaderIsSpeculative(htup))
return false; /* speculative insert 未完成 */
if (HeapTupleHeaderXminCommitted(htup))
/* 2. 检查 t_xmax */
...;
else if (HeapTupleHeaderXminInvalid(htup))
return false; /* 事务已中止 */
else
{
/* 当前事务内创建的版本但未提交 */
if (snapshot->snapshot_source == SNAPSHOT_MVCC)
return false;
/* ... */
}
}
else if (XidInMVCCSnapshot(htup->t_xmin, snapshot))
return false; /* t_xmin 在快照的活跃列表中,不可见 */
else if (TransactionIdDidCommit(htup->t_xmin))
{
/* t_xmin 已提交,对快照可见 */
...
}
else
{
/* t_xmin 已中止 */
return false;
}
/* 3. 判断 t_xmax */
if (TransactionIdIsCurrentTransactionId(htup->t_xmax))
{
/* 由当前事务删除/更新 */
if (snapshot->curcid >= htup->t_cid)
return false; /* 当前命令之后的元组不可见 */
}
else if (TransactionIdIsInProgress(htup->t_xmax))
{
return true; /* xmax 事务仍在运行,说明删除尚未生效 */
}
else
{
/* xmax 事务已提交,元组对快照不可见 */
return false;
}
return true;
}
值得注意的是,当 PostgreSQL 在系统目录(pg_xact)中无法直接确认某个 xmin/xmax 的提交状态时(比如对应的事务提交记录已被 VACUUM 清理),会回退到 pg_xact 的子事务提交日志或采取保守策略。这就是我们后面将要讨论的 Frozen Transaction ID 的价值所在。
3.2 Command ID 与同一事务内可见性
PostgreSQL 中,同一事务内如果先插入一条记录再更新它,t_cid 就发挥作用了:
BEGIN;
INSERT INTO users (id, name) VALUES (1, 'Alice'); -- cid = 0
UPDATE users SET name = 'Bob' WHERE id = 1; -- cid = 1
COMMIT;
第一行(t_xmin=100, t_xmax=101, t_cid=0)由于 t_xmax 等于当前事务 ID,且 curcid >= t_cid(1 ≥ 0),在快照中不可见;第二行(t_xmin=101, t_xmax=0, t_cid=1)可见。这个机制让同一事务总能"看到自己刚才的修改",同时不影响其他并发事务。
第四章 VACUUM 机制:从空间回收到事务 ID 回卷防护
MVCC 的"追加写"语义天然会带来空间膨胀(Dead Tuple 积累)和事务 ID 回卷(XID Wraparound)两大问题。PostgreSQL 通过 VACUUM 机制同时解决这两个问题。
4.1 VACUUM 的工作内容
VACUUM 有三种主要变体:
VACUUM:标准清理,回收死元组空间,更新 Visibility Map 和 Free Space MapVACUUM FULL:全表重写,完全压缩表并回收磁盘空间(但需要排他锁)VACUUM FREEZE:强制冻结元组,将所有 xmin 冻结为FROZEN_TRANSACTION_ID
标准 VACUUM 执行流程(src/backend/commands/vacuum.c):
- 扫描表的所有页:逐页读取数据
- 识别死元组:对每个元组调用
HeapTupleSatisfiesVacuum(),判断其对所有活跃快照是否都不可见 - 更新 Visibility Map(VM):将全为可见元组的页标记为
all-visible - 清理死元组:从 Line Pointer 数组移除死元组,更新 Free Space Map
- 截断表末尾的空页:将末尾全空的页归还操作系统
- 更新系统目录和统计信息
pg_class.reltuples、pg_class.relpages
4.2 Autovacuum 自适应清理
PostgreSQL 默认启用 autovacuum 守护进程(autovacuum_worker),它会根据表的 reltuples 和 relfrozenxid 自动决定何时触发 VACUUM。关键参数:
autovacuum_vacuum_threshold = 50 -- 至少需要 50 个死元组
autovacuum_vacuum_scale_factor = 0.2 -- 死元组占比超过 20% 时触发
autovacuum_vacuum_cost_delay = 2ms -- 每个清理周期的延迟
autovacuum_vacuum_cost_limit = -1 -- 成本限制(-1 表示使用默认值)
autovacuum_freeze_max_age = 200000000 -- 达到 2 亿事务前强制冻结
最佳实践:对于高写入的表,autovacuum_vacuum_scale_factor 需要调小(比如 0.01),避免在表很大时死元组比例虽然很低但绝对值巨大才触发清理。
4.3 事务 ID 回卷(XID Wraparound)—— PG 的"世界末日"
PostgreSQL 的事务 ID 是 32 位整数,取值范围 0 ~ 2^32。当事务计数器达到 2^32 后,会从最小值重新开始——这被称为 XID Wraparound。可怕的是,新事务获得的 xid 可能比旧事务的 xid 还小,导致快照可见性判断错误(认为旧事务"未提交"或"未开始"),后果是静默数据丢失或损坏。
PostgreSQL 的防护策略:
- Freeze:将旧元组的
t_xmin替换为特殊值FROZEN_TRANSACTION_ID (2),所有快照都认为"已提交" - Vacuum freeze:将
relfrozenxid推进到当前 xid 附近 - 强制 autovacuum freeze:当表中最老的 xid 距离达到
autovacuum_freeze_max_age时触发紧急冻结 - 单用户模式紧急处理:如果数据库因为 xid 接近回卷而进入"单用户模式",必须立即执行
VACUUM FREEZE
关键告警:当表中最老的 xid 达到 autovacuum_freeze_max_age - 1000000(1.99 亿),PostgreSQL 会在日志中发出告警,之后会以每秒一条的速度持续告警,直到执行冻结。生产系统中务必监控 pg_database.datfrozenxid 和 pg_class.relfrozenxid。
第五章 性能优化:HOT 更新、Index-Only Scan 与页面内 Prune
MVCC 的"追加写"语义虽然优雅,但会带来显著的性能开销:每次 UPDATE 都会插入新版本,如果版本链过长,查询需要链式遍历;死元组积累导致表膨胀、I/O 增加;索引也需要更新(指向新版本 TID)。PostgreSQL 引入了多种优化手段。
5.1 HOT 更新(Heap-Only Tuple Update)
当 UPDATE 不修改任何索引列,且新版本的旧版本可以在同一页内放下时,PostgreSQL 会走 HOT 路径:
- 新版本紧跟旧版本插入同一页
- 旧版本的
t_ctid指向新版本 - 不更新任何索引(因为索引仍然指向旧版本所在页+LinePointer,通过 TID 链仍能定位到最新版本)
- 设置旧元组的
HEAP_HOT_UPDATED标志和新元组的HEAP_ONLY_TUPLE标志
优势:避免索引维护开销,减少 WAL 日志量,推迟索引膨胀。
限制条件:
- 更新的列不能是任何索引列
- 新元组必须能放入当前页(有足够空闲空间)
- 如果页内无空间,会退化为普通 FSM/非 HOT 更新
调优建议:表上设置合理的 FILLFACTOR(比如 70-80),预留 20-30% 的页空间给 HOT 更新,减少因空间不足退化为普通更新的概率。
-- 设置 FILLFACTOR 为 80,预留 20% 空间给 HOT 更新
ALTER TABLE orders SET (fillfactor = 80);
-- 创建表时指定
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
) WITH (fillfactor = 85);
5.2 页面内 Prune(Page-level Prune)
在 VACUUM 之外,PostgreSQL 还在页面访问时进行即时剪枝(heap_page_prune()):当发现有死元组对其他所有事务都不再可见时,会原地将其移除。这有以下好处:
- 减少 VACUUM 的压力
- 更新 Visibility Map,加速 Index-Only Scan
- 清理 HOT 链中间版本,缩短 TID 链长度
Prune 操作不涉及 WAL(因为其效果由后续的 VACUUM 保证),也不需要全量扫描。它能够原地修改 Line Pointer 数组,并可能造成页内碎片;全页写(Full Page Write)确保在异常宕机后可恢复。
5.3 Index-Only Scan 与 Visibility Map
在 PostgreSQL 中,大多数索引(如 B-Tree、GiST)不包含版本信息,索引扫描后还需要回表检查可见性——这导致额外的 I/O。为了优化,PostgreSQL 引入了 Visibility Map(VM)——一个辅助的位图结构,标记哪些页中所有元组对所有快照都可见。
当执行 Index-Only Scan 时,PostgreSQL 先通过索引获取候选 TID,然后检查 VM 中对应页的 all-visible 标志;如果全可见,则直接返回无需回表;否则仍需回表做可见性判定。
利用 Index-Only Scan 的最佳实践:
- 定期
VACUUM表,推进 VM 覆盖范围 - 对于只读或少量写入的宽表,收益特别显著
- 监控
pg_stat_user_tables.n_tup_hot_updates和n_tup_upd评估 HOT 效率 - 使用
pgstattuple扩展检查表膨胀程度
第六章 事务隔离级别实战演示
理论终归要结合实践。下面我们通过具体 SQL 演示不同隔离级别下的行为差异。
6.1 Read Committed 下的"不可重复读"
-- 初始化
CREATE TABLE accounts (id INT PRIMARY KEY, balance DECIMAL);
INSERT INTO accounts VALUES (1, 100);
-- 事务 A(左) -- 事务 B(右)
BEGIN ISOLATION LEVEL READ COMMITTED; BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id=1;
-- balance = 100
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id=1;
-- balance = 200 ← 同一事务内两次 READ 看到不同结果
COMMIT;
在 Read Committed 模式下,事务 B 提交后,事务 A 的下一条语句会获取新快照,因此看到更新后的值。这就是"不可重复读"。
6.2 Repeatable Read 下的"写倾斜"
-- 场景:医生排班系统,要求同一班次至少一名医生在岗
-- doctors_on_call = [Alice: ON, Bob: ON]
-- 事务 A(左) -- 事务 B(右)
BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors_on_call; SELECT count(*) FROM doctors_on_call;
-- count = 2 ≥ 2, OK 允许下班 -- count = 2 ≥ 2, OK 允许下班
UPDATE doctors_on_call SET on_call=false UPDATE doctors_on_call SET on_call=false
WHERE name = 'Alice'; WHERE name = 'Bob';
COMMIT; COMMIT;
-- 结果:Alice 和 Bob 同时下班,无人在岗!违反约束
在 Repeatable Read 下,两个事务各自基于事务开始时的快照看到了 count=2,都"通过"检查并更新。但 PostgreSQL 默认不使用行锁保护范围条件——最终两个医生同时下班,违反了约束。这种问题被称为 Write Skew。
解决方案:
SELECT ... FOR UPDATE显式加行锁- 使用
SERIALIZABLE隔离级别(SSI 会检测到 rw-conflict 并中止其中一个事务) - 应用层队列串行化
6.3 SERIALIZABLE 的行为
-- 使用 Serializable 隔离级别重做上面的例子
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors_on_call;
-- count = 2
UPDATE doctors_on_call SET on_call=false WHERE name = 'Alice';
-- ...
COMMIT;
-- 事务 B COMMIT 时会收到:
-- ERROR: could not serialize access due to read/write dependencies
-- DETAILs: Reason code: Identical snapshots were not concurrent.
-- HINT: The transaction might succeed if retried.
SERIALIZABLE 级别下,后提交的事务会被中止,需要应用层实现重试循环。通常的模式是:
import psycopg2
from psycopg2 import errorcodes
MAX_RETRIES = 3
def run_serializable_transaction(conn, func):
for attempt in range(MAX_RETRIES):
try:
with conn:
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE)
result = func(conn)
return result
except psycopg2.Error as e:
if e.pgcode == errorcodes.SERIALIZATION_FAILURE:
if attempt < MAX>
第七章 生产级调优实战:避免膨胀与冻结危机
7.1 Autovacuum 调优
Autovacuum 是防止膨胀的第一道防线,但默认参数对现代负载来说通常过于保守。建议针对高写入表单独调整:
-- 表级覆盖默认 autovacuum 参数
ALTER TABLE high_write_table SET (
autovacuum_vacuum_scale_factor = 0.01, -- 死元组达 1% 就触发
autovacuum_vacuum_threshold = 1000, -- 至少 1000 个死元组
autovacuum_analyze_scale_factor = 0.005, -- 统计信息更新更频繁
autovacuum_vacuum_cost_delay = 2, -- 2ms 延迟(默认 20ms 太慢)
autovacuum_vacuum_cost_limit = 2000 -- 提高单次清理量(默认 200)
);
-- 全局参数建议(postgresql.conf)
ALTER SYSTEM SET autovacuum_max_workers = 6; -- 清理进程数量
ALTER SYSTEM SET autovacuum_naptime = 10; -- 调度间隔
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = 1; -- 全局延迟
ALTER SYSTEM SET autovacuum_freeze_max_age = 200000000; -- 冻结年龄上限
7.2 监控指标清单
需要持续监控的核心指标:
-- 1. 表膨胀率(Dead Tuple 占比)
SELECT schemaname, relname, n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / NULLIF(n_live_tup, 0), 2) AS dead_pct,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- 2. 表空间膨胀(物理大小 vs 逻辑大小)
SELECT schemaname, relname,
pg_size_pretty(pg_relation_size(relid)) AS physical_size,
pg_size_pretty(pg_table_size(relid)) AS table_size,
round(100 * (1 - pg_relation_size(relid)::float / NULLIF(pg_table_size(relid), 0)), 1) AS bloat_pct
FROM pg_stat_user_tables
WHERE pg_table_size(relid) > 100 * 1024 * 1024
ORDER BY pg_table_size(relid) DESC;
-- 3. 事务 ID 回卷风险评估
SELECT datname, age(datfrozenxid) AS xid_age,
200000000 - age(datfrozenxid) AS remaining_xids
FROM pg_database
WHERE datallowconn
ORDER BY age(datfrozenxid) DESC;
-- 4. 单表 relfrozenxid 进度
SELECT schemaname, relname, age(relfrozenxid) AS xid_age,
200000000 - age(relfrozenxid) AS remaining_xids
FROM pg_stat_user_tables
ORDER BY age(relfrozenxid) DESC
LIMIT 20;
-- 5. HOT 更新效率
SELECT schemaname, relname, n_tup_upd, n_tup_hot_updates,
round(n_tup_hot_updates * 100.0 / NULLIF(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY n_tup_upd DESC;
7.3 VACUUM FULL 与 pg_repack
当表膨胀到严重影响 I/O 性能时,需要执行空间回收。但 VACUUM FULL 会获取 ACCESS EXCLUSIVE 锁,阻塞所有读写,不适合 7x24 生产环境。
推荐方案:pg_repack
# 安装扩展
CREATE EXTENSION pg_repack;
# 在线重组表(重建表 + 复制数据 + 切换表名,全程低锁)
pg_repack -d mydb --table bloated_table --no-order
# 重建索引(不要在生产高峰期整表)
pg_repack -d mydb --table bloated_table --only-indexes
pg_repack 通过触发器捕获增量变更,在线完成表重建,无需长时间锁表。相比 VACUUM FULL 更适合生产环境。
7.4 分区表与 VACUUM 策略
对于超大型时序数据表,PostgreSQL 原生分区是更好的选择:
-- 创建范围分区表
CREATE TABLE measurements (
id BIGSERIAL,
measured_at TIMESTAMPTZ NOT NULL,
sensor_id INT,
value DOUBLE PRECISION
) PARTITION BY RANGE (measured_at);
-- 按月分区
CREATE TABLE measurements_y2024m01 PARTITION OF measurements
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE measurements_y2024m02 PARTITION OF measurements
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- 不需要的月份直接 DROP TABLE(秒级释放空间,无需 VACUUM)
DROP TABLE measurements_y2023m01;
分区表的优势:删除过期数据时无需 VACUUM,可直接 DETACH + DROP 分区;每个子表的 VACUUM 独立运行,互不干扰;autovacuum 按子表粒度调度更精准。
第八章 深入内核:WAL、CLOG 与 Subtransaction 交互
对于真正理解 PostgreSQL MVCC 在复杂场景下的行为,还需要了解 WAL(预写式日志)、CLOG(提交日志)和子事务的交互机制。
8.1 WAL 与 MVCC 的关系
MVCC 的版本管理在存储层,而 WAL(Write-Ahead Log)保护的是持久性——每个页的修改必须先写 WAL 再写数据页。一次完整的 UPDATE 流程:
- 读取目标页到 Buffer Pool
- 写入 WAL 记录(页面物理变更)
- 在页内标记旧元组 t_xmax,插入新元组 t_xmin = 当前 xid
- 更新页头的 LSN(Log Sequence Number)
- CLOG 记录事务准备状态
- 事务提交后 CLOG 更新为 committed
- 后台 writer/checkpointer 将脏页刷到磁盘
WAL 记录的是物理变更("第 23 号页的偏移 456 字节处内容被修改"),而非逻辑变更("将 id=1 的 balance 改为 200")。这确保了任意时刻宕机都可以通过 redo WAL 恢复一致性。
8.2 CLOG(Commit Log)—— 事务提交状态的中心缓存
CLOG(存储在 pg_xact 目录中)是 PostgreSQL 用于快速查询任意 xid 提交状态的核心结构。它将每个 xid 映射到一个 2 位状态码:
0 (IN_PROGRESS):事务进行中1 (COMMITTED):已提交2 (ABORTED):已中止3 (SUB_COMMITTED):子事务已提交
CLOG 的数据结构是一个 2 位数组,每 2MB 使用一个 8KB 页(每页 8192*4 = 32768 个 xid)。CLOG 的"提交历史"也面临被 VACUUM 清理的命运——当 CLOG 中的旧事务提交状态被清理后,PostgreSQL 只能通过 TransactionIdDidCommit() 的"Heuristic"(启发式)回退策略或查询 xmin 是否 frozen 来判断。
8.3 子事务、保存点与 MVCC
PostgreSQL 支持 SAVEPOINT(保存点)语义,实际上是通过 Subtransaction 实现的。每个子事务都有唯一的 subxid,其提交状态独立于父事务。CLOG 中为每个 subxid 维护独立的状态码。
BEGIN;
INSERT INTO orders VALUES (1, 'Alice');
SAVEPOINT sp1;
UPDATE orders SET name = 'Bob' WHERE id = 1;
-- 此时当前事务内能看到 Bob
ROLLBACK TO SAVEPOINT sp1;
-- 回滚后当前事务仍能看到 Alice(UPDATE 被撤销)
COMMIT;
实现上,ROLLBACK TO SAVEPOINT 并不会重新插入旧版本,而是通过 CLOG 将对应的 subxid 标记为 ABORTED;MVCC 可见性判定会检查对应 subxid 的状态,如果发现 subxid 已被中止,则认为该元组不可见。
8.4 两阶段提交(2PC)与 MVCC
PostgreSQL 支持 XA 两阶段提交协议,XA 事务的 PREPARE 阶段:
- 写入 prepares WAL 记录
- CLOG 将
t_xmin标记为TRANSACTION_STATUS_PREPARED - 事务对当前其他并发事务不可见(视为未提交)
这正是为什么应用代码中如果 PREPARE TRANSACTION 后没有在短时间内 COMMIT PREPARED/ROLLBACK PREPARED,会存在严重的锁竞争问题——因为 "prepared" 状态等价于长时间运行的事务,它的 t_xmin / t_xmax 一直不推出所有活跃快照,导致快照 xmin 无法推进,间接引发表膨胀与 XID 增长。
第九章 常见陷阱与故障模式
9.1 长事务引发的雪崩效应
问题:长事务(执行几分钟甚至几小时)持有过期快照,导致所有 xmin < 该事务快照 xid 的死元组无法被 VACUUM 回收——整个数据库都在"为该长事务保留历史版本"。
表现:
n_dead_tup急剧飙升- 表膨胀、索引膨胀、查询变慢
- 其他事务的锁等待加剧(因为失效版本链变长)
- 严重的 XID 回卷风险
防范:
-- 监控长事务
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - xact_start > interval '5 minutes'
ORDER BY duration DESC;
-- 使用 idle_in_transaction_session_timeout 自动杀死空闲事务
ALTER DATABASE mydb SET idle_in_transaction_session_timeout = '30s';
ALTER DATABASE mydb SET statement_timeout = '60s';
ALTER DATABASE mydb SET lock_timeout = '10s';
9.2 随机 UUID 作为主键的 MVCC 性能问题
UUID(特别是 v4 随机版本)作为主键时,新插入的行在 B-Tree 索引上是无序的。这会导致:
- 频繁的随机页 I/O
- B-Tree 索引大量"分裂",产生页级碎片
- 索引占用空间增加 3-5 倍
- 因为 B-Tree 热点页冲突,并发写入性能下降
推荐:
- 使用
uuid_generate_v7()(时间单调 UUID)——PostgreSQL 16+ 可通过pgcrypto扩展实现 - 使用
bigserial/IDENTITY序列作为主键 - 雪花 ID 等有序 ID 方案
9.3 HOT 链过长导致查询变慢
当同一行被连续更新多次(热门秒杀库存、频繁修改的计数器行),会形成很长的 HOT 链。虽然理论上 Index-Only Scan 可以通过 VM 跳过,但每次沿 HOT 链跳转都涉及 CPU 缓存不命中。
解决方法:
- 调低
n_dead_tup阈值,让 autovacuum 更频繁地清理 - 将热点行拆分到单独的小表(减少整页扫描)
- 使用
pg_prewarm预热热点页 - 避免在极高频更新的行上建过多索引
9.4 Parallel VACUUM 的陷阱
PostgreSQL 13+ 支持 Parallel VACUUM,可以多个 worker 并发清理索引。但需要注意:
- Parallel VACUUM 仍然需要
SHARE UPDATE EXCLUSIVE锁(与 DDL 冲突) - 每个 worker 是一个独立的 backend,parallel vacuum workers 数量受限于
max_parallel_maintenance_workers(默认 2)和 max_worker_processes - Parallel VACUUM 不能同时处理 TOAST 表
- 对于小表,parallel vacuum 反而因为协调开销变得更慢
第十章 总结:MVCC 思维模型与未来展望
PostgreSQL 的 MVCC 是一套"优雅的复杂"系统——它用较低的日常开销实现了读写并行和事务一致,但也带来了独特的运维挑战(膨胀、回卷、SSD 写放大等)。理解其内部机制,是高效使用 PostgreSQL 的必经之路。
我们在本文中覆盖了从底层存储结构到生产调优的完整链路:
- 物理层:HeapTupleHeaderFields、数据页布局、TID 链
- 逻辑层:快照判断、隔离级别、SSI、CLOG、子事务
- 清理层:VACUUM、Autovacuum、冻结、回卷防护
- 优化层:HOT 更新、Index-Only Scan、FILLFACTOR、分区
- 运维层:监控指标、长事务检测、表膨胀评估、故障排查
PostgreSQL 社区在 MVCC 方向上的持续演进也值得关注:
- PG 13+:Parallel VACUUM、Incremental Sort
- PG 14+:Multirange types、查询 pipelining
- PG 15+:Compression (LZ4/ZSTD)、Merge command 增强
- PG 16+:SyNChronous commit、Logical replication、NULLS NOT DISTINCT
- Future:Zheap 存储引擎的回归(替代 heap 减少膨胀)
最终,MVCC 的思维方式——通过版本隔离实现并发、通过延迟清理换取读写并行——不仅是 PostgreSQL 的设计哲学,也是现代分布式系统(如 CockroachDB、 YugabyteDB、甚至 Redis MVCC-like copy-on-write)的核心范型。理解 PostgreSQL 的 MVCC,也就打开了深入理解数据库内核世界的一扇门。
参考资源:
- PostgreSQL 官方文档:Concurrency Control(MVCC)
- PostgreSQL 内部结构详解(《The Internals of PostgreSQL》)
- Routine Vacuuming
- PostgreSQL 源码:
src/backend/utils/time/tqual.c、src/backend/commands/vacuum.c

发表评论 取消回复