Skip to content
MVCC 与 Vacuum

MVCC 与 Vacuum

PostgreSQL 的多版本并发控制(MVCC)把"历史"直接存在表里,代价是垃圾必须由 Vacuum 回收——这一章讲透一行数据从可见、变死到被清理的全过程,以及生产环境怎么配置 autovacuum 才不会半夜被叫醒。


一、元组可见性:xmin/xmax 与快照

每行数据自带两个"版本号"

先建立一个和多数数据库不同的直觉:PostgreSQL 的 UPDATE 不是原地改,DELETE 也不是真删。表里存的单位是元组(tuple),每个元组头部都藏着两个事务 ID:

  • xmin:创建这个版本的事务 ID;
  • xmax:删除或更新(导致旧版本失效)这个版本的事务 ID;若这个版本还"活着",xmax 为无效值。

用一条时间线看 users 表里 id = 1 这一行的遭遇:

txid=100  INSERT INTO users(email,nickname,password_hash) VALUES('a@x.com','A','x');
          → 版本 A:  xmin=100, xmax=无效            (盘上只有这一个版本)

txid=101  UPDATE users SET nickname='B' WHERE id=1;
          → 版本 A:  xmin=100, xmax=101             (被标记,没被删)
          → 版本 B:  xmin=101, xmax=无效            (新追加的元组)
          此刻 A 和 B 同时躺在磁盘上,A 就是未来的"死元组"

txid=102  DELETE FROM users WHERE id=1;
          → 版本 B:  xmin=101, xmax=102             (也变死,A、B 等 vacuum 收尸)

一条查询能看到哪个版本,取决于它拿到的快照:快照记录了拍快照瞬间"已提交/仍在进行/尚未分配"的事务集合。判定规则可以浓缩成一句话——xmin 在快照前已提交,且 xmax 无效 / 已回滚 / 在快照时尚未提交,这个版本就可见。套到上面时间线:

  • txid=102 提交后,一个新开的 READ COMMITTED 事务查 id=1:A 的 xmin=100 已提交但 xmax=101 也已提交(死了),B 的 xmin=101 已提交且 xmax=102 在它的语句快照里还没提交——看见 B;
  • 若在 txid=101 进行中时查询:B 的 xmin=101 还没提交,不可见,A 的 xmax=101 还没生效,仍可见——看见 A。写不阻塞读,就是这么来的。

为什么要设计成"标记 + 追加"

因为收益太直接:读快照即可,读写互不加锁;每个事务的世界线一致,回滚只是丢弃新追加的版本、把 xmax 当没写过,代价为零。类比 Git:提交是追加新文件版本,回滚是扔掉工作区副本,历史永远在 .git 里等人来清理。清理 MVCC 历史的"git gc"就是 VACUUM——这也是为什么 PG 里 vacuum 不是可选项,而是和 checkpoint 同级的日常机制。

顺带一提两个工程细节,后面反复用到:

  • HOT(Heap-Only Tuple):UPDATE 时,如果没有改动任何被索引的列,并且新元组塞得进同一个数据页,PG 就不往任何索引里写新条目,只在页内把旧元组指向新元组。页里没空间时 HOT 失败、索引照写——这就是后文 fillfactor 的全部意义。HOT 链越长,顺序扫描时要沿链走的跳转越多,所以它不是越"省"越好,得靠 vacuum 周期性把死元组腾出的空间还给页内。
  • hint bit:第一次访问某元组、查 pg_xact(事务状态提交日志)确认 xmin 已提交或 xmax 已回滚后,PG 会把结论写进元组头部的提示位,之后同页访问不再回查状态表。所以"第一次查询慢、之后变快"的玄学现象多半是 hint bit 在生效;反过来说 hint bit 设置了,pg_xact 对应页才能被后续 vacuum 清掉。
  • 索引里也有死条目:非 HOT 更新会让每个索引多一个指向旧 TID 的条目,它们占空间、拖慢扫描,同样只能等 vacuum 顺路清理。这也是索引膨胀的成因之一,详见 04-索引实战.md。

复现脚本:开两个事务亲眼看一次可见性

准备两个 psql 会话。会话 A 用 REPEATABLE READ 把快照钉死,会话 B 并发写入:

-- ============ 会话 A ============
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders;        -- 假设输出 100

-- ============ 会话 B(另一窗口)============
BEGIN;
INSERT INTO orders(order_no, user_id, product_id, quantity, unit_price)
VALUES ('o-9001', 42, 7, 1, 99.00);
COMMIT;                             -- 已提交,A 仍看不到

-- ============ 回到会话 A,再查 ============
SELECT count(*) FROM orders;        -- 仍是 100:快照是事务开始时拍的

-- ============ 会话 B 再来一笔大更新 ============
UPDATE orders SET unit_price = round(unit_price * 1.01, 2);   -- 全表更新:每个版本都会多一个死元组
COMMIT;

-- ============ 会话 A ============
SELECT count(*) FROM orders;        -- 依旧 100
COMMIT;

-- ============ 会话 C(READ COMMITTED,新事务)============
SELECT count(*) FROM orders;        -- 101:看见所有已提交内容

注意 会话 A 在 ROLLBACK/COMMIT 之前,它的事务快照把全库的清理水位钉在了 BEGIN 那一刻——哪怕它只是开着去喝咖啡。这正是下一节的主题。


二、xmin horizon:谁在阻止 vacuum 清理

VACUUM 丢弃死元组的前提是:对数据库里任何还在进行的事务都不可见了。这个边界叫 xmin horizon(全库最老的仍然有效的快照对应的事务 ID)。horizon 不动,vacuum 扫得再勤也带不走任何行,还会在日志里反复"空转"。

常见凶手按出现频率排:

  1. 长事务 / idle in transaction:上面脚本里的会话 A 就是标准模型。连接池把事务悬在半开、ORM 忘了 commit、手动 BEGIN 后去改代码,都会钉住 horizon。事务与隔离级别见 05-事务与锁.md。
  2. 复制槽:物理/逻辑复制槽为保证备库能重放,会同时钉住 xmin 和 WAL。槽长期不消费(比如挂了个没人看的逻辑解码),膨胀和 WAL 堆积双杀。见 10-复制与高可用.md。
  3. 两阶段 Prepared 事务:PREPARE TRANSACTION 悬而未决的事务同样是有效快照。
  4. 显式 point-in-time 快照(pg_snapshot 扩展,PG 13+):导出的历史快照被谁拿着,谁就在钉水位。

用一组查询把嫌疑人都叫出来:

-- 最老的事务在干什么(长事务排第一)
SELECT pid, usename, application_name, state,
       xact_start, now() - xact_start AS xact_age,
       left(query, 60) AS current_query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;

-- 悬着的两阶段事务
SELECT gid, prepared, owner, database FROM pg_prepared_xacts;

-- 复制槽钉住的xmin/WAL位点
SELECT slot_name, slot_type, active, xmin, catalog_xmin, restart_lsn
FROM pg_replication_slots;

关于 old_snapshot_threshold:一些旧教程会教你设它,让 vacuum 可以"越过头顶的长快照"清理旧行,换取空间回收、报 “snapshot too old” 错误了事。这条路在 PG 17 被官方移除了(17 的 release notes 明确写了 Remove server variable old_snapshot_threshold),因为它破坏可见性承诺、实现复杂。到 PG 18 不要找这个参数——正确姿势永远是解决长事务本身,而不是允许 vacuum 牺牲它。


三、死元组与膨胀:数字怎么看

n_dead_tup 的真实语义

pg_stat_user_tables.n_dead_tup 不是对磁盘的盘点,而是统计采集器按 DML 增量记账的结果:每次 UPDATE/DELETE 往账上加,vacuum 后清账。它有两类偏差:多版本并发下统计是近似值;而且 autovacuum 的触发判断用的是 n_dead_tup 与 reltuples 的比例,reltuples 本身也是估计值。把它当趋势信号用,别当精确库存——09-查询优化与EXPLAIN.md 里统计视图同理。

表膨胀:一个直观的对比实验

CREATE TABLE bloat_demo (id bigint PRIMARY KEY, filler text);
INSERT INTO bloat_demo
SELECT g, repeat('x', 100) FROM generate_series(1, 2000000) g;

SELECT pg_size_pretty(pg_total_relation_size('bloat_demo')) AS initial;  -- 约 150MB 量级

-- 制造死元组:半行更新 + 三分之一行再更新
UPDATE bloat_demo SET filler = repeat('y', 100) WHERE id % 2 = 0;
UPDATE bloat_demo SET filler = repeat('z', 100) WHERE id % 3 = 0;

SELECT pg_size_pretty(pg_total_relation_size('bloat_demo')) AS bloated;  -- 明显变大

-- "有效体积"参照物:同样的数据紧凑重写一遍
CREATE TABLE bloat_compact AS SELECT * FROM bloat_demo;
SELECT pg_size_pretty(pg_total_relation_size('bloat_compact')) AS compact;
DROP TABLE bloat_compact;

VACUUM bloat_demo;                            -- 死元组被标记、进空闲地图
SELECT pg_size_pretty(pg_total_relation_size('bloat_demo')) AS after_vacuum;
-- 文件几乎不变小——这是预期行为,不是 bug

膨胀率就是 bloated 与 compact 的差距。VACUUM 之后空间被放进 FSM 供后续 INSERT/UPDATE 复用,但对操作系统来说文件大小没变——想要真正把空间还给磁盘,要么靠尾部截断(第四节讲),要么 VACUUM FULL / 重建。

藏在暗处的膨胀

  • TOAST:超过约 2KB 的列值(长文本、jsonb)被拆进 TOAST 表,更新大字段时 TOAST 块同样"追加新版本 + 等 vacuum 清理"。主表看着不胖、库体积疯涨,多半是 TOAST——查 pg_class 里名字形如 pg_toast_XXXX 的表大小即可定位。
  • 索引:每次非 HOT 更新在每个索引上留下死条目。btree 的特殊性在于它靠"页内分裂-合并"复用空间,页里只要还剩几条活跃元组整页就不释放,所以索引膨胀比表膨胀更难自愈,兜底方案是 REINDEX CONCURRENTLY(PG 12+,见第七节)。
  • 估算工具 pgstattuple:
CREATE EXTENSION pgstattuple;
SELECT table_len, dead_tuple_percent, free_percent
FROM pgstattuple('bloat_demo');                -- 顺序读全表,小心大表耗时
SELECT avg_leaf_density, leaf_fragmentation
FROM pgstatindex('bloat_demo_pkey');           -- 仅 btree

四、VACUUM 到底做了什么

一次普通 VACUUM 对每张表干四件事:扫描标记死元组、把它们的 TID 从各索引摘除、把释放的空间写进 FSM(free space map,供本表后续复用)、维护 VM(visibility map,记录哪些页"全可见")。外加顺路冻结老元组、清 TOAST、尝试尾部截断。

其中 VM 值得多说一句:index-only scan 只敢在 VM 标记 all-visible 的页上免回表(呼应 04-索引实战.md 的覆盖索引章节)。vacuum 把一页所有元组都确认对当前快照可见后才设 all-visible;而防回绕的 freeze 扫描又会清掉这些位、稍后再重设——所以大表 freeze 之后几天 index-only scan 退化成回表扫描是正常现象,别急着调参数,更别因此关 freeze。

VACUUM 不回文件大小,除了尾部截断

VACUUM 只会丢弃表末尾连续的空页(需要拿一会儿 ACCESS EXCLUSIVE 锁再降级,通常无感)。中间的死洞只能靠复用。控制这个行为的开关:

  • vacuum_truncate 存储参数:PG 12 就引入了(ALTER TABLE t SET (vacuum_truncate = false),含 toast.vacuum_truncate),设为 false 可让 vacuum 不做尾部截断,减少锁冲突,代价是不再归还磁盘空间;
  • PG 18 将其升级为服务器级 GUC(18 release notes:Add server variable vacuum_truncate),也可在单条语句上用 VACUUM (TRUNCATE false) 指定。全局默认仍是开启,高写入表如果频繁因截断拿不到锁可以关掉试试。

VACUUM、VACUUM FULL、CLUSTER:三个"清理"的天壤之别

命令 锁 文件体积 适用场景
VACUUM 不阻塞读写 不变(尾部截断除外) 日常回收,autovacuum 自动做
VACUUM FULL ACCESS EXCLUSIVE(表级死锁式排他) 重写为紧凑新文件,缩 膨胀比悬殊且能停写
CLUSTER ACCESS EXCLUSIVE 重写并按指定索引物理排序 大范围顺序读、顺带治碎片

VACUUM FULL 重写表期间,SELECT、INSERT 全部排队,索引全要重建,还会生成等量 WAL——生产大表上它几乎永远是错的答案。真需要收缩时,更常见的做法是"新建表导入 + 改名交换 + 删旧表",锁窗口只在最后改名的一瞬。CLUSTER 对已经靠合理索引访问的库收益很小,别当例行公事跑。

autovacuum 为什么默认开启、几乎不该关:关闭它不是省资源,而是把回绕风险(第五节)和膨胀债务转嫁给人肉运维。合法的例外是批量导入的临时/staging 表:

ALTER TABLE staging_orders SET (autovacuum_enabled = false);
-- 灌数据、做完转换……
VACUUM ANALYZE staging_orders;
ALTER TABLE staging_orders RESET (autovacuum_enabled);

顺带一提 PG 18 的异步 I/O:新 AIO 子系统(io_method 等服务器变量)让顺序扫描、bitmap 堆扫描和 vacuum 都能批量排队读请求,大表 vacuum 扫页的吞吐明显改善(18 release notes 实测确认)。这是"vacuum 太慢"这个老抱怨在引擎层面的答案,而不是靠激进调参。


五、事务 ID 回绕(freeze):数据库的"跨日"问题

为什么 32 位是个死穴

事务 ID 只有 32 位,用完回绕重用,靠模运算比较先后——和船上跨国际日期变更线判"昨天/明天"一模一样:只能安全比较半个周期以内的两个 xid。PG 可见性判断的可用窗口约 20 亿(maximum_age 略小于 2^31),一旦某张表的"最老未冻结 xid"和当前 xid 的距离逼近这个窗口,PG 将无法判断这个老版本相对新快照的可见性——继续写下去就是数据错乱,所以 PG 的自保方式是直接拒写。

冻结(freeze)就是把元组的 xmin 覆写成"已冻结"标记,宣告"它比任何在途事务都老,永远可见",从而把它从水位计算里摘出去。相关坐标:

  • 每张表有 pg_class.relfrozenxid,每个库有 pg_database.datfrozenxid:语义是"比它老的事务肯定都已处理";age(xid) 返回"距离当前还有多少事务预算"。
  • autovacuum_freeze_max_age(默认 2 亿):任一表或库的年龄逼近它,autovacuum 会强制安排 anti-wraparound vacuum,哪怕表上没有死元组——这就是"autovacuum 关了也会回来"的场景之一。相关阈值链(当前版本文档实测默认值):vacuum_freeze_min_age 5000 万(单行冻结门槛)、vacuum_freeze_table_age 1.5 亿(超过则整表 aggressive 扫描)、vacuum_failsafe_age 16 亿(回绕告急时跳过限速和索引清理,先推水位)。
  • 如果监控失守:距回绕点 4000 万事务时,日志开始刷 WARNING: database "xxx" must be vacuumed within ... transactions(实测文档原句);剩不足 300 万时,整个库拒绝分配新事务 ID,普通业务全挂,只能由超级用户跑 VACUUM FREEZE。真到了要进单用户模式的地步,那是危险预案(官方文档也明确说多数情况不再需要,仅保留给"drop/truncate 不要的大表腾地方"),不属于日常操作,这里不展开。
  • multixact(MXID)同样会回绕:多个事务同时锁一行会累积多事务 ID,autovacuum_multixact_freeze_max_age 默认 4 亿,警戒线和拒写规则与 XID 同构。用 SELECT FOR UPDATE、外键检查、ON CONFLICT 的库要把它一起盯。

PG 18 对 freeze 过程有个实打实的改进:普通 vacuum 现在可以顺路冻结一部分 all-visible 页(eager freezing,激进度由 vacuum_max_eager_freeze_failure_rate 控制),把过去"攒到 1.5 亿边界一次全表大冻结"的风暴摊平——这是 18 release notes 实测确认的行为变化。

age 排名监控 SQL(照抄进巡检)

-- 各库水位(离回绕点的余量视角)
SELECT datname, age(datfrozenxid) AS xid_age,
       (2000000000 - age(datfrozenxid)) / 1000000 AS still_safe_million
FROM pg_database ORDER BY xid_age DESC;

-- 库内表级排名:谁最接近强制 vacuum,谁最接近危险
SELECT c.relname,
       age(c.relfrozenxid) AS xid_age,
       age(c.relminmxid)   AS mxid_age,
       c.reltuples::bigint AS approx_rows
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m','t')
  AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY xid_age DESC, mxid_age DESC
LIMIT 20;

日常告警看的是第二条查询的头部:正常 autovacuum 下,大库热点表的 relfrozenxid 年龄应该长期在 2 亿上下被压回去;如果某张表一路涨到 5 亿以上,说明 anti-wraparound 也在排队或限速拖住了它,回头查第六节。


六、autovacuum 调优:生产最常用的参数组

触发公式:先把"什么时候会动手"算清楚

vacuum  触发条件: n_dead_tup  >  autovacuum_vacuum_threshold(默认 50)
                              +  autovacuum_vacuum_scale_factor(默认 0.2) × reltuples
analyze 触发条件: n_mod_tup   >  autovacuum_analyze_threshold(默认 100)
                              +  autovacuum_analyze_scale_factor(默认 0.1) × reltuples

0.2 的默认值对万级小表很合理,对亿级大表是灾难:一亿行的表要攒到两千万死元组才动手,期间查询扫着半表垃圾、优化器还拿着过期的统计信息。所以大表的标配是表级覆盖(存储参数就是干这个的):

ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor   = 0.01,   -- 1% 死元组即触发
  autovacuum_vacuum_threshold      = 5000,   -- 兜底常数,避免 1% 太碎
  autovacuum_analyze_scale_factor  = 0.005,
  toast.autovacuum_vacuum_scale_factor = 0.01
);

注意 toast 前缀那行:TOAST 表默认继承,但大字段表往往也需要显式压低阈值。另有一类"纯插入、几乎不更新"的表(日志、事件),死元组少但未冻结页多,PG 13+ 为此提供了按页插入量触发的 insert vacuum(autovacuum_vacuum_insert_threshold 默认 1000 / scale 0.2),防回绕用,一般不用动。

worker 与限速:为什么你的小表总排在大表后面

autovacuum launcher 每隔 autovacuum_naptime(默认 1 分钟)在一个库里挑活,干活的 worker 默认只有 3 个(autovacuum_max_workers,PG 18 新增 autovacuum_worker_slots,配好槽位后 worker 上限可以在线调、不用重启)。更隐蔽的是代价限速:VACUUM 对每页操作记成本,攒满预算就睡——autovacuum 默认 autovacuum_vacuum_cost_limit = -1(回落到全局 200,且在多个 worker 间按比例分),睡时 autovacuum_vacuum_cost_delay = 2ms,超支越多睡越久(官方公式:实际睡眠 = cost_delay × 累积欠账 / cost_limit,封顶 4 倍 cost_delay)。一个 worker 啃一亿行大表可能要啃好几个小时,期间只占 1/3 的工人,又因分到的预算极小走得极慢——但剩下的 worker 也各有慢活,于是你最急的小表就在"下一轮 naptime 再看"里被无限顺延。

调优方向不是"调快 autovacuum",而是给关键表单独发预算(存储参数 autovacuum_vacuum_cost_limit、autovacuum_vacuum_cost_delay 都可按表覆盖),或在低峰用手动 VACUUM (COST_LIMIT = 4000, PARALLEL = 4) 补位。PG 18 还把限速计时暴露给了观测:track_cost_delay_timing 打开后,vacuum 日志和 pg_stat_progress_vacuum 会报告睡了多少。

观测:三张监控 SQL

-- 1) vacuum 正在干什么、干到哪了(phase: scanning heap / vacuuming index /
--    lazy vacuum of dead tuples ...)
SELECT pid, datname, relid::regclass AS table_name, phase,
       heap_blks_total, heap_blks_scanned,
       num_dead_tuples, max_dead_tuples
FROM pg_stat_progress_vacuum;

-- 2) 各表上次被动清理的时间与累计次数/时长(PG 18 起 pg_stat_*_tables
--    新增 total_autovacuum_time 等列,可直接算平均耗时)
SELECT relname, last_autovacuum, last_autoanalyze,
       autovacuum_count, n_dead_tup
FROM pg_stat_user_tables
ORDER BY last_autovacuum NULLS FIRST   -- 从未清理过的排最前
LIMIT 20;

-- 3) 待工作量排名:距离触发线还差多少(注意:有表级覆盖时以真实值为准,
--    此查询用全局值近似)
SELECT s.relname, s.n_dead_tup, c.reltuples::bigint,
       (current_setting('autovacuum_vacuum_threshold')::numeric
        + current_setting('autovacuum_vacuum_scale_factor')::numeric
          * c.reltuples)::bigint AS trigger_at,
       greatest(0, (s.n_dead_tup
        - (current_setting('autovacuum_vacuum_threshold')::numeric
           + current_setting('autovacuum_vacuum_scale_factor')::numeric
             * c.reltuples)))::bigint AS work_pending
FROM pg_stat_user_tables s
JOIN pg_class c ON c.oid = s.relid
ORDER BY work_pending DESC
LIMIT 15;

日志侧把 log_autovacuum_min_duration = 0 打开,谁被清了、清掉多少、睡了多少(18+)全记下来——这是排查"为什么膨胀还在涨"的第一手材料。另外记住 pg_stat_progress_vacuum 里 num_dead_tuples 逼近 max_dead_tuples 意味着 vacuum 要分多轮扫堆,大表可调 maintenance_work_mem(vacuum 单轮能跟踪的死元组上限和它成正比)——上限不足会让大表 vacuum 反复扫描堆。

关于"18 的 lazy memory vacuum":网传的参数名 vacuum_buffer_usage_limit 在 PG 18 正式发布版中不存在——开发周期(beta 阶段)确有该补丁(限制 VACUUM 跟踪死元组的内存上限),正式发布前被撤回,18 的 release notes 与当前文档都没有它。在官方重新引入之前,按现状调 maintenance_work_mem 即可,别照文章写进配置。


七、膨胀综合治理:把节奏做对,比猛药有效

单靠"多跑 vacuum"治不了膨胀,膨胀治理的核心是别一次制造太多死元组。

分批大更新 + 及时回收。千万行的历史数据修正,一条 UPDATE 出去就是千万死元组 + 一个巨型事务 + 等量 WAL,还会把 horizon 钉住。拆成 10 万一批:

-- 应用层循环(伪代码):每批之间让业务喘口气
UPDATE bloat_demo SET filler = 'new'
WHERE id BETWEEN :lo AND :lo + 99999
  AND filler <> 'new';
COMMIT;
-- 全部批次完成后,跑一次 VACUUM(必要时 ANALYZE),让空间尽快进 FSM 被后续写入复用

每批独立提交,长事务问题顺带解决;批间 SELECT pg_sleep(0.5) 给 autovacuum 让路也是常见配方。

热表用 fillfactor 买 HOT 概率:

ALTER TABLE users SET (fillfactor = 80);
-- 每页留 20% 空位给同页更新,UPDATE 不膨胀索引、页内链变短

users/orders.status 这类高频 UPDATE 但不常动索引列的表,80–90 是常见起步值;纯 append 表保持默认 100。这是事前设计,不是事后补救(详见 04-索引实战.md)。

索引膨胀兜底:确认 pgstatindex 的 avg_leaf_density 长期低于 50%、或索引体积离谱时,用 REINDEX INDEX CONCURRENTLY idx;(PG 12+)在线重建,不阻塞写。

日志/事件表用分区代替 DELETE:亿级表按天/月分区后,过期数据 DETACH/DROP 分区秒删(PG 14+ 支持 DETACH PARTITION ... CONCURRENTLY),根本不进"死元组账本";分区语法见 07-高级SQL.md,归档生命周期见 11-备份恢复与运维.md。


实战建议

参数起步值(在默认值之上做的最小增量,改完观察一周日志再迭代):

参数 默认 OLTP 起步 说明
autovacuum_max_workers 3 4–6 库多、大表多先加工人
autovacuum_naptime 1min 30s 响应更快,开销可忽略
autovacuum_vacuum_cost_limit -1(=200) 500–1000 给 worker 总预算,SSD 盘可再大
autovacuum_vacuum_scale_factor 0.2 全局不动,亿级大表按表 0.01 表级覆盖是主战场
log_autovacuum_min_duration 10min 0 全量记录,排查必需
maintenance_work_mem 64MB 512MB–1GB 大表 vacuum 少扫堆
热表 fillfactor 100 80–90(UPDATE 多的表) 事前设计

三条监控必配告警,缺一不可:

  1. 回绕水位:age(relfrozenxid) 任表超过 4 亿(autovacuum_freeze_max_age 的两倍)发 warning,10 亿发 critical——这一条救命,用第五节的排名 SQL;
  2. horizon 钉子户:pg_stat_activity 中 xact_age > 10min 的会话发 warning,> 1h 自动告警到值班;pg_replication_slots 中 active = false 超过一天的槽单独告警;
  3. 清理停摆:pg_stat_user_tables.last_autovacuum 距上次清理超过 3 天、且 work_pending(第六节第三条 SQL)为正的表数量 > 0;同时用 pgstattuple 每周采样膨胀率 > 50% 的热点表进待办。

最后一句心法:在 PostgreSQL 里,膨胀从来不是 vacuum 不够努力,而是有人把快照钉在了过去。告警 2 的命中永远比告警 3 更早,先看它。