VACUUM 与空间回收(VACUUM and Space Reclamation)
前面几节讲了死元组是怎么产生的、可见性是怎么判断的。但死元组不会自己消失——它们会一直占着磁盘空间,直到 VACUUM 把它们清理掉。VACUUM 是 MVCC 的「清道夫」,没有它,任何 PostgreSQL 数据库都会在几周内膨胀到不可用。本节把 VACUUM 的四个目标、两种模式(普通 / FULL)、与 autovacuum 的分工、以及它如何联动 FSM 与 VM 讲透。
前置知识
阅读本节前,建议先了解:可见性判断规则
基础概念
VACUUM 是 PostgreSQL 负责回收死元组、推进冻结水位线、维护辅助映射文件(FSM/VM)的维护操作。它既可以被手动触发,也可以由 autovacuum 守护进程自动触发。
理解 VACUUM 需要先统一以下术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| 死元组 | Dead Tuple | 对所有事务都不再可见的元组版本,等待回收 |
| 普通 VACUUM | Plain VACUUM | 标记空间可复用,不归还操作系统,不持排他锁 |
VACUUM FULL | Full VACUUM | 整表重写,归还空间给操作系统,持 ACCESS EXCLUSIVE 锁 |
| 空间回收 | Space Reclamation | 让死元组占的空间可被后续插入复用 |
| 冻结 | Freeze | 把元组的 xmin 设为特殊值 2,使其永不参与回卷计算 |
| 冻结年龄 | age(relfrozenxid) | 表最老未冻结 XID 的距离,接近 20 亿会触发保护 |
| FSM | Free Space Map | 记录每页可用空间的辅助文件,VACUUM 负责更新 |
| VM | Visibility Map | 记录页是否全可见/全冻结的辅助文件,VACUUM 负责设置 |
| 自动清理 | Autovacuum | 后台守护进程,按阈值自动触发 VACUUM/ANALYZE |
| 清理阈值 | autovacuum_vacuum_threshold | 死元组绝对数下限 |
| 缩放因子 | autovacuum_vacuum_scale_factor | 死元组占比阈值,与阈值共同决定何时触发 |
| 截断 | Truncation | 表末尾的空页被 VACUUM 从文件尾移除 |
| 清理延迟 | vacuum_cost_delay | VACUUM 控制 I/O 冲击的限速机制 |
VACUUM 的四个目标
很多人把 VACUUM 理解成「清理垃圾」,其实它同时做四件事,任何一件不做都会出问题:
① 回收空间:把死元组占的空间标记为可复用(更新 FSM)
② 设置 VM:把"全可见页"和"全冻结页"标记出来,加速 Index-Only Scan
③ 推进冻结:把老元组的 xmin 冻结为 2,推进 relfrozenxid,避免 XID 回卷
④ 更新统计:可选地触发 ANALYZE,更新 pg_statistic-- 一条 VACUUM 命令同时完成前三件事
VACUUM (VERBOSE, ANALYZE) orders;INFO: vacuuming "public.orders"
INFO: launched 1 parallel vacuum worker
INFO: scanned index "orders_pkey" to remove 1204 row versions
INFO: "orders": removed 1204 dead row versions in 87 pages
INFO: "orders": found 1204 removable, 30912 nonremovable row versions in 420 pages
DETAIL: 0 dead row versions cannot be removed yet.
INFO: "orders": truncated 12 pages from 420 to 408
INFO: analyzing "public.orders"普通 VACUUM 与 VACUUM FULL 的对比
这是最容易混淆的一组概念,必须分清:
| 对比项 | 普通 VACUUM | VACUUM FULL |
|---|---|---|
| 空间处理 | 标记可复用,文件不缩小 | 整表重写,文件缩小 |
| 空间归还 OS | 不归还(除非文件尾截断) | 归还 |
| 锁级别 | SHARE UPDATE EXCLUSIVE(可与读写并发) | ACCESS EXCLUSIVE(阻塞一切) |
| 额外磁盘需求 | 几乎不需要 | 需要约一张表大小的额外空间 |
| 是否更新 FSM | 是 | 是(新表重排后) |
| 是否设置 VM | 是 | 是 |
是否推进 relfrozenxid | 是 | 是 |
| 执行速度 | 快(只扫不重排) | 慢(读写整表) |
| 适用场景 | 日常维护、绝大多数情况 | 表严重膨胀且可接受停机窗口 |
| 是否能中断回滚 | 可安全中断 | 中断需回滚,代价大 |
-- 普通 VACUUM:可并发执行,标记空间可复用
VACUUM orders;
-- VACUUM FULL:持排他锁,整表重写,慎用
VACUUM FULL orders;VACUUM FULL 不是「更好的 VACUUM」
VACUUM FULL 不推荐日常使用。它会长时间持 ACCESS EXCLUSIVE 锁阻塞所有读写,需要额外磁盘空间,且中断后回滚成本高。需要回收空间时,优先选 pg_repack(在线重整理)或 CREATE TABLE ... AS SELECT + 切换。
核心机制
死元组回收的判定条件
VACUUM 并不是「看到死元组就删」。它必须先确认「没有任何活跃事务可能还需要看到这个版本」,这个判断依赖一个水位线:
判定条件:t_xmax 已提交,且 t_xmax < 所有活跃快照中最老的 xmin翻译成人话:只有当删除它的那个事务已经提交,并且所有比它更老的读者都结束了,这个死元组才真正可以被回收。
-- 观察"钉住"水位线的长事务
SELECT pid, state, backend_xmin, xact_start,
now() - xact_start AS xact_age,
left(query, 60) AS query_head
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin
LIMIT 10; pid | state | backend_xmin | xact_age | query_head
-------+---------------------+--------------+------------------+------------------
41002 | idle in transaction | 412349810 | 05:12:44.102 | SELECT count(*) ...
21043 | active | 412360001 | 00:00:01.201 | UPDATE orders ...会话 41002 的 backend_xmin 是 412349810,它就是当前的回收水位线。只要它不结束,晚于它的死元组都清不掉。
-- VACUUM 会明确告知"有多少死元组删不掉"
VACUUM (VERBOSE) orders;INFO: "orders": found 2210 removable, 30912 nonremovable row versions in 420 pages
DETAIL: 1056 dead row versions cannot be removed yet.cannot be removed yet 就是水位线限制的直接体现。
VACUUM 的执行流程
一次普通 VACUUM 的执行可以拆成下面几步:
① 扫描堆页
逐个读取页里的元组,对每个元组跑一遍可见性判断(用"最老快照")
│
▼
② 标记死元组
确认可回收的在 Line Pointer 上标记 LP_DEAD / LP_UNUSED
(PG 14+ 可能直接标记整页为可整理)
│
▼
③ 重建 FSM
根据页里剩余的可用空间,更新空闲空间映射
│
▼
④ 设置 VM
若某页所有元组都对所有事务可见 → 设置 all-visible
若全部已冻结 → 同时设置 all-frozen
│
▼
⑤ 扫描索引
把指向死元组的索引条目删掉(这是 VACUUM 里最重的部分)
│
▼
⑥ 推进 relfrozenxid
若表的冻结条件满足,把 relfrozenxid 推进到安全位置
│
▼
⑦ 截断尾页
若文件末尾连续的空页超过阈值,从文件尾截断并归还空间-- 用 VERBOSE 观察每个阶段
VACUUM (VERBOSE, ANALYZE) orders;冻结:VACUUM 最重要的隐藏任务
如果 VACUUM 只回收空间,那它只是个「保洁员」。它真正的战略意义是推进冻结水位线,避免 32 位 XID 回卷:
| 阈值 | 默认值 | 触发什么 |
|---|---|---|
vacuum_freeze_min_age | 50000000 | 元组 xmin 年龄超过它,VACUUM 才愿意冻结该元组 |
vacuum_freeze_table_age | 150000000 | 表 relfrozenxid 年龄超过它,VACUUM 做全表扫描式冻结 |
autovacuum_freeze_max_age | 200000000 | 表年龄超过它,强制 autovacuum 冻结(即使 autovacuum 被关闭) |
-- 观察冻结相关阈值与当前年龄
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('autovacuum_freeze_max_age',
'vacuum_freeze_table_age',
'vacuum_freeze_min_age');
-- 数据库级年龄
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC; name | setting | unit
------------------------------------+-----------+------
autovacuum_freeze_max_age | 200000000 |
vacuum_freeze_table_age | 150000000 |
vacuum_freeze_min_age | 50000000 |
datname | xid_age
-----------+---------
demo | 41234981
postgres | 1029561冻结后,元组的 xmin 变成 2,并且带上 HEAP_XMIN_FROZEN 标志位:
-- 冻结前后对比
SELECT lp, t_xmin, t_infomask FROM heap_page_items(get_raw_page('freeze_demo', 0));
VACUUM (FREEZE) freeze_demo;
SELECT lp, t_xmin, t_infomask,
(t_infomask & 512) <> 0 AS has_xmin_frozen
FROM heap_page_items(get_raw_page('freeze_demo', 0)); 冻结前 冻结后
lp | t_xmin | t_infomask → lp | t_xmin | t_infomask | has_xmin_frozen
----+--------+------------- → ----+--------+------------+-----------------
1 | 76301 | 257 → 1 | 2 | 1282 | t截断(Truncation):唯一能缩小文件的普通 VACUUM
普通 VACUUM 通常不缩小文件,但有一个例外:文件末尾连续的空页可以直接从文件尾截断,并把空间归还操作系统。
-- 触发截断的场景:删除大量"最后插入"的数据
CREATE TABLE trunc_demo AS SELECT g AS id, 'x' AS note FROM generate_series(1, 100000) AS g;
SELECT pg_size_pretty(pg_relation_size('trunc_demo')) AS size_before;
-- 删除靠后的 90% 数据
DELETE FROM trunc_demo WHERE id > 10000;
VACUUM trunc_demo;
SELECT pg_size_pretty(pg_relation_size('trunc_demo')) AS size_after; size_before | size_after
-------------+------------
3592 kB | 376 kB ← 尾部空页被截断,空间归还了反之,如果删除的是头部数据,中间页仍被占用,就无法截断:
-- 删除靠前的数据,无法截断
DELETE FROM trunc_demo WHERE id <= 10000;
VACUUM trunc_demo;
-- 文件大小基本不变(中间还有活元组,尾部页不连续)autovacuum 的触发条件
autovacuum 何时对一张表动手,由下面的公式决定:
VACUUM 触发条件:
死元组数 > autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor × 活元组数
ANALYZE 触发条件:
变更元组数 > autovacuum_analyze_threshold
+ autovacuum_analyze_scale_factor × 活元组数-- 用这条查询估算每张表距离下一次 autovacuum 还有多远
SELECT relname,
n_live_tup,
n_dead_tup,
current_setting('autovacuum_vacuum_threshold')::int AS base_threshold,
current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale_factor,
(current_setting('autovacuum_vacuum_threshold')::int
+ current_setting('autovacuum_vacuum_scale_factor')::numeric * n_live_tup) AS trigger_at,
round(100.0 * n_dead_tup
/ NULLIF(current_setting('autovacuum_vacuum_threshold')::int
+ current_setting('autovacuum_vacuum_scale_factor')::numeric * n_live_tup, 0), 2)
AS pct_to_trigger
FROM pg_stat_user_tables
WHERE n_live_tup > 0
ORDER BY pct_to_trigger DESC NULLS LAST
LIMIT 10; relname | n_live_tup | n_dead_tup | trigger_at | pct_to_trigger
---------+------------+------------+------------+----------------
orders | 309120 | 62000 | 62074.0 | 99.88
users | 50000 | 210 | 10050.0 | 2.09orders 的 pct_to_trigger = 99.88%,说明它马上要被 autovacuum 清理了。
相关参数
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
autovacuum | on | user | 总开关,关闭后仍有「防回卷 autovacuum」兜底 |
autovacuum_max_workers | 3 | 重启 | 并发 autovacuum 工作进程数上限 |
autovacuum_naptime | 60s | user | 两次巡检的间隔 |
autovacuum_vacuum_threshold | 50 | user | 死元组绝对数下限 |
autovacuum_vacuum_scale_factor | 0.2 | user | 死元组占比阈值(大表建议下调) |
autovacuum_analyze_threshold | 50 | user | ANALYZE 的变更元组数下限 |
autovacuum_analyze_scale_factor | 0.1 | user | ANALYZE 的变更占比阈值 |
autovacuum_freeze_max_age | 200000000 | 重启 | 强制防回卷冻结阈值 |
autovacuum_multixact_freeze_max_age | 400000000 | 重启 | MultiXact 防回卷阈值 |
autovacuum_vacuum_cost_delay | 2ms | user | autovacuum 每轮延迟(PG12+ 默认 2ms) |
autovacuum_vacuum_cost_limit | -1 | user | autovacuum 的成本上限,-1 表示沿用 vacuum_cost_limit |
vacuum_cost_delay | 0 | user | 手动 VACUUM 的延迟(默认不限速) |
vacuum_cost_limit | 200 | user | 手动 VACUUM 的成本限额 |
vacuum_cost_page_hit | 1 | user | 命中共享缓冲区的页成本 |
vacuum_cost_page_miss | 10 | user | 未命中共享缓冲区的页成本 |
vacuum_cost_page_dirty | 20 | user | 弄脏页的成本 |
vacuum_truncate | on | user | 是否允许截断尾部空页(PG12+) |
max_parallel_maintenance_workers | 2 | user | 并行 VACUUM 的最大工作进程数(PG12+) |
log_autovacuum_min_duration | -1 | user | 记录长时间 autovacuum,0 表示全记录 |
maintenance_work_mem | 64MB | user | VACUUM 的内存上限,影响索引清理效率 |
# postgresql.conf
# 大表友好:让 autovacuum 更早、更勤地介入
autovacuum_max_workers = 6
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.05 # 大表别等 20% 才动手
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 1000 # 提高吞吐,加快清理
maintenance_work_mem = 512MB # 加速索引清理
# 记录超过 5 秒的 autovacuum,便于排查
log_autovacuum_min_duration = 5s-- 表级覆盖:只对这张表生效,优先级高于全局
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 0
);
-- 查看表级覆盖是否生效
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'orders' AND reloptions IS NOT NULL; relname | reloptions
---------+----------------------------------------------------------------------------------
orders | {autovacuum_vacuum_scale_factor=0.02,autovacuum_vacuum_threshold=1000,autovacuum_vacuum_cost_delay=0}详细说明
VACUUM 与 FSM、VM 的联动
VACUUM 之所以不可替代,是因为它是 FSM 与 VM 的主要维护者:
| 辅助文件 | VACUUM 的动作 | 不 VACUUM 的后果 |
|---|---|---|
| FSM | 重新计算每页可用空间 | INSERT 找不到合适页,表虚增 |
| VM (all-visible) | 设置全可见位 | Index-Only Scan 退化为必须回表 |
| VM (all-frozen) | 设置全冻结位 | 每次 VACUUM 都要重复扫描无变化的页 |
-- 观察 VM 位在 VACUUM 前后的变化
CREATE EXTENSION IF NOT EXISTS pg_visibility;
SELECT count(*) FILTER (WHERE all_visible) AS visible_pages, count(*) AS total
FROM pg_visibility_map('orders');
VACUUM orders;
SELECT count(*) FILTER (WHERE all_visible) AS visible_pages, count(*) AS total
FROM pg_visibility_map('orders'); VACUUM 前 VACUUM 后
visible_pages | total → visible_pages | total
---------------+------ ---------------+------
0 | 420 → 402 | 420-- 用 FSM 观察可用空间
SELECT blkno, pg_size_pretty(avail) AS free_space
FROM pg_freespace('orders')
WHERE avail > 0
ORDER BY avail DESC
LIMIT 5;并行 VACUUM(PG 12+)
从 PostgreSQL 12 起,索引清理阶段可以并行化,这对「表有很多索引」的场景提升明显:
-- 启用并行 VACUUM
SET max_parallel_maintenance_workers = 4;
VACUUM (VERBOSE, PARALLEL 4) orders;INFO: vacuuming "public.orders"
INFO: launched 3 parallel vacuum workers
INFO: scanned index "orders_pkey" to remove 1204 row versions
INFO: scanned index "idx_orders_user_id" to remove 1204 row versions
INFO: "orders": removed 1204 dead row versions in 87 pages限制条件:
| 条件 | 说明 |
|---|---|
| 仅并行索引清理阶段 | 堆扫描始终是单进程 |
| 事务不能有未完成的操作 | 在事务块内不能并行 |
| 索引必须允许并行 | 通常 B-tree 都支持 |
受 max_parallel_maintenance_workers 限制 | 默认 2 |
VACUUM 与 REINDEX 的取舍
VACUUM 会清理死元组的索引条目,但不整理索引页内部的碎片,也不合并半满页。索引本身的膨胀需要 REINDEX:
| 操作 | 清理死条目 | 合并半满页 | 锁级别 |
|---|---|---|---|
VACUUM | 是 | 否 | SHARE UPDATE EXCLUSIVE |
REINDEX | 是 | 是 | ACCESS EXCLUSIVE(旧式) |
REINDEX CONCURRENTLY | 是 | 是 | 不阻塞读写(PG12+) |
REINDEX (VERBOSE) TABLE | 是 | 是 | 视索引而定 |
-- 观察索引膨胀
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC;-- 在线整理索引,不阻塞业务
REINDEX INDEX CONCURRENTLY idx_orders_user_id;
-- PostgreSQL 17+ 支持按表级联
REINDEX TABLE CONCURRENTLY orders;手动 VACUUM 的常见使用场景
| 场景 | 推荐命令 | 原因 |
|---|---|---|
| 大批量 DELETE 之后 | VACUUM (ANALYZE) t | 主动回收,不等 autovacuum |
| 大批量 UPDATE 之后 | VACUUM (ANALYZE) t | 死元组数量剧增 |
| 刚导入大量数据 | VACUUM (ANALYZE) t | 设置 VM,让 Index-Only Scan 生效 |
| 冻结老化严重的表 | VACUUM (FREEZE, VERBOSE) t | 主动推进 relfrozenxid |
| 表明显虚胖、需要归还空间 | VACUUM FULL t 或 pg_repack | 整表重写 |
| 只需更新统计信息 | ANALYZE t | 比 VACUUM 轻得多 |
-- 批量操作后的标准组合拳
BEGIN;
DELETE FROM orders WHERE created_at < now() - interval '1 year';
COMMIT;
VACUUM (VERBOSE, ANALYZE) orders;
-- 检查死元组是否已清空
SELECT relname, n_live_tup, n_dead_tup, last_vacuum
FROM pg_stat_user_tables WHERE relname = 'orders';实战示例
示例:从膨胀诊断到空间归还的完整处置
这个示例模拟一次典型的「表膨胀事故」,走完诊断、处置、验证全流程。
第 1 步:制造膨胀
CREATE TABLE blog_posts (
id bigserial PRIMARY KEY,
author_id integer NOT NULL,
title text,
content text,
views integer DEFAULT 0
);
CREATE INDEX idx_blog_author ON blog_posts (author_id);
-- 插入 20 万行
INSERT INTO blog_posts (author_id, title, content, views)
SELECT (g % 500) + 1,
'title-' || g,
repeat('x', 200),
0
FROM generate_series(1, 200000) AS g;
-- 模拟"每次访问都更新计数"的坏实践:更新 5 轮
DO $$
BEGIN
FOR i IN 1..5 LOOP
UPDATE blog_posts SET views = views + 1;
END LOOP;
END $$;
VACUUM (ANALYZE) blog_posts;
SELECT pg_size_pretty(pg_relation_size('blog_posts')) AS heap_size,
pg_size_pretty(pg_relation_size('idx_blog_author')) AS author_idx_size; heap_size | author_idx_size
-----------+-----------------
262 MB | 7840 kB第 2 步:诊断膨胀
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum,
last_autovacuum,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = 'blog_posts'; relname | n_live_tup | n_dead_tup | dead_pct | last_vacuum | n_tup_upd | n_tup_hot_upd | hot_pct
------------+------------+------------+----------+-------------+-----------+---------------+---------
blog_posts | 200000 | 1000000 | 83.33 | <ts> | 1000000 | 1000000 | 100.00-- 用 pgstattuple 看物理层面的真实情况
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT table_len, tuple_count, dead_tuple_count,
round(dead_tuple_percent, 2) AS dead_pct,
round(free_percent, 2) AS free_pct
FROM pgstattuple('blog_posts'); table_len | tuple_count | dead_tuple_count | dead_pct | free_pct
-----------+-------------+------------------+----------+----------
274726912 | 200000 | 0 | 0.00 | 26.41关键发现:n_dead_tup = 1000000 但 dead_tuple_count = 0。 原因是第 1 步已经执行过 VACUUM,死元组的空间已被标记可复用(free_pct = 26.41%),统计视图里的 n_dead_tup 是自上次 VACUUM 以来的累计计数,不是物理死元组数。这个差异是最常见的诊断陷阱。
第 3 步:判断是否需要 VACUUM FULL
-- 关键指标:空间被 "可复用" 但没归还 → FREE 空间占比
-- 若 free_pct 高且不再有写入 → 空间被浪费;若持续写入 → 很快会被复用
SELECT pg_size_pretty(pg_relation_size('blog_posts')) AS physical_size,
200000 * (SELECT avg(pg_column_size(t)) FROM blog_posts t) AS estimated_live_bytes; physical_size | estimated_live_bytes
---------------+---------------------
262 MB | 56700000 ← 物理文件是实际数据的约 4.6 倍第 4 步:用 pg_repack 在线回收(生产推荐)
# pg_repack 需要安装扩展与命令行工具
psql -U postgres -d demo -c "CREATE EXTENSION IF NOT EXISTS pg_repack;"
# 在线重整表与索引,不长时间阻塞业务
pg_repack -U postgres -d demo -t public.blog_posts --no-superuser-checkINFO: repacking table "public.blog_posts"
INFO: repacking indexes of "public.blog_posts"
INFO: repacking table "public.blog_posts" ... done第 5 步:验证结果
SELECT pg_size_pretty(pg_relation_size('blog_posts')) AS heap_size,
pg_size_pretty(pg_relation_size('idx_blog_author')) AS author_idx_size;
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables WHERE relname = 'blog_posts'; heap_size | author_idx_size
-----------+-----------------
61 MB | 4304 kB ← 从 262 MB 降到 61 MB
relname | n_live_tup | n_dead_tup
------------+------------+------------
blog_posts | 200000 | 0第 6 步:从源头修复——把 views 从主表挪走
问题的根源是「每次浏览都 UPDATE 主表」,这会产生海量死元组。更合理的做法是:
-- 方案 A:把高频更新的计数拆到单独的小表,减少主表膨胀
CREATE TABLE blog_view_counts (
post_id bigint PRIMARY KEY,
views bigint NOT NULL DEFAULT 0,
updated timestamptz NOT NULL DEFAULT now()
);
-- 方案 B:主表上设置更激进的 autovacuum 参数
ALTER TABLE blog_posts SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 500,
autovacuum_vacuum_cost_delay = 0,
fillfactor = 80
);结果解读:
- 膨胀的根因不是 VACUUM 不工作,而是写入模式:5 轮全表 UPDATE 产生了 100 万死元组,而 HOT 命中率 100% 说明索引没膨胀(这点做对了)。
n_dead_tup与dead_tuple_count的含义完全不同,前者是统计累计值,后者是物理现状;诊断时必须同时看。free_pct = 26.41%说明空间已被回收但未归还:如果表后续还有写入,这些空闲空间会被复用,不一定需要VACUUM FULL;如果表已成只读或写入极少,才值得重整理。pg_repack把 262 MB 降到 61 MB,全程未长时间阻塞业务,这是生产环境回收空间的标准做法。- 长期方案是改写入模式:把高频更新的列拆到小表,或降低
fillfactor+ 调激进 autovacuum 参数,从源头控制膨胀速度。
注意事项
- VACUUM 不能在有未提交事务的连接中清理其所在表的相关版本,长事务是 VACUUM 效果的最大杀手。
-- 不推荐:长事务中做耗时业务逻辑
-- BEGIN;
-- SELECT ... ; -- 长时间不提交VACUUM FULL会持有ACCESS EXCLUSIVE锁,期间所有读写都被阻塞,且需要等额磁盘空间,生产环境慎用。
-- 不推荐:业务高峰直接 VACUUM FULL 大表
-- VACUUM FULL orders;n_dead_tup是累计统计值,VACUUM 后不会立刻归零(由VACUUM或ANALYZE重置统计);不要据此判断物理死元组数量。
-- 物理层面的真相要用 pgstattuple
SELECT dead_tuple_count, dead_tuple_percent FROM pgstattuple('orders');- VACUUM 默认不限速(
vacuum_cost_delay = 0),手动 VACUUM 大表可能打满 I/O,影响业务;生产上可临时限速。
-- 手动 VACUUM 时主动限速,避免冲击 I/O
SET vacuum_cost_delay = 10;
SET vacuum_cost_limit = 200;
VACUUM orders;
RESET vacuum_cost_delay;
RESET vacuum_cost_limit;- 关闭 autovacuum 极其危险:即使
autovacuum = off,为防 XID 回卷,PostgreSQL 仍会启动「防回卷 autovacuum」,但它只做冻结、不做常规清理,表会持续膨胀。 vacuum_freeze_min_age单位是「事务个数」,不是时间也不是容量;写错单位(如50MB)会导致配置报错。- VACUUM 无法清理「跨页 HOT 链上的老版本」以外的索引膨胀,索引碎片要靠
REINDEX。 VACUUM VERBOSE里的cannot be removed yet是重要信号,持续大于 0 说明有长事务或老的复制槽在钉住版本。- 复制槽(Replication Slot)也会钉住版本,一个失活的槽能让表无限膨胀。
-- 检查不活跃的复制槽(最常见又被忽视的膨胀原因)
SELECT slot_name, slot_type, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE NOT active;ANALYZE不回收空间,VACUUM 才回收;很多人把二者混为一谈,导致表持续膨胀。- 并行 VACUUM 只在索引清理阶段生效,且不能在事务块内使用。
最佳实践
- 永远保持 autovacuum 开启,需要优化时只调参数,不要按表关闭。
- 按表规模分级调参:小表用默认值即可,大表(千万行以上)必须下调
autovacuum_vacuum_scale_factor。
-- 大表统一调整模板
ALTER TABLE big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 0,
autovacuum_naptime = '30s'
);- 把「死元组比例」纳入监控告警,超过 20% 主动介入。
-- 死元组比例 TOP 10,作为日常巡检查询
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_live_tup + n_dead_tup > 10000
ORDER BY dead_pct DESC NULLS LAST
LIMIT 10;- 把「最老事务年龄」纳入监控,它既反映膨胀风险,也反映回卷风险。
-- 数据库级年龄,超过 2 亿立即排查
SELECT datname, age(datfrozenxid) AS xid_age,
round(100.0 * age(datfrozenxid) / 2000000000, 2) AS pct_to_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;- 批量 DML 之后立刻手动 VACUUM,不要等 autovacuum 慢慢追。
- 用
log_autovacuum_min_duration = 5s记录慢清理,这是发现异常信号最省事的办法。 - 调整
maintenance_work_mem(如 256MB~1GB),能显著加速 VACUUM 的索引清理阶段。 - 回收空间优先用
pg_repack,其次才是VACUUM FULL,并安排在有维护窗口时。 - 定期
REINDEX CONCURRENTLY索引膨胀严重的表,VACUUM 无法整理索引碎片。 - 审视写入模式:把「高频更新的计数器」拆成独立小表、把状态变更改为追加日志,是从根上减少死元组的唯一方法。
- 归档业务不要用
DELETE,用分区DETACH+DROP:瞬时完成,且完全绕过死元组问题。
-- 分区表的归档方式:几乎零成本
ALTER TABLE orders DETACH PARTITION orders_2023_01;
DROP TABLE orders_2023_01;- 不要用
TRUNCATE替代DELETE来"省事":TRUNCATE不记录行级信息、不可回滚到中间状态,且需要ACCESS EXCLUSIVE锁。
下一节
继续学习:MVCC 的优缺点