Skip to content

VACUUM 与空间回收(VACUUM and Space Reclamation) ​

前面几节讲了死元组是怎么产生的、可见性是怎么判断的。但死元组不会自己消失——它们会一直占着磁盘空间,直到 VACUUM 把它们清理掉。VACUUM 是 MVCC 的「清道夫」,没有它,任何 PostgreSQL 数据库都会在几周内膨胀到不可用。本节把 VACUUM 的四个目标、两种模式(普通 / FULL)、与 autovacuum 的分工、以及它如何联动 FSM 与 VM 讲透。

前置知识

阅读本节前,建议先了解:可见性判断规则

基础概念 ​

VACUUM 是 PostgreSQL 负责回收死元组、推进冻结水位线、维护辅助映射文件(FSM/VM)的维护操作。它既可以被手动触发,也可以由 autovacuum 守护进程自动触发。

理解 VACUUM 需要先统一以下术语:

术语英文说明
死元组Dead Tuple对所有事务都不再可见的元组版本,等待回收
普通 VACUUMPlain VACUUM标记空间可复用,不归还操作系统,不持排他锁
VACUUM FULLFull VACUUM整表重写,归还空间给操作系统,持 ACCESS EXCLUSIVE 锁
空间回收Space Reclamation让死元组占的空间可被后续插入复用
冻结Freeze把元组的 xmin 设为特殊值 2,使其永不参与回卷计算
冻结年龄age(relfrozenxid)表最老未冻结 XID 的距离,接近 20 亿会触发保护
FSMFree Space Map记录每页可用空间的辅助文件,VACUUM 负责更新
VMVisibility Map记录页是否全可见/全冻结的辅助文件,VACUUM 负责设置
自动清理Autovacuum后台守护进程,按阈值自动触发 VACUUM/ANALYZE
清理阈值autovacuum_vacuum_threshold死元组绝对数下限
缩放因子autovacuum_vacuum_scale_factor死元组占比阈值,与阈值共同决定何时触发
截断Truncation表末尾的空页被 VACUUM 从文件尾移除
清理延迟vacuum_cost_delayVACUUM 控制 I/O 冲击的限速机制

VACUUM 的四个目标 ​

很多人把 VACUUM 理解成「清理垃圾」,其实它同时做四件事,任何一件不做都会出问题:

text
① 回收空间:把死元组占的空间标记为可复用(更新 FSM)
② 设置 VM:把"全可见页"和"全冻结页"标记出来,加速 Index-Only Scan
③ 推进冻结:把老元组的 xmin 冻结为 2,推进 relfrozenxid,避免 XID 回卷
④ 更新统计:可选地触发 ANALYZE,更新 pg_statistic
sql
-- 一条 VACUUM 命令同时完成前三件事
VACUUM (VERBOSE, ANALYZE) orders;
text
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 的对比 ​

这是最容易混淆的一组概念,必须分清:

对比项普通 VACUUMVACUUM FULL
空间处理标记可复用,文件不缩小整表重写,文件缩小
空间归还 OS不归还(除非文件尾截断)归还
锁级别SHARE UPDATE EXCLUSIVE(可与读写并发)ACCESS EXCLUSIVE(阻塞一切)
额外磁盘需求几乎不需要需要约一张表大小的额外空间
是否更新 FSM是是(新表重排后)
是否设置 VM是是
是否推进 relfrozenxid是是
执行速度快(只扫不重排)慢(读写整表)
适用场景日常维护、绝大多数情况表严重膨胀且可接受停机窗口
是否能中断回滚可安全中断中断需回滚,代价大
sql
-- 普通 VACUUM:可并发执行,标记空间可复用
VACUUM orders;

-- VACUUM FULL:持排他锁,整表重写,慎用
VACUUM FULL orders;

VACUUM FULL 不是「更好的 VACUUM」

VACUUM FULL 不推荐日常使用。它会长时间持 ACCESS EXCLUSIVE 锁阻塞所有读写,需要额外磁盘空间,且中断后回滚成本高。需要回收空间时,优先选 pg_repack(在线重整理)或 CREATE TABLE ... AS SELECT + 切换。

核心机制 ​

死元组回收的判定条件 ​

VACUUM 并不是「看到死元组就删」。它必须先确认「没有任何活跃事务可能还需要看到这个版本」,这个判断依赖一个水位线:

text
判定条件:t_xmax 已提交,且 t_xmax < 所有活跃快照中最老的 xmin

翻译成人话:只有当删除它的那个事务已经提交,并且所有比它更老的读者都结束了,这个死元组才真正可以被回收。

sql
-- 观察"钉住"水位线的长事务
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;
text
  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,它就是当前的回收水位线。只要它不结束,晚于它的死元组都清不掉。

sql
-- VACUUM 会明确告知"有多少死元组删不掉"
VACUUM (VERBOSE) orders;
text
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 的执行可以拆成下面几步:

text
① 扫描堆页
   逐个读取页里的元组,对每个元组跑一遍可见性判断(用"最老快照")
        │
        ▼
② 标记死元组
   确认可回收的在 Line Pointer 上标记 LP_DEAD / LP_UNUSED
   (PG 14+ 可能直接标记整页为可整理)
        │
        ▼
③ 重建 FSM
   根据页里剩余的可用空间,更新空闲空间映射
        │
        ▼
④ 设置 VM
   若某页所有元组都对所有事务可见 → 设置 all-visible
   若全部已冻结 → 同时设置 all-frozen
        │
        ▼
⑤ 扫描索引
   把指向死元组的索引条目删掉(这是 VACUUM 里最重的部分)
        │
        ▼
⑥ 推进 relfrozenxid
   若表的冻结条件满足,把 relfrozenxid 推进到安全位置
        │
        ▼
⑦ 截断尾页
   若文件末尾连续的空页超过阈值,从文件尾截断并归还空间
sql
-- 用 VERBOSE 观察每个阶段
VACUUM (VERBOSE, ANALYZE) orders;

冻结:VACUUM 最重要的隐藏任务 ​

如果 VACUUM 只回收空间,那它只是个「保洁员」。它真正的战略意义是推进冻结水位线,避免 32 位 XID 回卷:

阈值默认值触发什么
vacuum_freeze_min_age50000000元组 xmin 年龄超过它,VACUUM 才愿意冻结该元组
vacuum_freeze_table_age150000000表 relfrozenxid 年龄超过它,VACUUM 做全表扫描式冻结
autovacuum_freeze_max_age200000000表年龄超过它,强制 autovacuum 冻结(即使 autovacuum 被关闭)
sql
-- 观察冻结相关阈值与当前年龄
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;
text
                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 标志位:

sql
-- 冻结前后对比
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));
text
 冻结前                              冻结后
 lp | t_xmin | t_infomask      →   lp | t_xmin | t_infomask | has_xmin_frozen
----+--------+-------------      →  ----+--------+------------+-----------------
  1 |  76301 |         257      →    1 |      2 |       1282 | t

截断(Truncation):唯一能缩小文件的普通 VACUUM ​

普通 VACUUM 通常不缩小文件,但有一个例外:文件末尾连续的空页可以直接从文件尾截断,并把空间归还操作系统。

sql
-- 触发截断的场景:删除大量"最后插入"的数据
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;
text
 size_before | size_after
-------------+------------
 3592 kB     | 376 kB        ← 尾部空页被截断,空间归还了

反之,如果删除的是头部数据,中间页仍被占用,就无法截断:

sql
-- 删除靠前的数据,无法截断
DELETE FROM trunc_demo WHERE id <= 10000;
VACUUM trunc_demo;
-- 文件大小基本不变(中间还有活元组,尾部页不连续)

autovacuum 的触发条件 ​

autovacuum 何时对一张表动手,由下面的公式决定:

text
VACUUM 触发条件:
  死元组数 > autovacuum_vacuum_threshold
           + autovacuum_vacuum_scale_factor × 活元组数

ANALYZE 触发条件:
  变更元组数 > autovacuum_analyze_threshold
             + autovacuum_analyze_scale_factor × 活元组数
sql
-- 用这条查询估算每张表距离下一次 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;
text
 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.09

orders 的 pct_to_trigger = 99.88%,说明它马上要被 autovacuum 清理了。

相关参数 ​

参数默认值生效方式说明
autovacuumonuser总开关,关闭后仍有「防回卷 autovacuum」兜底
autovacuum_max_workers3重启并发 autovacuum 工作进程数上限
autovacuum_naptime60suser两次巡检的间隔
autovacuum_vacuum_threshold50user死元组绝对数下限
autovacuum_vacuum_scale_factor0.2user死元组占比阈值(大表建议下调)
autovacuum_analyze_threshold50userANALYZE 的变更元组数下限
autovacuum_analyze_scale_factor0.1userANALYZE 的变更占比阈值
autovacuum_freeze_max_age200000000重启强制防回卷冻结阈值
autovacuum_multixact_freeze_max_age400000000重启MultiXact 防回卷阈值
autovacuum_vacuum_cost_delay2msuserautovacuum 每轮延迟(PG12+ 默认 2ms)
autovacuum_vacuum_cost_limit-1userautovacuum 的成本上限,-1 表示沿用 vacuum_cost_limit
vacuum_cost_delay0user手动 VACUUM 的延迟(默认不限速)
vacuum_cost_limit200user手动 VACUUM 的成本限额
vacuum_cost_page_hit1user命中共享缓冲区的页成本
vacuum_cost_page_miss10user未命中共享缓冲区的页成本
vacuum_cost_page_dirty20user弄脏页的成本
vacuum_truncateonuser是否允许截断尾部空页(PG12+)
max_parallel_maintenance_workers2user并行 VACUUM 的最大工作进程数(PG12+)
log_autovacuum_min_duration-1user记录长时间 autovacuum,0 表示全记录
maintenance_work_mem64MBuserVACUUM 的内存上限,影响索引清理效率
ini
# 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
sql
-- 表级覆盖:只对这张表生效,优先级高于全局
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;
text
 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 都要重复扫描无变化的页
sql
-- 观察 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');
text
 VACUUM 前                           VACUUM 后
 visible_pages | total         →   visible_pages | total
---------------+------            ---------------+------
             0 |   420         →              402 |   420
sql
-- 用 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 起,索引清理阶段可以并行化,这对「表有很多索引」的场景提升明显:

sql
-- 启用并行 VACUUM
SET max_parallel_maintenance_workers = 4;
VACUUM (VERBOSE, PARALLEL 4) orders;
text
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是是视索引而定
sql
-- 观察索引膨胀
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;
sql
-- 在线整理索引,不阻塞业务
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 轻得多
sql
-- 批量操作后的标准组合拳
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 步:制造膨胀

sql
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;
text
 heap_size | author_idx_size
-----------+-----------------
 262 MB    | 7840 kB

第 2 步:诊断膨胀

sql
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';
text
 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
sql
-- 用 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');
text
 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

sql
-- 关键指标:空间被 "可复用" 但没归还 → 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;
text
 physical_size | estimated_live_bytes
---------------+---------------------
 262 MB        |             56700000     ← 物理文件是实际数据的约 4.6 倍

第 4 步:用 pg_repack 在线回收(生产推荐)

bash
# 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-check
text
INFO: repacking table "public.blog_posts"
INFO: repacking indexes of "public.blog_posts"
INFO: repacking table "public.blog_posts" ... done

第 5 步:验证结果

sql
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';
text
 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 主表」,这会产生海量死元组。更合理的做法是:

sql
-- 方案 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 效果的最大杀手。
sql
-- 不推荐:长事务中做耗时业务逻辑
-- BEGIN;
-- SELECT ... ;   -- 长时间不提交
  • VACUUM FULL 会持有 ACCESS EXCLUSIVE 锁,期间所有读写都被阻塞,且需要等额磁盘空间,生产环境慎用。
sql
-- 不推荐:业务高峰直接 VACUUM FULL 大表
-- VACUUM FULL orders;
  • n_dead_tup 是累计统计值,VACUUM 后不会立刻归零(由 VACUUM 或 ANALYZE 重置统计);不要据此判断物理死元组数量。
sql
-- 物理层面的真相要用 pgstattuple
SELECT dead_tuple_count, dead_tuple_percent FROM pgstattuple('orders');
  • VACUUM 默认不限速(vacuum_cost_delay = 0),手动 VACUUM 大表可能打满 I/O,影响业务;生产上可临时限速。
sql
-- 手动 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)也会钉住版本,一个失活的槽能让表无限膨胀。
sql
-- 检查不活跃的复制槽(最常见又被忽视的膨胀原因)
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。
sql
-- 大表统一调整模板
ALTER TABLE big_table SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 1000,
  autovacuum_vacuum_cost_delay = 0,
  autovacuum_naptime = '30s'
);
  • 把「死元组比例」纳入监控告警,超过 20% 主动介入。
sql
-- 死元组比例 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;
  • 把「最老事务年龄」纳入监控,它既反映膨胀风险,也反映回卷风险。
sql
-- 数据库级年龄,超过 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:瞬时完成,且完全绕过死元组问题。
sql
-- 分区表的归档方式:几乎零成本
ALTER TABLE orders DETACH PARTITION orders_2023_01;
DROP TABLE orders_2023_01;
  • 不要用 TRUNCATE 替代 DELETE 来"省事":TRUNCATE 不记录行级信息、不可回滚到中间状态,且需要 ACCESS EXCLUSIVE 锁。

下一节

继续学习:MVCC 的优缺点

参考链接 ​