引言:为什么要同时理解"冻结"和"死元组回收"
PostgreSQL 采用 MVCC(多版本并发控制) 实现读写不互斥。每次 UPDATE 并不会原地修改旧行,而是产生一条新版本元组(tuple),旧版本则被标记为"已删除/已更新",成为死元组(dead tuple)。这带来两个彼此独立、但都被 VACUUM 解决的持久化问题:
- 死元组堆积(膨胀 / bloat):死元组占着物理空间,查询时还要扫描它们,导致空间膨胀和性能退化。
- 事务 ID(XID)回卷(wraparound):每个元组都记录其创建/删除的 XID,XID 空间有限(约 42 亿),一旦回卷将导致数据库强制停库。必须通过"冻结(freeze)"把足够老的元组标记为"所有事务可见",从而允许最老事务 ID 前移。VACUUM 是同时承载这两大职责的机制。理解它的参数,本质上就是理解 vacuumlazy.c(PG14 前叫 vaclazy.c)、heapam.c、visibilitymap.c 中那几个关键代码路径。
一、先看机制:VACUUM 到底在代码里做了什么
要对参数有"源码级"理解,需要先建立一条主线。以当前主流的 vacuumlazy.c 为例,一次常规 VACUUM 大体分为:- lazy_scan_heap():顺序扫描堆表的每一个 page。
- 逐行调用可见性判断(HeapTupleSatisfiesVacuum()),把元组归类为:存活(LIVE)、死亡(DEAD)、刚死亡(RECENTLY_DEAD)、插入中(INSERT_IN_PROGRESS)、删除中(DELETE_IN_PROGRESS) 等状态。
- 对 DEAD 元组,把其 TID(6 字节,page + offset) 记入一个内存数组 ------ 这就是后文 死元组 TID 数组。
- 对足够老的元组执行 freeze(设置 HEAP_XMIN_FROZEN 位,见 heap_freeze_tuple())。
- 更新 visibility map(可见性位图) 中的 all-visible / all-frozen 位,为下一次扫描提供"可跳过页面"的依据(PG9.6 起引入 all-frozen,极大优化 freeze)。
- lazy_vacuum_heap_rel() 与 lazy_scan_index():当死元组 TID 数组收集到一定量后,进入"堆清理 + 索引清理"阶段。由于索引中没有可见性信息,不能安全地直接把死元组对应的索引条目删掉,必须先清理堆,再扫描索引删除悬垂指针(这就是死元组→索引悬垂指针的根因)。若 TID 数组放不下所有死元组,就要拆成多趟"索引清理"扫描(见下文第五节)。
- lazy_truncate_heap():清理完死元组后,把表末尾全空的 page 返回给操作系统。
- vac_update_datfrozenxid():扫描 pg_class,推进数据库级与表级的 frozenxid,这是对抗 XID 回卷的核心动作(在表数量巨大时,这一步本身会成为瓶颈)。理解了这条主线,下面每一个参数都对应着主线上某个环节的"旋钮"。
二、冻结(freeze)相关参数:对抗 XID 回卷
冻结的目标是:让足够老的元组不再记录具体 XID,从而使最老活跃事务的 xmin 可以越过它们继续前进。相关参数如下:
1. vacuum_freeze_min_age(默认 50,000,000)
- 作用:手工 VACUUM(含 VACUUM FREEZE)在执行时,若某元组的 XID 年龄 ≥ 该值,就把它冻结。
- 源码落点:该参数定义"冻结起点"。在 heap_freeze_tuple() / heap_prepare_freeze_tuple() 中,比较元组 xmin 与 cutoff_xid(由该参数推导出的阈值 OldestXmin - vacuum_freeze_min_age)来决定是否置 HEAP_XMIN_FROZEN。
- 特性:PGC_USERSET,可在会话级在线设置,也可 per-table 覆盖。冻结不是越早越好------过早冻结会产生额外 WAL 和写放大;过晚会增加回卷风险。
2. vacuum_freeze_table_age(默认 150,000,000)
- 作用:当表的 relfrozenxid 年龄达到该值时,VACUUM 必须执行一次 "激进冻结"(aggressive vacuum)------即不能只依赖 visibility map 跳过页面,而要真正扫描所有页面,把能冻结的全部冻结。
- 与上者的区别(图谱中亦有明确记录):vacuum_freeze_min_age 管"单行元组的冻结阈值";vacuum_freeze_table_age 管"整表是否进入全页扫描冻结"的阈值。
- 意义:它是 autovacuum_freeze_max_age 之前的一道防线,确保表级年龄不至于拖到危险线。
3. autovacuum_freeze_max_age(默认 200,000,000)
- 作用:回卷的硬保护线。当某张表的 relfrozenxid 年龄达到该值时,autovacuum 强制对该表执行 VACUUM 以推进冻结,无论死元组数量是否达到普通 autovacuum 触发阈值。
- 关键:这是防止 XID 回卷导致数据库 database is shut down 的最后手段之一。即使关掉普通 autovacuum 清理,这条线也仍然生效(这也是"关闭 autovacuum 导致死元组堆积"之外,最危险的行为)。
4. autovacuum_freeze_min_age(默认 50,000,000)
- 与 vacuum_freeze_min_age 相同语义,但专用于 autovacuum 进程。即:autovacuum 触发 freeze 的单行年龄阈值。手工 VACUUM 用 vacuum_freeze_min_age,autovacuum 用 autovacuum_freeze_min_age(两者默认都是 5 千万)。
5. autovacuum_vacuum_insert_threshold / autovacuum_vacuum_insert_scale_factor(PG 14 新增)
- 背景:传统 autovacuum 靠"死元组数量"触发。但只插入不更新/删除的表(如日志表、流水表)几乎没有死元组,因此永远不触发普通 autovacuum,而 relfrozenxid 却在持续增长,形成 autovacuum_freeze_stall 风险。
- 作用:这两个参数让 autovacuum 也能依据 插入行数(而非死元组)触发 VACUUM,从而及时冻结"insert-only"表。autovacuum_vacuum_insert_threshold 是固定行数基线,autovacuum_vacuum_insert_scale_factor 是按表行数的比例,触发阈值 = threshold + scale_factor × reltuples。
6. 关于 vacuum_freeze_table_age 场景的"IO 风暴"
图谱中记录了 pg_freeze_io_storm 故障:在 PG9.6 之前(visibility map 无 all-frozen 位),一旦表年龄达到阈值触发全表 freeze,会造成大量数据文件读写与 WAL 生成,引发 IOPS 飙升。这正是 vacuum_freeze_min_age、vacuum_freeze_table_age 与 visibility map 协同作用时,参数调大/调小会影响 VACUUM 频率和 IO 成本的典型体现。
三、死元组回收(VACUUM 触发与节流)相关参数
1. autovacuum 触发类
- autovacuum_vacuum_threshold(默认 50) 与 autovacuum_vacuum_scale_factor(默认 0.2)
- 触发公式:n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples。
- 高更新场景核心:n_dead_tup 由 pg_stat_user_tables 统计。对高频 UPDATE 的大表,若保持默认 0.2,意味着要累积到"表行数的 20% + 50"才触发,表越大,容忍的死元组越多,膨胀越严重。生产上通常大幅调低 scale_factor 或改用固定阈值。
- autovacuum_naptime(默认 60s):autovacuum launcher 每多少秒扫描一次数据库,判断哪些表需要 VACUUM。调小可让触发更及时,但也增加开销。
- autovacuum_max_workers(默认 3):同一时刻最多并行的 autovacuum worker 数。它直接决定内存预算(见第五节)。
以上参数均可通过 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = ...) 做表级覆盖,这是对高频热表做精细化调优的关键手段。
2. VACUUM 节流(cost-based delay)类
VACUUM 若不节流,会猛读猛写,抢占业务 IO。PostgreSQL 引入代价模型,在 VACUUM 过程中累积代价,达到 cost_limit 后 sleep cost_delay 毫秒:
- vacuum_cost_page_hit(默认 1)/ vacuum_cost_page_miss(默认 10)/ vacuum_cost_page_dirty(默认 20):三种 IO 操作的单位代价。
- autovacuum_vacuum_cost_delay(默认 2ms) / autovacuum_vacuum_cost_limit(默认 -1,继承 vacuum_cost_limit 默认 200):autovacuum 使用的节流参数。
- vacuum_cost_delay / vacuum_cost_limit:手工 VACUUM 使用。vacuum_cost_limit 默认 200,即每次累积 200 点代价就 sleep 一次 vacuum_cost_delay。
调优矛盾:
- 想让 VACUUM 更快(减少堆积)→ 调大cost_limit、调小cost_delay,甚至设 autovacuum_vacuum_cost_delay = 0(完全不节流)。
- 想保护业务 IO(减少抖动)→ 调小cost_limit、调大cost_delay。对高频 UPDATE 的热表,通常倾向在低谷期放开节流,让 VACUUM 尽快清理死元组,避免膨胀失控。
3. 清理收尾类
- vacuum_truncate(PG9.0 引入,默认 true):是否把表尾全空页面截断归还 OS。高频 UPDATE 的表若集中在中间区域,可能意义不大,但可减少文件大小。
- vacuum_cleanup_index_scale_factor:PG11 引入、PG14 移除。老版本用于控制索引清理(不归零索引死元组比例的触发)。PG14 起该机制被简化,此处提醒:不要在新版本配置文件中再出现它。
4. 与"长事务阻塞"的关系(间接但极重要)
- idle_in_transaction_session_timeout(默认 0 = 关闭):长事务(尤其 idle in transaction)持有的 backend_xmin 会卡死最老活跃事务,使 OldestXmin 无法前移,进而导致 VACUUM 无法回收其后产生的死元组(HEAPTUPLE_RECENTLY_DEAD 状态),并加速回卷风险。
- 注意甄别(图谱铁律):只有 backend_xmin非空且年龄大时才阻塞 VACUUM;backend_xmin 为空或年龄很小的事务不阻塞。调优时应结合 pg_stat_activity.backend_xmin 判断,而非仅看 state='idle in transaction'。
四、maintenance_work_mem:高频 UPDATE 场景的灵魂参数
这是你特别关注的重点。先说结论:对一张高频 UPDATE 的表而言,maintenance_work_mem 直接决定了单次 VACUUM 能否"一趟"清完死元组,还是被迫"多趟"反复扫索引,从而成为 CPU/IO 与耗时的分水岭。
1 它在源码里管什么
- 常规用途:maintenance_work_mem(默认 64MB,PGC_SUSET,会话级可 SET)为维护类操作分配内存,典型是 CREATE INDEX / REINDEX(B-tree 排序内存)、ALTER TABLE ADD FOREIGN KEY、以及 VACUUM。
- VACUUM 中的具体角色:在 vacuumlazy.c 中,死元组的 TID 数组(dead tuple TID array) 就是用它分配的。收集死元组时,每条死元组占用 约 6 字节(一个 ItemPointerData)。
2 为什么"内存不够"会引发多趟索引扫描(核心机制)
这是理解本参数最重要的一个点:
- 死元组 TID 数组的大小上限由 maintenance_work_mem 决定。
- 当一次扫描堆积的死元组数超过了数组容量(约 maintenance_work_mem / 6 条)时,VACUUM 必须把当前已收集的一批先清掉------即暂停堆扫描,进入"索引清理"阶段。
- 清理完这批后继续扫描堆,又积累一批,再次停下清索引......如此往复,就产生了 多次索引扫描。
- 每趟索引扫描都要从头扫一遍所有相关索引的 B-tree 并删除悬垂指针,CPU 与 IO 开销随趟数线性放大,VACUUM 总耗时显著上升。
对应图谱故障模型 "死元组 TID 数组溢出导致多趟索引扫描":
根因是 maintenance_work_mem 过小,无法容纳所有死元组的 TID(每 6 字节)。VACUUM VERBOSE 显示多次 index scan,CPU 和 I/O 负载高,耗时增长。
对高频 UPDATE 表意味着什么:一张表若每秒产生大量死元组,那么一次 VACUUM 收集到的死元组数量极可能远超默认 64MB 所能容纳的 TID 条数(约 64MB / 6 ≈ 1100 万条),于是频繁触发多趟索引扫描------即使业务空闲,VACUUM 也又慢又耗资源,进一步加剧膨胀恶性循环。
3.公式与配置建议(源码可验证的估算)
单条死元组 TID 约 6 字节,因此可粗略估算:
bash
maintenance_work_mem 能容纳的死元组数 ≈ maintenance_work_mem / 6
若你想让 VACUUM 一趟扫完某次会话的预期死元组总量 D,则需:
bash
maintenance_work_mem ≈ D × 6 字节
实操建议:
- 先看现状:VACUUM VERBOSE 输出的 index scan 趟数,若 > 1,说明 TID 数组溢出。
- 调大 maintenance_work_mem,让单趟能装下更多死元组。但受并发预算约束:
bash
单实例 autovacuum 维护内存峰值 ≈ maintenance_work_mem × autovacuum_max_workers
(autovacuum 实际使用 autovacuum_work_mem,若未单独设置则继承 maintenance_work_mem。若设置过 autovacuum_work_mem,则 autovacuum 用后者,手工 VACUUM 仍用 maintenance_work_mem。)
- 内存账要算清:maintenance_work_mem 是每个维护会话/每个 worker 各自分配的。设得过大 × worker 多,会撑爆系统内存。建议从 min(2GB, 主机内存/4 / autovacuum_max_workers) 起步评估(图谱给出的经验公式),再结合观测到的死元组峰值微调。
五、实战:某张表存在高频 UPDATE,如何配置
5.1 场景画像
- 单表行数大(如千万级),UPDATE 频率极高(每秒数百~数千次)。
- 症状:pg_stat_user_tables.n_dead_tup 持续很高、表膨胀明显、VACUUM 出现多趟索引扫描、业务高峰 VACUUM 抢占 IO。
5.2 配置清单与理由

5.3 表级覆盖(推荐做法,避免全局一刀切)
对高频 UPDATE 的热表,优先用 ALTER TABLE 做表级覆盖,而非全局改:
bash
ALTER TABLE hot_table SET (autovacuum_vacuum_scale_factor = 0.05);
bash
ALTER TABLE hot_table SET (autovacuum_vacuum_threshold = 1000);
bash
ALTER TABLE hot_table SET (autovacuum_work_mem = '1GB');
说明:autovacuum_work_mem 若未设置,会沿用全局 maintenance_work_mem;对单张热表单独抬高,既能加速其 VACUUM,又不影响全局内存预算。
5.4 观测与验证手段
- 监控死元组:pg_stat_user_tables.n_dead_tup 及 last_autovacuum。
- 确认是否多趟扫描:VACUUM (VERBOSE) 观察 "index scan" 出现次数。
- 确认是否被长事务阻塞:查 pg_stat_activity.backend_xmin 的年龄(仅 age 大且非空才阻塞)。
- 观察膨胀:对比 pg_relation_size 与实际逻辑大小,结合 pgstattuple(需装扩展)估算膨胀率。
5.5 配置后的目标效果
理想情况下应看到:VACUUM 单趟完成(index scan = 1)、n_dead_tup 快速回落、表膨胀受控、VACUUM 耗时大幅下降、业务 IO 抖动减少、relfrozenxid 年龄稳步推进。
六、总结:一张"参数---机制"对照表

最终建议:对高频 UPDATE 的某张表,优先做表级覆盖:压低 autovacuum_vacuum_scale_factor / 设固定 autovacuum_vacuum_threshold,按 死元组峰值 × 6 估算并适度抬高该表的 autovacuum_work_mem,配合低谷期放开 VACUUM 节流、设置长事务超时,即可在源码机制层面同时解决"死元组收得掉"和"冻结跟得上"两大问题。
说明:以上参数与源码机制分析基于 PostgreSQL 官方文档及知识图谱中收录的源码级知识点(含 vacuumlazy.c/heapam.c/visibilitymap.c 相关机制)。autovacuum_vacuum_insert_threshold/scale_factor 为 PG14 引入;vacuum_cleanup_index_scale_factor 于 PG14 移除,配置时请勿沿用。具体数值请结合你实例的内存、IO 与业务低谷时段实测调优。