一张订单表,业务数据 800 万行,表文件却占 40GB,而同样结构的测试库同样数据才 6GB。查询全表扫描慢了三倍,索引也胖了一圈。这时候你 SELECT count(*) 看行数对,但 pg_total_relation_size 大得离谱。这病叫表膨胀(bloat),是 PostgreSQL MVCC 机制绕不开的代价,不是你建表建错了。
死元组是怎么攒出来的
PG 没有原地更新。你 UPDATE 一行,它实际是在表里插一个新版本,把旧版本标记成"对以后的事务不可见"。旧版本就是死元组(dead tuple)。DELETE 也是先打删除标记,不马上回收空间。
这些死元组得有人清理,否则表文件只增不减。清理工叫 VACUUM。它的活有两件:
- 把死元组占的空间标记为空闲,让后续本表的新插入能复用(注意:默认 VACUUM 不把空间还给操作系统,只在本表内回收);
- 更新 visibility map,让索引扫描、仅索引扫描(index-only scan)能跳过死元组,顺带更新统计信息供 planner 用。
如果 VACUUM 一直没跟上,死元组越堆越多,扫描时要跳过它们,IO 和耗时都涨——这就是膨胀带来的性能劣化。
autovacuum 不是万能的
PG 自带 autovacuum 后台进程,按阈值自动触发。但它默认很"佛系":
SHOW autovacuum_vacuum_threshold; -- 默认 50
SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2
触发条件是:死元组数 > threshold + scale_factor × 表行数。一张 1000 万行的表,要攒够 50 + 0.2×1000万 = 2,000,050 个死元组才触发一次。如果业务是高频 UPDATE(比如每秒几百次状态变更),死元组产生速度远超 autovacuum 清理速度,阈值迟迟达不到,或者达到了但清理速率跟不上写入,膨胀就发生了。
我见过一个"心跳表",每行每秒被 update 一次,50 万行。autovacuum 永远在追,但追不上,半年表涨到原始大小的 40 倍。这种表必须单独调参,后面说。
先量化:你的表到底胀了多少
别凭感觉。用一段 SQL 估算膨胀率:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname, relname,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) AS total_size,
round(100 * (1 - pg_relation_size(schemaname||'.'||relname)::numeric
/ NULLIF(pg_total_relation_size(schemaname||'.'||relname),0)), 1) AS bloat_pct
FROM pg_stat_user_tables
WHERE pg_total_relation_size(schemaname||'.'||relname) > 1e8
ORDER BY pg_total_relation_size(schemaname||'.'||relname) DESC
LIMIT 20;
更精确的死元组占比看这里:
SELECT relname,
n_live_tup, n_dead_tup,
round(100 * n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum, last_vacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
dead_pct 长期高于 20%~30%,或者 n_dead_tup 一直在涨、last_autovacuum 很久没更新,就该动手了。
动手清理:VACUUM 的几个姿势
最轻量,只回收本表空间、不锁表、不阻断读写:
psql -d mydb -c "VACUUM orders;"
但它不收缩文件。想真的把空间还给 OS,要 VACUUM FULL——可这会拿 ACCESS EXCLUSIVE 锁,整张表读写全停,大表可能锁几分钟到几十分钟,线上慎之又慎。
折中方案是用 pg_repack 工具在线重建。它建一张新表、并行拷贝、最后瞬间用短锁切换,业务几乎无感:
pg_repack -d mydb -t public.orders --no-kill-backend
--no-kill-backend 表示如果拿不到短锁就不强行踢连接,避免误杀长事务。大表重建前先算好它能腾出多少空间,别为了 5% 的膨胀冒险。
治本:把 autovacuum 调得跟得上写入
临时 vacuum 治标。真要让膨胀不再复发,得让 autovacuum 在你的写入节奏下跑得动。两个层面。
表级单独调(推荐,影响面最小)。对高频更新表设更激进的阈值:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 500,
autovacuum_analyze_scale_factor = 0.05
);
把 scale_factor 从 0.2 降到 0.02,意味着 1000 万行的表只要攒 20 万+ 死元组就触发,而不是 200 万。清理频率上来了,单次工作量小了,膨胀就压住了。
全局参数(库负载整体偏高时)。在 postgresql.conf:
autovacuum_vacuum_cost_limit = 2000 # 默认 200,调大让 autovacuum 干活更猛
autovacuum_vacuum_cost_delay = 10ms # 默认 2ms,适当留延迟避免抢 IO
autovacuum_max_workers = 4 # 默认 3,并发清理更多表
autovacuum_naptime = 15s # 默认 1min,巡检更勤
cost_limit 是 autovacuum 的"体力上限",越大清理越快但越占 IO。SSD 上加到 2000~4000 通常没问题;机械盘保守点。cost_delay 是每批活之后的休息,调大就温柔、调小就激进。
改完 postgresql.conf 要 SELECT pg_reload_conf(); 或 systemctl reload postgresql 让参数生效(标了 context=postmaster 的那些需重启才生效)。
几个容易忽略的点
长事务是 autovacuum 的天敌。autovacuum 不能回收"比最老活跃事务还老"的死元组,否则那个老事务就看不到它该看的数据了。所以一个跑了几小时的 BEGIN 没提交,会拖住整库清理。查一下:
SELECT pid, age(backend_xid) AS xact_age, state, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY xact_age DESC
LIMIT 10;
看到 xact_age 特别大的,先搞清楚是谁,能结束就 SELECT pg_terminate_backend(pid);。
复制槽(replication slot)也会卡膨胀。物理/逻辑槽如果下游消费慢或断了,PG 不敢清理槽位点之后的死元组,怕下游要。定期检查:
SELECT slot_name, active, pg_current_wal_lsn() - confirmed_flush_lsn AS lag
FROM pg_replication_slots;
lag 一直涨的槽,要么修下游,要么在确认安全后 pg_drop_replication_slot 删掉。
还有 FILLFACTOR。对纯 UPDATE 的表,设 FILLFACTOR=70 给每行留 30% 余地,HOT(Heap Only Tuple)更新能在同页内完成,少产生死元组,还能避免跨页。建表或 ALTER 后对这个表才有用:
ALTER TABLE orders SET (fillfactor = 70);
我踩过的坑
一次大表 VACUUM FULL 选在了业务低峰,结果低峰比预期短,锁没释放完流量就上来了,一堆查询堆在 Lock 等待,告警连环。后来这类操作一律丢给 pg_repack 走在线重建,并且在维护窗口用 pg_isready 确认连接数真低了再动。
另一回是死活清不掉膨胀,查 pg_stat_activity 才发现有个 BI 工具开着长事务读从库(级联场景),上游主库被它间接拖住。把那个 BI 连接改成短事务、用完即关,膨胀第二天就稳住。
表膨胀不是疑难杂症,它的因果链很短:死元组堆积 → 扫描变慢、体积变大 → autovacuum 没跟上。工具就那几个:先用量化 SQL 看清楚胀在哪、胀多少,再按表调 autovacuum,必要时 pg_repack 重建,顺手清掉长事务和废复制槽。把它当日常巡检的一项,库就能一直保持该有的体型。

