Autovacuum 为什么跟不上:清理受阻、任务积压与执行缓慢
本页目录
n_dead_tup 一直上涨,表越来越大,autovacuum 却并没有停。看到这样的监控,很容易想到增加 worker、提高内存,或者手动补一次 Vacuum。
这些操作是否有用,取决于清理停在哪个环节。有的表还没有轮到处理,有的正在反复扫描索引,还有的已经完成 Vacuum,却留下了大量暂时不能回收的旧版本。最后一种情况,即使再跑一遍,结果也可能一样。
Baji Shaik 和 Mohamed Ali 把这些问题分成 Blocked、Backlogged 和 Slow 三类。这个分类很适合排查,但三者也会同时出现:一个旧事务拖住清理,表逐渐变大,后续每次 Vacuum 又要处理更多页面,最终其他表也开始积压。
Vacuum 完成了,旧版本为什么还在
PostgreSQL 更新一行时,会产生新的行版本;删除一行后,旧版本也不会立刻从页面中消失。只要还有事务可能需要读取它,Vacuum 就必须保留。
这种限制往往没有阻塞链,Vacuum 也能正常结束。需要关注的是回收边界,也就是哪些事务、复制消费者仍可能需要较早的版本。
本次在 PostgreSQL 18.4 中做了一个小实验:表里有 10,000 行,会话 A 使用 REPEATABLE READ 查询后保持事务不结束;会话 B 删除其中 5,000 行并提交,然后执行普通 Vacuum。结果如下:
| 操作时点 | Vacuum 删除的行版本 | dead but not yet removable |
|---|---|---|
| A 仍持有旧快照 | 0 | 5,000 |
| A 提交后再次 Vacuum | 5,000 | 0 |
第一次 Vacuum 后,A 仍能读到原来的 10,000 行,新会话则只能读到 5,000 行。保留旧版本正是为了满足这两个快照的不同可见性。此时增加清理内存或 I/O 预算,都无法让 Vacuum 越过这个边界。
实际排查可以从下面的会话开始。查询同时显示 backend_xmin 和 backend_xid:前者反映快照边界,后者有助于发现已经分配事务 ID、仍未结束的事务。
SELECT pid, datname, usename, state,
clock_timestamp() - xact_start AS transaction_duration,
age(backend_xmin) AS xmin_age,
age(backend_xid) AS xid_age,
wait_event_type, wait_event,
left(query, 160) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND (backend_xmin IS NOT NULL OR backend_xid IS NOT NULL)
ORDER BY greatest(age(backend_xmin), age(backend_xid)) DESC NULLS LAST;
事务持续时间和 XID age 要分开看:前者是时间,后者按事务 ID 的推进量计算。一个单纯执行了 BEGIN 的会话,也未必已经持有旧快照;判断 idle in transaction 的影响,需要结合隔离级别、事务已经做过的操作和上述字段。
如果最老的会话并不能解释问题,还要继续检查:
pg_prepared_xacts中未完成的两阶段事务。处理前应与事务协调器核对,不能只因持续时间长就选择提交或回滚。pg_replication_slots的xmin、catalog_xmin和消费状态。catalog_xmin主要约束系统目录旧版本的清理;restart_lsn保留的是 WAL,几者应分别观察。- 主库
pg_stat_replication.backend_xmin与备库上的长查询。启用hot_standby_feedback后,备库可能把旧快照的需求反馈给主库。
active = false 只能说明复制槽当前未被使用,不能据此认定它已废弃。删除槽或让槽失效,可能使下游需要重新初始化。PostgreSQL 18 提供了 idle_replication_slot_timeout,应先确认消费者允许的中断时间,再决定是否启用。
备库侧的 max_standby_streaming_delay 约束 WAL 回放遇到冲突时的等待,不是所有查询的最长执行时间。需要控制报表时长时,应单独评估 statement_timeout;应用留下的空闲事务,则可以结合 idle_in_transaction_session_timeout 治理。
锁等待与回收边界是两种问题
旧快照会让某些版本暂时不能回收,关系锁冲突则可能让 Vacuum 连表都无法开始处理。
普通 Vacuum 需要 SHARE UPDATE EXCLUSIVE 锁。在另一组实验中,先让一个事务持有表的 SHARE ROW EXCLUSIVE 锁,再从第二个会话手动执行 Vacuum,可以观察到 wait_event_type = Lock、wait_event = relation,pg_blocking_pids() 也能找到持锁事务。释放锁后,Vacuum 才继续完成。
Autovacuum 对冲突还有自己的处理方式:它可能跳过暂时无法取得锁的表;普通 autovacuum 已在运行时,也可能因其他命令申请冲突锁而被取消。为防止事务 ID 回卷而运行的 autovacuum,不会按同样方式自动让路。因此,频繁 DDL 不一定表现为一个长期等待的 worker,也可能表现为反复跳过或中断。
另外一种等待是 BufferPin。这是页面被其他会话持有 pin,不能照着普通关系锁的阻塞链处理。pg_blocking_pids() 返回空数组,并不足以排除这种等待。需要结合等待事件、相关查询和当时的活动继续定位。
下面的查询用于查看当前 worker 和正在执行的普通 Vacuum,包含手动与自动任务。需要有查看其他会话详情的权限,例如 pg_read_all_stats。没有进度行的 worker 可能在分析、准备工作或切换处理对象,不能直接判定为卡住。
SELECT a.pid, a.datname, a.backend_type,
clock_timestamp() - a.query_start AS query_duration,
CASE WHEN p.datid = (SELECT oid FROM pg_database
WHERE datname = current_database())
THEN p.relid::regclass::text
WHEN p.relid IS NOT NULL THEN 'oid=' || p.relid::text
END AS relation,
p.phase,
round(100.0 * p.heap_blks_scanned /
nullif(p.heap_blks_total, 0), 1) AS heap_scan_pct,
p.index_vacuum_count,
p.dead_tuple_bytes, p.max_dead_tuple_bytes,
p.delay_time AS cost_delay_ms,
a.wait_event_type, a.wait_event,
pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_stat_activity AS a
LEFT JOIN pg_stat_progress_vacuum AS p ON p.pid = a.pid
WHERE a.backend_type = 'autovacuum worker' OR p.pid IS NOT NULL
ORDER BY a.query_start;
这里的 heap_scan_pct 只反映堆扫描进度,而且包含依据 visibility map 跳过的页面。它达到 100% 后,仍可能有索引清理等工作,不能当作整个 Vacuum 的完成百分比。跨数据库的关系 OID 也不能在当前数据库里直接解释成表名,所以查询对其他数据库只显示 OID。
哪些表还在等待清理
如果当前没有明显阻碍,下一步要看待处理工作是否持续增加。
Autovacuum 根据统计信息、表级设置与事务年龄选择工作,并不存在一个可以直接查询的、严格先进先出的任务队列表。通常所说的“积压”,是对已经满足触发条件、尚未得到处理的表进行估算。
对于更新、删除产生的死元组,PostgreSQL 18 的普通 Vacuum 触发门槛为:
threshold + scale_factor × reltuples
如果 max_threshold 不为 -1,再取上述结果与 max_threshold 的较小值。
对应参数是 autovacuum_vacuum_threshold、autovacuum_vacuum_scale_factor 和 autovacuum_vacuum_max_threshold;表级设置优先。reltuples 与死元组数量来自统计估计,统计更新也有延迟,因此算出来的是判断依据,不是精确的调度承诺。
按默认的 50 和 0.2 计算,一张估计有 1 亿行的表,要累积超过约 2,000 万个死元组才满足这一路触发条件。对于频繁更新的业务表,允许积累到这个量,往往已经影响空间和查询成本。此时降低该表的 scale factor,可以把一次很大的清理拆成更频繁、较小的清理;但启动得更早,也会增加维护频率,需要继续看实际完成速度。
PostgreSQL 18 新增的 autovacuum_vacuum_max_threshold 默认是 1 亿,为这一路门槛设置上限。检查旧版监控 SQL 时要注意这一项,不能一直按固定比例推算大表的门槛。
只统计 n_dead_tup 还会漏掉其他工作:只插入的表也需要维护 visibility map 和冻结行版本;PostgreSQL 18 的插入触发条件会考虑表中尚未冻结的比例。Autoanalyze 根据插入、更新、删除带来的修改量判断;XID 和 MultiXact 年龄又有各自的防回卷要求。
判断积压,建议连续观察“满足条件的表数量、处理完成量、最老任务的持续时间”,再对照 worker 使用情况。短暂用满 worker 很正常;如果长期用满,待处理表和事务年龄仍在上升,才需要进一步评估处理能力。分区应按叶子表计算,系统目录和 TOAST 也不能全部漏掉。
已有的Autovacuum 队列和进度提供了队列估算思路。用于不同版本时,应同时核对新增的触发条件与进度字段。
增加 worker 之前,检查它们在忙什么
更多 worker 可以并行处理不同的表,但不会让 PostgreSQL 18 的多个 autovacuum worker 合作清理同一张大表。若大部分工作集中在一张表上,增加并发上限可能帮助不大。
还要考虑成本限速。参与成本均衡的 worker 会分配共享预算,增加 worker 并不等于按比例增加总清理吞吐。针对表单独设置了成本参数的 worker,又不参与相同的均衡计算。调整并发后,需要一起检查成本预算、磁盘延迟和业务查询,不能只看进程数。
可以先查看实例实际参数及生效方式:
SELECT name, setting, unit, context, pending_restart
FROM pg_settings
WHERE name IN (
'autovacuum', 'track_counts', 'autovacuum_naptime',
'autovacuum_max_workers', 'autovacuum_worker_slots',
'autovacuum_vacuum_cost_limit', 'autovacuum_vacuum_cost_delay',
'vacuum_cost_limit', 'vacuum_cost_delay',
'autovacuum_work_mem', 'maintenance_work_mem',
'track_cost_delay_timing'
)
ORDER BY name;
本次 PostgreSQL 18.4 实例中,autovacuum_max_workers 的 context 为 sighup,可以通过重载配置调整;autovacuum_worker_slots 为 postmaster,需要重启。后者预留进程槽位,前者控制运行上限,把前者设得超过槽位数也不会得到更多 worker。
如果增加并发,内存预算也应一起调整。autovacuum_work_mem 是每个 worker 的额度,设为 -1 时使用 maintenance_work_mem。手动会话执行 SET maintenance_work_mem,不会改变其他 autovacuum 进程的配置。
对于少数更新很重的表,优先评估表级设置更容易控制影响范围。若频繁创建、删除临时表或分区,还应检查系统目录的死元组与清理情况。目录也要维护,大量 DDL 会增加这部分工作。
单次 Vacuum 慢,时间花在了哪里
进度视图中的 phase 比一个总耗时更有用。同样是“跑了半小时”,持续扫描堆、重复清理索引、等待 I/O 和主动限速,处理方向完全不同。
大部分时间在扫描堆
比较间隔采样的 heap_blks_scanned 增量,再看 I/O 延迟、吞吐量以及业务负载。扫描速度下降可能是存储繁忙,也可能是 Vacuum 在主动让出资源。
PostgreSQL 18 可以在开启 track_cost_delay_timing 后,通过 delay_time 观察成本限速累计睡眠时间。没有开启时该字段为零,不代表完全没有限速。采样中经常遇到 VacuumDelay,也值得检查成本参数。
如果瓶颈确实是限速,并且存储还有余量,可以逐步提高成本预算或降低延迟,同时观察业务请求的尾延迟。如果磁盘已经繁忙,放开限速可能只是让维护任务与业务争用得更激烈。
反复进入索引清理阶段
Vacuum 扫描堆时,需要记录待从索引中清理的条目标识。可用内存不足时,会先处理一批索引条目,再继续扫描,因而可能出现多个索引清理周期。
观察 index_vacuum_count 是否持续增加,再结合 dead_tuple_bytes 与 max_dead_tuple_bytes 判断。这里记录的是待清理条目信息的内存,不是把整张表装进内存。
PostgreSQL 17 改进了这部分存储结构,并移除了 Vacuum 使用这类内存时原有的 1 GB 限制。旧版本的 num_dead_tuples、max_dead_tuples 监控脚本不能原样套到新版本上。
内存调大后,应该比较同类负载下的周期数和总耗时。只看到某次 index_vacuum_count = 0,不能直接推导出提速效果;还要确认索引清理是否被跳过、当时处于哪个阶段,以及两次待处理的数据量是否接近。
索引数量本身也影响维护成本。长期不使用的索引值得审查,但删除前要确认约束依赖、低频查询和统计重置时间。能够使用 HOT update 的更新,通常可以减少索引维护;是否满足条件,还取决于更新列、索引类型和页内空间,不能只凭调低 fillfactor 判断。
堆扫描完成后仍迟迟不结束
继续看是否处于索引清理或文件尾部截断阶段。普通 Vacuum 可以回收文件内部空间,而把尾部空页归还给操作系统时,可能需要短暂取得 ACCESS EXCLUSIVE 锁。对于不能接受这类锁影响的表,可评估关闭 truncate,代价是保留尾部空间。
TOAST 同样需要维护,但默认对主表执行 Vacuum 时会处理关联 TOAST。应检查它的大小和清理状态,而不是假定必须另加一条手动命令。相关实验见规划开销、分区锁与表膨胀。
手动并行 Vacuum 可以并行处理符合条件的索引阶段,不会把整张堆表拆成多个并行扫描任务。本文的 PostgreSQL 18 autovacuum 也不使用这种并行索引清理,调整 worker 数时需要区分这两个层面的并发。
冻结压力需要单独观察
死元组不多,不代表表可以一直不做 Vacuum。事务 ID 会持续消耗,旧行版本需要冻结,MultiXact 也有对应的年龄限制。
当 age(relfrozenxid) 达到防回卷触发条件时,会启动相应的 autovacuum,即使普通 autovacuum 被关闭也一样。日常的 aggressive vacuum 与“为防回卷触发”是相关但不同的概念:前者描述扫描、冻结策略,后者描述任务触发原因。
监控应结合实例配置,观察 age(datfrozenxid)、age(relfrozenxid) 和 MultiXact 年龄的变化速度。只套一组固定告警数字,很容易错过增长特别快的数据库;更有用的是判断按当前增长速度,还剩多少处理时间。
如果进入 failsafe,Vacuum 会减少限速和非必要工作,优先推进冻结。这应按事件处理,调查长期未能完成清理的原因。后续需要恢复正常索引清理,但并不意味着所有索引都必须重建。
留下能比较的清理记录
last_autovacuum 很久没变,只能作为线索。一张几乎不变的表与持续更新的热点表,不能使用相同的时间阈值判断。n_dead_tup 也是估计值,不能直接换算成物理膨胀率。
日志能补上一次采样看不到的过程。根据日志容量和问题频率设置 log_autovacuum_min_duration;设为 0 会记录每次自动维护,短期排查很有帮助,长期全量记录则要评估日志量。重点关注运行时长、删除与保留的版本、索引清理周期、读写量,以及跳过、中断和报错。
调整后的观察也要对应原问题:旧事务结束后,能否回收此前保留的版本;增加处理能力后,待处理表是否减少;内存调整后,重复索引周期是否下降。不要只以“worker 更多了”或“Vacuum 又跑了一次”作为问题解决的依据。
遇到无法连接的数据库、损坏页面或 Vacuum 持续报错,应转入对应的故障处理。删除数据库、跳过损坏页面等操作有数据丢失风险,不属于常规 autovacuum 调优步骤。
参考资料
- Baji Shaik、Mohamed Ali:Autovacuum Blocked, Backlogged, or Slow?! Understanding Why It Can't Keep Up,Postgres Conference 2026 议程,2026-04-23。
- 日常 Vacuum 与触发条件、Vacuum 参数。
- Vacuum 进度视图、VACUUM 命令、维护内存与缓冲区。
- 复制槽字段、复制与备库参数。
- PostgreSQL 17 发布说明、PostgreSQL 18 发布说明。
- 本文补充实验使用 PostgreSQL 18.4,详见实验与校订记录。