AI时代首先数据库-PostgreSQL实战笔记 | 28 PostgreSQL MVCC机制介绍

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 的核心职责是回收死元组占用的空间,并更新可见性信息。具体来说:

  1. 清理死元组 :xmax 对应事务已提交且对所有快照都不可见的行版本,其空间可以被回收复用;
  2. 更新 visibility map:标记哪些页"全是可见行",为 Index Only Scan 提供基础;
  3. 更新统计信息 (ANALYZE 时):让优化器做出正确的成本估算;
  4. 冻结旧事务 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 回收旧版本吗?欢迎在评论区聊聊。

相关推荐
奇牙coding1 小时前
doubao-seed-2.1-pro 报 HTTP 401 怎么办?Key 正确也要检查请求头和端点
网络·数据库·http·ai
lll23526917795 小时前
2026行业语音转写工具专业术语转写功能横向分析
笔记·智能手机
geovindu9 小时前
sql:SQL Pagination and Limiting Results using PorstgreSQL 18.0
大数据·数据仓库·sql·postgresql·数据库开发·数据库架构·1024程序员节
不脱发的程序猿9 小时前
纳指ETF量化策略
数据库·人工智能·1024程序员节
程序员塔塔喵10 小时前
【MySQL篇|第一篇】索引失效的情况
数据库·mysql·索引失效
北京青蓝智慧马老师11 小时前
比外部黑客更难防的,是那个有权限的自己人(3 个脱敏复盘)
大数据·数据库·人工智能
笃行35011 小时前
拆一台数据集成产品:KFS 的骨架、肌肉和神经
数据库
4SAPI11 小时前
搭建 AI 数据分析工具大模型 API 选型指南
数据库·人工智能·架构·php·1024程序员节
隔窗听雨眠11 小时前
PostgreSQL单表从Windows迁移到Linux:差异剖析、方案对比与全流程实战
linux·windows·postgresql