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 扫得再勤也带不走任何行,还会在日志里反复"空转"。
常见凶手按出现频率排:
- 长事务 / idle in transaction:上面脚本里的会话 A 就是标准模型。连接池把事务悬在半开、ORM 忘了 commit、手动 BEGIN 后去改代码,都会钉住 horizon。事务与隔离级别见
05-事务与锁.md。 - 复制槽:物理/逻辑复制槽为保证备库能重放,会同时钉住 xmin 和 WAL。槽长期不消费(比如挂了个没人看的逻辑解码),膨胀和 WAL 堆积双杀。见
10-复制与高可用.md。 - 两阶段 Prepared 事务:
PREPARE TRANSACTION悬而未决的事务同样是有效快照。 - 显式 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_age5000 万(单行冻结门槛)、vacuum_freeze_table_age1.5 亿(超过则整表 aggressive 扫描)、vacuum_failsafe_age16 亿(回绕告急时跳过限速和索引清理,先推水位)。- 如果监控失守:距回绕点 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) × reltuples0.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 多的表) | 事前设计 |
三条监控必配告警,缺一不可:
- 回绕水位:
age(relfrozenxid)任表超过 4 亿(autovacuum_freeze_max_age 的两倍)发 warning,10 亿发 critical——这一条救命,用第五节的排名 SQL; - horizon 钉子户:
pg_stat_activity中xact_age > 10min的会话发 warning,> 1h自动告警到值班;pg_replication_slots中active = false超过一天的槽单独告警; - 清理停摆:
pg_stat_user_tables.last_autovacuum距上次清理超过 3 天、且 work_pending(第六节第三条 SQL)为正的表数量 > 0;同时用 pgstattuple 每周采样膨胀率 > 50% 的热点表进待办。
最后一句心法:在 PostgreSQL 里,膨胀从来不是 vacuum 不够努力,而是有人把快照钉在了过去。告警 2 的命中永远比告警 3 更早,先看它。