VACUUM 原理与作用
VACUUM 是 PostgreSQL 维护体系的基石。因为 PostgreSQL 使用 MVCC(Multi-Version Concurrency Control,多版本并发控制)实现读写不互相阻塞,UPDATE 与 DELETE 都不会原地修改或删除数据行,而是留下旧版本(称为死元组,dead tuple),再由 VACUUM 事后统一回收。
这套设计的代价是:如果 VACUUM 不及时执行,表会持续膨胀、顺序扫描变慢、磁盘占用只增不减;更严重的是,事务 ID(XID)会不断老化,最终逼近 32 位上限,触发回卷保护(XID wraparound)导致数据库强制只读甚至停机。
本节从配置与日常运维的角度讲清 VACUUM:它到底做了什么、为什么执行完文件大小不变、有哪些执行形式、锁级别如何、怎么观察进度。命令的全部参数细节可参考 SQL 基础章节的清理命令页,MVCC 层面的机制细节可参考架构章节。
前置知识
阅读本节前,建议先了解:聚合与追加扫描(Aggregate / Append)、元组版本与 MVCC
基础概念
MVCC 的代价:死元组是怎么产生的
PostgreSQL 的一行数据在磁盘上不是一个「会变的对象」,而是一串版本。每个元组(tuple)头部有两个关键字段:
| 字段 | 含义 |
|---|---|
xmin | 创建该版本的事务 ID |
xmax | 删除(或更新掉)该版本的事务 ID;0 表示仍然存活 |
一次 UPDATE 在物理上是「插入新版本 + 给旧版本打上 xmax」:
UPDATE products SET price = 99 WHERE id = 1;
执行前(只有 1 个版本):
[ xmin=100 xmax=0 id=1 price=50 ] ← 对所有事务可见
执行后(2 个版本共存):
[ xmin=100 xmax=205 id=1 price=50 ] ← 旧版本,成为 dead tuple
[ xmin=205 xmax=0 id=1 price=99 ] ← 新版本,可见DELETE 同理,只是不再插入新版本:
DELETE FROM products WHERE id = 1;
执行后:
[ xmin=100 xmax=205 id=1 price=50 ] ← 仍占着磁盘页,等待回收旧版本之所以不能立刻物理删除,是因为可能还有更早开始的事务需要看到它——这正是 MVCC 保证一致性的方式。只有当没有任何活跃事务可能看到某个旧版本时(xmax 早于所有活跃快照中最老的 xmin),它才真正「死透」,可以被回收。
死元组带来的四个问题
| 问题 | 表现 | 后果 |
|---|---|---|
| 表膨胀(Bloat) | 文件页数远超实际所需 | 磁盘占用虚高,备份变慢 |
| 顺序扫描变慢 | 要读的页变多,命中率下降 | Seq Scan 耗时线性增长 |
| 统计虚高 | pg_class.relpages、优化器行数估算失真 | 执行计划选错(如该用索引却走全表扫描) |
| 冻结停滞 | age(relfrozenxid) 持续增长 | 逼近 20 亿时触发回卷保护 |
前三项是性能问题,第四项是可用性问题。这也是为什么 VACUUM 不是「可选的优化手段」,而是必须定期执行的例行动作。
VACUUM 做的四件事
一次普通的 VACUUM 会完成以下工作:
VACUUM 的四个目标
├── 1. 回收死元组空间
│ 把死元组所在的槽位置为空,并登记到 FSM(Free Space Map)
│ 供同表后续 INSERT/UPDATE 复用 → 防止文件继续变大
│
├── 2. 更新可见性映射(VM, Visibility Map)
│ 把「整页所有元组都可见」的页标记为 all-visible
│ 这是 Index Only Scan 生效的前提
│
├── 3. 更新统计信息(仅当带 ANALYZE 时)
│ VACUUM ANALYZE = VACUUM + ANALYZE,一次扫描做两件事
│
└── 4. 冻结老事务 ID(Freeze)
把足够老的元组 xmin 改成 FrozenTransactionId(特殊值 2)
使其永远不再参与回卷判断四件事中,第 1 件决定「空间会不会被浪费」,第 2 件决定「索引扫描快不快」,第 4 件决定「数据库会不会被强制停机」。
VACUUM 与 VACUUM FULL
这是最容易混淆的一组概念:
| 维度 | VACUUM(普通) | VACUUM FULL |
|---|---|---|
| 物理动作 | 标记死元组空间可复用 | 重写整张表到新文件 |
| 文件大小 | 通常不变 | 显著变小 |
| 空间归还给 OS | 否(只有尾部空页会被截断) | 是 |
| 锁级别 | SHARE UPDATE EXCLUSIVE | ACCESS EXCLUSIVE |
| 阻塞读写 | 不阻塞 | 完全阻塞 |
| 执行时长 | 短(与死元组数量相关) | 长(与表大小相关) |
relpages | 不清零 | 重置为实际使用页数 |
| 消耗磁盘 | 几乎不额外消耗 | 约需表大小的 1~2 倍空闲空间 |
| 日常使用 | 每天/每小时例行 | 例外手段,谨慎使用 |
一句话结论:
日常只跑
VACUUM;VACUUM FULL是「治病」而不是「保养」。
术语表
| 概念 | 术语 | 说明 |
|---|---|---|
| 死元组 | Dead Tuple | 对所有事务都不可见的旧版本行 |
| 普通清理 | Plain VACUUM | 标记空间可复用,不缩文件,不长期锁表 |
| 完全清理 | VACUUM FULL | 整表重写,归还空间给操作系统 |
| 冻结 | Freeze | 将元组 xmin 置为特殊值,使其永不参与回卷判断 |
| 膨胀 | Bloat | 死元组与可复用空间占比过高导致表体积虚大 |
| 空闲空间映射 | FSM(Free Space Map) | 记录每页剩余空间,供插入时复用 |
| 可见性映射 | VM(Visibility Map) | 标记「整页全可见」,仅索引扫描的前提 |
| 自动清理 | Autovacuum | 后台守护进程,按阈值自动触发 |
| 清理进度 | pg_stat_progress_vacuum | 查看正在进行的清理进度 |
| 回卷 | XID Wraparound | 32 位事务 ID 耗尽导致的保护性停机风险 |
| 年龄 | age(relfrozenxid) | 表最老未冻结 XID 的距离 |
关于 VACUUM 需要先建立三个认识:
- 它不是「删除垃圾」,而是「让空间可复用」。 执行后文件大小往往不变,这是正常的。
- 它必须定期执行。 不执行的后果不只是表变大,而是冻结停滞、回卷风险累积。
- 它默认不阻塞业务。 普通
VACUUM只取SHARE UPDATE EXCLUSIVE锁,与读写兼容。
语法与用法
基本语法
VACUUM [ ( option [, ...] ) ] [ table_and_columns [, ...] ]
table_and_columns 为:
table_name [ ( column_name [, ...] ) ]其中 option 可以是下列之一:
| 选项 | 含义 | 版本 |
|---|---|---|
FULL | 执行完全清理(重写整表) | — |
FREEZE | 更积极地冻结元组(等价于把参数调低为 0) | — |
VERBOSE | 输出每张表的详细清理报告 | — |
ANALYZE | 顺带收集统计信息 | — |
DISABLE_PAGE_SKIPPING | 不做基于 VM 的页跳过,全量扫描 | — |
SKIP_LOCKED | 拿不到锁就跳过该表,不等待 | — |
INDEX_CLEANUP | 是否顺带清理索引中的死项(AUTO/ON/OFF) | 12+ |
PROCESS_MAIN | 是否处理主表 | 13+ |
PROCESS_TOAST | 是否处理 TOAST 表 | 13+ |
TRUNCATE | 是否截断尾部空页 | 12+ |
PARALLEL n | 并行清理,n 为并行工作进程数 | 12+ |
ONLY | 不处理继承子表 | 18+ |
SKIP_DATABASE_STATS | 不更新数据库级统计 | 14+ |
BUFFER_USAGE_LIMIT | 限制清理使用的缓冲区大小 | 16+ |
PostgreSQL 18 的行为变更
从 PostgreSQL 18 起,VACUUM 与 ANALYZE 默认会处理继承子表(父表加上所有子表)。若只想处理父表本身,需要显式加上 ONLY:
VACUUM ONLY parent_table; -- 18+ 才有的写法,只清理父表自身五种常见执行形态
| 形态 | 写法 | 用途 |
|---|---|---|
| 全库清理 | VACUUM; | 清理当前数据库所有表,例行动作 |
| 指定表 | VACUUM products; | 针对热点表手动画 |
| 带统计 | VACUUM (VERBOSE, ANALYZE) products; | 清理并更新统计,适合批量写后 |
| 完全重写 | VACUUM FULL products; | 需要归还磁盘时才用 |
| 强制冻结 | VACUUM FREEZE products; | 冻结测试库或跨版本升级后 |
| 并行清理 | VACUUM (PARALLEL 4) orders; | 大表、多索引、CPU 空闲时 |
-- 日常最常用:清理 + 更新统计,并打印过程
VACUUM (VERBOSE, ANALYZE) orders;INFO: vacuuming "public.orders"
INFO: scanned index "orders_pkey" to remove 4210 row versions
DETAIL: CPU: user: 0.04 s, system: 0.00 s, elapsed: 0.06 s
INFO: "orders": removed 4210 row versions in 322 pages
DETAIL: CPU: user: 0.05 s, system: 0.01 s, elapsed: 0.08 s
INFO: "orders": found 4210 removable, 11880 nonremovable row versions in 700 out of 700 pages
DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 7341
INFO: analyzing "public.orders"
INFO: "orders": scanned 700 of 700 pages, containing 11880 live rows and 4210 dead rows; 11880 rows in sample, 11880 estimated total rows
VACUUM读法要点:
found ... removable, ... nonremovable:可移除的死元组数与仍需保留的元组数。0 dead row versions cannot be removed yet:因为最老活跃事务(oldest xmin)仍在用,暂时不能清理——正常现象,下次再跑即可。scanned index ... to remove N row versions:索引中死项的清理情况(INDEX_CLEANUP控制)。
锁级别
| 操作 | 获取的锁 | 与读写是否冲突 | 与哪些操作冲突 |
|---|---|---|---|
VACUUM | SHARE UPDATE EXCLUSIVE | 不冲突 | 其他 VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY、ALTER TABLE 等 DDL |
VACUUM FULL | ACCESS EXCLUSIVE | 冲突,阻塞一切 | 所有其他操作 |
VACUUM FREEZE | SHARE UPDATE EXCLUSIVE | 不冲突 | 同上 |
VACUUM ANALYZE | 先取 SHARE UPDATE EXCLUSIVE(清理阶段),再取 SHARE UPDATE EXCLUSIVE(分析阶段) | 不冲突 | 同上 |
并发 VACUUM 之间会互相阻塞
同一张表上不能同时跑两个 VACUUM(因为双方都要 SHARE UPDATE EXCLUSIVE)。如果手工 VACUUM 期间 autovacuum 也想处理同一张表,后到者会等锁,超时后放弃并记录日志。这不是故障,但会让你的「手工清理」偶发地在目标表上排队。
观察清理进度
PostgreSQL 9.6 起提供 pg_stat_progress_vacuum,可以实时看到清理到哪一步:
SELECT pid, relid::regclass AS table_name, phase,
heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
index_vacuum_count, max_dead_tuples, num_dead_tuples
FROM pg_stat_progress_vacuum; pid | table_name | phase | heap_blks_total | heap_blks_scanned | heap_blks_vacuumed | index_vacuum_count
-------+------------+------------------------+-----------------+-------------------+--------------------+--------------------
41237 | orders | vacuuming indexes | 12800 | 12800 | 9200 | 1phase 的取值含义:
| phase | 说明 |
|---|---|
initializing | 准备阶段,扫描开始前 |
scanning heap | 扫描主表,识别死元组 |
vacuuming indexes | 清理索引中的死项 |
vacuuming heap | 真正回收主表空间(可能多轮) |
cleaning up indexes | 索引收尾 |
truncating heap | 截断尾部空页 |
performing final cleanup | 更新 FSM、VM、统计 |
判断进度可以用 heap_blks_scanned / heap_blks_total。如果长时间停在 vacuuming indexes,说明表上索引很多、INDEX_CLEANUP 开销大——这是考虑 PARALLEL 或调整 INDEX_CLEANUP 的信号。
详细说明
为什么执行完文件大小没变
这是最常见的疑问。普通 VACUUM 只是把死元组占用的行指针(line pointer)标记为「可用」,并把可用空间登记到 FSM,文件本身一页都不会释放。原因有两个:
- 空间是给同表复用的,不是给操作系统复用的。后续
INSERT会优先插入这些空洞,从而阻止文件继续增长——这才是VACUUM真正的作用。 - 文件内的页编号与索引中的 TID(ctid)绑定。如果中间任意一页被移走,所有指向该页的索引项都会失效。因此只有文件末尾连续的空页才能安全地截断。
清理前的物理布局(□ = 活元组页,· = 只剩死元组的页)
页号: 1 2 3 4 5 6
[□] [□] [·] [□] [·] [·]
普通 VACUUM 后:
页 3、5、6 的槽位被登记进 FSM 供复用,但文件仍是 6 页
[□] [□] [空] [□] [空] [空]
↑
后续 INSERT 会先填这里
尾部连续空页会被截断:
页 5、6 位于文件末尾且全空 → 截断,文件变成 4 页
[□] [□] [空] [□]所以:
- 想「还给操作系统」 → 只有
VACUUM FULL、CLUSTER或pg_repack才能做到。 - 想知道「到底浪费了多少」 → 依赖 FSM/VM 统计或
pgstattuple扩展,见 表膨胀检测与处理。
尾部截断(TRUNCATE 选项)
VACUUM 默认会尝试截断文件末尾连续的空页(对应选项 TRUNCATE on,默认值)。这能让一部分空间真正归还给操作系统,但只对文件末尾的空页有效,中间空洞无法回收。
VACUUM (TRUNCATE ON) orders; -- 默认行为,显式写出便于说明
VACUUM (TRUNCATE OFF) orders; -- 关闭截断按表关闭截断
截断需要短暂的 ACCESS EXCLUSIVE 锁(只在截断那一刻)。对延迟极敏感的短事务表,可以用存储参数按表关闭:
ALTER TABLE orders SET (vacuum_truncate = off);PostgreSQL 18 起,vacuum_truncate 同时也是一个可以在 postgresql.conf 中设置的服务器级参数,用于统一控制是否允许截断。
并行清理(PARALLEL)
PostgreSQL 12 引入了并行 VACUUM:主表扫描与索引清理可以由多个并行工作进程分担。PostgreSQL 13 进一步让索引清理阶段也能并行。
VACUUM (PARALLEL 4) orders;注意事项:
- 并行度受
max_parallel_maintenance_workers(默认 2)限制,写 4 只会用到 2。 - 每张表只能由一个进程处理主表扫描,因此并行度上限与「表上的索引数量」也有关。
- 只有索引数量多或索引很大的表收益才明显;小表反而增加调度开销。
- 并行清理同样占用
maintenance_work_mem,每组并行进程都可能申请一份。
-- 临时放开并行度(仅当前会话)
SET max_parallel_maintenance_workers = 4;
VACUUM (PARALLEL 4, VERBOSE) orders;手动 vs 自动:# 什么时候必须手工介入
| 场景 | 建议动作 |
|---|---|
大批量 UPDATE/DELETE 之后 | 立即 VACUUM (ANALYZE) 目标表 |
批量导入(COPY)之后 | VACUUM ANALYZE;自 PostgreSQL 13 起 autovacuum 也能感知纯插入 |
| 表已明显膨胀,但 autovacuum 反复清理无效 | 考虑 VACUUM FULL 或 pg_repack,见 VACUUM FULL 与重建 |
age(relfrozenxid) 逼近 2 亿 | 检查 autovacuum,必要时手工 VACUUM FREEZE,见 冻结事务 ID |
长事务长期占用 oldest xmin | 先处理长事务,否则清理无法回收任何东西 |
| 只在低峰期维护窗口 | 用 VACUUM (SKIP_LOCKED) 避免被业务锁卡住 |
长事务是 VACUUM 的天敌
只要有一个长时间运行的事务(包括空闲但未提交的 idle in transaction 会话)存在,它的快照就可能需要看到很旧的版本,oldest xmin 就不会推进,VACUUM 也就不能回收任何东西。
排查思路:
SELECT pid, state, xact_start, now() - xact_start AS xact_age, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;先杀掉/提交长事务,再跑 VACUUM,效果立竿见影。
PostgreSQL 18 的相关改进
PostgreSQL 18 针对清理与冻结做了几项实质改进,配置时值得知道:
| 变更 | 说明 |
|---|---|
| 新增异步 I/O 子系统 | 顺序扫描、位图堆扫描、VACUUM 都可受益;由 io_method 控制(worker/io_uring/sync),官方称部分场景提升可达 3 倍 |
| 常规 VACUUM 主动冻结更多页面 | 以前 VACUUM 不会处理已经 all-visible 的页,现在会顺带冻结,降低后续激进清理的开销;由 vacuum_max_eager_freeze_failure_rate 控制(支持服务器级与按表设置) |
新增 autovacuum_worker_slots | 预留 autovacuum 工作进程槽位,允许在运行时上调 autovacuum_max_workers 而不必重启 |
新增 autovacuum_vacuum_max_threshold | 为死元组数量设置绝对上限阈值(超过即触发),弥补纯百分比阈值在大表上「永远不触发」的缺陷 |
VACUUM/ANALYZE 默认处理继承子表 | 可用新选项 ONLY 恢复旧行为 |
| 统计视图增强 | pg_stat_all_tables 新增 total_vacuum_time、total_autovacuum_time、total_analyze_time、total_autoanalyze_time;清理延迟时间可通过 track_cost_delay_timing 采集 |
实战示例
下面的示例统一使用 demo 库中的 orders 表。
场景一:批量更新后手工清理并观察效果
-- 1. 制造一批死元组
UPDATE orders SET status = 'archived' WHERE created_at < DATE '2026-01-01';
UPDATE 4210
-- 2. 看清理前的状态
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / greatest(n_live_tup + n_dead_tup, 1), 2) AS dead_pct,
pg_size_pretty(pg_relation_size('orders')) AS size
FROM pg_stat_user_tables
WHERE relname = 'orders'; relname | n_live_tup | n_dead_tup | dead_pct | size
---------+------------+------------+----------+---------
orders | 11880 | 4210 | 26.17 | 5480 kB-- 3. 清理并更新统计
VACUUM (VERBOSE, ANALYZE) orders;
-- 4. 再看:死元组归零,但文件大小基本不变(除非尾部空页被截断)
SELECT relname, n_live_tup, n_dead_tup,
pg_size_pretty(pg_relation_size('orders')) AS size
FROM pg_stat_user_tables
WHERE relname = 'orders'; relname | n_live_tup | n_dead_tup | size
---------+------------+------------+---------
orders | 11880 | 0 | 5336 kB文件从 5480 kB 降到 5336 kB,是尾部空页被截断的结果——中间的空洞被留给了后续插入复用,不会归还给操作系统。
场景二:用 pg_stat_progress_vacuum 观察大表清理
大表清理时间长,观察进度很有必要。开两个会话:
-- 会话 A:启动清理
VACUUM (VERBOSE) orders;-- 会话 B:反复执行,观察 phase 推进
SELECT phase,
heap_blks_total,
heap_blks_scanned,
round(heap_blks_scanned * 100.0 / greatest(heap_blks_total, 1), 1) AS scan_pct,
heap_blks_vacuumed,
index_vacuum_count
FROM pg_stat_progress_vacuum; phase | heap_blks_total | heap_blks_scanned | scan_pct | heap_blks_vacuumed | index_vacuum_count
--------------------+-----------------+-------------------+----------+--------------------+--------------------
scanning heap | 12800 | 4480 | 35.0 | 0 | 0
vacuuming indexes | 12800 | 12800 | 100.0 | 9200 | 1
truncating heap | 12800 | 12800 | 100.0 | 12800 | 1写入运维脚本时,可以用它估算剩余时间:
-- 估算剩余时间(思路:用已扫描块数进度 × 已耗时)
SELECT phase,
now() - xact_start AS elapsed,
round(heap_blks_scanned * 100.0 / greatest(heap_blks_total, 1), 1) AS scan_pct,
CASE WHEN heap_blks_scanned > 0
THEN pg_size_pretty(
(heap_blks_total - heap_blks_scanned)::bigint *
current_setting('block_size')::int)
END AS approx_remaining_bytes
FROM pg_stat_progress_vacuum p
JOIN pg_stat_activity a USING (pid);注意事项
1. 普通 VACUUM 不会缩小文件,不要因此以为它「没生效」
判断是否生效要看 n_dead_tup 是否归零、FSM 是否登记了空闲空间,而不是看文件大小。要看文件大小变化必须用 VACUUM FULL。
-- 错误判断:只看 size 没变就说 VACUUM 无效
-- 正确:看死元组是否被回收、后续插入是否复用了空间
SELECT relname, n_dead_tup,
pg_size_pretty(pg_relation_size('orders')) AS size
FROM pg_stat_user_tables WHERE relname = 'orders';2. 长事务在前,VACUUM 在后
只要存在老快照,清理就会「看得到但删不掉」,日志里会一直出现 N dead row versions cannot be removed yet。
-- 先找出并处理长事务
SELECT pid, state, xact_start, now() - xact_start AS age
FROM pg_stat_activity
WHERE xact_start < now() - interval '10 minutes';3. 不要在业务高峰期跑 VACUUM FULL
它持 ACCESS EXCLUSIVE 锁,会把整张表的读写全部挡在门外,并且需要额外磁盘空间。改用 pg_repack 或挑维护窗口。
-- 不推荐:高峰期直接执行
-- VACUUM FULL orders;
-- 推荐:设置锁超时,拿不到锁就放弃,快速失败而不是拖垮业务
BEGIN;
SET LOCAL lock_timeout = '3s';
VACUUM FULL orders;
COMMIT;4. VACUUM 不会清理索引膨胀
普通 VACUUM 只清理索引中被标记为死的条目,不会重建或压缩索引文件。索引内部的页级碎片需要 REINDEX 或 pg_repack 处理,参见 索引维护最佳实践。
5. 手动清理与 autovacuum 会互相排队
同一张表上的两个 VACUUM 不能并行。手工清理时如果 autovacuum 也在抢同一张表,会有一方等待。给手工任务加 SKIP_LOCKED 或安排在 autovacuum 低峰时段。
-- 只在能立即拿到锁时才清理,否则跳过
VACUUM (SKIP_LOCKED) orders;6. oldest xmin 也受预备事务与复制槽影响
两阶段提交(prepared transaction)和复制槽(replication slot)都会「钉住」oldest xmin,让清理无法推进:
-- 遗留的预备事务
SELECT gid, prepared, owner, database FROM pg_prepared_xacts;
-- 可能长期不推进的复制槽
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;7. VACUUM FULL 期间磁盘空间不足会失败
它需要把整表复制到新文件,峰值占用约为原表的 1~2 倍。执行前先确认空闲空间:
SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size;
-- 并结合 df -h 确认数据目录所在分区有足够余量8. 不要关闭 track_counts
autovacuum 依赖累计统计计数(累计的死元组数、插入数)。track_counts = off 会导致 autovacuum 完全失效,只剩回卷防护性的清理还能触发。
SHOW track_counts; -- 必须为 on9. VACUUM 的清理力度受 cost 限制,可能「跑一半就停」
成本延迟机制(cost-based vacuum delay)会让清理主动休眠以免打爆 I/O。这会让清理耗时变长,但已完成的部分是有效的,下次继续。
-- 手工清理时临时降低延迟,加快完成
SET vacuum_cost_delay = 0;
VACUUM (VERBOSE) orders;10. 清理进度视图看不到历史记录
pg_stat_progress_vacuum 只显示当前正在执行的清理。历史信息要去 pg_stat_user_tables 的 last_vacuum/last_autovacuum 字段或服务器日志(log_autovacuum_min_duration)里找。
最佳实践
1. 让 autovacuum 干活,手工清理只做补充
绝大多数情况下,把 autovacuum 调对(见 AutoVacuum 配置)就够了。手工 VACUUM 应留给「刚做完大批量写操作」或「autovacuum 明显跟不上」的场景。
2. 批量写操作后紧跟一次 VACUUM (ANALYZE)
批量 UPDATE/DELETE/COPY 之后立即清理,既回收空间又刷新统计,避免优化器拿着过期信息选错计划。
-- 数据迁移脚本的标准收尾
VACUUM (ANALYZE) orders;
VACUUM (ANALYZE) order_items;
ANALYZE customers;3. 为高频更新的表预留足够的可用空间
VACUUM 能复用空间的前提是页里有空洞。对更新极其频繁的表,可以调大存储、给更新分布不均的表设小 fillfactor:
-- 高频更新表:每页只填 80%,给 UPDATE 留出就地扩展空间
ALTER TABLE orders SET (fillfactor = 80);
-- 需要 VACUUM FULL 或重建后才会按新 fillfactor 重排现有数据4. 用 log_autovacuum_min_duration 让清理留下痕迹
默认 autovacuum 不写日志,出问题时无从追溯。建议设为 0 或 1000(1 秒):
ALTER SYSTEM SET log_autovacuum_min_duration = 1000; -- 超过 1 秒的清理都记录
SELECT pg_reload_conf();5. 监控三项指标,而不是盯文件大小
| 指标 | 视图/函数 | 健康区间参考 |
|---|---|---|
| 死元组比例 | pg_stat_user_tables.n_dead_tup / n_live_tup | 长期 < 10% |
| 最老未冻结年龄 | age(relfrozenxid) | < 2 亿,> 1.5 亿告警 |
| 最长事务时长 | pg_stat_activity.xact_start | 分钟级以内 |
-- 一站式巡检:死元组比例最高的表
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup * 100.0 / greatest(n_live_tup + n_dead_tup, 1), 2) AS dead_pct,
last_autovacuum,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 10;6. VACUUM FULL 前先评估替代方案
VACUUM FULL 是最后手段。多数「表膨胀」场景用 pg_repack 更安全(近乎不阻塞),少数「顺序性明显变差」的场景用 CLUSTER 更划算。完整对比见 VACUUM FULL 与重建。
7. 给维护动作加上超时保护
清理可能因为等锁而长时间挂起,拖住后续请求。用会话级 lock_timeout 让它快速失败,而不是无限等待:
-- 会话级设置:拿不到锁就立即放弃,快速失败
SET lock_timeout = '5s';
SET statement_timeout = '30min';
VACUUM (ANALYZE, VERBOSE) orders;
RESET statement_timeout;
RESET lock_timeout;VACUUM 不能在事务块内执行
VACUUM 与 VACUUM FULL 都不能在显式事务块(BEGIN ... COMMIT)中运行,因此上面的超时设置必须用会话级 SET,而不能用只作用于事务的 SET LOCAL。命令行下可以这样一次性传入:
PGOPTIONS='-c lock_timeout=5s -c statement_timeout=30min' psql -c 'VACUUM (ANALYZE, VERBOSE) orders'8. 升级或迁移后做一次全库清理与冻结检查
大版本切换、物理迁移、逻辑复制初始化之后,元组的冻结状态可能不理想。此时做一次受控的全库 VACUUM 并按需 VACUUM FREEZE,可以避免后续突然出现激进的回卷防护清理。
-- 全库清理(可分库、分表批量化,避免一次性打满 I/O)
VACUUM (ANALYZE);
-- 全库中年龄最大的表,优先关注
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace
ORDER BY age(relfrozenxid) DESC
LIMIT 10;下一节
继续学习:VACUUM FULL 与重建