表膨胀与 VACUUM
死元组不会自己消失。这一节把「膨胀 → 回收 → 复用」这条链完整跑一遍,然后解释生产环境里最常见的那个失败模式。
膨胀是怎么发生的
Section titled “膨胀是怎么发生的”CREATE EXTENSION IF NOT EXISTS pg_freespacemap;
DROP TABLE IF EXISTS bloat;
CREATE TABLE bloat (id int PRIMARY KEY, v int, pad text);
INSERT INTO bloat SELECT i, 0, repeat('y', 500) FROM generate_series(1, 2000) i;
SELECT pg_size_pretty(pg_relation_size('bloat')) AS 初始大小;约 1 MB。现在做五轮全表更新——注意没有插入任何新行,只是把每行的 v 加了五次:
UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1;
SELECT pg_size_pretty(pg_relation_size('bloat')) AS 五轮更新后,
count(*) AS 逻辑行数 FROM bloat;行数还是 2000,表大了六倍。 每一轮更新都留下 2000 个死元组,页面填满后只能向文件末尾追加新页。
VACUUM 回收了什么
Section titled “VACUUM 回收了什么”VACUUM bloat;
SELECT pg_size_pretty(pg_relation_size('bloat')) AS VACUUM之后;一个字节都没少。 这是让很多人困惑的地方——VACUUM 明明清理了死元组,为什么文件没缩小?
因为 VACUUM 回收的是页面内部的空间,它把这些空间登记到空闲空间图(FSM)里备用,而不是把文件截断。看一眼登记结果:
SELECT count(*) AS 有空闲空间的页数,
pg_size_pretty(sum(avail)::bigint) AS 可复用空间总量
FROM pg_freespace('bloat');约 5 MB 已经标记为可用。验证一下它确实会被复用——再做五轮同样的更新:
UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1; UPDATE bloat SET v = v + 1;
SELECT pg_size_pretty(pg_relation_size('bloat')) AS 再五轮之后;大小完全没变。 新版本填进了 VACUUM 腾出来的空隙里。
要真正把空间还给操作系统,只能重写整张表:
VACUUM FULL bloat;
SELECT pg_size_pretty(pg_relation_size('bloat')) AS VACUUM_FULL之后;回到了 1 MB 出头。但代价极高:
- 全程持有
ACCESS EXCLUSIVE锁——连SELECT都被阻塞,表越大停机越久; - 需要额外的磁盘空间放新表,峰值占用是原表的两倍;
- 所有索引一并重建。
生产环境几乎不应该用 VACUUM FULL。pg_repack 扩展能做同样的事而只在最后短暂加锁。
VACUUM 实际做的三件事
Section titled “VACUUM 实际做的三件事”- 回收死元组,把空间登记进 FSM;
- 更新可见性映射,把「整页都可见」的页标记出来——这是 Index-Only Scan 的前提(阶段四);
- 推进
relfrozenxid,冻结老元组以防事务号回卷(下一节)。
第 2、3 件事常被忽略,但它们说明了一件事:VACUUM 不只是「清垃圾」,它是 Postgres 正常运转的必需品。 一张表长期不被 VACUUM,即使不在乎空间,查询性能和事务号安全也会出问题。
autovacuum 的触发条件
Section titled “autovacuum 的触发条件”autovacuum 不是定时跑的,而是按「死元组比例」触发:
SELECT name, setting
FROM pg_settings
WHERE name IN ('autovacuum_vacuum_threshold',
'autovacuum_vacuum_scale_factor',
'autovacuum_vacuum_insert_threshold',
'autovacuum_naptime')
ORDER BY name;触发公式是:
死元组数 > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × 表行数 = 50 + 0.2 × 表行数最常见的失败模式:长事务
Section titled “最常见的失败模式:长事务”这是生产环境里 VACUUM 失效的头号原因,而且它的表现非常反直觉。
原因在于 VACUUM 的判断标准:一个死元组只有在「对所有仍然活跃的事务都不可见」时才能被清理。
会话 A 的快照是在它 BEGIN 的那一刻确定的。只要 A 还活着,Postgres 就必须假设 A 可能去查任何一张表——所以所有在 A 之后产生的死元组都得留着,哪怕 A 从头到尾只碰了一张无关的小表。
怎么判断一张表膨胀了
Section titled “怎么判断一张表膨胀了”生产环境用 pgstattuple 扩展能得到精确值。没有它时,常用的估算是比较「实际大小」和「按行数与平均行宽估算的大小」:
SELECT c.relname,
c.reltuples::bigint AS 行数估计,
pg_size_pretty(pg_relation_size(c.oid)) AS 实际大小,
pg_size_pretty((c.reltuples *
(SELECT sum(avg_width) FROM pg_stats WHERE tablename = c.relname))::bigint
) AS 数据估算大小
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r' AND c.reltuples > 0
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 8;这个估算忽略了页头、行指针和对齐填充,所以「实际比估算大 30~50%」是正常的。持续拉大才是信号。
先自己回答,再点开对照。
为什么一张行数不变的表会在几轮更新后涨到六倍?
因为 UPDATE 不是原地改值,而是「标记旧版本失效 + 插入一个新版本」。2000 行做五轮更新,就留下了 10000 个死元组;页面填满之后新版本只能向文件末尾追加新页。逻辑行数始终是 2000,文件从约 1 MB 涨到约 6 MB。
常见错误:以为更新的成本跟改动的字段大小有关——「只是把一个 int 加 1,应该很便宜」。Postgres 的 UPDATE 复制的是整行:这个实验里每行有 500 字节的 pad,它跟着被完整复制了五遍,尽管从头到尾没人碰过它。这就是为什么「宽表上的小字段高频更新」是最贵的一类写入——账单按行宽计,不按改动量计。把这类高频变动的列拆到一张窄表里,是最直接的解法。
(回到上面「膨胀是怎么发生的」一节)
VACUUM 之后表为什么不变小?它到底做了什么?
VACUUM 回收的是页面内部的空间,把它登记进空闲空间图(FSM)备用,而不截断文件。
实测这条链:
- 五轮更新后表约 6 MB,
VACUUM之后仍然是 6 MB,一个字节都没少; - 但
pg_freespace显示已经有约 5 MB 被标记为可复用; - 再做同样的五轮更新,表的大小完全没变——新版本填进了
VACUUM腾出来的空隙里。
所以它做的三件事是:回收死元组并登记进 FSM、更新可见性映射、推进 relfrozenxid。
常见错误:看到文件没缩小,判断成「VACUUM 没生效」,于是上 VACUUM FULL。判断 VACUUM 是否在工作的正确指标是空间有没有被复用——第二轮五次更新表还涨不涨——而不是文件大小。为一个本来就正常工作的 VACUUM 去付 VACUUM FULL 的代价(全程 ACCESS EXCLUSIVE 锁、连 SELECT 都被阻塞、峰值占用两倍磁盘、所有索引一并重建),是生产事故的常见起点。
「表停止增长」和「表变小」哪个才是 VACUUM 的目标?为什么?
停止增长。
一张表在正常的 autovacuum 下会稳定在某个「工作集大小」——比逻辑数据大一些,因为要给更新留出缓冲。这个稳态是健康的,不需要处理。真正的问题是膨胀持续增长,那说明 VACUUM 没跟上或者被阻塞了。
常见错误:把「表比逻辑数据大 3050%」当成膨胀报警线,定期跑 50% 本来就是正常的。按绝对比例设阈值,等于反复付 VACUUM FULL 去「压回去」。正文那条估算查询忽略了页头、行指针和对齐填充,实际比估算大 30ACCESS EXCLUSIVE 的代价去消灭一个本来就该存在的缓冲区——而下一轮更新会立刻把它涨回来,于是变成一个周期性的自制停机。要看的是趋势:同一张表的膨胀率是不是在一天天拉大。
autovacuum 的默认 scale_factor 为什么对大表是灾难?
因为触发条件是比例式的:
死元组数 > 50 + 0.2 × 表行数表越大,触发前允许积累的绝对膨胀量就越大——方向恰好和需求相反。一张 1 亿行的表要攒够 2000 万个死元组才会触发 autovacuum。
标准做法是给大表单独调小:
ALTER TABLE huge_table SET ( autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000);常见错误:以为默认值只是「保守」,最坏情况不过是清理得晚一点、表大一点。它真正的危害是把清理攒成一次尖峰:平时几周完全不跑,一跑就是一轮几小时、I/O 被打满的 VACUUM,而触发时机完全由写入量决定——也就是说它最可能在业务最忙的时候启动,正好把一次正常的流量高峰变成一次事故。小步快跑永远好过攒一大批。
一个只查询小表的只读事务,为什么会导致另一张大表持续膨胀?
因为 VACUUM 的判断标准是:一个死元组只有在「对所有仍然活跃的事务都不可见」时才能被清理。
会话 A 的快照在它 BEGIN 的那一刻就确定了。只要 A 还活着,Postgres 就必须假设 A 可能去查任何一张表——所以 A 开始之后产生的所有死元组都得留着,哪怕 A 从头到尾只碰了一张无关的小表。这个判断是全局的、按快照的,不是按表的。
常见错误:以为 Postgres 会「只保留 A 真正访问过的那张表的旧版本」。它做不到,也不打算做——快照建立的那一刻,没有任何机制能知道 A 接下来会查什么。正因如此这个故障的现场极其反直觉:膨胀的那张表和罪魁祸首的会话之间没有任何查询关系,从慢查询日志查不出来,从锁等待也查不出来(只读事务不持有任何冲突的锁)。唯一的入口是 pg_stat_activity 里按 xact_start 排序找最老的事务,重点看 state = 'idle in transaction' 的那些。
除了长事务,还有哪两样东西会以同样的方式阻止 VACUUM?
- 未消费的逻辑复制槽;
- 滞后的备库(当
hot_standby_feedback = on时)。
三者的共同点是同一件事:有人还需要看到旧版本。
常见错误一:以为把消费端的应用停掉就解除了钉住。恰恰相反——「未消费」说的正是这个状态:槽还在,它记录的 xmin 就还在往前钉,而且消费端下线之后这个位置再也不会推进,只会越钉越久。要解除,得显式把槽删掉。这是一类只在「某个下游服务下线了但没人清理」时才暴露的故障,测试环境几乎不可能复现。
常见错误二:以为备库上的查询只影响备库。开了 hot_standby_feedback 之后,备库上一条跑了两小时的报表查询,钉住的是主库的清理——主库上找不到任何长事务,pg_stat_activity 干干净净,膨胀却停不下来。