PostgreSQL 的表膨胀(bloat)是生产环境最隐蔽的性能杀手之一。数据库运行一段时间后,明明 DELETE 了大批数据,表文件却只增不减;明明表里只有 100 万行,实际占用却有 500 万行的空间。
这一切的根源是 MVCC(多版本并发控制):PostgreSQL 不原地覆盖旧数据,而是生成新版本。被更新、被删除的旧版本就变成了死元组(dead tuple),需要 VACUUM 来回收。
核心认知:VACUUM 不是可选的运维杂务,而是 PostgreSQL 健康运转的前提条件。理解死元组从哪里来、Autovacuum 如何触发、膨胀如何诊断,才能让你的数据库长期保持稳定性能。
一、MVCC 与死元组形成机制
1.1 MVCC 的版本存储
当一条记录被 UPDATE 时,PostgreSQL 会:
- 在页面中插入一条新元组(含新数据)
- 用
xmax标记旧元组为"已失效" - 让索引更新指向新元组(或利用 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 的核心工作:
- 扫描表,标记并清理死元组占用的空间
- 更新可见性映射(Visibility Map),使
Index Only Scan生效 - 更新空闲空间映射(FSM),让新插入复用被回收的页面
- **冻结(freeze)**老元组,防止事务 ID 回卷(wraparound)
- 可选
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_threshold | 50 | 死元组基数阈值 |
autovacuum_vacuum_scale_factor | 0.2 | 死元组比例系数 |
autovacuum_vacuum_cost_delay | 2ms | 每轮成本后的停顿 |
autovacuum_vacuum_cost_limit | -1(继承 vacuum_cost_limit) | 每轮成本上限 |
autovacuum_naptime | 60s | 检查间隔 |
autovacuum_max_workers | 3 | 最大 worker 数 |
autovacuum_freeze_max_age | 200000000 | 强制冻结年限 |
autovacuum_vacuum_insert_threshold | 1000 | 纯插入表的插入阈值 |
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 FULL | ACCESS EXCLUSIVE | 还给 OS | 否 | 膨胀严重、低峰期 |
| CLUSTER | ACCESS EXCLUSIVE | 还给 OS | 是(按索引序) | 需要物理聚集优化扫描 |
经验:优先让 Autovacuum 正常运转,定期监控,把 VACUUM FULL 当作最后手段而非例行任务。
七、防数据膨胀设计(高频 UPDATE 优化)
7.1 从源头减少死元组
设计上降低死元组产生的速率,比事后清理更有效:
| 策略 | 原理 |
|---|---|
| HOT(Heap-Only Tuple)更新 | 非索引列 UPDATE 不产生索引条目变化 |
降低 fillfactor | 页面预留空间,HOT 更容易就地更新 |
| 避免更新索引列 | 更新主键/索引列必然产生索引死条目 |
| 批量 UPDATE 而非逐条 | 减少事务数量与死元组峰值 |
| 合理的表分区 | 只对活跃分区做高频率维护 |
7.2 开启 HOT 的条件
HOT(Heap-Only Tuple)更新需要同时满足:
- UPDATE 不涉及任何索引列
- 目标页面上有足够空余空间放下新版本
-- 为热点表预留 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_tup | pg_stat_user_tables | > 10000 或 > 表行数 10% |
| 死元组比例 | n_dead_tup / n_live_tup | > 0.2 |
| 最近 autovacuum 时间 | last_autovacuum | 超过阈值间隔 N 天 |
| 表/索引膨胀率 | pgstattuple / pgstatindex | free_percent > 30% |
| 事务冻结年龄 | age(relfrozenxid) | > autovacuum_freeze_max_age × 0.8 |
| 长事务数 | pg_stat_activity | idle 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 的底线指标。
相关阅读
- PostgreSQL 性能调优 — 参数配置、VACUUM/Autovacuum 调优
- PostgreSQL 查询优化实战 — EXPLAIN ANALYZE、慢查询治理
- PostgreSQL 索引类型深度实战 — 索引膨胀诊断与 REINDEX 重建
- PostgreSQL 事务、隔离级别与锁 — 长事务对死元组回收的影响
- PostgreSQL 监控与诊断体系 — 死元组告警规则
- PostgreSQL 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。