PG17:VACUUM 内存改进
VACUUM 是 PostgreSQL 的 garbage collector,也是长期性能与稳定性的命脉。但直到 PG16,VACUUM 在大表上有一个长期的痛点:清理超大表时,收集「死元组引用」的内存上限实际被压在 1GB 左右——即使你把 maintenance_work_mem 配到 8GB、32GB,超过 1GB 的部分也不会被 VACUUM 用上。结果就是:大表的死元组太多时,VACUUM 只能多次循环扫描(n 个索引就扫 n 遍),一次清理拖成几小时,autovacuum 长期追不上写入。
PostgreSQL 17 对 VACUUM 的内存管理做了整体重构:死元组引用改用更紧凑的存储结构(TidStore),VACUUM/ANALYZE 不再被 1GB 限制(官方措辞:VACUUM/ANALYZE 可用超过 1GB 的 maintenance_work_mem);同时移除与冻结元组的效率提高、VACUUM 产生的 WAL 更紧凑、无索引表的清理被优化、vacuum_buffer_usage_limit 默认值提升。这些变化叠加起来,让超大表的 VACUUM 与 autovacuum 体验显著改善。
本页讲清这轮重构改了什么、怎么配置受益、以及如何用 PG17 的新观测点验证效果。
前置知识
建议先阅读:PG17:批量加载性能 与 PG16:VACUUM 改进。
基础概念
| 概念 | 英文 / 记法 | 说明 |
|---|---|---|
| 死元组 | dead tuple | 已被 UPDATE/DELETE 淘汰、等待 VACUUM 回收的行版本 |
| 死元组收集 | dead tuple collection | VACUUM 扫描表时收集死元组位置(TID)的过程 |
| TidStore | tuple identifier store | PG17 重构的死元组引用存储结构,基于基数树的紧凑整数集合 |
maintenance_work_mem | — | VACUUM、CREATE INDEX 等维护操作的内存上限 |
autovacuum_work_mem | — | autovacuum worker 专用内存上限(-1 表示沿用 maintenance_work_mem) |
| 索引扫描轮次 | index scan passes | 清理阶段遍历索引的次数,内存不足时被迫多轮 |
| 预冻结 | freezing ahead of time | VACUUM 在扫描时顺带冻结「合适」的页面,减少后续冻结负担 |
vacuum_buffer_usage_limit | — | VACUUM 可使用的共享缓冲区数量上限(Buffer Access Strategy) |
| 索引清理 | index vacuuming | 从索引里删除指向死元组的条目 |
旧版 VACUUM 的 1GB 天花板
理解 PG17 的改进,先要看清 PG16 及之前的瓶颈在哪:
- 收集阶段:VACUUM 扫描表堆,把每个死元组的 TID 收集进内存集合。这个集合要覆盖本次清理周期内的全部死元组。
- 清理阶段:对每个索引,扫描全部索引条目,删掉指向死元组的条目;然后回到表堆回收死元组。
- 关键限制:PG16 及之前,死元组集合的实现(数组 + 排序结构)内存效率不高,且内部把它限制在约 1GB——
maintenance_work_mem配得再大,这个集合也最多用 1GB。
后果是:10 亿死元组级别的清理,1GB 集合装不下 → VACUUM 只能「收集一批、清一轮索引、再收集下一批」循环往复。表上如果有 10 个索引,每轮都要扫全部索引,索引扫描轮次(index scan passes)成倍增加,一次 VACUUM 从分钟级拖到小时级,期间还要与业务争 I/O。
PG17 的重构内容
按官方 release notes,PG17 在 VACUUM 上的变化(均新增于 PG17):
| 变化 | 说明 |
|---|---|
| 死元组引用更高效地存储 | TidStore(紧凑整数集合)替代旧结构,同等内存可存远更多 TID |
| 不再受 1GB 限制 | maintenance_work_mem / autovacuum_work_mem 配多大就能用多大 |
| 更高效地移除与冻结元组 | 清理与冻结合并进主扫描,VACUUM 的 WAL 更紧凑 |
| 无索引表的清理优化 | 没有索引的表清理更快 |
vacuum_buffer_usage_limit 默认 2MB | Buffer Access Strategy 的默认配额从 256KB 提升 |
| VACUUM 进度视图增强 | pg_stat_progress_vacuum 增加 indexes_total / indexes_processed |
一句话总结:同样的内存预算能装下多得多的死元组 → 一轮清完的比例大增 → 索引扫描轮次减少 → 大表 VACUUM 从小时级回到分钟级。
语法与用法
让 VACUUM 用上更多内存
-- 手工 VACUUM:为本次会话调高内存
SET maintenance_work_mem = '4GB';
VACUUM (VERBOSE, ANALYZE) big_table;
-- autovacuum:为 worker 单独设内存(不影响手工维护操作)
ALTER SYSTEM SET autovacuum_work_mem = '2GB';
SELECT pg_reload_conf();两条配置的分工:
| 参数 | 作用域 | 典型取值 | 说明 |
|---|---|---|---|
maintenance_work_mem | 所有维护操作(VACUUM/ANALYZE/CREATE INDEX/REINDEX) | 大表环境 1GB~8GB | 每个维护会话一份,手工操作也要警惕多会话叠加 |
autovacuum_work_mem | 仅 autovacuum worker(-1 表示沿用上者) | 512MB~2GB | 独立于 maintenance_work_mem 控制 autovacuum,便于分别管理 |
autovacuum_max_workers | worker 并发数 | 3(默认) | 内存预算 = 单 worker 内存 × worker 数 |
PG17 的意义在于:这些参数终于「说到做到」。以前配 8GB 只有 1GB 生效,现在配置即生效——相应地,内存预算的规划也变得更重要:autovacuum_max_workers × autovacuum_work_mem 是 autovacuum 的总内存上限,调高单 worker 内存时同步核对总量。
预算核对的三个命令
调大内存前先核对「总量账」:
-- 1) 当前配置
SHOW maintenance_work_mem;
SHOW autovacuum_work_mem; -- -1 表示沿用 maintenance_work_mem
SHOW autovacuum_max_workers;
-- 2) autovacuum 总预算换算(bytes)
SELECT CASE WHEN current_setting('autovacuum_work_mem') = '-1'
THEN current_setting('maintenance_work_mem')::bigint
ELSE current_setting('autovacuum_work_mem')::bigint
END * current_setting('autovacuum_max_workers')::bigint AS autovacuum_budget;
-- 3) 正在运行的维护进程
SELECT pid, datname, backend_type, now() - backend_start AS running_for
FROM pg_stat_activity
WHERE backend_type IN ('autovacuum worker', 'autovacuum launcher', 'autovacuum vacuum');第 2 个查询算出的就是「autovacuum 满负荷时的内存占用」,与手工维护的 maintenance_work_mem 峰值相加,才是维护时段的内存安全线。生产上把这段查询固化成视图(CREATE VIEW v_maintenance_budget),容量评审一屏看清。
多 worker 环境的预算示例
一台 64GB 内存、6 个 worker 的仓库库,预算拆解示例:
shared_buffers 16GB
autovacuum_work_mem 2GB × 6 = 12GB(autovacuum 满负荷)
maintenance_work_mem 4GB(手工维护峰值,任务串行)
OS 页缓存 + 业务会话余量 约 30GB示例里故意让 autovacuum 预算接近物理内存的 20%:它平时用不满(常态并发少、单个 worker 很少顶到上限),预算的意义是「最坏情况不崩」。反过来,负载稳定、清理压力可预测的库,把预算压到 10% 换更多页缓存同样合理——预算是约束,不是目标。
表级调参与全局调参的配合
内存参数(本页主题)是全局/会话级的;清理的触发时机还有表级一档(reloptions),两层配合使用:
-- 为热点表单独设定触发阈值
ALTER TABLE hot_table SET (
autovacuum_vacuum_scale_factor = 0.01, -- 全局默认 0.2,热点表调低
autovacuum_vacuum_threshold = 10000,
autovacuum_vacuum_cost_delay = 0 -- 该表清理不限速(业务允许时)
);
-- 查看当前生效值
SELECT relname, reloptions FROM pg_class WHERE relname = 'hot_table';分工原则:内存与 buffer 参数管「单次清理多快多稳」,reloptions 管「什么时候开始清」。注意内存参数没有表级版本——单表的清理内存由承担它的 worker 决定;个别巨型表需要特殊内存预算时,只能用维护窗口手工 VACUUM 前会话级 SET maintenance_work_mem 实现(示例五的做法)。两层各司其职:别用 reloptions 干内存的活,也别找不存在的「表级内存开关」。
查看实际内存使用
-- 会话级确认
SHOW maintenance_work_mem;
SHOW autovacuum_work_mem;
-- VACUUM (VERBOSE) 的输出会报告内存使用情况(PG17)
VACUUM (VERBOSE) big_table;INFO: vacuum of "big_table": processing: ...
INFO: index "big_table_pkey" now contains 50000000 row versions in 134217 pages
...
INFO: memory usage: 2013265920 bytes in 104857600 dead tuples
...
VACUUMmemory usage 行直接报告死元组集合的内存量——PG17 上它可以超过 1GB(以前会被压在 1GB 内),这是验证「配置生效」的最直接方式。
autovacuum worker 的运行观测
autovacuum 是常驻后台,运行状态有自己的观测点:
-- 正在跑的 autovacuum(含防回卷的特殊标记)
SELECT pid, datname, now() - query_start AS running_for,
(query ~* 'to prevent wraparound') AS is_anti_wraparound,
left(query, 70) AS query
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker';
-- 每张表上次 autovacuum 时间与历史次数
SELECT relname, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
ORDER BY last_autovacuum DESC NULLS LAST LIMIT 10;autovacuum 在 pg_stat_activity.query 里形如 autovacuum: VACUUM public.big_table;防回卷清理会带 to prevent wraparound 后缀——它是「被迫 aggressive 清理」的信号,频繁出现说明冻结推进不及时,回到「死元组治理」一节找原因。
进度观测:pg_stat_progress_vacuum
SELECT
pid,
datname,
phase,
heap_blks_total, heap_blks_scanned,
indexes_total, -- 新增于 PG17:本次清理要处理的索引总数
indexes_processed -- 新增于 PG17:已处理完的索引数
FROM pg_stat_progress_vacuum; pid | datname | phase | heap_blks_total | heap_blks_scanned | indexes_total | indexes_processed
-------+----------+---------------------+-----------------+-------------------+---------------+-------------------
4822 | warehouse| vacuuming indexes | 10485760 | 10485760 | 8 | 3
(1 row)indexes_total / indexes_processed 让「索引清理进行到第几个」实时可见——对照一次 VACUUM 里 indexes_processed 是否从 1 数到 N 反复多遍,就能判断这次清理有没有发生多轮扫描(多轮 = 内存不够或死元组太多)。
详细说明
TidStore:死元组引用的紧凑存储
PG17 把死元组集合重构成 TidStore——一种基于基数树(radix tree)的整数集合,专为「存储大量块号+偏移号」优化:
| 维度 | PG16 及之前的数组结构 | PG17 TidStore |
|---|---|---|
| 每 TID 内存占用 | 约 6 字节起(数组项 + 开销) | 常见场景可压缩到约 1~2 字节量级(共享前缀) |
| 上限 | 实际约 1GB | 受 maintenance_work_mem 硬约束 |
| 装载能力(1GB) | 约 1.5 亿级死元组 | 数倍提升(数十亿级 TID) |
TidStore 的细节(radix tree 实现、内存度量)属于实现层面,使用时记住两条结论即可:同等内存下能装更多死元组;上限由你配置的内存决定而不是被悄悄压死。
一轮清完:索引扫描轮次的数学
VACUUM 的总耗时近似可以写成:
VACUUM 耗时 ≈ 表堆扫描耗时
+ 索引扫描轮次 × 每轮全部索引扫描耗时
+ 死元组回收耗时- 表堆扫描与死元组回收的量是「客观存在」的,不随内存配置变化;
- 索引扫描轮次是弹性最大的部分:内存够 → 1 轮;内存不够 → N 轮(N = 死元组总量 / 单轮容量,向上取整)。
例:10 索引大表、3 亿死元组。
- PG16(1GB 上限,约 1.5 亿 TID/轮)→ 2 轮 × 10 索引 = 20 次索引全扫;
- PG17(4GB 内存,TidStore 装载能力翻数倍)→ 1 轮 × 10 索引 = 10 次索引全扫。
仅此一项,索引清理阶段的耗时直接减半;对死元组更多、索引更多的表,改善是成倍的。给 VACUUM 加内存,买的是「减少索引扫描轮次」——这是 PG17 内存改进的本质收益。
更高效的移除与冻结
官方 release notes 的原文是 "Allow vacuum to more efficiently remove and freeze tuples",并注明 VACUUM 产生的 WAL 也更紧凑。机制上,PG17 让清理与冻结动作更多地融合进主扫描流程:扫描阶段遇到「适合冻结」的页面就顺带处理,而不是留给后面的专项冻结阶段集中处理。收益有两面:
- WAL 更紧凑——同样的清理工作产生更少的 WAL 量(对复制延迟敏感的环境是直接利好);
- 后续冻结压力更小——顺带冻结让
relfrozenxid的推进更平滑,减少被迫的 aggressive vacuum(anti-wraparound vacuum)突袭。
关于「eager freezing」的说法
社区文章常把这轮优化称为「eager freezing of pages(提前/主动冻结页面)」。官方 release notes 的措辞是「更高效地移除与冻结元组」;机制描述以上述融合扫描的理解为准,不必纠结名称。
无索引表的清理优化
没有索引的表在旧版 VACUUM 里也要走完一套「收集 + 索引清理跳过 + 回收」的完整流程,仍有不必要的开销。PG17 对无索引表的清理做了针对性优化(官方: "Optimize vacuuming of relations with no indexes")。典型受益场景:staging 表、UNLOGGED 中转表、批量导入前临时卸载索引的表。
vacuum_buffer_usage_limit 默认 2MB
vacuum_buffer_usage_limit 控制 VACUUM 在共享缓冲区里可以「独占」的配额(Buffer Access Strategy 的环形缓冲)。PG17 把默认值从 256KB 提升到 2MB(官方:默认值提升至 2MB):
- 更大的策略缓冲 → VACUUM 扫描时对脏页的写入更成批,减少与业务负载在共享缓冲上的互相驱逐;
- 成本可控——2MB 仍远小于典型 shared_buffers,不会挤占业务缓存。
SHOW vacuum_buffer_usage_limit; -- PG17 默认 2MB
-- 手工 VACUUM 时可临时调整
VACUUM (BUFFER_USAGE_LIMIT '16MB') big_table; -- 128kB ~ 16GB
VACUUM (BUFFER_USAGE_LIMIT '0') big_table; -- 0 表示不使用策略缓冲(不推荐线上)MAINTAIN 权限:让应用账号合法地做维护
PG17 同步补了权限模型:新增 MAINTAIN 表权限与 pg_maintain 预定义角色(新增于 PG17)。拥有 MAINTAIN 权限的角色可以对相应表执行 VACUUM、ANALYZE、REINDEX、REFRESH MATERIALIZED VIEW、CLUSTER、LOCK TABLE:
-- 允许应用账号对自己的表做 VACUUM/ANALYZE(以前必须是表 owner 或超级用户)
GRANT MAINTAIN ON orders TO app_maintainer;
-- 或按角色授权
GRANT pg_maintain TO app_maintainer;此前「应用想自己调度 VACUUM 但账号没权限」是个常见尴尬,运维被迫用超级用户跑维护脚本。PG17 之后,维护权限可以按最小化原则授予。
对 autovacuum 的实际影响
autovacuum 是这轮重构的最大受益者:
- 追平能力提升。autovacuum 的目标通常是「不让死元组堆到 scale factor 触发的量」,内存上限放大后,单次 worker 运行能处理更多死元组,追平写入的速度更快。
- 大表的自动清理不再超时式徘徊。以前大表 autovacuum 反复「清一轮被写入反超」;现在单轮装载能力翻数倍,循环次数骤减。
- 可观测性提升。
indexes_total/indexes_processed+VACUUM (VERBOSE)的 memory usage + autovacuum 日志,让「autovacuum 到底干得怎么样」第一次可以量化跟踪。
配套的运维检查清单:
-- 1. 当前 autovacuum 内存预算
SHOW autovacuum_work_mem; -- -1 表示沿用 maintenance_work_mem
SHOW maintenance_work_mem;
SHOW autovacuum_max_workers;
-- 2. 检查大表的 autovacuum 行为(是否长期滞后)
SELECT relname,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
-- 3. 检查清理过程中的多轮扫描(清理进行时)
SELECT pid, phase, indexes_total, indexes_processed
FROM pg_stat_progress_vacuum;这份清单建议纳入季度例行评审:内存预算随数据量增长重新校准、worker 数随硬件扩容调整,让「配置是否还合理」变成有据可依的例行公事,而不是出了问题才翻配置。
清理对业务缓存的挤占:Buffer Access Strategy
VACUUM 要扫描海量页面,却不应该把业务热数据挤出共享缓冲区。PostgreSQL 用 Buffer Access Strategy(环形缓冲)解决这个矛盾:VACUUM 默认只用一小块环形缓冲(PG17 起由 vacuum_buffer_usage_limit 控制,默认 2MB),脏页在环里循环复用,尽量不去淘汰业务热页。
-- 常规在线清理:默认 2MB 环形缓冲,对业务缓存几乎无感
VACUUM orders;
-- 维护窗口的全量清理:放开环形缓冲换速度(BUFFER_USAGE_LIMIT 选项新增于 PG17)
VACUUM (BUFFER_USAGE_LIMIT '1GB') big_table;
VACUUM (BUFFER_USAGE_LIMIT 0) big_table; -- 0 = 禁用策略,与普通会话共用缓冲区三个实践结论:
- 在线时段不要随手
BUFFER_USAGE_LIMIT 0。禁用策略等于让清理与业务公平争抢缓存,热页被冲掉后业务命中率下降,代价比清理慢几个百分点大得多; - 维护窗口的大清理可以放开 limit。表本来就是全量扫,缓存挤占无所谓,更大的环形缓冲减少环内的换入换出;
- 命中率监控要看挤占痕迹。清理后业务命中率骤降、读 I/O 骤升,第一嫌疑就是清理以非策略模式运行或 limit 设置过大。
死元组堆积的预防性治理
PG17 的内存改进让「清理大堆积」变快,但最好的清理依然是不让堆积发生:
- 监控
n_dead_tup的趋势而不是绝对值。pg_stat_user_tables.n_dead_tup的单点数值受统计刷新时机影响,连续采样看斜率——斜率持续为正且 autovacuum 追不上,才是真堆积。 - 高频小更新的表调低 scale factor。会话状态表、计数器表死元组生成速率高,
autovacuum_vacuum_scale_factor = 0.01起步(甚至更激进),让清理频繁而小口地跑。 - 长事务是最恶劣的死元组制造者。xmin 被长事务钉住的行无法被任何 VACUUM 回收——先治理
pg_stat_activity里的idle in transaction(配置idle_in_transaction_session_timeout),再谈清理参数。 - 批量删除在写入时就想好淘汰方式。一次性
DELETE千万行的替代方案是分区 DROP、TRUNCATE 换表这类结构性手段,VACUUM 是兜底不是设计。
治理的节奏感也很重要:死元组的产生速率由业务写入模式决定,清理速率由配置决定,两者在「数据龄」维度上匹配即可——不必追求 n_dead_tup 归零,那既做不到也没必要。
把「清理更快」与「产生更少」分开管理:前者是数据库版本给的,后者是 schema 设计给的。
内存与 I/O 相关参数速查
| 参数 | 默认 | 说明 |
|---|---|---|
maintenance_work_mem | 64MB | 手工维护操作的内存上限;PG17 起 VACUUM 可全额使用 |
autovacuum_work_mem | -1(沿用上者) | autovacuum worker 的独立上限;大表环境建议显式设置 |
autovacuum_max_workers | 3 | 并发 worker 数;总预算 = 单 worker 内存 × worker 数 |
vacuum_buffer_usage_limit | 2MB | VACUUM 的 Buffer Access Strategy 缓冲大小(0 = 禁用策略) |
autovacuum_vacuum_cost_limit | -1(沿用 vacuum_cost_limit) | 清理节流预算 |
idle_in_transaction_session_timeout | 0(不限制) | 长事务防线,保护死元组的可回收性 |
track_counts | on | 统计收集开关;关掉它 pg_stat_user_tables 就不更新,巡检全部失效 |
记忆原则:worker 内存决定「一轮能装多少死元组」,cost limit 决定「装下后跑多快」,buffer limit 决定「对业务缓存挤占多少」——三个旋钮管三件事,调优前先想清楚要动哪一件。另一个容易被忽略的联动:autovacuum_max_workers 调大后,worker 抢占的不只是内存,还有 I/O 与 CPU——预算核对要三项一起做,共享缓冲的挤占评估则回到 Buffer Access Strategy 一节。
实战示例
示例一:PG16 与 PG17 的大表 VACUUM 对比
同一张 2 亿行、8 索引、堆积 5 亿死元组的模拟大表:
# PG16(maintenance_work_mem = 4GB,实际死元组集合被压在 1GB 内)
psql -p 5432 -c "SET maintenance_work_mem='4GB';"
psql -p 5432 -c "\timing on" -c "VACUUM (VERBOSE) big_table;"INFO: "big_table": found 500000000 removable, 200000000 nonremovable row versions
in 3906251 out of 3906251 pages
INFO: vacuum of "big_table": index scans: 4 ← 被迫 4 轮索引扫描
Execution Time: 4812.441 s ← 约 80 分钟# PG17(同样 maintenance_work_mem = 4GB,配置全额生效)
psql -p 5433 -c "SET maintenance_work_mem='4GB';"
psql -p 5433 -c "\timing on" -c "VACUUM (VERBOSE) big_table;"INFO: "big_table": found 500000000 removable, 200000000 nonremovable row versions
in 3906251 out of 3906251 pages
INFO: vacuum of "big_table": index scans: 1 ← 一轮清完
Execution Time: 1698.220 s ← 约 28 分钟(数字为示意量级,实际随硬件、索引大小、死元组分布而变。)关键看两行:index scans 从多轮降到 1 轮,总耗时同比例下降。
值得注意的反直觉点:PG16 示例里尽管配置了 4GB 内存,死元组集合仍被压在约 1GB 内做多轮扫描——配置在,收益不在,这正是 1GB 天花板最迷惑人的地方。升级到 PG17 后不改任何配置,同样的清理立即多轮变一轮,这也是升级评审里最容易被量化上报的收益。
顺带一提,多轮扫描的不只是「慢」:每多一轮索引扫描,就多一轮「索引页被载入又被失效」的缓存扰动与 WAL 放大。把轮次压到 1,对清理窗口之外的业务负载同样友好。
示例二:验证内存配置生效
-- PG17:调大内存后 VACUUM (VERBOSE) 的 memory usage 应突破旧的 1GB 上限
SET maintenance_work_mem = '8GB';
VACUUM (VERBOSE) huge_table;...
INFO: memory usage: 4294967296 bytes in 2147483647 dead tuples
...memory usage: 4294967296 bytes ← 4GB,突破了旧版约 1GB 的实际天花板对照试验:同一张表在 PG16 上同配置执行,memory usage 会被限制在 1GB 左右。
示例三:autovacuum 参数调优组合
-- 目标:8 索引的 5 亿行主表,死元组生成速率高
-- 策略:单 worker 内存 2GB × 4 workers = 8GB autovacuum 总预算
ALTER SYSTEM SET autovacuum_work_mem = '2GB';
ALTER SYSTEM SET autovacuum_max_workers = 4;
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000; -- 提高清理节流上限
SELECT pg_reload_conf();
-- 超大表单表加速:触发阈值调低 + scale factor 调低
ALTER TABLE big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 10000
);调优原则:
- 先算总账再提单 worker:
autovacuum_work_mem × autovacuum_max_workers不应超过物理内存的合理份额(例如 25%); - 节流参数与内存预算配套:内存大了、装载多了,
vacuum_cost_limit的限速也要跟上,否则「装得下、跑不快」; - 单表参数覆盖全局:热点大表用
ALTER TABLE ... SET (...)单独调,不把全库参数迁就个别大表。
示例四:监控「多轮扫描」是否消失
-- 清理进行中持续采样(每隔 10s 执行一次)
SELECT
phase,
indexes_total,
indexes_processed,
now()
FROM pg_stat_progress_vacuum; phase | indexes_total | indexes_processed | now
----------------------+---------------+-------------------+-------------------------------
vacuuming indexes | 8 | 1 | 2026-10-07 11:20:03.112+08
vacuuming indexes | 8 | 2 | 2026-10-07 11:20:13.455+08
vacuuming indexes | 8 | 3 | 2026-10-07 11:20:24.009+08
vacuuming indexes | 8 | 4 | 2026-10-07 11:20:34.887+08
...健康的清理是 indexes_processed 从 1 单调增长到 indexes_total 后进入回收阶段;如果数到 N 后又从 1 重新开始,说明进入了下一轮索引扫描——内存预算不足以一轮装下全部死元组,需要回看上一节的调优组合。
采样脚本本身也要克制:这个视图的查询开销很低,但 1 秒级的高频采样对超大清理没有额外信息量,10 秒间隔足够还原轮次曲线。
示例五:维护窗口的内存容量规划
给「周末批量清理 + 重建索引」的维护窗口做内存预算:
#!/bin/bash
# weekly_maintain.sh —— 任务串行执行,单任务前显式声明内存
psql -c "SET maintenance_work_mem='4GB'; VACUUM (VERBOSE) orders;"
psql -c "SET maintenance_work_mem='2GB'; REINDEX INDEX CONCURRENTLY orders_pkey;"
psql -c "ANALYZE;"规划规则:
- 串行化维护任务,峰值内存 = 最大单任务内存,而不是所有任务之和;
- autovacuum 的预算独立预留(
autovacuum_work_mem × autovacuum_max_workers),维护窗口内它照常运行,两者之和不得超过物理内存的安全份额(如 25%); - 给页缓存留余量:
shared_buffers+ 维护峰值之外,VACUUM 的 I/O 仍大量经过操作系统页缓存,不能按「物理内存减 shared_buffers」满打满算; - 用调度框架或 advisory lock 互斥,防止两个运维脚本同时跑把内存翻倍。执行完窗口任务记得把会话配置
RESET(连接归还连接池前的规范动作),避免 4GB 的会话级maintenance_work_mem随连接「漏」给下一个租借者。
窗口结束后的「账单」也要留档:本次各任务的实际耗时与内存声明、与预估的偏差——它是下一次窗口预算的直接输入,偏差大的任务重点复盘(多半是对索引大小或 I/O 吞吐的假设错了)。
示例六:把清理纳入每日巡检
-- 1) 死元组堆积 Top 10(关注占比与 last_autovacuum)
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / greatest(n_live_tup + n_dead_tup, 1), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;
-- 2) autovacuum 长期未跑的表(超过 3 天且已有堆积)
SELECT relname, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
AND (last_autovacuum IS NULL OR last_autovacuum < now() - interval '3 days');
-- 3) 阻碍回收的长事务(xmin 被钉住)
SELECT pid, state, now() - xact_start AS xact_age, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start < now() - interval '1 hour'
ORDER BY xact_age DESC;巡检报表让「内存参数够不够」「哪些表要单表调参」「谁在钉住 xmin」三类问题每天自动浮出水面,而不是等慢查询告警才回头排查。
示例七:解读 VACUUM (VERBOSE) 的输出
VERBOSE 输出的每一行都有运维含义:
INFO: vacuum of "orders": index scans: 1
INFO: "orders": found 0 removable, 12345678 nonremovable row versions
in 98304 out of 98304 pages
INFO: memory usage: 1073741824 bytes in 31457280 dead tuples逐行解读:
index scans: 1:索引扫描轮数。PG17 的目标是让它停在 1;大于 1 说明死元组集合超出内存预算被迫多轮(PG17 起 1GB 天花板不再是约束,轮次主要由「死元组数量 ÷ 内存」决定);found 0 removable:可回收元组数。明明有堆积却报 0?九成是 xmin 被长事务或复制槽钉住;nonremovable偏大同理;in 98304 out of 98304 pages:扫描页数与总页数。前者远小于后者说明走了 Visibility Map 快速路径,清理效率高;memory usage:死元组集合的实际内存占用,验证maintenance_work_mem/autovacuum_work_mem配置是否生效的第一手证据。
把 VERBOSE 输出按月归档,index scans 与 memory usage 的趋势线就是「清理健康度」的量化档案。建议把这两个字段提取进监控系统做时序存储——它们是本页所有结论的地面真值(ground truth),任何调参争议都回到这两个数字上。
一个容易忽视的坑:VERBOSE 输出进日志后,日志级别配置不当会让这些 INFO 被整段吞掉(client_min_messages / log_min_messages 配置过低时)。巡检脚本拿不到输出时,先核对日志级别,再怀疑数据库本身。
示例八:大表清理的时间预算评估
给一张 5 亿行、12 索引的表预估清理耗时,避免「随手 VACUUM 卡半天」:
输入估算:
- 表 180GB,堆扫描 @ 500MB/s → 约 6 分钟
- 索引共 220GB,1 轮全扫 @ 300MB/s → 约 12 分钟
- 死元组回收(2 亿 TID,随机写) → 20~40 分钟
- 其他(锁等待、WAL 同步) → 约 10%
预算:≈ 45~65 分钟(PG17,单轮扫描完成)同样的表在 PG16 上,死元组一旦超过约 1.5 亿(旧 1GB 上限的装载量级),索引扫描翻倍,预算直接加 12 分钟起步。评估公式的价值不在精确而在数量级:知道是「分钟级」还是「小时级」,才能决定安排在维护窗口还是在线执行。
预算还应随清理规模动态修正:死元组只占表的一部分时,堆扫描可借助 Visibility Map 跳过干净页,实际耗时低于满表估算;反之所有页都脏时按满表算。估算永远给区间、给量级,精确到分钟的承诺只会打脸。
注意事项
内存配置「生效」不等于「免费」。 PG17 让 maintenance_work_mem 全额可用后,多会话并发的维护操作会真的同时占用各自份额——手工 VACUUM + CREATE INDEX 并发时,内存预算要按「可能并发的最大维护会话数 × 单会话内存」规划。
autovacuum_work_mem 与 maintenance_work_mem 分开规划。 前者只管 autovacuum worker,后者管一切维护操作;只调 maintenance_work_mem 不影响 autovacuum(worker 默认沿用后改),配置前先分清目标。
内存不是唯一瓶颈。 死元组再多内存也装不下时(如死元组数十亿级、内存有限),多轮扫描仍会发生;配合锁表清理、拆分区、降低 scale factor 等手段综合治理。
BUFFER_USAGE_LIMIT 0 会加剧与业务的缓冲争抢。 非维护窗口不要随手关闭 Buffer Access Strategy;2MB 的默认策略缓冲对业务负载几乎无感。
WAL 更紧凑是相对收益。 VACUUM 的 WAL 量仍与死元组数量同阶,大批清理期间的复制延迟抬升依旧要监控,不要因为「PG17 WAL 更紧凑」就放松限流。
VERBOSE 的 memory usage 是过程量。 它报告的是本次清理中死元组集合的峰值/累计占用(随实现细节变化),用于「配置是否生效」的定性判断与纵向对比,不要当精确账本横比不同表。
冻结优化的收益体现在长期。 更高效的冻结减少的是后续 anti-wraparound vacuum 的频率与时长,单次清理未必立竿见影——把它当「慢性收益」纳入季度容量复盘,而不是期待下一次 VACUUM 快十倍。
PG17 的收益是「装得多」,不是「无限装」。 maintenance_work_mem 配得过大(如 32GB)对单次清理未必更快——内存分配本身与页缓存的争抢都有成本;4~8GB 是大表环境的常见甜点区,超过先做对照实验。
升级后旧监控阈值要复核。 依据 PG16 行为(多轮扫描、WAL 量级)设定的告警阈值在 PG17 上会系统性偏保守,重建基线后同步修订阈值,避免「永远不告警」的僵尸监控。
VERBOSE 输出里 nonremovable 偏大是诊断线索。 可回收为 0 而堆积存在,九成是 xmin 被长事务、复制槽或 prepared transaction 钉住;先查 pg_stat_activity 与 pg_replication_slots,再动内存参数。
内存不是清理慢的唯一原因。 I/O 吞吐、索引膨胀、锁等待同样拖慢 VACUUM;先看 pg_stat_progress_vacuum 卡在哪个 phase,再决定动哪个旋钮。
VACUUM FULL 不在本页讨论范围。 它是「重建表」而不是「清理」——全程排他锁、需要双倍磁盘空间,线上大表慎用;运维语境里的「清理」默认指普通 VACUUM。
autovacuum_naptime 与内存参数正交。 它只管 worker 的调度节奏(多久醒来找活干),不管单次清理的内存与速度;「autovacuum 追不上」时别只调 naptime,先核对内存预算与 cost 参数。
最佳实践
- 大表环境把
autovacuum_work_mem显式配置起来。 别再依赖默认的沿用关系:1GB~2GB起步,按单 worker 内存 × workers 数 ≤ 内存预算 25%校准总账。 - 调大内存后核对 autovacuum 总预算与
autovacuum_max_workers。 PG17 让配置全额生效,漏算 worker 数量是内存超卖的新风险。 - 用
pg_stat_progress_vacuum的 indexes_processed 监控轮次。 「数到 N 又从 1 开始」直接定位内存不足的多轮扫描,比事后看日志高效。 - 热点大表单独
ALTER TABLE ... SET (...)调 autovacuum 参数。 全局参数迁就个别大表会拖累全库清理节奏;scale factor 0.01~0.05 是热点表的常见区间。 - VACUUM 节流(cost limit)与内存预算配套调整。 内存装载能力提升后,
autovacuum_vacuum_cost_limit同步上调,避免「装得下、跑不快」。 - 大批清理安排在低峰并限流。 PG17 降低了 WAL 与时长,但「亿级死元组清理」仍是重 I/O 事件;分批(按分区/按表)+ 监控复制延迟是纪律。
- 手工维护会话遵守「一次一个」并声明内存。
SET maintenance_work_mem在会话内生效,多会话并发时总量失控;维护脚本加互斥锁或调度框架串行化。 - 利用 MAINTAIN 权限把 VACUUM 下放给应用账号。 最小化授权让业务侧可以自愈高频清理,运维不必全程代理。
- 无索引/staging 表享受新优化但仍要控制死元组规模。
UNLOGGED+ 周期TRUNCATE的组合比依赖 VACUUM 更彻底。 - 升级 PG17 后重建 VACUUM 基线。 对 Top 10 大表各跑一次记录
index scans与耗时,作为容量规划与后续回归的对照。 - 冻结类收益放进季度复盘。 跟踪
relfrozenxid年龄分布与 anti-wraparound vacuum 的触发频率,验证长期收益是否兑现。 - 把 PG16 → PG17 的 VACUUM 变化写进升级检查单。 内存参数语义变化(1GB 天花板消失)影响容量规划与监控阈值,升级评审必须覆盖。
下一节
继续学习:PG17:逻辑复制槽故障转移
参考链接
- PostgreSQL 17 Release Notes(官方发布说明)
- PostgreSQL 官方文档:VACUUM
- PostgreSQL 官方文档:Routine Vacuuming(VACUUM 原理与 autovacuum)
- PostgreSQL 官方文档:maintenance_work_mem
- PostgreSQL 官方文档:autovacuum_work_mem
- PostgreSQL 官方文档:vacuum_buffer_usage_limit
- PostgreSQL 官方文档:pg_stat_progress_vacuum
- PostgreSQL 官方文档:pg_stat_progress_autovacuum
- PostgreSQL 官方文档:MAINTAIN 权限与 pg_maintain 角色
- PostgreSQL 官方文档:Preventing Transaction ID Wraparound(冻结与回卷防护)