Postgres autovacuum 被一条长事务卡住,dead tuple 怎么清都清不掉

dead tuple 一直涨、表越来越胖,VACUUM 日志却说 dead but not yet removable。一条没人管的长事务把 xmin horizon 拽住,vacuum 啥也回收不了。找出来干掉它。

Postgres 监控里 dead tuple 数量一直往上爬,最大那张表两周内磁盘体积翻了三倍,行数却几乎没动。你手动跑了一次 VACUUM ANALYZE,几秒就返回了,啥也没抱怨。autovacuum 日志也说跑过了。但 pg_stat_user_tables.n_dead_tup 还是涨,那张表的 p99 也从 40 ms 一路爬到 900 ms。

锅基本不在 autovacuum 自己身上。多半是集群里某个地方有一条卡住的事务,把全局 xmin horizon 拽住,导致 vacuum 即使看起来跑成功了,实际啥行都回收不到。

一句话先救火

先跑 VACUUM (VERBOSE) public.你的表;。如果输出里报了一个非零计数、后面还跟着 oldest xmin——比如 "1165 are dead but not yet removable, oldest xmin: 108204095"(PostgreSQL 17+ 的逐表那行也会写成 "... dead tuples cannot be removed yet, oldest xmin: ...")——说明 vacuum 是被 xmin horizon 挡住了,不是它自己失败。那个 oldest xmin 的值就是拽住一切的事务 id。接着找出拽住 horizon 的最老事务:

SELECT pid, datname, usename, state,
       backend_xmin,
       now() - xact_start AS xact_age,
       now() - state_change AS idle_age,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 5;

最上面那行基本就是元凶。用 SELECT pg_terminate_backend(<pid>); 干掉它,再重跑一次 VACUUM。如果 pg_stat_activity 里压根没有老连接,那 holder 就是 replication slot 或者 prepared transaction(看原因 2 和原因 4)。只要别再赖在 autovacuum 头上,大多数情况五分钟内就能解决。

常见原因

按命中率从高到低排。每一种最后都拽住同一个 xmin horizon,但诊断和清理的方式各不一样,用下面这张表直接对号入座。

你看到的现象大概率原因去哪看
有连接卡在 idle in transaction 好几分钟/小时忘了关的开着的事务(原因 1)pg_stat_activity.state
有个 SELECT 已经 active 跑了好几个小时长分析查询或 pg_dump 快照(原因 3)pg_stat_activity + backend_xmin
完全没有老连接,horizon 却不往前走孤儿 replication slot(原因 2)pg_replication_slotsactive = false
holder 重连、重启都不消失2PC 留下的 prepared transaction(原因 4)pg_prepared_xacts
horizon 被拽住,但本机没有任何老事务standby 把 xmin 反向喂回来(原因 5)pg_stat_replication.backend_xmin
日志反复出现 canceling autovacuum taskautovacuum 一直给锁让位(原因 6)pg_stat_user_tables.autovacuum_count
age(datfrozenxid) 一路爬到 2 亿以上anti-wraparound vacuum 卡住(原因 7)pg_database

1. 有个 idle in transaction 的连接挂了几个小时

某个 worker 跑了 BEGIN、做了一个查询,然后就去等外部 API 了。连接还活着,事务一直没 commit。

怎么发现:跑 SELECT pid, state, xact_start, now() - xact_start AS age, query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY xact_start; 任何 age 超过几分钟的就是嫌疑人。

2. 一个没人消费的 replication slot

之前给 CDC pipeline 建过一个 logical replication slot,那个 pipeline 挂了 / 被删了 / 落后太多。slot 一直把 catalog_xmin 往后拽,vacuum 没法清掉比 slot 更新的行。

怎么发现SELECT slot_name, active, restart_lsn, confirmed_flush_lsn, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag FROM pg_replication_slots; 看到 active = false、lag 还在涨的那个就是。PostgreSQL 18(2025 年 9 月 25 日发布,截至 2026 年 6 月最新小版本是 18.4)的 pg_replication_slots 视图还多了一列 invalidation_reason,已经因为闲置被回收的 slot 这里会显示 idle_timeout,另外 inactive_since 会告诉你它闲置了多久。PostgreSQL 18 新增了 idle_replication_slot_timeout 可以自动作废闲置 slot,但它默认是 0(关闭),而且就算设了也要等到下一次 checkpoint 才生效——细节见下面的预防部分。

3. 有个跑了几小时的分析查询

BI 工具或者 pg_dump 开了一个 REPEATABLE READ 的快照在导数据。它没结束之前,所有该快照能看到的行都不能被清。

怎么发现:在 pg_stat_activity 里找 state = 'active' 又跑了很久的 SELECT,看它的 backend_xmin 是不是特别小。

4. 2PC 留下来的 prepared transaction

一个 two-phase commit 走到 prepared 状态,但没人调 COMMIT PREPAREDROLLBACK PREPARED。这东西很顽固,重连、重启都不会消失。

怎么发现SELECT * FROM pg_prepared_xacts; 里任何一行基本都是坏的。

5. 某个 standby 开了 hot_standby_feedback,又在跑慢查询

某个 replica 在跑长查询,把自己的 xmin 反向喂给 primary。primary 的 vacuum horizon 就被 replica 那边的活动拖住了。

怎么发现:在 primary 上看 SELECT application_name, backend_xmin FROM pg_stat_replication;,对比 txid_current()。如果 backend_xmin 比 current 落后很多,那就是 replica 在拽。

6. autovacuum 一直在跑,但一直被 cancel

锁竞争重的表上,autovacuum 一启动就让位给冲突锁。日志里看着像跑过,实际零工作量完成。

怎么发现:对比 pg_stat_user_tables.autovacuum_countlast_autovacuum。如果 count 一直涨但 n_dead_tup 不掉,就是 vacuum 被反复 cancel 了。Postgres log 里会有 canceling autovacuum task 这种消息。

7. autovacuum_freeze_max_age 触发了 anti-wraparound vacuum,而且卡住了

集群快要到 transaction ID wraparound 的时候,autovacuum 会拉起 aggressive freeze worker,这种是不能被 cancel 的。一旦它被堵,整个集群就一起停摆。

怎么发现SELECT datname, age(datfrozenxid) FROM pg_database; 超过 2 亿就已经在危险区了。

最短修复路径

第 1 步:先找出最老的 xmin 持有者

这条查询最有用,先跑这个。

SELECT 'idle_in_tx' AS source, pid::text, xact_start::text, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
UNION ALL
SELECT 'long_query', pid::text, xact_start::text, query
FROM pg_stat_activity
WHERE state = 'active' AND xact_start < now() - interval '5 min'
UNION ALL
SELECT 'prepared_xact', gid, prepared::text, '2PC'
FROM pg_prepared_xacts
UNION ALL
SELECT 'repl_slot', slot_name, xmin::text, 'replication'
FROM pg_replication_slots
WHERE xmin IS NOT NULL
ORDER BY 3;

时间最早的那条就是元凶。

第 2 步:干掉或解除它

idle in transaction 的连接,先问下 owner,确认可以就 terminate。

SELECT pg_terminate_backend(12345);

孤儿 replication slot:

SELECT pg_drop_replication_slot('dead_cdc_slot');

prepared transaction:

ROLLBACK PREPARED 'transaction-gid-here';

第 3 步:确认 xmin horizon 真的往前走了

-- PostgreSQL 13+(64 位、不怕 wraparound,推荐):
SELECT now(), pg_current_xact_id(),
       pg_snapshot_xmin(pg_current_snapshot());
-- 13 之前(旧的 32 位等价写法):
-- SELECT now(), txid_current(), txid_snapshot_xmin(txid_current_snapshot());

snapshot 的 xmin 现在应该非常接近当前 xid。如果还差很远,那说明还有第二个 holder,回到第 1 步再扫一遍。

第 4 步:手动给胖表跑一次激进 vacuum

horizon 解开之后,强制跑一次能真的回收 page 的 vacuum。

VACUUM (VERBOSE, ANALYZE) public.orders;
-- 如果能接受短暂排他锁,并且想把磁盘空间拿回来:
VACUUM (FULL, VERBOSE) public.orders;

VACUUM FULL 会重写整张表并拿 ACCESS EXCLUSIVE 锁——要么在维护窗口做,要么用 pg_repack 走在线重建。

怎么确认真的修好了

vacuum 之后,你要看到两件事同时成立:dead tuple 掉下去了,并且 “not yet removable” 那行消息没了。

SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders';

n_dead_tup 应该明显下降(刚 vacuum 过的表会趋近于 0)。再跑一次 VACUUM (VERBOSE) public.orders;,确认输出里不再报有行 cannot be removed yet。如果 n_dead_tup 还偏高、但 VACUUM VERBOSE 已经能干净地清掉行,那只是积压,让 autovacuum 慢慢追上或者手动再 vacuum 一次就行。如果 “not yet removable” 那行还在,说明还有第二个 xmin holder,回到第 1 步。

第 5 步:集群级别压低 idle_in_transaction 超时

ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
ALTER SYSTEM SET statement_timeout = '30min';  -- 需要的话按 session 设
SELECT pg_reload_conf();

这样忘记关的事务会被自动 kill,不用等人来救。

第 6 步:加一个 autovacuum 监控查询

-- 最老事务超过 30 分钟就告警
SELECT max(extract(epoch FROM now() - xact_start)) AS oldest_tx_seconds
FROM pg_stat_activity
WHERE xact_start IS NOT NULL;

接到 Prometheus 或者你用的监控系统,超过 1800 就 page。

第 7 步:给热表单独调 autovacuum 参数

默认的 autovacuum 阈值对写量大的表偏保守。5000 万行、一天 100 万次 update 的表,把 scale factor 调低。

ALTER TABLE public.orders SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_cost_limit = 2000,
  autovacuum_naptime = 10
);

这种情况不怪你

托管服务(RDS、Cloud SQL、Aurora)有时候会跑自己内部的长事务做 backup 或者大版本升级。这种会短暂占住 xmin,你也 kill 不掉。如果 pg_stat_activity 里唯一的老事务 owner 是 rdsadmincloudsqladmin,那就等它跑完。

容易被误诊成

磁盘慢。症状一模一样:查询变慢、IOPS 飙、监控看着像吃满了。然后大家就想换更大的实例。真正的问题是索引里全是 Postgres 没法清掉的 dead tuple,所以每次扫描要碰 5 倍多的 page。换大盘只能让你再撑一阵子,根本问题没解。

另一种常见误诊:怪 query planner。plan 变差是因为 statistics 过期了,但 statistics 过期是因为 analyze 没法在满是 dead row 的表上正常工作。

预防

  • 集群级别设 idle_in_transaction_session_timeout(PostgreSQL 9.6 起就有)。事务挂几小时没 commit,基本没有合法理由。
  • PostgreSQL 18 上设一下 idle_replication_slot_timeout(默认 0 = 关闭),让闲置 slot 被自动作废,而不是悄无声息地永远拽住 horizon。作废只在下一次 checkpoint 时触发,所以实际延迟是你设的值再加上最多一个 checkpoint_timeout;它对不保留 WAL 的 slot、以及从 primary 同步过来的 standby slot 不生效。PostgreSQL 17 及更早没有自动清理,只能改成监控 slot 闲置时长来告警。
  • 监控 oldest_tx_secondsn_dead_tup / n_live_tup 比例、pg_replication_slots 的 lag。
  • 不要随便建 replication slot 又不配套清理逻辑;孤儿 slot 当 memory leak 处理。
  • 1000 万行以上、写量大的表,把 autovacuum_vacuum_scale_factor 改成 0.01-0.02,别用全局默认的 0.2。
  • 每周跑一次 SELECT * FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20; 做体检。

FAQ

  • kill backend 会丢数据吗? 不会。Postgres 会干净地 rollback 整个事务,应用层只看到连接断开,应该自己重试。其他每个事务已经 commit 的数据完全不受影响。
  • VACUUM FULL 能常用吗? 不建议。它是 ACCESS EXCLUSIVE 锁重写整张表,期间这张表读写全堵,还需要大约和表本身一样大的空闲磁盘。日常在线清 bloat 用 pg_repackVACUUM FULL 留给维护窗口里救火用。
  • 普通 VACUUM 明明返回成功,n_dead_tup 为啥不掉? 因为那些 dead row 对最老的开着的事务(也就是 xmin horizon)还可见。VACUUM 跑了,发现这些行 “dead but not yet removable”,就原样留着。只有挡路的事务或 slot 没了之后,它才能回收。
  • 我把长事务干掉了,但磁盘空间没回来。 普通 VACUUM 只是把 page 标记成可复用,不会把磁盘上的文件缩小;它让表停止变大,而不是变小。要把空间真正还给操作系统,得用 VACUUM FULLpg_repack
  • 这算 autovacuum 的 bug 吗,要不要上报? 基本不算。autovacuum 拒绝清掉某个活动快照还能看到的行,是 MVCC 在按设计正常工作。该修的是那条挡路的事务,不是 vacuum 参数。
  • 托管数据库(RDS、Cloud SQL、Aurora)上还能 kill backend 吗? 能,pg_terminate_backend 对你自己的 session 有效。但你 kill 不掉服务商自己的 session,比如 rdsadmincloudsqladmin;这种只在 backup 或升级时短暂占住 xmin,等它跑完就行。

外部参考

标签: #后端 #排查 #infra #postgres #数据库 #autovacuum #bloat