news 2026/10/6 9:11:55

PostgreSQL MVCC机制详解:多版本并发控制与VACUUM清理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL MVCC机制详解:多版本并发控制与VACUUM清理实战

并发改同一行数据的时候,数据库怎么保证不丢更新、不出脏读?最简单粗暴的办法是加锁,写锁互斥,谁也不许乱动。可一旦读操作也得排队等写锁释放,业务高峰期的吞吐量立刻给你脸色看。PostgreSQL给出的答案就是MVCC(Multi-Version Concurrency Control,多版本并发控制):同一行数据在物理上保留多个历史版本,每个事务基于自己的快照去读,读写互不阻塞。这篇内容我会从行版本结构、快照可见性、死元组回收、隔离级别到生产环境监控,把PostgreSQL的MVCC机制完整拆一遍。适合后端开发、DBA,以及每个正在纠结"表怎么越用越胖、查询怎么越来越慢"的PostgreSQL使用者。

1. 为什么搞懂MVCC:并发写入与读放大是绕不开的坎

1.1 两个事务同时更新一行:锁方案的尴尬

先抛一个最常见的场景。订单表里有一行余额数据,事务A要把余额从100改成80,事务B同时要把同一行从100改成90。如果没有任何控制,最终结果取决于谁后写入,后写覆盖先写,这是丢失更新,用户的钱莫名其妙少一笔或者多一笔,业务上完全不可接受。

传统做法是加行锁。事务A先拿到锁,事务B的更新只能在锁上等待,直到A提交或回滚。这个机制能保证数据正确,问题在于锁的范围一旦扩大,读操作也被拖下水。很多数据库早期的实现里,读也要申请共享锁,写申请排他锁,共享锁和排他锁互斥,于是读的人多了,写的人就排队;写的人多了,读的人也跟着排队。OLTP系统里读多写少,这种互相阻塞直接就把并发能力打没了。

MVCC的思路换个角度:不跟锁较劲,而是给数据做版本。每次更新不是覆盖旧值,而是生成一个新版本,旧版本暂时留在那里。每个事务在开始读的时候,拍一张"快照",记录当时哪些事务还在活跃、哪些已经提交,然后只认自己快照范围内的版本。事务A改余额的时候,事务B读到的还是旧版本100,两边各干各的,互不干扰。

写写冲突依然存在。两个事务同时改同一行,MVCC不解决这个,还是要靠行锁协调,但关键改进在于:读永远不需要等写,写也永远不需要等读。这一条就把并发读多写少场景下最大的瓶颈解掉了。

1.2 同样是多版本,PostgreSQL和InnoDB的玩法完全不同

这里必须做个对比,不然很多人会把MySQL InnoDB的经验直接套到PostgreSQL上,踩坑。InnoDB的MVCC是"表里只放最新版本,旧版本放到undo log里"。读操作发现当前行版本不满足快照要求时,顺着undo链把旧版本捞出来。更新一行,物理上还是那一行,在内存里改掉,同时在undo里记一笔旧值。事务回滚时,拿undo里的旧值恢复。

PostgreSQL走的是另一条路:新版本直接插入到表文件里,和旧版本堆在一起。更新一行,物理上这行就多了一个版本,旧版本标记为失效,但它还躺在那页里占地方。回滚非常快,因为旧版本根本没被覆盖,把新版本标记无效就行。代价是旧版本不会自动消失,得靠VACUUM进程像收废品一样挨页扫过去,把没人要的旧版本清理掉。

这个设计差异直接影响运维方式。InnoDB要关心undo log膨胀,PostgreSQL要关心表和索引膨胀。两边都叫MVCC,但一个把垃圾扔进回收站集中清运,一个把垃圾堆在房间里定期大扫除。理解了这一点,后面所有内容都顺了。

2. 行版本、快照与可见性:MVCC的三块基石

2.1 每条记录头上的"出生证"和"死亡证"

PostgreSQL里每一行物理记录叫一个tuple(元组),可以理解为行的某个版本。除了你建表时定义的字段,每个tuple头部还藏着一组管理信息,其中三个字段是理解MVCC的核心:

  • xmin:创建这个版本的事务ID,相当于"出生证",记录谁把我生出来的。
  • xmax:删除或更新这个版本的事务ID,相当于"死亡证",记录谁要让我消失。值为0表示当前没人标记我死亡。
  • t_ctid:指向这个逻辑行的最新版本位置。如果元组被更新,旧版本的ctid会指向新版本所在位置。

事务ID就是数据库内部给每个事务盖的章,从3开始递增分配。别看它只是个数字,所有版本判断都围着它转。

拿一次UPDATE举例。假设T0事务插入了一行余额100,事务T1要改成80。T1执行更新时,PostgreSQL先把原版本标记为"被T1删除"(xmax=T1),再插入一个新版本,新版本的xmin=T1,xmax=0,同时旧版本的t_ctid指向新版本的位置。注意这个过程在T1提交之前就已经发生了,只是从物理层面看,两个版本同时存在。

这时候第三个事务T2来查询,走的是快照判断:T1还没提交,在T2的快照里属于活跃事务,所以新版本(xmin=T1)不可见,旧版本(xmax=T1但T1未提交,删除未生效)可见,T2读到余额100。等T1提交之后,T2再开一个新查询,快照里T1已经不是活跃状态,新版本变成可见,旧版本因为xmax=T1且T1已提交,变成不可见。整个过程读操作没有阻塞过一秒。

还有一个细节:同一个事务里对同一行执行多次UPDATE,靠xmin/xmax是区分不出来的,因为都是同一个事务ID。PostgreSQL在tuple头部还有一个t_cid(command id),记录事务内的第几条命令对它做了修改,用来在同一事务内区分不同语句的修改结果。这个平时感知不到,但理解版本机制时值得知道。

2.2 快照机制:每个事务看到的世界长什么样

快照(Snapshot)是MVCC判断可见性的核心数据。一条查询语句开始执行时,PostgreSQL会生成一个快照,里面记录了三个关键信息:

  • xmin:当前所有活跃事务里最小的事务ID。
  • xmax:当前已分配的最大事务ID加1。
  • xip_list:所有仍处于活跃状态的事务ID列表。

判断可见性时,快照就是一个天然的过滤器。元组的xmin如果是活跃事务,说明它还没提交,不能给别人看;如果xmin小于快照的xmin,或者不在xip_list里,说明这个事务在快照生成时已经结束,可以放心看到它提交的结果。同理,元组的xmax如果是活跃事务,说明删除者还没提交,这个版本实际上还活着;如果xmax已经提交,那这个版本确实已经被删了。

可以把这个过程想成拍合影。按下快门的瞬间,照片里只有当时在场的人。快照生成之后才走进镜头的人(新提交的事务),在这张照片里不存在;快照生成前就已经离开集体照的人(已提交事务),照片里也不会记录他们后来的动作。每个查询或事务就是拿着自己那张照片去看数据,照片拍得早,看到的世界就早。

Read Committed隔离级别下,每条SQL语句都会重新拍一张快照,所以同一个事务的不同语句可能看到不同版本的数据。Repeatable Read下,快照只拍一次,整个事务都拿同一张照片看数据,其他事务再提交也影响不到它。这就是为什么PostgreSQL的Repeatable Read不会出现不可重复读。

2.3 一张表看懂元组可见性规则

把上面说的内容收敛成一张规则表,排查问题时对照着看非常快。以下假设当前判断者是一个独立的事务:

场景版本状态结论
创建者还没提交xmin是活跃事务不可见
创建者已提交,没有删除者xmin已提交,xmax=0可见
创建者已提交,删除者未提交xmin已提交,xmax是活跃事务可见
创建者已提交,删除者已提交xmin已提交,xmax已提交不可见
创建者是当前事务自己xmin=当前事务ID可见(除非版本已被自己删除)

实际操作中还有一个隐藏加速器叫Hint Bits(提示位)。每次判断都要查事务提交状态日志(clog),成本太高,PostgreSQL会在tuple的标记位里缓存"这个事务已提交/已中止"的信息。第一次判断时查一次clog,然后把结果刻在tuple头上,后续再判断同一行就直接读标记位。这个设计很小,但它是PostgreSQL读性能能撑住高并发的底气之一。

3. 死元组、VACUUM和HOT更新:表膨胀从哪来、怎么治

3.1 一次UPDATE如何留下一个死元组

把第二章的例子再往后推。T1更新了余额,提交了,但Update前那个旧版本(余额100)并没有被物理删除,它只是变成一个"对所有事务都不可见"的残留版本。在PostgreSQL里,这种没人能再看到的版本叫死元组(dead tuple),页面里有了死元组,空间并没有释放,只是被标记为可复用。

问题在于,如果不做清理,死元组会越积越多。一张千万行的表,如果每天有百万行被更新,死元组可能占到实际空间的几倍。查询用索引定位到某个页后,要扫描页内所有元组,把死元组过滤掉再返回结果,页数越多、扫描开销越大,缓存命中率也在下降。表现就是:表数据量没怎么涨,查询却越来越慢,索引也越建越臃肿。

这就像系统里删文件但不回收磁盘,可用空间越来越少,磁盘碎片越来越多。系统的文件回收机制,对应到PostgreSQL就是VACUUM。

3.2 VACUUM到底在干什么

很多人对VACUUM有误解,以为它跟Oracle的undo清理一样,或者以为跑完VACUUM表物理大小会变小。实际上常规VACUUM做三件事:

  • 扫描表数据页,把死元组标记为可复用空间,并更新空闲空间映射(FSM)。
  • 更新可见性映射(VM),标记哪些页里所有元组对所有事务都可见。有了VM,才能走只读索引扫描(index-only scan),不需要回表。
  • 清理索引中指向死元组的索引条目。

注意,常规VACUUM不会把空间归还给操作系统,它只是把死元组占的空间变成"下次插入可以复用"的槽位。表文件的物理大小通常不会缩小,除非你用VACUUM FULL把表重写一遍,但那会拿ACCESS EXCLUSIVE锁,生产环境在线操作基本别碰,尽量安排维护窗口,或者用pg_repack这类在线重建工具。

默认情况下,自动清理(autovacuum)是开着的。它的触发逻辑是:某个表上死元组数量超过阈值,就安排一个worker去清理。阈值公式大致是threshold + scale_factor * 当前表行数,不同版本细节有差异。很多生产事故其实不是autovacuum没跑,而是长事务堵住了清理的推进——后面第5章专门讲这个。

3.3 HOT更新:让死元组变少的小聪明

UPDATE一个带索引的表,如果新版本插到另一个页里,那么所有相关索引的条目都要跟着更新,指向新位置,否则索引就失效了。索引更新本身也是写放大,而且每个索引条目更新也会留下死条目,膨胀翻倍。

PostgreSQL有一个优化叫HOT更新(Heap-Only Tuple)。它的适用条件非常明确:更新的列不涉及任何索引列,而且当前页里有足够空间放新版本。满足这两个条件时,新版本直接插到旧版本所在的数据页里,索引条目完全不用改,还继续指向旧版本的位置,通过旧版本的t_ctid跳到新版本。

HOT更新带来的收益很直观:索引完全不用动,死索引条目不再产生,VACUUM压力大减。但反过来,一旦更新了索引列、或者页内空间不足,HOT就失效,所有索引都得跟着更新。所以高频更新的表,尽量保证更新语句不碰索引列,更不要无脑给所有列都建索引——那等于亲手封死HOT这条路。

3.4 事务ID回卷风险

事务ID是32位整数,最多到42亿多,但这可不是取之不尽。PostgreSQL为了区分新旧事务,把事务ID空间分成两半:一半代表"过去",一半代表"未来"。当数据库运行超过约21亿个事务后,事务ID会回卷(wraparound),如果不处理,旧事务可能被误判为未来事务,导致数据可见性彻底错乱。

解决办法是冻结(freeze)。VACUUM在扫描时会把足够老的元组的xmin标记为一个特殊状态:frozen,表示这个版本对任何事务都可见,不再依赖xmin做判断。每个数据库还有一个datfrozenxid,记录冻结推进到哪个事务ID,用它算出数据库的年龄age(datfrozenxid)。一旦年龄逼近回卷阈值,autovacuum会进入紧急模式强制清理,甚至拒绝执行新事务来保护数据。

这个风险平时不出事,出一次就是大事故。所以生产环境必须有监控盯着数据库年龄,不能只盯死元组数量。事务密集的系统尤其要注意,别等告警邮件炸了才去补vacuum。

4. 从隔离级别看MVCC:Read Committed与Repeatable Read的差异

4.1 四个隔离级别在PG里的真实行为

SQL标准定义了四个隔离级别,PostgreSQL全支持,默认是Read Committed。很多人从MySQL迁过来,默认级别不同,行为差异很容易踩坑,这里展开说。

Read Committed下,每个语句开头生成新快照。这意味着事务里第一条SELECT看到的是一张照片,第二条SELECT可能是另一张照片。中间如果别的会话提交了修改,第二条SELECT就能看到。具体到一个UPDATE语句,PostgreSQL还会特殊处理:UPDATE执行时会重新读取目标行的最新版本,而不是单纯按语句开始时的快照过滤,不然两个事务并发更新同一行时,第二个事务会傻乎乎地覆盖第一个的修改。

Repeatable Read下,快照在事务第一个语句时固定,整个事务内所有查询共用一张照片。其他事务再提交,这个事务也看不到。换句话说,同一事务里反复查同一个查询,结果必然一样,不可重复读消失,幻读在PostgreSQL的RR级别下也不会出现——因为快照天然让"后来被插入的行"不进入视线。

Serializable级别更进一步,在RR快照基础上加了串行化快照隔离(SSI)检测。它监控事务间的读写依赖,一旦发现两个事务可能产生"串行执行时不可能出现"的冲突结果,就强制abort其中一个,应用层收到序列化失败需要重试。这是应对银行转账、库存扣减这类强一致场景的手段,代价是更高的abort率,需要用重试机制兜底。

4.2 和InnoDB对比:同样叫MVCC,差别不小

对比项PostgreSQLMySQL InnoDB
默认隔离级别Read CommittedRepeatable Read
旧版本存放位置数据页内(堆表多版本)undo log
旧版本清理VACUUMpurge线程
RR级别防幻读手段快照天然隔离间隙锁
锁等待冲突检测基于xmax标记等待锁结构显式等待

这个表格信息量很大。先说默认级别,PostgreSQL默认RC,很多MySQL开发者默认是RR,迁移后没仔细看配置,照搬业务代码,可能发现"明明同一个事务里两次查询结果不一样",这不是PG坏了,是隔离级别行为本来就不一样。

再说幻读。MySQL的RR靠间隙锁挡住其他事务插入,但也带来一个副作用:间隙锁范围大,死锁概率高,性能受锁影响明显。PostgreSQL的RR靠快照解决,同一事务看不到别人后插入的行,完全不需要间隙锁。代价是:如果你在RR事务里先SELECT判断"不存在再INSERT",PostgreSQL不会阻止另一个事务同时插入相同数据,因为快照机制管不到物理层面。你必须在业务上加唯一约束或者用INSERT ... ON CONFLICT来兜底,不能指望数据库锁帮你挡。

一句话总结:MVCC让PostgreSQL的RR更轻量,但也更需要程序员理解快照的边界。锁少了,不等于并发安全就自动有了。

5. 生产环境怎么监控和排查MVCC带来的问题

5.1 用两个查询快速定位死元组堆积和长事务

MVCC机制本身不直接导致故障,故障几乎都出在"死元组堆积没人清"和"长事务卡住清理"这两个环节。所以监控SQL是保命基本功。

第一个查询,看每个表的死元组情况和上次清理时间:

SELECT relname, n_live_tup, n_dead_tup, CASE WHEN n_live_tup + n_dead_tup > 0 THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 2) ELSE 0 END AS dead_ratio, last_autovacuum, last_vacuum FROM pg_stat_user_tables ORDER BY dead_ratio DESC LIMIT 20;

注意这里的n_live_tup和n_dead_tup是统计信息,不是精确值,是估值,但趋势判断完全够用。如果某张表死元组比例持续超过20%,而且last_autovacuum显示很久没跑,优先怀疑长事务阻塞。

第二个查询,找正在阻塞清理的长事务:

SELECT pid, now() - xact_start AS xact_age, state, backend_xmin, age(backend_xmin) AS xmin_age, query FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY xact_age DESC LIMIT 20;

backend_xmin是这条连接上最老事务快照的标记,只要它存在,VACUUM就算扫描到对应的死元组,也不敢把它清掉,因为还有老快照要看旧版本。这个查询跑出来的第一行,往往就是生产事故的元凶:一个忘了提交的事务、一个跑了几个小时的报表、一个连接池里漏掉的孤儿连接。

5.2 autovacuum参数怎么调才不背锅

很多人一见到表膨胀就喊"关了autovacuum吧",这是本末倒置。autovacuum默认配置适合中小表,大表和高更新频率场景确实需要调,但不是暴力改全局参数。

常用参数和我的建议:

  • autovacuum_vacuum_threshold:触发vacuum的死元组基数阈值,默认50。小表够用。
  • autovacuum_vacuum_scale_factor:按表大小比例叠加的阈值系数,默认0.2(部分新版本默认已调低,以你实际版本的pg_settings为准)。千万行的大表,等死元组攒到表行数的20%才触发清理,通常已经晚了。
  • autovacuum_naptime:autovacuum的检查间隔,默认60秒。不需要动。
  • autovacuum_max_workers:默认3个worker。如果表特别多,适当调到5左右,但别贪多,每个worker都要占用IO和CPU。

全局参数调起来影响面大,我通常更推荐按表精细控制。比如一张高频更新的热表:

ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_threshold = 500, autovacuum_vacuum_cost_delay = 5 );

意思就是这张表死元组超过500行或者表大小的5%时就开始清理,清理时的IO成本延迟控制低一点,让它跑快些。小表用默认,大表和热表单独设,这是最稳妥的运维姿势。另外,调vacuum参数的时候一定盯着主库的IO和CPU,万一autovacuum太积极把IO打满,业务影响比膨胀还快。

5.3 一个典型的膨胀故障处理记录

说一个我自己踩过的坑,相当典型。某个订单核心表,平时数据量600万行左右,表加索引接近5GB,某天业务反馈查询从几十毫秒涨到两秒多。翻监控,n_live_tup只有620万,但表的物理大小已经15GB,死元组比例超过35%。

查下来原因很简单:应用端有个事务里做了批量UPDATE,一次更新50万行,事务没提交,然后因为应用框架重试机制,又开了好几个类似的长事务,几个小时后才被kill。期间autovacuum反复尝试清理这张表,每次都被那些活跃的长快照挡住,死元组越积越多。

处理路径是这样:先把应用的长事务全停掉,确认pg_stat_activity里没有残留的backend_xmin,然后手动执行VACUUM (VERBOSE, ANALYZE) orders回收死元组空间,表物理大小从15GB降回6GB左右。没有用VACUUM FULL,因为那个排他锁在线业务扛不住,而常规VACUUM已经能复用大部分空间。最后给这张表单独设置了更激进的autovacuum参数,并且在监控面板里加了一条长事务告警,超过10分钟就报警。

教训就一句话:MVCC本身不产生脏数据,产生脏数据的是没人管的长事务。监控快照年龄比监控表大小重要得多。

5.4 版本选择:新装PostgreSQL到底选16还是17

回到很多人关心的问题:现在下载安装PostgreSQL,选哪个版本?这其实和MVCC也有关系,因为新版本对vacuum和perf的改进直接影响运维体验。

PostgreSQL 16是上一代主力版本,稳定、生态兼容性好,社区资料多,如果追求稳,选16没毛病。PostgreSQL 17在VACUUM上做了不少实打实的优化,比如引入了流式IO,清理大表时IO模式更友好;autovacuum的默认触发参数也调得更灵敏,死元组积压到出问题的概率进一步降低。新项目的建议是:生产环境等一两个小版本补丁之后直接上17,个人学习和测试可以直接装17。至于还在维护期之前的老版本,无论功能多熟悉,都不建议新环境使用,安全和功能代差都摆在那。

安装方式不管是用apt、docker、二进制包还是从源码编译,MVCC的行为机制完全一致,差别只在默认参数和性能调优空间。源码编译的同学记得在configure阶段认真选CPU指令集和并发选项,编译参数优化加上去,vacuum和大查询的性能差异还是很明显的。

最后说点我的实操体会

MVCC这块内容,文档一搜一大把,但我个人觉得真正值钱的是把这些机制跟生产现象连起来。表膨胀了,不是先想着调参或者rebuild,而是先查长事务;死元组比例高了,不是抱怨PostgreSQL"垃圾回收慢",而是先确认是不是业务事务边界没控制好。我自己吃过亏之后,现在的规矩很简单:每天例行看一遍pg_stat_user_tables和pg_stat_activity,事务超过15分钟就预警,超过30分钟直接钉人。你要是也想在PostgreSQL上省心一点,建议从这两个查询开始,比背任何参数都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 9:11:29

PAT乙级1014福尔摩斯的约会:字符串配对与范围限定全解析

PAT乙级的题库刷到1014这道“福尔摩斯的约会”时,我停了一下。不是因为这道题算法有多难,而是它的题目描述实在是太像一道“脑筋急转弯”:四行字符串,长得跟乱码似的,却要从中读出星期几、几点几分。当时我第一遍读题&…

作者头像 李华
网站建设 2026/10/6 9:10:26

Agent-Reach:AI Agent执行层CLI工具的设计与安全实践

1. 从标题说起:Agent-Reach 到底想解决什么问题 第一次看到 Agent-Reach 这个名字,我脑子里冒出来的第一个念头是:又是一个给 AI Agent 套壳的 CLI 工具?毕竟这两年打着 "Agent" 旗号的项目太多了,真正能落地…

作者头像 李华
网站建设 2026/10/6 9:08:28

AI Skills:可编排、可治理的原子化智能能力单元

1. 项目概述:从“skills”这个词开始,我们到底在谈什么?“skills”这个词最近在开发者社区里高频出现,但它早已不是字典里那个泛泛而谈的“技能”释义。它现在特指一类可注册、可编排、可复用的原子化智能能力单元——不是模型本身…

作者头像 李华
网站建设 2026/10/6 9:04:54

PageOffice 4.6.0.4 Java集成部署实战与排障指南

简介:PageOffice是常用于Web系统的在线文档编辑中间件,此Java版本压缩包面向需要集成文档在线预览、编辑与协同办公功能的Java开发者,也适合对办公系统二次开发感兴趣的技术人员。包内共1030个文件,以JSP动态页面、CSS/SCSS样式资…

作者头像 李华
网站建设 2026/10/6 9:04:41

OpenShell:Windows 11 开始菜单定制与效率提升完全指南

前阵子帮同事调一台 Windows 11 笔记本,他指着屏幕上一大块“推荐项目”问我能不能去掉。我说简单,装个 OpenShell 就完事了。这个名字很多老用户不陌生——它就是当年 Classic Shell 的延续,一个开源、免费、专门用来改开始菜单的工具。这篇…

作者头像 李华
网站建设 2026/10/6 9:04:20

XCAT集群管理实战:从PXE批量部署到自动化运维

我最早接触XCAT,是因为机房里有几十台服务器等着装系统。原来那套人工装机流程——开箱、插显示器、用U盘一个个装——在大规模场景下根本跑不动。一个管理员面对批量裸机,最需要的不是“能装一台”的工具,而是“一次管一批”的调度能力。XCA…

作者头像 李华