适用场景

本文适用于更新、删除频繁的订单、任务、消息、审计记录等 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_tupn_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;

它需要额外磁盘空间,并持有排他锁,期间会阻塞对该表的正常访问,因此不能作为每日例行操作。执行前至少确认:

  1. 已有可验证的备份与回滚方案;
  2. 维护窗口允许表不可用;
  3. 临时磁盘空间足够完成重写和索引重建;
  4. 上游应用具备降级、暂停写入或快速失败能力;
  5. 已先修复 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_autovacuumautovacuum_count 是否按预期更新;
  • 表与索引增长速度是否回归业务数据增长速度;
  • autovacuum 是否频繁被取消或长期排队;
  • 长事务数量与最大事务年龄是否下降;
  • 查询 P95/P99、缓冲区命中、磁盘延迟和 WAL 速率是否改善。

建议为“长事务”“死元组持续增长”“autovacuum 长时间未执行”和“磁盘剩余空间不足”分别设置告警。单一的磁盘告警通常发现得太晚,也无法直接指出根因。

总结

PostgreSQL 删除数据后文件不缩小,通常是 MVCC 和空间复用机制的正常结果;真正需要关注的是死元组是否持续积压、查询性能是否恶化,以及磁盘是否失控。正确顺序是先用统计视图建立证据,再检查 autovacuum 触发阈值和长事务,随后执行受控 VACUUM 并调整热点表参数。

VACUUM FULL 和并发重建索引都不是第一反应:前者会重写表并持有排他锁,后者会消耗额外空间与 I/O。只有把事务边界、批量清理方式、表级 autovacuum 参数和监控一起治理,才能避免表膨胀周期性复发。