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_slots(active = false) |
| holder 重连、重启都不消失 | 2PC 留下的 prepared transaction(原因 4) | pg_prepared_xacts |
| horizon 被拽住,但本机没有任何老事务 | standby 把 xmin 反向喂回来(原因 5) | pg_stat_replication.backend_xmin |
日志反复出现 canceling autovacuum task | autovacuum 一直给锁让位(原因 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 PREPARED 或 ROLLBACK 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_count 和 last_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 是 rdsadmin 或 cloudsqladmin,那就等它跑完。
容易被误诊成
磁盘慢。症状一模一样:查询变慢、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_seconds、n_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_repack,VACUUM FULL留给维护窗口里救火用。- 普通 VACUUM 明明返回成功,
n_dead_tup为啥不掉? 因为那些 dead row 对最老的开着的事务(也就是 xmin horizon)还可见。VACUUM 跑了,发现这些行 “dead but not yet removable”,就原样留着。只有挡路的事务或 slot 没了之后,它才能回收。 - 我把长事务干掉了,但磁盘空间没回来。 普通
VACUUM只是把 page 标记成可复用,不会把磁盘上的文件缩小;它让表停止变大,而不是变小。要把空间真正还给操作系统,得用VACUUM FULL或pg_repack。 - 这算 autovacuum 的 bug 吗,要不要上报? 基本不算。autovacuum 拒绝清掉某个活动快照还能看到的行,是 MVCC 在按设计正常工作。该修的是那条挡路的事务,不是 vacuum 参数。
- 托管数据库(RDS、Cloud SQL、Aurora)上还能 kill backend 吗? 能,
pg_terminate_backend对你自己的 session 有效。但你 kill 不掉服务商自己的 session,比如rdsadmin或cloudsqladmin;这种只在 backup 或升级时短暂占住 xmin,等它跑完就行。
Related
- Postgres connection pool 被打爆
- Supabase connection pool exhausted
- MongoDB aggregation 很慢
- Database migration 卡住
- Redis cluster failover 卡住
- Supabase RLS 把数据挡掉
- Docker OOM killed
- 消息队列 dead letter 堆积
- GraphQL rate limit 级联
- Supabase cold start 很慢
外部参考
- PostgreSQL 文档:Routine Vacuuming —— xmin horizon 与 dead tuple 为什么留着
- PostgreSQL 文档:VACUUM ——
FULL、VERBOSE、ANALYZE等确切选项 - PostgreSQL 文档:pg_replication_slots —— slot 状态与 PG18 的
invalidation_reason列