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 的代价必须写清楚,否则很容易被当成随手可用的瘦身按钮:

所以真实项目里做表瘦身,更常见的组合是 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。后者才是根因指标。

生产边界

动手

  1. 对一张 10 万行的表做一次全表 UPDATE,用 pgstattuple 记录 dead_tuple_percent,再 VACUUM 一次,看这个百分比降到多少、表大小有没有变。
  2. 开一个事务读一次就不提交,制造 idle in transaction,同时让另一个会话连续更新同一张表,观察 n_dead_tup 是否持续上涨到被 VACUUM 拒绝清理。
  3. 把某张表的 autovacuum_vacuum_scale_factor 调到 0.02,用它自己的阈值算一遍「多少死元组会触发清理」,和默认值对比。

可观察结果:你能给出「这张表现在有多少比例是垃圾」「为什么它没被清理」「要清掉它该动哪个参数」三个答案。

自测

  1. 一万次更新之后,A 组表大小不变而 B 组涨到 35 页,决定这个差别的是哪一件事?
  2. VACUUM 之后 pg_total_relation_size 反而变大,为什么?判断膨胀该看哪个数字?
  3. VACUUM FULL 和 VACUUM 的差别,用「空间还给谁」和「持有什么锁」各说一句。
  4. autovacuum 的触发公式是什么?为什么大表的膨胀往往比小表严重?
  5. 事务号回卷的防线有三个参数,各自在什么条件下起作用?为什么说回卷事故的根因是 backend_xmin 而不是事务号本身?

↓ 下一步:03 章 · WAL、检查点与崩溃恢复 —— 数据页改完还不算数,得先有日志保证它不丢。

进入 keel 阅读