数据库运维9 min read次阅读

PostgreSQL 表膨胀治理:MVCC 死元组和 autovacuum 调优实战

一张订单表,业务数据 800 万行,表文件却占 40GB,而同样结构的测试库同样数据才 6GB。查询全表扫描慢了三倍,索引也胖了一圈。这时候你 SELECT count(*) 看行数对,但 pg_total_relation_size 大得离谱。这病叫表膨胀(bloat),是 PostgreSQL MVCC 机制绕不开的代价,不是你建表建错了。

死元组是怎么攒出来的

PG 没有原地更新。你 UPDATE 一行,它实际是在表里插一个新版本,把旧版本标记成"对以后的事务不可见"。旧版本就是死元组(dead tuple)。DELETE 也是先打删除标记,不马上回收空间。

这些死元组得有人清理,否则表文件只增不减。清理工叫 VACUUM。它的活有两件:

  1. 把死元组占的空间标记为空闲,让后续本表的新插入能复用(注意:默认 VACUUM 不把空间还给操作系统,只在本表内回收);
  2. 更新 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.confSELECT 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 重建,顺手清掉长事务和废复制槽。把它当日常巡检的一项,库就能一直保持该有的体型。

分享:

相关文章

PostgreSQL 物理备份与时间恢复(PITR):一次 basebackup 到任意时间点的完整链路
数据库运维12 min read

PostgreSQL 物理备份与时间恢复(PITR):一次 basebackup 到任意时间点的完整链路

pg_dump 是逻辑备份,做不了时间点恢复,库一大还慢得离谱。真要扛误删表、误更新全表这种事故,得靠物理备份加 WAL 归档的 PITR。从建备份账号、开归档、跑 pg_basebackup,到真的把库恢复到「出事前一秒」,把每一步命令和每个坑都摊开讲。

PostgreSQL 分区表实战:从分区裁剪到在线运维
数据库运维8 min read

PostgreSQL 分区表实战:从分区裁剪到在线运维

什么时候该分区 一张表涨到几千万、上亿行,即使有索引也开始"变肉": - **索引膨胀**:B+ 树层级加深,单点查询也要多次 IO。 - **VACUUM/ANALYZE 慢**:整张大表清理、统计一遍成本极高。 - **热点数据被冷数据稀释**:80% 查询只看最近一个月,却要扫全表

评论区