KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · VACUUM 与表膨胀 — keel 龙骨
第 01 章结尾留了一个因果链:老快照会让死版本清不掉。这一章把这条链走完——死元组从哪来、autovacuum 什么时候才动手、膨胀怎么量、VACUUM 和 VACUUM FULL 到底把空间还给了谁。
第 01 章结尾留了一个因果链:老快照会让死版本清不掉。这一章把这条链走完——死元组从哪来、autovacuum 什么时候才动手、膨胀怎么量、VACUUM 和 VACUUM FULL 到底把空间还给了谁。
先说结论,免得被「VACUUM 能瘦身」这句口口相传的话带偏:普通的 VACUUM 不缩小文件。它只把死元组占的位置标成可复用,文件大小一点不动。真正让文件变小的只有 VACUUM FULL,而它要付出的代价比大多数人预期的大得多。
现场:一行数据,35 个页
我做了两组对照,都是对同一行反复更新 10000 次。
A 组只改不带索引的列,走 HOT 更新:
=== A 组:只改非索引列(id 主键索引不变,允许 HOT)===
阶段 heap idx total pages live dead
insert 8192 16384 32768 0 0 0
10000 updates 8192 16384 32768 0 0 0
after VACUUM 8192 16384 65536 1 1 0
after VACUUM FULL 8192 16384 32768 1 1 0
一万次更新之后,堆文件还是 8192 字节——一个页。原因是 HOT 更新可以在页内复用空间,加上 HOT 剪枝会在更新过程中顺手把已经没人需要的旧版本清掉。行小、页没满,于是一直没溢出。
B 组每次都改一个带索引的列 k:
=== B 组:每次都改带索引的列 k(无法 HOT)===
insert heap= 8192 idx= 32768 total= 49152 pages= 0 dead= 0
1000 updates heap= 32768 idx= 57344 total= 122880 pages= 0 dead= 0
3000 updates heap= 90112 idx= 106496 total= 229376 pages= 0 dead= 2317
6000 updates heap= 172032 idx= 172032 total= 376832 pages= 0 dead= 5110
10000 updates heap= 286720 idx= 262144 total= 581632 pages= 0 dead= 8000
after VACUUM heap= 286720 idx= 262144 total= 589824 pages= 35 dead= 0
after VACUUM FULL heap= 8192 idx= 32768 total= 49152 pages= 1 dead= 0
同样是一行数据、同样一万次更新,堆从 8 KB 涨到 280 KB(35 页),索引从 32 KB 涨到 256 KB。差别只有一个:有没有动索引列。这就把第 00 章那句「索引越多,更新越难 HOT」从定性说法变成了可测的数字。
三条真实数字,分别回答「还给了谁」
| 阶段 | heap | index | total | dead | 空间还给谁 |
|---|---|---|---|---|---|
| 10000 次更新后 | 286720 | 262144 | 581632 | 8000 | — |
VACUUM 之后 |
286720 | 262144 | 589824 | 0 | 还给这张表自己:位置空出来可被本表后续 INSERT/UPDATE 复用,文件大小不变 |
VACUUM FULL 之后 |
8192 | 32768 | 49152 | 0 | 还给操作系统:重写整表到新文件,旧文件删除,磁盘占用真正下降 |
看第三列会发现一个反直觉的地方:VACUUM 之后 total 反而变大了(581632 → 589824)。这是因为 VACUUM 会更新可见性映射和统计信息,pg_class 里的元数据页也跟着变,pg_total_relation_size 把这些都算进去了。判断膨胀要看堆和索引各自的 size,不要只盯 total。
VACUUM FULL 的代价必须写清楚,否则很容易被当成随手可用的瘦身按钮:
- 它持有
ACCESS EXCLUSIVE锁,期间这张表读写全停; - 它把整表重写到新文件,峰值要额外一份表大小的磁盘空间;
- 大表上它可能要跑很久,而它期间占着锁,等于把「膨胀」换成了「不可用」。
所以真实项目里做表瘦身,更常见的组合是 pg_repack 这类工具(在线重建,短暂持锁),或者干脆把「清理」和「归档删除」分开:先按月分区、再 DETACH 掉老分区,把大表瘦身这件事从「重写」变成「摘掉一个子表」。
autovacuum 什么时候才会来
autovacuum 不是按时间触发的,是按估算的死元组数量触发的:
触发条件:n_dead_tup > autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor × reltuples
本机的默认值(来自 pg_settings):
autovacuum_analyze_scale_factor | 0.1
autovacuum_max_workers | 3
autovacuum_naptime | 60 s
autovacuum_vacuum_cost_delay | 2 ms
autovacuum_vacuum_scale_factor | 0.2
autovacuum_vacuum_threshold | 50
代入公式:一张 100 万行的表,要攒到 50 + 0.2 × 1000000 = 200050 个死元组,autovacuum 才会开始处理。表越大,允许的死元组比例越高——这就是大表特别容易膨胀、而小表反而「看起来一直很干净」的原因。autovacuum_vacuum_scale_factor 是按表可调的,热点大表通常会单独调低(例如 0.02),或者直接给这张表设固定的死元组阈值。
autovacuum_naptime=60s 是「多久起来看一轮」,不是「多久清一次」。真正的清理频率由上面的公式决定。
失败路径:清理被老快照挡住
把 autovacuum 换成手工 VACUUM 也一样会被挡住。下面这个实验里,会话 A 开着一个 RR 事务读完一次就不动了,会话 B 更新全部 5 万行然后立刻 VACUUM:
初始: (0, 0)
会话 A 开了一个 RR 事务并读了一次(快照被缓存): (50000,)
会话 A 未提交时,B 更新了全部 5 万行 -> (50000, 50000)
B 执行 VACUUM(A 还开着)-> (50000, 50000)
A 提交
B 再执行一次 VACUUM -> (50000, 0)
表大小: 3629056
第一行是 (n_live_tup, n_dead_tup)。A 还开着的时候,B 的 VACUUM 一个死元组都没清掉——因为那 5 万个旧版本对 A 的快照仍然可见,它们不是「死元组」,只是「对别人死了」。A 一提交,第二次 VACUUM 立刻把 n_dead_tup 从 50000 清到 0。最后一行是重点:表大小 3629056 字节,一个字节都没变。
这条链路解释了生产上最常见的一种膨胀:一个忘记提交的事务,或者一个跑了很久的报表查询,或者一个长时间不推进的备库/复制槽,都会让整库的清理水位停在某个很旧的位置。单个会话卡住,整张表跟着涨。
flowchart TD
UPD["UPDATE / DELETE 产生新版本<br/>旧版本被标死"] --> DEAD["旧版本进入死元组集合"]
DEAD --> TRIG{"n_dead_tup 超过<br/>threshold + scale_factor × reltuples?"}
TRIG -->|"没到"| WAIT["继续攒。表越大阈值越高,<br/>允许积压的死元组越多"]
TRIG -->|"到了"| AV["autovacuum worker 启动"]
WAIT -.->|"长期不清理"| BLOAT["表文件只涨不缩"]
AV --> CHECK{"有没有比死元组更老的活快照?"}
CHECK -->|"有:长事务 / 旧 backend_xmin / 复制槽"| SKIP["跳过这些死元组,本次清理无效"]
CHECK -->|"没有"| RECLAIM["标记为可复用空间<br/>更新可见性映射"]
SKIP -.->|"每次都被跳过"| BLOAT
RECLAIM --> FREE["空间还给这张表自己<br/>文件大小不变"]
FREE -.->|"必须缩小文件时"| VF["VACUUM FULL:ACCESS EXCLUSIVE 锁<br/>重写整表,峰值需额外一份磁盘空间"]
RECLAIM -.->|"推进 relfrozenxid"| FREEZE["冻结老事务号,防回卷"]
style UPD fill:#e3f2fd,color:#0d3b66
style DEAD fill:#e3f2fd,color:#0d3b66
style TRIG fill:#fff3e0,color:#8a4b00
style WAIT fill:#fff3e0,color:#8a4b00
style AV fill:#e8f5e9,color:#1b5e20
style CHECK fill:#fff3e0,color:#8a4b00
style SKIP fill:#ffebee,color:#b71c1c
style RECLAIM fill:#e8f5e9,color:#1b5e20
style FREE fill:#e8f5e9,color:#1b5e20
style BLOAT fill:#ffebee,color:#b71c1c
style VF fill:#ffe0b2,color:#8a4b00
style FREEZE fill:#f1f8e9,color:#33691e
膨胀怎么量
n_dead_tup 只是估算。要拿到物理层面的真实数字,用 pgstattuple:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('blk');
-- dead_tuple_percent 就是这张表的「膨胀率」
它的原理是真的扫描整个文件统计死元组,所以在大表上开销不小,别在生产高峰对几百 GB 的表随便跑。要估算可以用 pgstattuple_approx,它只读可见性映射,快得多但只能给近似值。
事务号回卷:VACUUM 的另一半职责
清理死元组只是 VACUUM 的一半工作,另一半是推进 relfrozenxid。每张表都记着一个「从哪个事务号开始,这行数据一定还没被冻结」的起点,用 age(relfrozenxid) 看:
datname | xid_age | pct_to_wrap
----------+---------+-------------
postgres | 20905 | 0.00
labpg | 20905 | 0.00
事务号是 32 位无符号,可用范围是 2^31(约 21.4 亿)。一旦某个事务号落后当前超过 21.4 亿,它就会「看起来比当前新」——这就是回卷。PostgreSQL 的防线是三个阈值:
| 参数 | 本机取值 | 作用 |
|---|---|---|
vacuum_freeze_min_age |
50000000 | 事务号老到这个岁数,普通 VACUUM 就顺手冻结它 |
vacuum_freeze_table_age |
150000000 | 表老到这个岁数,VACUUM 改成全表冻结扫描 |
autovacuum_freeze_max_age |
200000000 | 到这岁数,即使你把 autovacuum 关了也会强制唤起一个 anti-wraparound worker |
冻结的效果很直接,VACUUM FREEZE 前后对比:
relname | xid_age | relfrozenxid -- freeze 前
wrap_t | 2 | 21627
relname | xid_age_after_freeze | relfrozenxid
wrap_t | 0 | 21629
回卷事故的成因几乎从来不是「事务号真的走到了 2^31」。真实的链条通常是:某个长事务或某个没人消费的复制槽,把最老的 xmin 钉在过去某个位置,导致 VACUUM 连冻结都推进不了,于是 age(datfrozenxid) 一路涨,autovacuum 反复失败重试,最后逼近 2^31,实例进入拒绝新事务的保护状态。所以监控回卷要同时看两个量:max(age(datfrozenxid)),以及所有会话里最老的 backend_xmin。后者才是根因指标。
生产边界
- 教学里 10000 次更新几秒钟跑完。生产里膨胀是几个月积累出来的,趋势比瞬时值重要。
VACUUM FULL在测试环境随便用,在生产大表上等于一次计划内停机。用之前先算:表多大、需要多少额外磁盘、锁要持有多久。autovacuum的 worker 上限是autovacuum_max_workers(本机 3)。实例里同时有很多大表需要清理时,worker 会排队,表现为「明明配了 autovacuum 却总也清不完」。这种情况要调阈值,而不是简单调大 worker 数(worker 多了会抢 IO)。- 上线要盯的:
pg_stat_user_tables.n_dead_tup的增速、last_autovacuum距今多久、max(age(datfrozenxid))、以及最老backend_xmin的年龄。前两个管膨胀,后两个管回卷。 - 分区是替代
VACUUM FULL的最实用手段:把「重写整表」换成「DETACH 老分区」,一条 DDL 就完成瘦身,锁的粒度也小得多。第 06 章会讲分区的适用条件。
动手
- 对一张 10 万行的表做一次全表 UPDATE,用
pgstattuple记录dead_tuple_percent,再VACUUM一次,看这个百分比降到多少、表大小有没有变。 - 开一个事务读一次就不提交,制造
idle in transaction,同时让另一个会话连续更新同一张表,观察n_dead_tup是否持续上涨到被 VACUUM 拒绝清理。 - 把某张表的
autovacuum_vacuum_scale_factor调到 0.02,用它自己的阈值算一遍「多少死元组会触发清理」,和默认值对比。
可观察结果:你能给出「这张表现在有多少比例是垃圾」「为什么它没被清理」「要清掉它该动哪个参数」三个答案。
自测
- 一万次更新之后,A 组表大小不变而 B 组涨到 35 页,决定这个差别的是哪一件事?
VACUUM之后pg_total_relation_size反而变大,为什么?判断膨胀该看哪个数字?VACUUM FULL和VACUUM的差别,用「空间还给谁」和「持有什么锁」各说一句。- autovacuum 的触发公式是什么?为什么大表的膨胀往往比小表严重?
- 事务号回卷的防线有三个参数,各自在什么条件下起作用?为什么说回卷事故的根因是
backend_xmin而不是事务号本身?
↓ 下一步:03 章 · WAL、检查点与崩溃恢复 —— 数据页改完还不算数,得先有日志保证它不丢。