Skip to content

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」:

text
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 同理,只是不再插入新版本:

text
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 会完成以下工作:

text
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 EXCLUSIVEACCESS 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 Wraparound32 位事务 ID 耗尽导致的保护性停机风险
年龄age(relfrozenxid)表最老未冻结 XID 的距离

关于 VACUUM 需要先建立三个认识:

  • 它不是「删除垃圾」,而是「让空间可复用」。 执行后文件大小往往不变,这是正常的。
  • 它必须定期执行。 不执行的后果不只是表变大,而是冻结停滞、回卷风险累积。
  • 它默认不阻塞业务。 普通 VACUUM 只取 SHARE UPDATE EXCLUSIVE 锁,与读写兼容。

语法与用法 ​

基本语法 ​

text
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:

sql
VACUUM ONLY parent_table;   -- 18+ 才有的写法,只清理父表自身

五种常见执行形态 ​

形态写法用途
全库清理VACUUM;清理当前数据库所有表,例行动作
指定表VACUUM products;针对热点表手动画
带统计VACUUM (VERBOSE, ANALYZE) products;清理并更新统计,适合批量写后
完全重写VACUUM FULL products;需要归还磁盘时才用
强制冻结VACUUM FREEZE products;冻结测试库或跨版本升级后
并行清理VACUUM (PARALLEL 4) orders;大表、多索引、CPU 空闲时
sql
-- 日常最常用:清理 + 更新统计,并打印过程
VACUUM (VERBOSE, ANALYZE) orders;
text
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 控制)。

锁级别 ​

操作获取的锁与读写是否冲突与哪些操作冲突
VACUUMSHARE UPDATE EXCLUSIVE不冲突其他 VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY、ALTER TABLE 等 DDL
VACUUM FULLACCESS EXCLUSIVE冲突,阻塞一切所有其他操作
VACUUM FREEZESHARE UPDATE EXCLUSIVE不冲突同上
VACUUM ANALYZE先取 SHARE UPDATE EXCLUSIVE(清理阶段),再取 SHARE UPDATE EXCLUSIVE(分析阶段)不冲突同上

并发 VACUUM 之间会互相阻塞

同一张表上不能同时跑两个 VACUUM(因为双方都要 SHARE UPDATE EXCLUSIVE)。如果手工 VACUUM 期间 autovacuum 也想处理同一张表,后到者会等锁,超时后放弃并记录日志。这不是故障,但会让你的「手工清理」偶发地在目标表上排队。

观察清理进度 ​

PostgreSQL 9.6 起提供 pg_stat_progress_vacuum,可以实时看到清理到哪一步:

sql
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;
text
  pid  | table_name |         phase          | heap_blks_total | heap_blks_scanned | heap_blks_vacuumed | index_vacuum_count
-------+------------+------------------------+-----------------+-------------------+--------------------+--------------------
 41237 | orders     | vacuuming indexes      |           12800 |             12800 |               9200 |                  1

phase 的取值含义:

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,文件本身一页都不会释放。原因有两个:

  1. 空间是给同表复用的,不是给操作系统复用的。后续 INSERT 会优先插入这些空洞,从而阻止文件继续增长——这才是 VACUUM 真正的作用。
  2. 文件内的页编号与索引中的 TID(ctid)绑定。如果中间任意一页被移走,所有指向该页的索引项都会失效。因此只有文件末尾连续的空页才能安全地截断。
text
清理前的物理布局(□ = 活元组页,· = 只剩死元组的页)

  页号:  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,默认值)。这能让一部分空间真正归还给操作系统,但只对文件末尾的空页有效,中间空洞无法回收。

sql
VACUUM (TRUNCATE ON) orders;    -- 默认行为,显式写出便于说明
VACUUM (TRUNCATE OFF) orders;   -- 关闭截断

按表关闭截断

截断需要短暂的 ACCESS EXCLUSIVE 锁(只在截断那一刻)。对延迟极敏感的短事务表,可以用存储参数按表关闭:

sql
ALTER TABLE orders SET (vacuum_truncate = off);

PostgreSQL 18 起,vacuum_truncate 同时也是一个可以在 postgresql.conf 中设置的服务器级参数,用于统一控制是否允许截断。

并行清理(PARALLEL) ​

PostgreSQL 12 引入了并行 VACUUM:主表扫描与索引清理可以由多个并行工作进程分担。PostgreSQL 13 进一步让索引清理阶段也能并行。

sql
VACUUM (PARALLEL 4) orders;

注意事项:

  • 并行度受 max_parallel_maintenance_workers(默认 2)限制,写 4 只会用到 2。
  • 每张表只能由一个进程处理主表扫描,因此并行度上限与「表上的索引数量」也有关。
  • 只有索引数量多或索引很大的表收益才明显;小表反而增加调度开销。
  • 并行清理同样占用 maintenance_work_mem,每组并行进程都可能申请一份。
sql
-- 临时放开并行度(仅当前会话)
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 也就不能回收任何东西。

排查思路:

sql
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 表。

场景一:批量更新后手工清理并观察效果 ​

sql
-- 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';
text
 relname | n_live_tup | n_dead_tup | dead_pct |  size
---------+------------+------------+----------+---------
 orders  |      11880 |       4210 |    26.17 | 5480 kB
sql
-- 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';
text
 relname | n_live_tup | n_dead_tup |  size
---------+------------+------------+---------
 orders  |      11880 |          0 | 5336 kB

文件从 5480 kB 降到 5336 kB,是尾部空页被截断的结果——中间的空洞被留给了后续插入复用,不会归还给操作系统。

场景二:用 pg_stat_progress_vacuum 观察大表清理 ​

大表清理时间长,观察进度很有必要。开两个会话:

sql
-- 会话 A:启动清理
VACUUM (VERBOSE) orders;
sql
-- 会话 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;
text
       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

写入运维脚本时,可以用它估算剩余时间:

sql
-- 估算剩余时间(思路:用已扫描块数进度 × 已耗时)
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。

sql
-- 错误判断:只看 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。

sql
-- 先找出并处理长事务
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 或挑维护窗口。

sql
-- 不推荐:高峰期直接执行
-- 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 低峰时段。

sql
-- 只在能立即拿到锁时才清理,否则跳过
VACUUM (SKIP_LOCKED) orders;

6. oldest xmin 也受预备事务与复制槽影响

两阶段提交(prepared transaction)和复制槽(replication slot)都会「钉住」oldest xmin,让清理无法推进:

sql
-- 遗留的预备事务
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 倍。执行前先确认空闲空间:

sql
SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size;
-- 并结合 df -h 确认数据目录所在分区有足够余量

8. 不要关闭 track_counts

autovacuum 依赖累计统计计数(累计的死元组数、插入数)。track_counts = off 会导致 autovacuum 完全失效,只剩回卷防护性的清理还能触发。

sql
SHOW track_counts;   -- 必须为 on

9. VACUUM 的清理力度受 cost 限制,可能「跑一半就停」

成本延迟机制(cost-based vacuum delay)会让清理主动休眠以免打爆 I/O。这会让清理耗时变长,但已完成的部分是有效的,下次继续。

sql
-- 手工清理时临时降低延迟,加快完成
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 之后立即清理,既回收空间又刷新统计,避免优化器拿着过期信息选错计划。

sql
-- 数据迁移脚本的标准收尾
VACUUM (ANALYZE) orders;
VACUUM (ANALYZE) order_items;
ANALYZE customers;

3. 为高频更新的表预留足够的可用空间

VACUUM 能复用空间的前提是页里有空洞。对更新极其频繁的表,可以调大存储、给更新分布不均的表设小 fillfactor:

sql
-- 高频更新表:每页只填 80%,给 UPDATE 留出就地扩展空间
ALTER TABLE orders SET (fillfactor = 80);
-- 需要 VACUUM FULL 或重建后才会按新 fillfactor 重排现有数据

4. 用 log_autovacuum_min_duration 让清理留下痕迹

默认 autovacuum 不写日志,出问题时无从追溯。建议设为 0 或 1000(1 秒):

sql
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分钟级以内
sql
-- 一站式巡检:死元组比例最高的表
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 让它快速失败,而不是无限等待:

sql
-- 会话级设置:拿不到锁就立即放弃,快速失败
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。命令行下可以这样一次性传入:

bash
PGOPTIONS='-c lock_timeout=5s -c statement_timeout=30min' psql -c 'VACUUM (ANALYZE, VERBOSE) orders'

8. 升级或迁移后做一次全库清理与冻结检查

大版本切换、物理迁移、逻辑复制初始化之后,元组的冻结状态可能不理想。此时做一次受控的全库 VACUUM 并按需 VACUUM FREEZE,可以避免后续突然出现激进的回卷防护清理。

sql
-- 全库清理(可分库、分表批量化,避免一次性打满 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 与重建

参考链接 ​