周一早上到公司,监控上有个红色数字卡在 2.3s 不动,出问题的是订单主表 orders,820 万行。上上周我还压测过这张表,主键 Index Scan 平均 30 到 40 毫秒。现在同样的查询 Explain Analyze 出来 2.3 秒,Buffers 那一栏显示 shared hit 四万多。我第一反应是统计信息过期,ANALYZE orders 跑了一遍,没变化。又去 pg_stat_activity 里翻了翻,没有锁等待、没有长查询。真正让我反应过来的是 pg_stat_user_tables 里那三列数字——n_live_tup 780 万,n_dead_tup 320 万,last_autovacuum 停在 6 天前。
PG 的 autovacuum 触发条件是死元组数超过这个值:autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × 表的估算行数。默认 threshold 是 50,scale factor 是 0.2。也就是说一张 780 万行的表,要攒到 156 万条死元组才会被排进 vacuum 队列。320 万早就超过这条线了,那为什么没跑?我后来在日志里翻到,那几天有三四个大表在轮流占着 autovacuum_max_workers(默认只有 3 个),队列排得很长;再加上默认的 vacuum_cost_limit 只有 200,一张 800 万行的表跑到一半就被成本限制打断、重新入队,来回几次,最后谁也没干完。排查的时候我一般先看这张排名表:
SELECT relname, n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze,
pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
不过参数只是表面。VACUUM 有一条硬规则:它只能清理掉比“最老还活着的快照”更老的死元组。换句话说,只要有一个长事务、一个没被消费的复制槽、或者一个挂着没提交的 prepared transaction,那 320 万条死元组就一条都删不掉,VACUUM 每次跑起来都在做无用功。我那次就是查出来一个 active = false 的逻辑复制槽,挂了三天,restart_lsn 落后了 20 多 GB 的 WAL。这俩查询我基本每个月都会跑一次:
-- 谁开了很久没关
SELECT pid, state, xact_start, now() - xact_start AS dur, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;

-- 有没有 inactive 的复制槽
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
把那个槽 drop 掉的当天晚上,autovacuum 自己就把 320 万条死元组收了,第二天查询回到 40 毫秒。但这是救火,长期还是得让触发线合理一点。我现在的做法是不动 postgresql.conf 的全局值(云上 RDS 很多也不让你改),改成在表级别设 storage parameter,写几个数字就是几个数字:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 2,
autovacuum_vacuum_cost_limit = 1000
);
scale factor 从 0.2 降到 0.02,意思是 780 万行的表攒到 15 万条死元组就会开 vac,而不是等 156 万。为什么是 0.02 不是 0.005?这是和我线上另外几张写入量接近的表对比着试出来的,0.005 在高写入时段会让 autovacuum 一直在跑、IO 吃得太满。另外提醒一句,autovacuum_max_workers 从 3 提到 6 之后,我这边晚上八点的写入高峰反而抖了一下,调参数最好一次只动一个,隔一天再看数据。
我原来写 MySQL 写了五年,转过来最大的认知落差是:InnoDB 的 purge 是后台线程持续在跑的,undo 空间该收缩会收缩,你基本不用操心;PG 不一样,MVCC 的旧行版本就躺在数据页里,等 VACUUM 把它们标成可复用。所以 VACUUM 不是“清理垃圾”,它是“给你腾地方写新数据”——死元组堆着不收,表会持续变大,而且新插入的行会越写越往后。我的一个不太主流的看法是:绝大多数号称“PG 变慢了”的案子,本质不是 planner 选错了索引,而是表或者索引膨胀了。我见过太多人一上来就调 random_page_cost、work_mem、effective_cache_size,调完 EXPLAIN 里的 cost 数字确实变好看了,实际耗时一点没动。我自己也这么干过,还一度怀疑是不是谁把 random_page_cost 改成了 1.1,后来发现没有,是我记错了。
索引膨胀比表膨胀更阴,因为它不好看。表可以装 pgstattuple 扩展,跑 pgstattuple_approx('orders') 直接拿到死元组占比;索引你往往只能靠大小对比去猜。我碰到过一个案例:日志表数据 12GB,几个索引加起来 41GB,原因是 B-tree 的页里塞满了指向已死元组的空槽。VACUUM 是会把索引里的死项清掉,但这些页不会被还给操作系统,得靠 REINDEX CONCURRENTLY 重建。PG 12 加的 REINDEX CONCURRENTLY 是我这些年用得最舒服的功能之一,之前用 REINDEX INDEX 会拿 ACCESS EXCLUSIVE 锁,锁上一秒业务就报警。
最后说两个我踩过的坑。第一,别拿 VACUUM FULL 做日常清理,它申请 ACCESS EXCLUSIVE 锁,等于停表,而且是把表整个重写一遍,磁盘上得先留出等于表大小的空闲空间;真要回收物理空间,用 pg_repack 或者 pg_squeeze 做在线重组。第二,如果你的 PG 已经升到 17,可以留意一下 vacuum 的变化:PG 17 把记录死元组的结构从内存数组换成了 TidStore(一棵 radix tree),以前几百 GB 的表跑 vacuum 得把 maintenance_work_mem 加到好几个 G 才不报错,现在内存占用压下来不少;pg_stat_progress_vacuum 也多了 indexes_total 和 indexes_processed 两列,能看着它一个索引一个索引地啃,进度条终于不骗人了。