PG实战笔记 | 28 PostgreSQL MVCC机制介绍
上一篇我们完整走了一遍写入和读取路径,看到了一次
UPDATE如何"生成新版本、保留旧版本"。这一篇,我们深入这个行为背后的机制:MVCC(多版本并发控制) 。它是 PostgreSQL 实现"读写互不阻塞"的根基,也是理解xmin/xmax、事务快照、VACUUM 和事务 ID 回卷的入口。不夸张地说,理解了 MVCC,你就理解了 PostgreSQL 并发设计的灵魂。

一、MVCC 要解决什么问题?
数据库的并发控制,核心矛盾是:读和写同时发生时,如何保证读到的数据是一致的,同时又不让读阻塞写、写阻塞读?
最朴素的做法是两阶段锁(2PL) :读加共享锁,写加排他锁,锁冲突时等待。这保证了正确性,但代价是读写互相阻塞------一个长事务持有锁,所有读和写都得排队。
MVCC 的思路完全不同:写操作不覆盖旧数据,而是生成一个新版本;读操作根据快照,看到"属于自己那个时刻"的版本。 读永远不阻塞写,写永远不阻塞读。不同事务看到的数据可以不同,但每个事务看到的都是一致的历史快照。
PostgreSQL 的 MVCC 实现有一个重要特征:旧版本留在原地,不立即删除。 新版本插入后,旧版本仍然占据数据页空间,只是对后续事务不可见。这个"留到以后清理"的决定,是 VACUUM 存在的根本原因,也是事务 ID 回卷问题的源头。
二、xmin 和 xmax:每一行的"时间戳"
PostgreSQL 的每个行版本(tuple)头部,都有两个关键的系统字段:
| 字段 | 含义 | 作用 |
|---|---|---|
xmin |
创建这个行版本的事务 ID | 判断"这个版本是哪个事务插入的" |
xmax |
删除/锁定这个行版本的事务 ID | 判断"这个版本是否已被删除或锁定" |
一行的生命周期可以这样理解:
- INSERT :新行版本的
xmin= 当前事务 ID,xmax= 0(未删除); - UPDATE :旧行版本的
xmax被设为当前事务 ID(标记为"已被这个事务删除"),同时插入一个新行版本,其xmin= 当前事务 ID; - DELETE :行版本的
xmax被设为当前事务 ID。
关键点:UPDATE 不是"原地修改",而是"标记旧版本过期 + 插入新版本"。 这就是为什么 PostgreSQL 的 UPDATE 会产生"死元组"------旧版本还在页上,只是 xmax 非零了。
三、快照与可见性判断:读到自己"该看到"的版本
每个事务在开始时(或第一条语句执行时,取决于隔离级别)会获取一个快照(snapshot) 。快照的核心信息包括:
xmin:快照中最小活跃事务 ID。所有小于这个值的事务,都已经提交或回滚,其结果确定可见或不可见;xmax:快照中最大事务 ID + 1。所有大于等于这个值的事务,在快照获取时尚未开始,其结果不可见;xip:快照获取时仍在进行中的事务 ID 列表。
判断一个行版本对当前快照是否可见,遵循一组规则(简化版):
规则一: 如果 xmin 对应的事务尚未提交 ,或者在快照获取时仍活跃 (在 xip 列表中),则不可见。
规则二: 如果 xmin 对应的事务已回滚,则不可见。
规则三: 如果 xmax 为 0,或 xmax 对应的事务未提交/已回滚,则可见(行未被有效删除)。
规则四: 如果 xmax 对应的事务已提交,则不可见(行已被删除)。
这套规则的意义在于:同一个物理行,在不同事务的快照下,可以有不同的可见性。 一个长事务开始时看到的旧版本,在另一个新事务看来已经被更新掉了。两者互不干扰,因为旧版本还在,新版本也在,各自读各自的。
四、读写为什么不阻塞?
回到核心问题。MVCC 下:
- 读操作:不需要加锁,只需要根据自己的快照判断行版本的可见性。读永远不会被写阻塞,因为写不会"锁住"行让读等待------写只是生成新版本,读按快照选择版本;
- 写操作 :
UPDATE和DELETE会尝试对目标行加行级锁 ,但只阻塞其他写操作,不阻塞读。两个事务同时更新同一行时,后到的写会等待先到的写提交或回滚(这是写-写冲突,由行锁处理)。
所以 MVCC 的并发模型是:读-写不阻塞,写-写才可能阻塞。 这是 PostgreSQL 在高并发读场景下表现优异的重要原因。
但代价也随之而来:旧版本不立即删除,导致表膨胀。 一个频繁 UPDATE 的表,可能实际存储了远超逻辑行数的物理行。这就是 VACUUM 必须存在的原因。
五、VACUUM:为什么不可省略?
VACUUM 的核心职责是回收死元组占用的空间,并更新可见性信息。具体来说:
- 清理死元组 :
xmax对应事务已提交且对所有快照都不可见的行版本,其空间可以被回收复用; - 更新 visibility map:标记哪些页"全是可见行",为 Index Only Scan 提供基础;
- 更新统计信息 (
ANALYZE时):让优化器做出正确的成本估算; - 冻结旧事务 ID:防止事务 ID 回卷(下一节详述)。
如果不做 VACUUM,会发生什么?
- 表持续膨胀,磁盘占用增长,扫描需要读更多页,查询变慢;
- 索引膨胀,因为索引项指向死元组,也需要维护;
- 事务 ID 回卷风险,这是最严重的问题,可能导致数据库停止服务。
PostgreSQL 的 autovacuum 进程会自动触发 VACUUM,但它的触发是有阈值的(autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor)。在高写入负载下,autovacuum 可能跟不上死元组的产生速度,需要手动调参或定期执行 VACUUM。
六、事务 ID 回卷:MVCC 的"定时炸弹"
PostgreSQL 的事务 ID 是 32 位 的,大约 42 亿个。当事务 ID 耗尽时,会从 0 重新开始------这就是回卷(wraparound) 。
问题在于:MVCC 的可见性判断依赖事务 ID 的大小比较。如果事务 ID 回卷,一个"很旧"的事务 ID 在数值上可能比"新"事务 ID 更大,可见性判断就会出错,导致旧数据突然变得可见,或新数据突然不可见------数据一致性被彻底破坏。
PostgreSQL 的防护机制是冻结(freeze) :当一个行版本的 xmin 足够旧(超过 vacuum_freeze_min_age),VACUUM 会将其标记为"冻结",表示"这个版本对所有事务都可见,不再需要比较事务 ID"。冻结后的事务 ID 不再参与回卷比较。
如果 VACUUM 长期不运行,冻结跟不上事务 ID 的增长,数据库会进入保护模式:拒绝新的写事务,直到 VACUUM 完成冻结。这就是"事务 ID 回卷"告警的严重性------它不是性能问题,而是可用性问题。
防御措施:
- 确保 autovacuum 正常运行,不要随意关闭;
- 监控
age(datfrozenxid),当接近autovacuum_freeze_max_age(默认 2 亿)时及时处理; - 对大表定期执行
VACUUM FREEZE; - 监控
pg_stat_activity中的长事务,长事务会阻止 VACUUM 清理其快照可见的旧版本。
七、MVCC 的代价与权衡
MVCC 不是免费的。它的代价集中在三处:
| 代价 | 表现 | 缓解手段 |
|---|---|---|
| 表膨胀 | 死元组占用空间,扫描变慢 | autovacuum、定期 VACUUM、控制 UPDATE 频率 |
| 索引膨胀 | 索引项指向死元组 | VACUUM、REINDEX、监控索引膨胀 |
| 事务 ID 回卷 | 数据库停止写入 | 监控 datfrozenxid、确保 autovacuum 运行 |
另一个常被忽略的代价是长事务的破坏性 。一个长事务持有旧快照,会阻止 VACUUM 清理它"可能还需要看到"的旧版本。一个运行数小时的事务,可能让整个表的死元组无法回收,导致膨胀加速。监控并限制长事务,是 MVCC 运维的重要一环。
八、总结
| 主题 | 核心结论 |
|---|---|
| MVCC 目标 | 读写互不阻塞,每个事务看到一致快照 |
| 行版本标记 | xmin 创建事务,xmax 删除/锁定事务 |
| UPDATE 本质 | 标记旧版本过期 + 插入新版本,不原地修改 |
| 可见性判断 | 基于快照的 xmin/xmax/xip 规则,同一行对不同事务可见性不同 |
| 并发模型 | 读-写不阻塞,写-写才可能阻塞 |
| VACUUM 必要性 | 回收死元组、更新 visibility map、冻结事务 ID;不做则膨胀和回卷 |
| 事务 ID 回卷 | 32 位 ID 耗尽导致可见性错乱;冻结是防护,autovacuum 是关键 |
一句话:MVCC 用"保留旧版本"换来了"读写不阻塞",但旧版本不会自己消失------VACUUM 是这笔交易的"还款日",事务 ID 冻结是防止"债务违约"的保险。理解 MVCC,就是理解 PostgreSQL 并发性能的来源,以及它必须付出的维护代价。
下一篇,我们进入缓存管理与性能优化原理:shared_buffers 和 OS page cache 到底是什么关系?缓存命中率低时,该调参数还是改查询?
思考题:
你监控过自己数据库的 age(datfrozenxid) 吗?有没有遇到过 autovacuum 跟不上死元组产生速度、导致表持续膨胀的情况?另外,你的系统里有没有运行数小时的长事务------你知道它会阻止 VACUUM 回收旧版本吗?欢迎在评论区聊聊。