PostgreSQL VACUUM 与表膨胀治理:MVCC 死元组、Autovacuum 调优与膨胀诊断

系统讲解 PostgreSQL 的 VACUUM 机制与表膨胀治理:MVCC 多版本并发控制如何产生死元组、VACUUM 与 AUTOVACUUM 的工作原理与触发条件、autovacuum 参数调优、表膨胀与索引膨胀诊断(pgstattuple/n_dead_tup)、手动 VACUUM FULL 与 CLUSTER、高频 UPDATE 场景的防膨胀设计,以及死元组监控与告警体系。

PostgreSQL 的表膨胀(bloat)是生产环境最隐蔽的性能杀手之一。数据库运行一段时间后,明明 DELETE 了大批数据,表文件却只增不减;明明表里只有 100 万行,实际占用却有 500 万行的空间。

这一切的根源是 MVCC(多版本并发控制):PostgreSQL 不原地覆盖旧数据,而是生成新版本。被更新、被删除的旧版本就变成了死元组(dead tuple),需要 VACUUM 来回收。

核心认知:VACUUM 不是可选的运维杂务,而是 PostgreSQL 健康运转的前提条件。理解死元组从哪里来、Autovacuum 如何触发、膨胀如何诊断,才能让你的数据库长期保持稳定性能。


一、MVCC 与死元组形成机制

1.1 MVCC 的版本存储

当一条记录被 UPDATE 时,PostgreSQL 会:

  1. 在页面中插入一条新元组(含新数据)
  2. 用 xmax 标记旧元组为"已失效"
  3. 让索引更新指向新元组(或利用 HOT 优化,见第七章)
UPDATE users SET email = 'new@example.com' WHERE id = 1;

页面示意(一行数据):
  Tuple v1: email='old@example.com'  xmin=100, xmax=200  ← 死元组
  Tuple v2: email='new@example.com'  xmin=200, xmax=0    ← 活元组
  • xmin:创建该元组的事务 ID
  • xmax:使该元组失效的事务 ID(0 表示仍有效)

1.2 死元组从哪里来

操作产生的垃圾说明
UPDATE旧版本变死元组最典型的膨胀来源
DELETE被删行变死元组需 VACUUM 回收
ROLLBACK已插入/更新的版本变死元组事务回滚不自动回收
INSERT 失败失败部分变死元组高频重试场景常见
长事务阻止死元组回收详见第八章

1.3 为什么死元组不能被立即删除

因为并发事务的可见性快照可能仍然需要读到旧版本。只有等到"所有可能看到该旧版本的活跃事务都结束",死元组才真正可回收——这正是 VACUUM 的职责。

-- 观察当前事务状态对死元组回收的影响
SELECT pid, state, xact_start, backend_xid, backend_xmin
FROM pg_stat_activity;
-- backend_xmin 是当前会话认为最早可见的事务,VACUUM 不能清理比它更新的死元组

二、VACUUM 工作机制

2.1 VACUUM 到底做了什么

-- 手动对单表执行 VACUUM
VACUUM (VERBOSE, ANALYZE) users;

VACUUM 的核心工作:

  1. 扫描表,标记并清理死元组占用的空间
  2. 更新可见性映射(Visibility Map),使 Index Only Scan 生效
  3. 更新空闲空间映射(FSM),让新插入复用被回收的页面
  4. **冻结(freeze)**老元组,防止事务 ID 回卷(wraparound)
  5. 可选 ANALYZE 更新统计信息

注意:普通 VACUUM 不会把空间还给操作系统,而是留在表内供复用。想压缩文件大小需要 VACUUM FULL(见第六章)。

2.2 VACUUM 的锁与开销

普通 VACUUM 只在清理时短暂持有 SHARE UPDATE EXCLUSIVE 锁,不阻塞读写,但会占用 CPU 与 IO。因此它受 cost 参数控制(见下节)。

-- 查看某表的死元组与最近 vacuum 时间
SELECT relname, n_dead_tup, n_live_tup,
       last_vacuum, last_autovacuum, vacuum_count, autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'users';

2.3 VACUUM 与事务 ID 回卷(wraparound)

事务 ID 是 32 位整数,约 42 亿个事务后会发生回卷(wraparound)。PostgreSQL 通过"冻结"老事务来解决。若 VACUUM 长期不运行,数据库会强制进入 autovacuum_freeze_max_age 保护模式甚至拒绝新事务。

-- 检查表的冻结年限(age)
SELECT relname, age(relfrozenxid) AS freeze_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC LIMIT 20;
-- 接近 2 亿(autovacuum_freeze_max_age 默认值)时需立即处理

三、AUTOVACUUM 触发与参数

3.1 触发条件

Autovacuum 默认开启,它会周期性(默认 1 分钟一次)检查所有表,当死元组数超过阈值时启动:

触发阈值 = autovacuum_vacuum_threshold
           + (autovacuum_vacuum_scale_factor × 表行数)

默认:50 + 0.2 × reltuples

即一张 100 万行的表,死元组超过 20 万 + 50 才会触发。

3.2 关键参数

-- 查看当前 autovacuum 配置
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_vacuum_cost_delay;
参数默认说明
autovacuum_vacuum_threshold50死元组基数阈值
autovacuum_vacuum_scale_factor0.2死元组比例系数
autovacuum_vacuum_cost_delay2ms每轮成本后的停顿
autovacuum_vacuum_cost_limit-1(继承 vacuum_cost_limit)每轮成本上限
autovacuum_naptime60s检查间隔
autovacuum_max_workers3最大 worker 数
autovacuum_freeze_max_age200000000强制冻结年限
autovacuum_vacuum_insert_threshold1000纯插入表的插入阈值

3.3 按表单独调优

-- 高频更新的热点表:更激进
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05);

-- 超大型归档表:放宽阈值减少扫描频率
ALTER TABLE archive_logs SET (autovacuum_vacuum_scale_factor = 0.4);

-- 查看表级参数
SELECT relname, reloptions FROM pg_class WHERE relname = 'orders';

调优建议:对 scale_factor 使用 0.05~0.1(而不是默认 0.2),配合监控 n_dead_tup 观察回收效果;大表优先用固定阈值而非比例。

3.4 Autovacuum 常见故障

故障现象常见原因
死元组持续增长长事务阻塞、worker 不足、cost 限速过低
Autovacuum 抢不到资源autovacuum_vacuum_cost_limit 过低
内存表频繁触发maintenance_work_mem 设置不合理
大量表同时需要清理autovacuum_max_workers 太少

四、表膨胀诊断(pgstattuple)

4.1 安装 pgstattuple

CREATE EXTENSION IF NOT EXISTS pgstattuple;

4.2 使用 pgstattuple

-- 统计表:死元组比例、空余空间
SELECT * FROM pgstattuple('orders');

-- 只取关键字段
SELECT table_len,
       dead_tuple_count,
       dead_tuple_percent,
       free_percent
FROM pgstattuple('orders');
字段含义健康值参考
table_len表物理字节数—
dead_tuple_count死元组数量趋近 0
dead_tuple_percent死元组占比< 5%
free_space空余页空间—
free_percent空余空间占比由 fillfactor 决定

4.3 从统计视图快速排查

-- 全库死元组最多的前 20 张表
SELECT
  schemaname, relname,
  n_live_tup, n_dead_tup,
  round(n_dead_tup::numeric / GREATEST(n_live_tup, 1), 3) AS dead_ratio,
  last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;

-- 判断表是否"真膨胀":对比实际大小与活元组理论大小
SELECT
  relname,
  pg_size_pretty(pg_total_relation_size(relid)) AS actual_size,
  pg_size_pretty(n_live_tup * 300) AS approx_live_size
FROM pg_stat_user_tables
WHERE n_live_tup > 0
ORDER BY (pg_total_relation_size(relid) / GREATEST(n_live_tup, 1)) DESC
LIMIT 10;

4.4 膨胀根因分析流程

发现 n_dead_tup 高 / 表文件过大
  ├─ 检查 pg_stat_activity 是否有长事务(backend_xmin 阻塞回收)
  ├─ 检查 last_autovacuum 是否很久未执行
  ├─ 检查 autovacuum 参数是否过宽(scale_factor 0.2)
  ├─ 检查表是否高频 UPDATE(HOT 未生效)
  └─ 决定:参数调优 or VACUUM FULL 或 CLUSTER

五、索引膨胀诊断

5.1 索引为什么也会膨胀

索引跟随表的 DML 一起变化。高频 UPDATE 更新索引列时,旧索引条目变成死条目,但索引页不会自动收缩,留下大量空页。

5.2 诊断索引膨胀

-- pgstatindex 查看索引的空余率
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstatindex('idx_orders_uid');

-- 关键字段
SELECT index_size,
       dead_tuples,
       free_space,
       free_percent
FROM pgstatindex('idx_orders_uid');
-- free_percent 超过 20~30% 说明索引明显膨胀

5.3 索引膨胀的连锁危害

危害说明
查询变慢B-tree 扫描需要遍历大量空页
缓存命中率下降有效索引页占比低,缓存效率差
Index Only Scan 失效可见性映射不完整导致回表
磁盘浪费索引可能膨胀到表体积的数倍

5.4 重建索引

-- 在线重建(PG 12+,不阻塞读写)
REINDEX INDEX CONCURRENTLY idx_orders_uid;
REINDEX TABLE CONCURRENTLY orders;

-- 非生产环境可直接重建
REINDEX TABLE orders;

六、手动 VACUUM FULL 与 CLUSTER

6.1 VACUUM FULL:把空间还给磁盘

-- 全量重写表,压缩到最小体积
VACUUM FULL orders;

-- 带统计更新
VACUUM (FULL, ANALYZE) orders;

注意:VACUUM FULL 会重写整个表,并持有 ACCESS EXCLUSIVE 锁,期间表不可读写。因此:

  • 生产环境务必在低峰期执行
  • 大表(> 100GB)重写时间可能以小时计
  • 需要预留足够的磁盘空间(约等于表大小)

6.2 CLUSTER:按索引顺序重排数据

-- 按指定索引的键序重排表(同时重建全部索引)
CLUSTER orders USING idx_orders_created;

-- 不加索引名:使用表上次 CLUSTER 的索引
CLUSTER orders;

-- 重排后再次按序扫描性能大幅提升
EXPLAIN SELECT * FROM orders WHERE created_at > '2026-09-01';

CLUSTER 与 VACUUM FULL 一样需要 ACCESS EXCLUSIVE 锁,但它额外带来了数据按索引键物理聚集的好处——非常适合时间列上的范围扫描。

6.3 三种清理方式对比

方式锁空间释放重排数据适用场景
普通 VACUUM弱锁,不阻塞不还给 OS,内部复用否常规维护
VACUUM FULLACCESS EXCLUSIVE还给 OS否膨胀严重、低峰期
CLUSTERACCESS EXCLUSIVE还给 OS是(按索引序)需要物理聚集优化扫描

经验:优先让 Autovacuum 正常运转,定期监控,把 VACUUM FULL 当作最后手段而非例行任务。


七、防数据膨胀设计(高频 UPDATE 优化)

7.1 从源头减少死元组

设计上降低死元组产生的速率,比事后清理更有效:

策略原理
HOT(Heap-Only Tuple)更新非索引列 UPDATE 不产生索引条目变化
降低 fillfactor页面预留空间,HOT 更容易就地更新
避免更新索引列更新主键/索引列必然产生索引死条目
批量 UPDATE 而非逐条减少事务数量与死元组峰值
合理的表分区只对活跃分区做高频率维护

7.2 开启 HOT 的条件

HOT(Heap-Only Tuple)更新需要同时满足:

  1. UPDATE 不涉及任何索引列
  2. 目标页面上有足够空余空间放下新版本
-- 为热点表预留 20% 页内空间,提升 HOT 命中率
ALTER TABLE orders SET (fillfactor = 80);

-- 观察 HOT 更新比例
SELECT relname,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / GREATEST(n_tup_upd, 1), 1) AS hot_percent
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY hot_percent ASC
LIMIT 20;
-- HOT 比例过低 → 检查是否有 UPDATE 索引列、fillfactor 是否过高

7.3 高频 UPDATE 场景的建模建议

-- 反模式:高频更新大文本字段(每次都是全行重写)
UPDATE sessions SET payload = payload || '...' WHERE session_id = ?;

-- 改进:把频繁变化的字段抽到子表,或使用 JSONB 局部更新
-- JSONB 局部更新(对 jsonb 列使用 || 只重写受影响部分)
UPDATE sessions
SET metadata = jsonb_set(metadata, '{last_seen}', to_jsonb(NOW()))
WHERE session_id = ?;

7.4 表膨胀预防清单

□ 热点表 fillfactor 降至 70~85
□ 避免更新主键/外键/高选择率索引列
□ 长事务严格限制时长(见第八章)
□ autovacuum 参数按表精细配置
□ 每周检查 n_dead_tup 与索引 free_percent
□ 大表考虑分区,按分区维护

八、监控与告警

8.1 关键监控指标

指标来源告警阈值
死元组数 n_dead_tuppg_stat_user_tables> 10000 或 > 表行数 10%
死元组比例n_dead_tup / n_live_tup> 0.2
最近 autovacuum 时间last_autovacuum超过阈值间隔 N 天
表/索引膨胀率pgstattuple / pgstatindexfree_percent > 30%
事务冻结年龄age(relfrozenxid)> autovacuum_freeze_max_age × 0.8
长事务数pg_stat_activityidle in transaction > 5min
Autovacuum worker 数进程统计持续占满 max_workers

8.2 一站式诊断 SQL

-- 找出"最需要 VACUUM"的表
SELECT
  schemaname, relname,
  n_live_tup, n_dead_tup,
  CASE WHEN n_live_tup > 0
       THEN round(100.0 * n_dead_tup / n_live_tup, 1)
       ELSE 0 END AS dead_pct,
  last_autovacuum,
  pg_size_pretty(pg_total_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_pct DESC
LIMIT 20;

8.3 定期健康检查脚本

# cron 每天执行,输出膨胀前 10 表
psql "$DATABASE_URL" <<'SQL'
\pset format unaligned
\pset footer off
SELECT '膨胀预警: ' || relname || ' dead_pct=' ||
       round(100.0 * n_dead_tup / n_live_tup, 1) || '%'
FROM pg_stat_user_tables
WHERE n_live_tup > 0
  AND 100.0 * n_dead_tup / n_live_tup > 20
ORDER BY n_dead_tup DESC LIMIT 10;
SQL

8.4 自动化运维建议

对"膨胀预警"表中的表,在低峰期批量执行 VACUUM FULL 是常见的运维手段;也可把本章的健康检查 SQL 封装成 cron 脚本,产出日报并接入告警。核心原则:先让 Autovacuum 自动工作,VACUUM FULL 只做兜底。


常见问题(FAQ)

为什么 DELETE 了大批量数据,表文件大小没变?

普通 VACUUM 只把死元组空间标记为可复用,不会归还给操作系统。要压缩物理文件大小,需要 VACUUM FULL(会锁表重写)。如果空间能复用且无锁风险,优先靠 Autovacuum 定期回收即可。

Autovacuum 一直没触发是怎么回事?

先检查 pg_stat_user_tables.n_dead_tup 是否超过 50 + 0.2 × 行数 的阈值;再检查 pg_stat_activity 中是否有长事务(backend_xmin 阻塞死元组回收);最后确认 autovacuum_max_workers 是否被大量表耗尽。

高频 UPDATE 的表怎么减少膨胀?

让 UPDATE 走 HOT 路径:不更新索引列 + fillfactor 降到 80 左右;同时把高频变化的字段抽离或改用 JSONB 局部更新。还可以单独为热点表调低 autovacuum_vacuum_scale_factor 到 0.05。

VACUUM FULL 和 CLUSTER 有什么区别?

VACUUM FULL 只是重写表压缩空间;CLUSTER 在重写的同时按指定索引键重新物理排序。若你的热点查询是时间范围扫描,CLUSTER 更优;只是膨胀严重选 VACUUM FULL。两者都持有 ACCESS EXCLUSIVE 锁。

事务 ID 回卷(wraparound)有多危险?

事务 ID 是 32 位,约 42 亿事务后回卷。若不及时 VACUUM 冻结,旧事务 ID 会被误判为"未来事务",导致数据损坏。数据库会强制保护甚至拒绝写事务。监控 age(relfrozenxid) 是 DBA 的底线指标。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 数据库安全加固与审计实战:权限最小化、加密、脱敏与合规
  2. 数据库容量规划与资源治理:从评估、监控到扩展路径
  3. 数据库字符集、排序规则与乱码实战:utf8mb4、Collation 选择与排查