PostgreSQL如何通过MVCC实现并发读写互不阻塞?

来源:MAC教程作者:上海GEO公司头衔:草根站长
导读:本期聚焦于上海GEO公司创作的《PostgreSQL如何通过MVCC实现并发读写互不阻塞?》,敬请观看详情。更新一行数据时,PostgreSQL并不会覆盖磁盘上的旧版本,而是追加一条新元组并把旧元组标记为不可见。这个设计让读操作完全不需要加锁,写操作也不会阻塞读,是实现高并发事务的根基。多版本并发控制(MVCC)通过为每个事务提供一致性快照,避免脏读、不可重复读和幻读,而PostgreSQL把旧版本直接存放在堆表中,这与其他数据库使用回滚段的方案有本质不同。本文拆解元组版本链、系统隐藏列、事务快照与可见性判断规则的具体实现,并说明为什么必须定期执行VACUUM,否则表会不断膨胀。理解了这套机制,你就能解释为什么长事务会拖慢清理、为什么更新频繁的表需要调整autovacuum参数,以及如何通过SQL直接观察同一行数据的多个历史版本。

PostgreSQL的多版本并发控制(MVCC)与其他数据库的最大区别在于,它不通过回滚段保存旧数据,而是直接在堆表中保留行的历史版本。当一个事务更新某行时,PostgreSQL会插入一条全新的物理元组,并在旧元组的头部写入一个标记,表示它已经不再对当前事务可见。这种追加式写入避免了写写冲突和读写冲突,但要求数据库定期清理不再需要的旧版本。下面结合系统列和事务快照来看具体实现。

PostgreSQL如何通过MVCC实现并发读写互不阻塞?

一、元组多版本与系统隐藏列

在PostgreSQL中,每条逻辑行在物理上可能对应多个元组版本。执行INSERT或UPDATE时,数据库并不会原地修改数据,而是为新版本分配一个新的元组,并把旧版本的xmax字段设置为当前事务ID,表示该版本已被更新或删除。每一个元组都带有几个系统隐藏列:xmin记录创建该版本的事务ID,xmax记录删除或更新该版本的事务ID,如果该版本仍然有效则xmax为0;cmincmax分别记录创建和删除该版本的命令序号,用于区分同一事务中的多个操作;ctid则是指向当前元组物理位置的指针,通常用来追踪版本链。普通的SELECT *不会显示这些列,必须显式列出才能查看。

下面的SQL示例展示了如何观察版本变化。首先创建一个账户表,插入一行数据,然后更新该行,最后查询隐藏列。

CREATE TABLE account (
    id INT PRIMARY KEY,
    balance NUMERIC
);

INSERT INTO account VALUES (1, 100);

-- 执行更新,产生新版本
UPDATE account SET balance = 150 WHERE id = 1;

-- 查看当前可见版本及隐藏列
SELECT xmin, xmax, cmin, cmax, ctid, id, balance FROM account;

执行更新后查询结果通常只有一行,但该行的ctid不再是初始值,而是指向新版本的物理位置。如果启用了pageinspect扩展,还可以直接检查数据页,看到旧元组仍然存在,只是其xmax被设置为了更新事务ID。这就是PostgreSQL MVCC最直观的体现:旧版本留在堆表中,为那些仍然需要看到旧数据的快照提供支持。

这种设计带来的最大好处是回滚非常快。事务回滚时只需要将事务标记为aborted,无需显式撤销已经写入的新元组,因为其他事务的快照根本看不到这些未提交的版本。代价则是表和索引会不断膨胀,必须依赖VACUUM机制回收空间。如此实现的MVCC使得读操作完全不需要获取行级锁,写操作也不会阻塞读,这是PostgreSQL在高并发OLTP场景下表现优秀的关键原因之一。

二、事务快照与可见性判断规则

每个SQL语句在执行时都会基于当前事务获取一个快照,快照记录了此刻系统中所有活跃事务的信息,包括最小活跃事务ID、下一个待分配事务ID以及活跃事务列表。PostgreSQL通过元组的xminxmax与快照中的事务状态进行比对,来确定某个元组版本是否对当前事务可见。核心规则可以概括为:如果xmin对应的事务已经提交,并且提交发生在快照建立之前,同时xmax为空或者xmax对应的事务尚未提交、或提交发生在快照建立之后,那么这个元组版本就是可见的。如果xmin属于未提交事务、或者xmin在快照建立之后才提交,则该版本不可见。

不同隔离级别获取快照的时机不同,直接影响了事务的行为。在READ COMMITTED级别下,每条语句都会获取一个新的快照,因此一个事务内的多条语句可能看到其他事务已经提交的不同数据。而在REPEATABLE READ级别下,快照在事务的第一条语句执行时获取一次,并在整个事务期间保持不变,从而避免了不可重复读。PostgreSQL的可重复读实现甚至能够避免幻读,因为快照固定之后,其他事务新插入的行即使已经提交,其xmin也大于当前快照的xmax,因此这些行对当前事务不可见。这与标准SQL中对可重复读的定义有所不同,是PostgreSQL在隔离级别实现上的一个亮点。

下面的示例用两个会话展示默认读已提交级别下的快照差异。会话A开启事务并更新数据但不提交,会话B开启事务后查询同一行。

-- 会话A
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
-- 此时未提交,会话B无法看到该修改

-- 会话B
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT * FROM account WHERE id = 1; -- 结果为150,看不到200
COMMIT;

-- 会话A随后提交
COMMIT;

-- 会话B再次开启新事务查询(新语句获取新快照)
SELECT * FROM account WHERE id = 1; -- 结果为200

事务快照的具体内部结构包括xminxmaxxip_list。其中xmin是当前所有活跃事务中的最小事务ID,xmax是下一个将要分配的事务ID,xip_list则列出所有活跃事务ID。判断元组可见性时还要结合提交日志(clog)中的事务状态。由于事务ID是32位整数,存在回卷问题,PostgreSQL使用冻结机制将足够老的事务ID冻结为特殊值,确保可见性判断在事务ID回卷后仍然正确。这是VACUUM的另一个重要职责,而不仅仅是空间回收。

三、VACUUM与版本链回收机制

因为MVCC在堆表中保留了旧版本,长时间运行的数据库会积累大量死元组。所谓死元组是指那些对于任何可能的活动快照都已经不可见的旧版本。PostgreSQL通过VACUUM命令清理这些死元组,将其占用的空间标记为可重用,或者在VACUUM FULL中重写表以归还空间给操作系统。普通的VACUUM只是回收死元组空间供后续插入使用,不会缩小表的物理文件,而VACUUM FULL虽然能彻底压缩表,但会持有排他锁,阻塞所有读写操作,因此生产环境中应谨慎使用。

-- 查看表的存活元组与死元组统计
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'account';

-- 手动执行VACUUM清理死元组
VACUUM account;

autovacuum后台进程会根据阈值自动触发清理。默认情况下,当表中的死元组数量超过autovacuum_vacuum_threshold加上autovacuum_vacuum_scale_factor乘以表行数时,自动清理就会启动。如果一个事务持有旧快照,那么在此快照之后产生的旧版本仍然可能对该事务可见,VACUUM就无法回收这些死元组。因此长事务是MVCC系统的大敌,它会阻止死元组清理,导致表膨胀、索引变大、查询性能下降。实际运维中应当设置idle_in_transaction_session_timeout来终止长时间空闲的事务,并在应用层避免交互式事务中保持打开状态。

另一个严重问题是事务ID回卷。PostgreSQL用32位无符号整数标识事务,最多约40亿个ID,当消耗到约20亿时需要回卷。如果数据库中存在非常古老的事务ID未冻结,可能导致可见性判断错乱,甚至数据丢失。因此VACUUM除了清理死元组,还会冻结足够老的元组的xmin,防止事务ID回卷风险。autovacuum_freeze_max_age参数控制冻结触发的阈值,对于写频繁的数据库需要特别关注。一旦出现事务ID回卷保护性停机,恢复将非常困难。

四、MVCC带来的问题与优化实践

理解PostgreSQL的MVCC实现后,就能针对性地进行优化。首先要避免任何形式的长事务,包括长查询和长时间空闲的事务。应用代码应当尽量缩短事务边界,将批量写入拆分成多个小事务,避免在一个事务中执行过多更新。其次要合理调整autovacuum参数。对于写密集的小表,可以降低autovacuum_vacuum_scale_factor,提高清理频率;对于大表,可以适当提高autovacuum_vacuum_threshold,避免频繁扫描。通过pg_stat_user_tables监控n_dead_tup的增长趋势,能够及时发现问题。

-- 查看表膨胀程度,需要安装pgstattuple扩展
SELECT * FROM pgstattuple('account');

-- 为特定表调整autovacuum触发参数
ALTER TABLE account SET (
    autovacuum_vacuum_threshold = 100,
    autovacuum_vacuum_scale_factor = 0.05
);

HOT(Heap Only Tuple)更新是PostgreSQL减少索引膨胀的重要优化。如果更新操作没有修改任何索引列,PostgreSQL可以在同一数据页中放置新元组,并将旧元组标记为HEAP_HOT_UPDATED,索引项仍然指向旧元组,通过旧元组内部指针找到新版本。这样就不需要为每次更新新增索引项,显著降低了索引维护成本。相反,如果更新涉及索引列,则不是HOT更新,索引中会插入新条目,旧条目成为垃圾,导致索引膨胀。所以在设计表结构时,尽量避免更新频繁的列作为索引键,或者将更新频繁的字段与索引字段分离。

总的来说,PostgreSQL的MVCC通过牺牲一定的存储空间和引入后台清理机制,换来了高效的并发控制能力。读操作不加锁、写操作不阻塞读是这套机制最直接的优点,但代价是表和索引膨胀、需要定期VACUUM、长事务危害被放大。理解了元组版本链、事务快照和VACUUM的交互关系,你就可以在数据库设计、SQL编写和参数调优中做出更合适的决策,避免遇到表膨胀或事务ID回卷等棘手问题。

MVCC多版本并发控制PostgreSQL事务快照VACUUM机制修改时间:2026-08-21 20:32:07

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。