VACUUM FULL 与重建
普通 VACUUM 只把死元组标记为「可复用」,从不缩小文件。当一张表已经被反复更新/删除折磨得千疮百孔——文件远大于实际数据、顺序扫描要读一堆空页、优化器行数估算失真——就需要一种能重写整表并归还磁盘空间的手段。
PostgreSQL 提供四种:VACUUM FULL、CLUSTER、pg_repack、以及手动「新建表 + INSERT ... SELECT」。它们效果相近,但锁级别、耗时、磁盘开销、是否重排数据顺序差别很大,选错了代价很高。
本节讲清这四者的取舍、VACUUM FULL 的执行细节与风险控制,以及生产环境里安全执行的完整流程。
前置知识
阅读本节前,建议先了解:VACUUM 原理与作用
基础概念
VACUUM FULL 到底做了什么
VACUUM FULL 不是「加强版的清理」,而是整表重写。它的物理流程如下:
VACUUM FULL orders 的执行流程
① 获取 ACCESS EXCLUSIVE 锁(阻塞该表一切读写)
│
▼
② 在同一个表空间内新建一个空的表文件(新 relfilenode)
│
▼
③ 顺序扫描旧文件,把所有「仍然存活」的元组按顺序写入新文件
死元组直接被丢弃,不写入
│
▼
④ 重建这张表上的所有索引
(相当于一次不带 CONCURRENTLY 的 REINDEX)
│
▼
⑤ 重写关联的 TOAST 表(大字段的溢出存储)
│
▼
⑥ 删除旧文件,把 relfilenode 指向新文件
│
▼
⑦ 更新 pg_class.relpages、reltuples、可见性映射、空闲空间映射
│
▼
⑧ 释放锁因为第 ③ 步是「按物理顺序把活元组紧凑地写一遍」,所以结果有两个好处:
- 文件变成最小可用大小,多余空间真正归还操作系统。
- 数据物理顺序变紧凑,同一页内的行更连贯,后续顺序扫描更快。
四种方案对比
| 维度 | VACUUM FULL | CLUSTER | pg_repack | 新建表 + INSERT SELECT |
|---|---|---|---|---|
| 是否缩小文件 | 是 | 是 | 是 | 是 |
| 是否重排数据顺序 | 是(按原顺序紧凑化) | 是(按指定索引顺序) | 可选(--order-by) | 可控(ORDER BY) |
| 锁级别 | ACCESS EXCLUSIVE | ACCESS EXCLUSIVE | 短暂持有 | 需自行设计 |
| 阻塞读写 | 全程阻塞 | 全程阻塞 | 几乎不阻塞 | 切换瞬间短暂阻塞 |
| 额外磁盘需求 | 约 1~2 倍表大小 | 约 1~2 倍表大小 | 约 1 倍表大小 + 日志表 | 约 1 倍 + 索引 |
| 需要扩展 | 否 | 否 | 需安装 pg_repack | 否 |
| 是否能带上索引 | 自动重建索引 | 自动重建索引 | 自动处理 | 需自行重建 |
| 中断后是否可恢复 | 回滚,原表不变 | 回滚,原表不变 | 可重跑 | 视脚本而定 |
| 适用场景 | 单表膨胀、维护窗口充足 | 范围查询多、想按列聚簇 | 大表需在线整理 | 需要改造表结构/分区时 |
一句话选型:
能停机、表不算超大 →
VACUUM FULL;想按某个索引排序 →CLUSTER;不能停机 →pg_repack;同时还要改结构 → 新建表。
需要 VACUUM FULL 的判定
不是「表有点大」就该做,要看是否满足下列具体症状:
| 症状 | 判断依据 |
|---|---|
| 文件大小远超实际数据 | pg_total_relation_size() 远大于 pgstattuple().tuple_len 之和 |
| 空闲空间占比长期很高 | pgstattuple().free_percent 持续 > 30% |
| 普通 VACUUM 反复无效 | 死元组仍在,但空间无法被新数据填满 |
| 顺序扫描明显变慢 | EXPLAIN (ANALYZE) 的 Seq Scan 读取页数异常多 |
| 优化器估算失真 | 计划中行数与实际差别巨大,且 ANALYZE 后仍不准 |
relpages 虚高 | 与 pg_relation_size()/block_size 严重不符 |
反过来说,下面这些情况不该用 VACUUM FULL:
- 只是死元组多,但没有空闲空间浪费(普通
VACUUM就够)。 - 表很大(比如几百 GB)且无法接受长时间停机 → 用
pg_repack。 - 真正的问题是索引膨胀而非表膨胀 → 用
REINDEX。 - 真正的问题是长事务导致清理无效 → 先解决长事务。
语法与用法
基本语法
VACUUM [ FULL ] [ FREEZE ] [ VERBOSE ] [ ANALYZE ] [ table_and_columns [, ...] ]常用形态:
-- 完全清理并更新统计
VACUUM (FULL, ANALYZE) orders;
-- 完全清理 + 打印过程(看每个阶段耗时)
VACUUM (FULL, VERBOSE) orders;
-- 只重写数据,不重建统计(不推荐,通常会连带 ANALYZE)
VACUUM FULL orders; -- 等价于 VACUUM (FULL)
-- 全库完全清理(极危险,慎用)
VACUUM FULL;VACUUM FULL 的可见选项
VACUUM FULL 并非支持所有 VACUUM 选项,实际可搭配的包括:
| 选项 | 与 FULL 是否可共用 | 说明 |
|---|---|---|
ANALYZE | ✅ | 重写后重新收集统计,强烈建议加上 |
VERBOSE | ✅ | 输出每阶段耗时 |
FREEZE | ✅ | 重写时直接冻结(等价于把冻结阈值调低) |
DISABLE_PAGE_SKIPPING | ✅ | 对完全清理意义不大 |
SKIP_LOCKED | ✅ | 拿不到锁就跳过该表;是避免卡死业务的关键手段 |
INDEX_CLEANUP | ✅ | 控制索引清理(对 FULL 影响有限) |
PROCESS_MAIN / PROCESS_TOAST | ✅ | 选择是否处理主表/TOAST 表 |
TRUNCATE | ⚠️ | FULL 本身就会收缩文件,该选项无实际意义 |
PARALLEL n | ❌ | VACUUM FULL 不支持并行 |
ONLY | ✅(18+) | 不处理继承子表 |
-- 生产环境推荐写法:锁超时保护 + 统计 + 详细输出
VACUUM (FULL, ANALYZE, VERBOSE, SKIP_LOCKED) orders;VACUUM FULL 不支持并行
不要写 VACUUM (FULL, PARALLEL 4),会直接报语法/参数错误。需要并行加速的大表重写,考虑 pg_repack(其内部使用 CREATE INDEX CONCURRENTLY)或拆分子表逐个处理。
关于 CLUSTER
CLUSTER 与 VACUUM FULL 的物理动作几乎一样(重写整表),区别在于重写时的排序依据:
-- 按 orders_pkey 的顺序物理重排整表
CLUSTER orders USING orders_pkey;
-- 之后一段时间内,新插入的行会尽量贴近该顺序
-- 但随更新增多,顺序性会逐渐退化,需要再次 CLUSTER
-- 查看某张表是否设置了默认聚簇索引
SELECT relname, relkind,
(SELECT indexrelid::regclass::text
FROM pg_index i
WHERE i.indrelid = c.oid AND i.indisclustered) AS clustered_index
FROM pg_class c
WHERE relname = 'orders';| 场景 | 推荐 |
|---|---|
| 只想缩小文件、顺带整理碎片 | VACUUM FULL |
想优化「按某列范围查询」的局部性(如按 created_at 查近期数据) | CLUSTER ... USING idx |
| 需要「一次重写同时改结构」 | 新建表方案 |
CLUSTER 的顺序会退化
CLUSTER 只在执行那一刻保证顺序。之后的 INSERT 会尽量插入合适位置,但更新产生的行会写到页的空洞里,位置逐渐偏离。因此 CLUSTER 是周期性运维动作,不是一次性设置。
pg_repack 的原理
pg_repack 是一个扩展 + 命令行工具的组合,用「在线重建」的思路避开长时间排他锁:
pg_repack 的工作流程
① 在目标表上创建 AFTER INSERT/UPDATE/DELETE 触发器
把并发产生的变更记录到一张日志表(repack.log_xxx)
│
▼
② 创建一张结构相同的新表(.repack 后缀),复制索引定义
(用 CREATE INDEX CONCURRENTLY,不阻塞写入)
│
▼
③ 把原表数据批量复制到新表
(这一步耗时长,但原表始终可读可写)
│
▼
④ 短暂持有 ACCESS EXCLUSIVE 锁,把日志表中的增量应用(apply)到新表
同时做最后一致性校验(checksum)
│
▼
⑤ 交换两个表的 relfilenode(表名不变),删除旧表与触发器
│
▼
⑥ 释放锁关键点:长时间的复制阶段不锁表,只有最后的 apply 阶段需要排他锁,且该阶段耗时与「日志表增量大小」成正比。因此实践中有两条重要建议:
- 应用阶段要快 → 复制阶段尽可能缩短,或在系统写入低谷期执行。
- 必须有足够磁盘容纳「原表 + 新表 + 索引 + 日志」。
# 常用命令形态
pg_repack -d demo -t public.orders --no-kill-backend -T 30
# 参数说明:
# -t 指定表(可多次出现)
# -T, --timeout 应用阶段拿锁的超时秒数
# --no-kill-backend 不主动杀掉阻塞的连接(更安全)
# -o 等价于 --order-by,按列排序复制
# --dry-run 先演练,不实际执行
# 只重建索引(相当于在线 REINDEX)
pg_repack -d demo -t public.orders --only-indexespg_repack 的三个硬约束
- 目标表必须有主键或唯一索引(用于日志表关联与一致性校验)。
- 需要安装扩展:
CREATE EXTENSION pg_repack;,且有相应权限。 - 应用阶段仍会短暂排他锁,所以「完全不停机」是不准确的——它是「不长时间停机」。
磁盘空间要求
VACUUM FULL 的空间账要算清楚:
执行 VACUUM FULL orders 时的磁盘占用
执行前: 旧表文件 100 GB
旧索引 30 GB
─────────
合计 130 GB
执行中: 旧表文件 100 GB ← 仍在
新表文件 100 GB ← 新写入(峰值)
新索引 30 GB
─────────
峰值 230 GB ← 需要 ≥ 230 GB 可用空间
执行后: 新表文件 ~60 GB ← 死元组被丢弃后变小
新索引 ~20 GB
─────────
合计 80 GB因此:
- 安全余量按 「现有表+索引总大小 × 2」 来留,而不是按「当前空闲空间 > 表大小」。
- WAL 也会因为重写而暴涨:整表重写会写大量 WAL。若是主从架构,还要考虑 WAL 保留量和从库回放压力。
- 如果空间不足,可以用「分片处理」:只对分区表逐个分区做
VACUUM FULL,或改用pg_repack(峰值约为 1 倍 + 日志)。
-- 执行前评估
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total_size,
pg_size_pretty(pg_relation_size('orders')) AS table_size,
pg_size_pretty(pg_indexes_size('orders')) AS index_size; total_size | table_size | index_size
------------+------------+------------
130 GB | 100 GB | 30 GB中断与恢复
这是 VACUUM FULL 最让人安心的一点:
VACUUM FULL是事务性的。如果中途失败(被杀、磁盘满、锁超时),它会整体回滚,原表完好无损。
回滚靠的是 WAL:新写入的数据都在 WAL 中有记录,旧文件在事务提交前不会被删除。所以:
Ctrl+C或pg_cancel_backend()中断 → 回滚,原表不变,只是白跑一场。- 磁盘写满 → 事务失败回滚,同样原表不变,但
pg_wal可能已经撑大,需要清理。 - 服务器崩溃 → 重启后通过 WAL 恢复,回到「未执行」状态。
中断不是免费的
虽然逻辑上安全,但代价是:
- 已经消耗的 I/O 与时间白费。
- 可能留下大量已写入的 WAL(在崩溃恢复前不会立即回收)。
- 新表文件在回滚后才被删除,期间磁盘占用一直高企。
因此宁可先设置 lock_timeout 让它快速失败,也不要盲目长时间等待。
重建索引与重写 TOAST 表
VACUUM FULL 会连带处理这两块,但要知道它们的代价:
索引重建:所有索引都会以非并发方式重建(因为已经持有排他锁,用 CONCURRENTLY 毫无意义)。索引越多,这一步越慢,maintenance_work_mem 越大越快。
-- 重建索引阶段想更快:临时提高内存
SET maintenance_work_mem = '512MB';
VACUUM FULL orders;
RESET maintenance_work_mem;TOAST 表重写:如果一个表有大字段(text、jsonb、bytea)被 TOAST 到外部存储,对应的 TOAST 表也会被重写,同样会缩小。可以单独控制是否处理:
-- 只处理主表,不碰 TOAST(一般不建议)
VACUUM (FULL, PROCESS_TOAST false) orders;详细说明
VACUUM FULL 期间到底阻塞什么
| 被阻塞的操作 | 是否阻塞 | 说明 |
|---|---|---|
SELECT | ✅ 阻塞 | 连普通读取都进不来 |
INSERT/UPDATE/DELETE | ✅ 阻塞 | 全部排队 |
其他 VACUUM/ANALYZE | ✅ 阻塞 | 锁级别不够 |
CREATE INDEX CONCURRENTLY | ✅ 阻塞 | 会被排他锁挡住 |
| 后续操作 | 排队等待 | 会形成「锁队列」,后续请求即使只需要弱锁也会排队(防止饥饿) |
排他锁会「堵住整条队伍」
ACCESS EXCLUSIVE 与所有锁冲突。一旦 VACUUM FULL 在等锁,它后面所有请求该表的语句都会排队——即使这些语句彼此并不冲突。这就是为什么一次 VACUUM FULL 可能让整站超时。
正确姿势是先设锁超时,快速失败:
-- 方式一:会话级设置(VACUUM 不能在事务块里,所以用会话级而非 SET LOCAL)
SET lock_timeout = '5s';
VACUUM (FULL, ANALYZE) orders;
RESET lock_timeout;# 方式二:一次性传给 psql,不改动会话状态
PGOPTIONS='-c lock_timeout=5s -c statement_timeout=2h' \
psql -d demo -c 'VACUUM (FULL, ANALYZE, VERBOSE) orders'如何找到「谁在阻塞」
VACUUM FULL 迟迟不动,通常是等锁。定位方法:
-- 1. 找到 VACUUM FULL 的 pid 与它等待的锁
SELECT pid, wait_event_type, wait_event, state,
now() - query_start AS running_for, query
FROM pg_stat_activity
WHERE query LIKE '%VACUUM%FULL%';
-- 2. 查找阻塞它的会话(PostgreSQL 9.6+)
SELECT pid, pg_blocking_pids(pid) AS blocking_pids,
state, now() - xact_start AS xact_age, left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0; pid | blocking_pids | state | xact_age | query
-------+---------------+----------------------+----------+---------------------------------
41882 | {41900} | active | 00:12:41 | SELECT * FROM orders WHERE ...
41900 | {} | idle in transaction | 00:19:03 | UPDATE orders SET ...上例中,罪魁是 41900 这个「空闲但在事务中」的会话。处理方式:
-- 温和地请它结束事务(会回滚,需确认业务影响)
SELECT pg_cancel_backend(41900);
-- 必要时强制终止
SELECT pg_terminate_backend(41900);VACUUM (FULL, ANALYZE) 的输出解读
VACUUM (FULL, ANALYZE, VERBOSE) orders;INFO: vacuuming "public.orders"
INFO: "orders": found 11880 removable, 11880 nonremovable row versions in 700 pages
DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 7341
INFO: rewriting table "orders"
INFO: "orders": truncated to 322 pages
DETAIL: CPU: user: 0.42 s, system: 0.05 s, elapsed: 0.61 s
INFO: "orders": removed 0 row versions in 322 pages
INFO: "orders": found 0 removable, 11880 nonremovable row versions in 322 pages
INFO: rebuilding index orders_pkey
INFO: "orders_pkey": removed 0 row versions in 322 pages
INFO: analyzing "public.orders"
INFO: "orders": scanned 322 of 322 pages, containing 11880 live rows and 0 dead rows; 11880 rows in sample, 11880 estimated total rows
VACUUM关键行:
| 输出行 | 含义 |
|---|---|
rewriting table "orders" | 进入整表重写阶段(VACUUM FULL 特有) |
truncated to 322 pages | 重写后文件从 700 页缩到 322 页 |
rebuilding index orders_pkey | 索引被重建 |
containing ... live rows and 0 dead rows | 重写后已无死元组 |
实战对比:三种方案的时间和空间账
假设有一张 100 GB、30 GB 索引、物理膨胀率 40% 的 orders 表:
| 方案 | 锁表时长 | 峰值额外磁盘 | 需要扩展 | 阻塞业务 |
|---|---|---|---|---|
VACUUM FULL | 全程(可能 1~3 小时) | ~130 GB | 否 | 是 |
CLUSTER | 全程(含排序,可能更久) | ~130 GB + 排序临时空间 | 否 | 是 |
pg_repack | 数秒~数十秒(应用阶段) | ~130 GB + 日志 | 是 | 几乎不阻塞 |
新建表 + INSERT SELECT | 切换瞬间 | ~130 GB | 否 | 切换瞬间 |
因此:
- 有维护窗口 → 直接
VACUUM FULL,最省事。 - 7×24 在线 →
pg_repack,或者用分区表逐分区处理。 - 本来就要改表结构/加分区 → 顺带新建表迁移。
实战示例
场景一:维护窗口内对中等表做 VACUUM FULL
-- 1. 执行前拍快照:大小、死元组、页数
SELECT
c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
c.relpages,
c.reltuples::bigint AS est_rows,
s.n_dead_tup
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relname = 'orders'; relname | total_size | table_size | relpages | est_rows | n_dead_tup
---------+------------+------------+----------+----------+------------
orders | 5480 kB | 5336 kB | 667 | 11880 | 4210-- 2. 执行完全清理(带锁超时保护)
SET lock_timeout = '5s';
SET maintenance_work_mem = '256MB';
VACUUM (FULL, ANALYZE, VERBOSE) orders;
RESET maintenance_work_mem;
RESET lock_timeout;INFO: vacuuming "public.orders"
INFO: rewriting table "orders"
INFO: "orders": truncated to 322 pages
INFO: rebuilding index orders_pkey
INFO: analyzing "public.orders"
VACUUM-- 3. 对比效果
SELECT
c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
c.relpages,
c.reltuples::bigint AS est_rows,
s.n_dead_tup
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relname = 'orders'; relname | total_size | relpages | est_rows | n_dead_tup
---------+------------+----------+----------+------------
orders | 2744 kB | 322 | 11880 | 0页数从 667 降到 322(约 52%),总大小减半,relpages 已重置为真实值。
场景二:不能停机时用 pg_repack 在线整理
# 1. 安装并启用扩展(需要超级用户)
psql -d demo -c 'CREATE EXTENSION IF NOT EXISTS pg_repack;'# 2. 先演练,确认能识别到表与索引
pg_repack -d demo -t public.orders --dry-run
# 3. 正式执行:应用阶段最多等 30 秒锁,不主动杀连接
pg_repack -d demo -t public.orders --no-kill-backend -T 30
# 4. 也可以只重建索引
pg_repack -d demo -t public.orders --only-indexesINFO: repacking table "public.orders"
INFO: repacking table "public.orders" (copying 11880 rows)
INFO: repacking table "public.orders" (applying 37 changes)
INFO: repacking table "public.orders" (swapping relation)监控整个过程中的锁等待情况:
-- 观察是否出现长时间的锁等待
SELECT pid, mode, granted, relation::regclass AS rel, query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE relation = 'orders'::regclass
ORDER BY granted, pid;场景三:新建表迁移(同时改结构)
当「重写表」和「改结构」可以一起做时,新建表方案最灵活:
-- 1. 新建目标表(顺带改结构:加列、改类型、加分区)
CREATE TABLE orders_new (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL DEFAULT 'pending',
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
region text -- 新增列
) WITH (fillfactor = 90);
-- 2. 数据迁移(大表建议分批,避免长事务与 WAL 峰值)
INSERT INTO orders_new (id, customer_id, status, amount, created_at, region)
SELECT id, customer_id, status, amount, created_at, 'unknown'
FROM orders
ORDER BY created_at; -- 顺带按时间聚簇
-- 3. 重建索引(先在空表上建,比迁移后建更快)
-- 主键已随表创建
-- 4. 切换:一个事务内换名,锁窗口极小
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders RENAME TO orders_old;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;
-- 5. 处理依赖(视图、外键、权限需要重新指向)
-- 建议在切换前用 pg_depend / \d+ 排查依赖关系
-- 6. 确认无误后清理旧表
DROP TABLE orders_old;换名会打断依赖对象
ALTER TABLE ... RENAME 会同步更新依赖对象的定义(视图、外键等会跟随改名),但序列、触发器、权限、外部引用(如应用里的表名缓存)需要人工核查。切换前务必梳理依赖:
-- 查看依赖该表的对象
SELECT DISTINCT dependent.relname, dependent.relkind
FROM pg_depend d
JOIN pg_class dependent ON dependent.oid = d.objid
JOIN pg_class source ON source.oid = d.refobjid
WHERE source.relname = 'orders' AND dependent.relname <> 'orders';注意事项
1. 永远不要在业务高峰期运行 VACUUM FULL
它全程持有 ACCESS EXCLUSIVE 锁,会把该表的所有读写挡在门外,并形成锁队列拖垮后续请求。必须挑维护窗口,或改用 pg_repack。
-- 不推荐
-- VACUUM FULL orders;
-- 推荐:先确认确实在低峰
SELECT count(*) AS active_conns FROM pg_stat_activity WHERE state = 'active';2. 必须预留双倍磁盘空间
重写过程中旧表与新表共存。空间不足会导致事务失败回滚,白跑一场。执行前用 df -h 确认数据目录分区的可用空间。
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS need_at_least;3. 一定要加 lock_timeout
否则拿不到锁就会无限等待,把后续请求全部拖住。快速失败远比慢速成功安全。
-- 正确
SET lock_timeout = '5s';
VACUUM (FULL, ANALYZE) orders;4. VACUUM FULL 不能在事务块里执行
BEGIN; VACUUM FULL ...; COMMIT; 会直接报错。因此 SET LOCAL 无效,必须用会话级 SET 或 PGOPTIONS。
-- 错误!VACUUM cannot run inside a transaction block
-- BEGIN; VACUUM FULL orders; COMMIT;5. 别忘了 ANALYZE
重写会大幅改变物理布局,旧的统计信息可能已失真。VACUUM (FULL, ANALYZE) 是标准写法。
VACUUM (FULL, ANALYZE) orders; -- 推荐6. 主从架构要考虑 WAL 与复制延迟
整表重写会产生海量 WAL。若 max_wal_size 较小,还会引发频繁检查点。建议:
-- 维护窗口内临时放宽 WAL 尺寸
ALTER SYSTEM SET max_wal_size = '8GB';
SELECT pg_reload_conf();
-- 维护结束后改回同时关注从库回放进度,避免延迟过大:
SELECT application_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;7. 小表不需要 VACUUM FULL
几十 MB 的表,普通 VACUUM 就够了,「缩小文件」的收益微乎其微,而锁风险一样存在。判断标准应是浪费空间的绝对量(例如超过 1 GB)而非比例。
-- 浪费空间可能很大的表才值得考虑
SELECT relname,
pg_size_pretty(pg_relation_size(relid)) AS size,
n_dead_tup
FROM pg_stat_user_tables
WHERE pg_relation_size(relid) > 100 * 1024 * 1024 -- 只看 > 100MB
ORDER BY n_dead_tup DESC;8. pg_repack 不是万能药
它要求表有主键/唯一索引、需要装扩展、应用阶段仍需短暂排他锁,且日志表也会膨胀。超大写入负载下,日志增长可能让应用阶段变得很长,反而加剧锁风险。
# 先 dry-run,评估日志增长规模
pg_repack -d demo -t public.orders --dry-run9. 分区表逐分区处理比整表处理更可控
对分区表,VACUUM FULL 单分区锁影响面小得多,还能分批、分时段执行。
-- 只处理今年的分区
VACUUM (FULL, ANALYZE) orders_2026;10. 中断后检查磁盘与 WAL 是否被撑大
失败回滚后,新表文件会被清理,但 WAL 的回收依赖检查点。看一眼实际占用:
SELECT pg_size_pretty(sum(size)) AS wal_size
FROM pg_ls_waldir();
-- 手动推进一次检查点,让 WAL 尽快可回收
CHECKPOINT;最佳实践
1. 把「表膨胀处理」建成决策树,而不是条件反射地跑 VACUUM FULL
表看起来很大
│
├── 只是 n_dead_tup 高,空闲空间占比低?
│ → 普通 VACUUM 即可
│
├── 空闲空间占比高(free_percent > 30%)且浪费 > 1GB?
│ ├── 可停机 → VACUUM FULL
│ └── 不可停机 → pg_repack
│
├── 是范围查询慢,想让数据物理按某列聚簇?
│ → CLUSTER tbl USING idx
│
├── 是索引膨胀为主?
│ → REINDEX(或 REINDEX CONCURRENTLY)
│
└── 同时要改表结构 / 加分区?
→ 新建表 + INSERT SELECT2. 优先在分区粒度上做重写
分区表的最大好处之一就是「维护动作可以按分区拆开」。按月分区的大表,可以每晚处理一个历史分区,彻底避免长时间排他锁。
-- 分批脚本示例:每次只重写一个分区
VACUUM (FULL, ANALYZE) orders_2025_01;
VACUUM (FULL, ANALYZE) orders_2025_02;
-- ... 逐个月份3. 用 fillfactor 从源头减少未来的膨胀
对高频更新的表,把 fillfactor 调低(如 90),让每个数据页预留空间,更新时就地写入,减少行迁移与新页产生,从而降低再次需要 VACUUM FULL 的频率。
ALTER TABLE orders SET (fillfactor = 90);
-- 注意:该参数只影响后续写入;现有数据要 VACUUM FULL / CLUSTER / pg_repack 后才按新 fillfactor 重排
VACUUM (FULL, ANALYZE) orders;4. 把 VACUUM FULL 纳入变更流程
它不是「随手执行的一条命令」,而是一次影响可用性的变更。应记录:执行时间、目标表、锁超时、预计耗时、回滚方案、执行后校验指标。
-- 执行后校验清单
SELECT relname, relpages, reltuples::bigint,
pg_size_pretty(pg_total_relation_size(oid)) AS total_size
FROM pg_class WHERE relname = 'orders';
-- 索引数量是否齐全(换名/重建后核对)
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index WHERE indrelid = 'orders'::regclass;5. 能用 pg_repack 就别用 VACUUM FULL
判断很简单:有维护窗口用 VACUUM FULL,没有就用 pg_repack。后者唯一的额外成本是安装扩展和准备日志空间,换来的是几乎不阻塞业务。
pg_repack -d demo -t public.orders --no-kill-backend -T 306. 重写前先确认「真正的瓶颈」在哪
如果慢的原因是长事务导致清理失效,或索引膨胀,那么 VACUUM FULL 只是治标:
-- 检查是否有长期占住 oldest xmin 的事务
SELECT pid, state, xact_start, now() - xact_start AS age
FROM pg_stat_activity
WHERE xact_start < now() - interval '5 minutes';
-- 检查索引膨胀(需要 pgstattuple 扩展)
SELECT * FROM pgstatindex('orders_pkey');7. 维护动作要留监控痕迹
用 log_autovacuum_min_duration 与自建监控记录每次重写的耗时与收益,才能判断「多久需要再来一次」。
ALTER SYSTEM SET log_autovacuum_min_duration = 1000;
SELECT pg_reload_conf();8. 在低峰期、低并发、低 WAL 位点的情况下执行
三个「低」同时满足时风险最小:业务并发低(锁影响小)、pg_stat_replication 延迟低(从库跟得上)、当前没有其他大事务(不抢 oldest xmin)。
-- 执行前的「状态体检」
SELECT count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_xact
FROM pg_stat_activity;下一节
继续学习:AutoVacuum 配置