PostgreSQL依靠MVCC机制实现高并发,更新和删除产生的旧版本行并不会立即从磁盘上移除,而是交给vacuum进程统一清理。但很多运维人员会遇到这样的现象:autovacuum明明一直在跑,某张表的体积却持续增长,磁盘占用居高不下,查询性能也明显下降。这背后十有八九是长事务在作怪——只要有一个事务长时间不结束,vacuum就无法回收这段时间内产生的死元组,表膨胀随之而来。本文将系统讲解这个问题的成因、定位方法与完整的处理办法。

一、长事务阻塞vacuum的底层原理
要理解这个问题,必须先弄清楚PostgreSQL的MVCC实现。每条元组头部记录着xmin(插入该元组的事务ID)和xmax(删除或更新该元组的事务ID)。当一行被UPDATE时,实际发生的是插入一条新元组并在旧元组上打上xmax标记,旧元组就成了死元组(dead tuple)。vacuum的任务就是把这些死元组回收,把空间归还给文件系统或者在表内部复用。
但vacuum并非想删就能删。PostgreSQL需要保证所有仍然活跃的事务都能看到它们应该看到的数据。为此,系统会维护一个全局的最老活跃事务快照,也就是所谓的xmin horizon。任何xmax大于这个xmin horizon的死元组,理论上都可能被某些事务看到,vacuum必须保留它。这就意味着,一个从三小时前开启且尚未提交的事务,会让过去三小时内所有表产生的死元组都无法回收,哪怕这些更新发生在这张表的几十亿行数据上。
更危险的是,长事务未必是正在执行SQL的事务。一个客户端建立了连接,执行了BEGIN加一条SELECT后就断开注意力不管了,事务会一直处于idle in transaction状态,它同样持有xmin快照。甚至一个废弃的复制槽(replication slot)、一个未正常关闭的prepared transaction,也都会阻止xmin推进。排查时不能只盯着活跃查询。
二、如何定位长事务与膨胀的表
定位长事务最直接的视图是pg_stat_activity。执行下面的查询可以找出所有运行时间超过一定阈值的事务,包括空闲在事务中的会话:
SELECT pid,
usename,
datname,
state,
now() - xact_start AS xact_duration,
now() - query_start AS query_duration,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_duration DESC;
重点关注state为idle in transaction的会话,这类会话往往就是问题的元凶,因为它看起来什么都没做,却牢牢占着xmin。除了活跃事务,还应该检查以下两个容易被忽视的地方:
-- 检查未消费的复制槽,active为false且lag很大的槽会阻止vacuum
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
-- 检查遗留的两阶段提交事务
SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
WHERE now() - prepared > interval '5 minutes';
至于判断表是否已经膨胀,可以先看pg_stat_user_tables中n_dead_tup字段,如果某张表的死元组数量长期保持在很高的水平且不下降,说明vacuum清不动它。更精确的做法是使用pgstattuple扩展,直接统计表中死元组占用的实际空间比例:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('your_bloated_table');
-- 关注 dead_tuple_percent 和 free_percent 两个字段
另外还可以对比表的估算行数与物理文件大小的比例关系,pg_class中relpages很大而reltuples很小,通常就是膨胀的信号。
三、处理办法:从事务层面到空间回收
第一步永远是干掉阻塞源头。通过上面的查询拿到阻塞事务的pid后,先尝试优雅终止:
SELECT pg_cancel_backend(pid); -- 取消当前查询 SELECT pg_terminate_backend(pid); -- 直接终止会话,回滚其事务
终止会话后,其持有的xmin快照被释放,xmin horizon得以推进,autovacuum在下一次处理该表时就能回收积压的死元组。如果是废弃的复制槽或prepared transaction,分别用pg_drop_replication_slot和COMMIT PREPARED或ROLLBACK PREPARED处理。
第二步是建立防御机制,防止长事务再次出现。PostgreSQL提供了专门的超时参数,推荐在数据库级别或业务账号级别设置:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min'; ALTER SYSTEM SET statement_timeout = '30min'; SELECT pg_reload_conf();
idle_in_transaction_session_timeout会自动终止空闲在事务中超过指定时长的会话,这是治理此类问题最有效的单参数。对个别确实需要跑批的长任务,可以在会话级将该参数设为0豁免。同时可以设置old_snapshot_threshold来限制快照的最大可保留时间,不过该参数有一些限制,需要结合业务评估。
第三步是善用锁超时和事务最佳实践。应用代码中应遵循事务短小原则:把网络调用、外部HTTP请求、用户交互等耗时操作全部移出事务,只在真正需要写一致性时才开启事务。许多框架的连接池在归还连接时会自动rollback,但如果应用自己管理事务,务必确保异常路径也能正确提交或回滚。
第四步处理已经膨胀的空间。需要注意,vacuum回收死元组后,空间只是标记为可复用,表文件不会自动缩小。若要把磁盘空间真正还给操作系统,在业务允许停写的情况下可以使用:
-- 11版本前 VACUUM FULL your_bloated_table; -- 12+ 推荐用CLUSTER方式重建 CLUSTER your_bloated_table USING index_name;
VACUUM FULL会持有排他锁,期间表完全不可读写,大表上代价很高。生产环境更推荐使用pg_repack工具,它只在短暂的切换阶段持有锁,可以在接近在线的状态下完成表重建,命令大致如下:
# 安装pg_repack扩展后执行 pg_repack -d your_database -t your_bloated_table -j 2
膨胀严重的索引则可以用REINDEX INDEX CONCURRENTLY在线重建,避免锁表。
四、预防措施与监控建议
治本之道在于监控和规范并行。监控层面,建议对以下指标配置告警:运行时间超过阈值的事务数量、idle in transaction会话数量、复制槽滞留的WAL大小、各表的n_dead_tup数值。一个简单实用的监控查询:
SELECT count(*) AS long_xact_count FROM pg_stat_activity WHERE xact_start IS NOT NULL AND now() - xact_start > interval '30 minutes';
参数层面,可以适当调低autovacuum_vacuum_cost_delay让autovacuum跑得更积极,并通过autovacuum_vacuum_scale_factor针对大表设置更小的阈值,让清理更频繁、每次工作量更小。对于更新频繁的核心表,也可以在表级别单独设置存储参数:
ALTER TABLE hot_table SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01 );
总结来看,长事务阻塞vacuum是一个原理清晰但危害很大的问题:xmin快照不动,死元组就不能回收。处理思路是先定位并终止阻塞源,再通过超时参数和事务规范防止复发,最后用pg_repack或VACUUM FULL善后膨胀空间。只要把监控和参数防线建立起来,这个问题完全可以被长期稳定地控制住。
PostgreSQL长事务vacuum阻塞表膨胀修改时间:2026-09-01 20:58:47