上个月我们经历了一次惊心动魄的磁盘空间报警:生产环境一个核心交易历史表,在执行了一批历史订单状态批量更新任务后,表大小在短短两小时内暴涨了整整 200GB,磁盘可用空间直接掉到 5% 以下。最诡异的是,这个表实际的有效数据行数只有一千多万,按理说最多也就 30GB 左右。当时整个运维群炸开了锅,紧急扩容磁盘后,我们开始排查这个极具杀伤力的 PostgreSQL 表膨胀(Table Bloat)大坑。

在 PostgreSQL 中,MVCC(多版本并发控制)的实现机制和 MySQL / Oracle 的 Undo Log 机制完全不同。PostgreSQL 对数据的每一行更新(UPDATE)或删除(DELETE),并不会直接在原物理位置修改数据,而是新插入一条带行标记的版本(Tuple),并将旧版本标记为“死元组(Dead Tuple)”。

这些死元组必须依赖背景进程 autovacuum 进行清理和物理回收。然而,autovacuum 的正常运转有一个致命前提:它只能清理那些对当前所有活跃事务都不可见的死元组

如果数据库中存在任何一个长事务未提交(例如:某位开发同学通过客户端连接生产库执行了 BEGIN 查询却没有执行 COMMIT,或者定时分析报表任务跑了几个小时还没结束),PostgreSQL 的全局最小活跃事务 ID(xmin)就会停滞不前。这导致在此长事务之后产生的全部 UPDATE/DELETE 死元组,在 autovacuum 看来都“可能对长事务可见”,因而无法被清理!

结果,在我们的批量更新任务不断修改数据的过程中,数千万死元组堆积在数据文件中,占用了物理存储空间却无法被重用,数据文件如同吹气球般暴涨,最终导致了这次由于长事务未提交引发的 Auto-Vacuum 延迟与表膨胀故障。

排查此类问题时,首先需要揪出是谁在阻塞 vacuum。以下是我们用于分析故障现场的实用 SQL 诊断工具包:

-- 1. 查询当前运行时间最长的活跃事务,找出阻塞 autovacuum 的罪魁祸首
SELECT 
    pid, 
    usename, 
    application_name, 
    client_addr, 
    backend_start, 
    xact_start, 
    now() - xact_start AS xact_duration,
    query, 
    state
FROM pg_stat_activity
WHERE state <> 'idle' 
  AND xact_start IS NOT NULL
ORDER BY xact_duration DESC
LIMIT 5;

-- 2. 查询死元组 (Dead Tuples) 堆积最多的前 10 张表,评估清理滞后情况
SELECT 
    schemaname, 
    relname, 
    n_dead_tup, 
    n_live_tup, 
    round(100.0 * n_dead_tup / nullif(n_dead_tup + n_live_tup, 0), 2) as dead_tuple_ratio,
    last_vacuum, 
    last_autovacuum
FROM pg_stat_user_tables
WHERE (n_dead_tup + n_live_tup) > 10000
ORDER BY n_dead_tup DESC
LIMIT 10;

揪出元凶并手动 kill 掉卡死事务后,虽然死元组能够被回收,但占用的磁盘文件空间(High Watermark)默认不会退还给操作系统。为了防止这种情况再次发生,我们需要在 postgresql.conf 中优化 autovacuum 参数,让其执行得更积极、更快:

# /var/lib/postgresql/data/postgresql.conf
# 开启自动 vacuum
autovacuum = on
# 记录所有耗时超过 200ms 的 autovacuum 运行细节,便于性能调优
log_autovacuum_min_duration = 200
# 增加并发 worker 数量,防大表排队(默认 3)
autovacuum_max_workers = 5
# 降低触发比例阈值,从默认的 20% 降低至 5%,更频繁地触发 vacuum 避免膨胀
autovacuum_vacuum_scale_factor = 0.05
# 提高单次 vacuum 操作允许消耗的代价上限,极大提升单次清理效率(默认 200)
autovacuum_vacuum_cost_limit = 2000

对于那些不幸已经膨胀到极致的超大表,如果不能接受 VACUUM FULL 产生的八级重度锁表代价(会导致读写完全阻塞),强烈建议使用 pg_repack 工具。它通过新建临时表并同步增量日志的方式,在不阻塞任何在线读写的前提下完成表空间的在线收缩与重构。

大家在维护千万级以上的 PostgreSQL 数据库时,有没有被各种未提交的长事务坑过?你们的 autovacuum 参数又是怎么调整的?