适用场景
本文适用于更新、删除频繁的订单、任务、消息、审计记录等 PostgreSQL 表。典型现象是:业务已经清理大量历史数据,但表文件没有缩小;索引扫描和备份越来越慢;监控中的磁盘使用持续上涨;执行 VACUUM 后空间仍未归还操作系统。
本文重点解决三个问题:如何确认膨胀来自死元组,为什么 autovacuum 没有及时回收,以及如何在不中断业务的前提下治理。示例中的阈值需要按表大小、写入速率和存储性能压测后调整。
现象描述
假设任务表每天更新数百万次,并定期删除过期记录:
DELETE FROM task_runs
WHERE finished_at < now() - interval '90 days';
删除成功后,count(*) 明显下降,但磁盘空间并不会立即缩小。原因是 PostgreSQL 使用 MVCC:UPDATE 通常会产生新版本,DELETE 只是把旧版本标记为对后续事务不可见。只要这些死元组尚未被清理,或者清理后的空闲页还保留在关系文件中,表文件就不会缩小。
普通 VACUUM 的目标是让空间可被该表后续写入复用,并维护可见性映射;它通常不会把关系文件中间的空洞归还操作系统。不要仅凭文件大小判断 VACUUM 无效。
第一步:建立表级证据
先查看目标表的大小、活元组、死元组和维护时间:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
round(
100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0),
2
) AS dead_tuple_pct,
last_autovacuum,
last_autoanalyze,
autovacuum_count,
autoanalyze_count
FROM pg_stat_user_tables
WHERE schemaname = 'public'
AND relname = 'task_runs';
n_live_tup 和 n_dead_tup 是统计估算值,适合发现趋势,不是账务级精确计数。重点看死元组是否持续增长、last_autovacuum 是否长时间不更新,以及自动清理次数是否与写入量匹配。
再拆分表和索引占用:
SELECT
pg_size_pretty(pg_relation_size('public.task_runs')) AS table_size,
pg_size_pretty(pg_indexes_size('public.task_runs')) AS indexes_size,
pg_size_pretty(pg_total_relation_size('public.task_runs')) AS total_size;
如果索引远大于表本体,还应检查无效或重复索引、低选择性索引以及索引膨胀。表膨胀和索引膨胀需要分别治理,不能只对表执行一次 VACUUM 就结束排查。
第二步:判断 autovacuum 是否正在工作
查看当前维护进度:
SELECT
p.pid,
p.datname,
p.relid::regclass AS relation,
p.phase,
p.heap_blks_scanned,
p.heap_blks_total,
p.num_dead_tuples,
p.max_dead_tuples
FROM pg_stat_progress_vacuum AS p;
如果没有记录,不代表 autovacuum 被禁用,也可能是任务刚结束、尚未达到触发阈值或工作进程繁忙。继续检查数据库与表级配置:
SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
'autovacuum',
'autovacuum_max_workers',
'autovacuum_naptime',
'autovacuum_vacuum_threshold',
'autovacuum_vacuum_scale_factor',
'autovacuum_vacuum_cost_limit',
'autovacuum_vacuum_cost_delay'
)
ORDER BY name;
SELECT reloptions
FROM pg_class
WHERE oid = 'public.task_runs'::regclass;
常规触发条件可理解为:
待清理元组数 > autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor × 估算表行数
大表的默认比例阈值可能意味着积累数百万死元组后才触发;高频更新表则可能刚清理完又迅速堆积。此时更适合给热点表设置独立参数,而不是粗暴修改全库配置。
第三步:排查阻止回收的长事务
即使 autovacuum 正常启动,过老的事务快照仍会让旧版本无法被安全移除。先找出长时间未结束的事务:
SELECT
pid,
usename,
application_name,
client_addr,
state,
xact_start,
now() - xact_start AS transaction_age,
wait_event_type,
wait_event,
left(query, 200) AS query_sample
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
重点关注:
idle in transaction且持续数分钟甚至数小时的连接;- 报表或导出任务持有长快照;
- 应用开启事务后等待网络、文件或第三方接口;
- 连接池把未提交事务的连接长期留在池中。
不要仅凭查询文本直接终止连接。先确认业务归属、事务是否可重试以及是否正在执行关键写入。确需处理时,先取消语句,再在评估影响后终止会话:
SELECT pg_cancel_backend(:pid);
SELECT pg_terminate_backend(:pid);
:pid 必须由人工确认或受控程序绑定,禁止把外部输入直接拼接进管理 SQL。
第四步:为热点表设置更合适的阈值
对于千万级且更新频繁的表,可先在低风险环境验证以下表级策略:
ALTER TABLE public.task_runs SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 5000,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_analyze_threshold = 5000
);
这不是通用推荐值。scale_factor 降低后,维护会更早触发,但也会增加扫描和 I/O 频率。应同时观察死元组曲线、autovacuum 执行时长、磁盘延迟、WAL 增量和业务 P99 延迟。
若全库多个热点表长期排队,可评估增加 autovacuum_max_workers 或提高清理速度。调整前必须核算 CPU、IOPS 和内存余量;把 worker 数量翻倍并不等于清理能力无成本翻倍。
第五步:执行一次受控清理
在解除长事务、确认有维护窗口后,可先对单表执行普通清理并更新统计信息:
VACUUM (ANALYZE, VERBOSE) public.task_runs;
VERBOSE 会输出维护细节,适合受控排查;自动化任务中应把输出收敛到可检索日志,避免无节制打印。执行期间持续观察:
SELECT
pid,
relid::regclass AS relation,
phase,
heap_blks_scanned,
heap_blks_total,
heap_blks_vacuumed,
index_vacuum_count
FROM pg_stat_progress_vacuum;
普通 VACUUM 可以与正常读写并行,但仍会消耗 I/O,并可能在部分阶段等待锁。高峰期不要同时对多个大表手工发起维护。
什么时候才需要 VACUUM FULL
如果业务确实需要立刻把大量空间归还操作系统,VACUUM FULL 会重写整张表:
VACUUM (FULL, ANALYZE) public.task_runs;
它需要额外磁盘空间,并持有排他锁,期间会阻塞对该表的正常访问,因此不能作为每日例行操作。执行前至少确认:
- 已有可验证的备份与回滚方案;
- 维护窗口允许表不可用;
- 临时磁盘空间足够完成重写和索引重建;
- 上游应用具备降级、暂停写入或快速失败能力;
- 已先修复 autovacuum 阈值或长事务根因,否则膨胀还会再次出现。
不能接受长时间排他锁时,可评估在线重组工具或新表迁移方案,但这会引入触发器、复制增量、切换锁和额外容量等复杂度,应先在同规模数据上演练。
索引膨胀的处理边界
VACUUM 不会把已经膨胀的 B-tree 索引恢复成紧凑结构。先查看索引使用情况:
SELECT
indexrelname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND relname = 'task_runs'
ORDER BY pg_relation_size(indexrelid) DESC;
确认索引确实膨胀且仍有业务价值后,可在支持的版本和索引类型上使用并发重建:
REINDEX INDEX CONCURRENTLY public.idx_task_runs_status_created_at;
并发重建耗时更长、产生额外 I/O 和临时空间,也会在切换阶段获取短锁。对从未扫描的索引,应先核对统计周期、约束依赖和备用查询,再决定删除,不能只凭 idx_scan = 0 直接操作。
应用侧预防措施
数据库参数只能缓解症状,事务边界仍需从应用修正:
- 事务中只保留数据库相关工作,不要夹杂 HTTP 调用、文件上传或人工等待;
- 为事务和语句设置与业务 SLA 匹配的超时;
- 批量删除分段执行,避免一个超大事务长期保留旧版本并制造 WAL 峰值;
- 更新表时尽量避免无意义地重复写入相同值;
- 对高频更新表合理设置
fillfactor,为 HOT update 留出页内空间; - 连接归还连接池前必须提交或回滚,失败路径也要释放资源。
分批删除可以使用稳定主键限制单次事务规模,例如由应用循环执行参数化语句:
WITH expired AS (
SELECT id
FROM task_runs
WHERE finished_at < $1
ORDER BY id
LIMIT $2
)
DELETE FROM task_runs AS t
USING expired AS e
WHERE t.id = e.id;
$1 是固定的清理截止时间,$2 是经过范围校验的批次大小。每批提交后短暂让出资源,并记录删除行数、耗时和剩余量;不要在无限循环中无间隔地压满数据库。
监控与验收
治理后至少持续观察一个完整业务周期:
n_dead_tup及死元组比例是否回落并保持稳定;last_autovacuum、autovacuum_count是否按预期更新;- 表与索引增长速度是否回归业务数据增长速度;
- autovacuum 是否频繁被取消或长期排队;
- 长事务数量与最大事务年龄是否下降;
- 查询 P95/P99、缓冲区命中、磁盘延迟和 WAL 速率是否改善。
建议为“长事务”“死元组持续增长”“autovacuum 长时间未执行”和“磁盘剩余空间不足”分别设置告警。单一的磁盘告警通常发现得太晚,也无法直接指出根因。
总结
PostgreSQL 删除数据后文件不缩小,通常是 MVCC 和空间复用机制的正常结果;真正需要关注的是死元组是否持续积压、查询性能是否恶化,以及磁盘是否失控。正确顺序是先用统计视图建立证据,再检查 autovacuum 触发阈值和长事务,随后执行受控 VACUUM 并调整热点表参数。
VACUUM FULL 和并发重建索引都不是第一反应:前者会重写表并持有排他锁,后者会消耗额外空间与 I/O。只有把事务边界、批量清理方式、表级 autovacuum 参数和监控一起治理,才能避免表膨胀周期性复发。
Discussion
评论