PG16:VACUUM 与性能改进
VACUUM 是 PostgreSQL 的呼吸系统,也是每个大版本最难调的部分。PostgreSQL 16 对它的改进集中在四件事上:冻结(freeze)更快——普通 VACUUM 过程中也会在合适的时机执行页级冻结,全表 freeze 的必要性大幅下降;缓冲区用量可控——新增 BUFFER_USAGE_LIMIT 选项与 vacuum_buffer_usage_limit 参数,让 VACUUM 不再把共享缓冲区搅得天翻地覆;精细化处理范围——PROCESS_MAIN / PROCESS_TOAST 与 SKIP_DATABASE_STATS 等新选项让「只清理想清的」成为可能;可观测性——全新系统视图 pg_stat_io 首次给出 I/O 层面的统一视角。以上能力均新增于 PG16,以官方 release notes 为准。
与前三页一样,先排除两个不属于 PG16 的传闻:其一,「VACUUM 在没有死元组时跳过不必要的索引清理」这一条目未见于 PG16 release notes(相关的内存管理与跳索引优化是 PG17 的工作,见文末展望),本页不作为 PG16 特性书写;其二,initdb --data-checksums 在 PG16 没有变为默认(数据校验和默认开启是更后面版本的变化),升级 PG16 后新建集簇的校验和行为不变。
VACUUM 的基础原理——死元组、冻结、autovacuum 触发条件——在VACUUM 基础一页已经完整讲过,本页聚焦「PG16 的增量」:每个特性先讲它解决什么问题,再给配置、验证与调参建议。性能侧同时覆盖与 VACUUM 同属于「写路径」的两项改进:仅 BRIN 索引列更新时的 HOT 更新放宽,与堆/索引页追加效率的提升。
前置知识
建议先阅读:PG16:逻辑复制增强
基础概念
PG16 VACUUM/性能改进总览
| 特性 | 类别 | 官方条目 | 直接价值 |
|---|---|---|---|
| 非冻结操作中适时页冻结 | VACUUM 冻结 | Performance | 全表 freeze 不再必要 |
| vacuum 冻结性能改进 | VACUUM 冻结 | Overview | freeze 扫描更快 |
BUFFER_USAGE_LIMIT 选项/参数 | 资源控制 | Performance | VACUUM 与业务共享缓冲区 |
PROCESS_MAIN / PROCESS_TOAST | 范围控制 | Utility Commands | 只处理 TOAST 表 |
SKIP_DATABASE_STATS / ONLY_DATABASE_STATS | 范围控制 | Utility Commands | 跳过库级统计更新 |
| autovacuum 更快响应延迟设置调整 | Autovacuum | Maintenance | 在线调参即时生效 |
vacuumdb --schema / --no-process-main 等增强 | 工具 | Server Applications | 批量维护更精细 |
| 仅 BRIN 列更新的 HOT 放宽 | 性能 | Performance | BRIN 表的更新开销大降 |
| 堆/索引页追加更高效 | 性能 | Performance | 高频插入场景加速 |
pg_stat_io 视图 | 监控 | Monitoring | I/O 可观测性 |
pg_locks 投机锁信息 / n_tup_newpage_upd 等统计 | 监控 | Monitoring | 细粒度排障证据 |
移除 vacuum_defer_cleanup_age | 兼容性变更 | Incompatibilities | 参数清理(详见注意事项) |
冻结与「全表 freeze 为什么昂贵」
行多事务 ID(XID)接近回绕边界时需要被冻结——把事务信息替换为特殊标记,使其对后续所有事务可见。PG16 之前的实现里,冻结几乎只发生在「aggressive / anti-wraparound VACUUM」(全表扫描、逐页检查)中,这类 VACUUM 对大表而言代价极高且往往在业务高峰被迫触发。PG16 的思路转变:在普通 VACUUM 顺路访问一个页面时,如果页内行的冻结阈值已满足,就顺手冻结。官方条目明确评价——This makes full-table freeze vacuums less necessary。增量式的冻结把「一次性巨债」摊薄成「日常小额还款」。
缓冲区访问策略(Buffer Access Strategy)
VACUUM 传统上使用环形缓冲区策略(Ring Buffer,默认 vacuum_buffer_usage_limit,256kB):扫描时只用一小圈缓冲,避免把业务热数据挤出共享缓冲区。PG16 之前的问题是这个策略不可调也不可关——对「专门为维护窗口腾出的空闲缓冲区」或「希望借 VACUUM 预热表」的场景都太僵化。PG16 引入三层控制:语句级 BUFFER_USAGE_LIMIT、工具级 vacuumdb --buffer-usage-limit、服务级 vacuum_buffer_usage_limit(同时控制 autovacuum)。
语法与用法
新选项的完整清单
-- 只冻结、跳过数据库级统计更新的快速冻结(新增于 PG16)
VACUUM (FREEZE, SKIP_DATABASE_STATS) my_table;
-- 只处理 TOAST 表:主表死元组不多但大字段频繁更新
VACUUM (PROCESS_MAIN = false) my_table; -- 只扫 TOAST
-- 大型维护窗口:放开缓冲区限制换取速度(新增于 PG16)
VACUUM (BUFFER_USAGE_LIMIT = '1GB') my_table;
-- 反向需求:业务高峰期做轻量清理,几乎不占缓冲区
VACUUM (BUFFER_USAGE_LIMIT = '256kB') my_table; -- 128kB ~ 16GB 可选
-- 只更新库级冻结统计(配合批量维护的收尾)
VACUUM (ONLY_DATABASE_STATS);vacuumdb 侧的对应增强:
# 服务级参数(同时作用于 autovacuum)
# postgresql.conf: vacuum_buffer_usage_limit = '256kB'
# 只处理指定 schema(新增于 PG16)
vacuumdb -d app --schema=public --schema=archive --analyze
# TOAST-only 与缓冲区用量
vacuumdb -d app --no-process-main --buffer-usage-limit=512MB
# 利用新选项的整体加速(官方条目:Use new VACUUM options to improve the performance of vacuumdb)
vacuumdb -d app --jobs=4 --buffer-usage-limit=1GB --skip-locked --verbosepg_stat_io:I/O 的统一观测面(新增于 PG16)
-- 按 backend 类型 / 对象 / 上下文三维查看 I/O 计数
SELECT backend_type,
object, -- relation / temp
context, -- normal / vacuum / bulkread / bulkwrite
reads, -- 读页数
hits, -- 缓冲区命中页数
writes, -- 写回页数
extends, -- 追加新页次数
evictions,
fsyncs,
op_bytes
FROM pg_stat_io
ORDER BY reads DESC
LIMIT 10;context 列是 PG16 特色的关键:vacuum 上下文的行代表 VACUUM 自身的 I/O,bulkread/bulkwrite 是顺序大扫描/COPY 类操作。业务 I/O 与维护 I/O 第一次可以分开计量——「VACUUM 是否在挤占业务 I/O」从猜测变成查询。配合 track_io_timing = on 还能拿到各操作耗时列,定位慢在「次数多」还是「单次慢」。
-- VACUUM 的 I/O 占比(以 reads 为例)
SELECT context,
sum(reads) AS reads,
round(100.0 * sum(reads) / sum(sum(reads)) OVER (), 1) AS pct
FROM pg_stat_io
WHERE backend_type LIKE '%worker%' OR context = 'vacuum'
GROUP BY context ORDER BY reads DESC;详细说明
非冻结操作中的页级冻结
PG16 之前的行为对比:
| 阶段 | PG15 | PG16 |
|---|---|---|
| 普通 VACUUM(有死元组) | 只清理死元组,不冻结已达标行 | 清理死元组 + 顺路冻结达标页 |
| 全表 aggressive VACUUM | 冻结的唯一常规入口 | 仍存在,但触发频率大幅下降 |
| XID 接近回绕 | 被迫全表 freeze | 大部分冻结已在日常完成 |
判断自己集簇受益程度的方法:观察 pg_stat_user_tables 的 last_autovacuum 与 last_autoanalyze 时间分布,以及 aggressive VACUUM 的日志频率(log_autovacuum_min_duration 打开后能看到 vacuum for wraparound prevention 字样)。PG16 升级后,这类「被迫全表 freeze」的日志应显著减少;如果依然频繁,说明死元组产生速率远超 autovacuum 的处理能力,问题在触发参数而不是冻结机制(治理思路见膨胀治理)。
BUFFER_USAGE_LIMIT 的三种取值哲学
默认 256kB —— 业务保护优先:环形缓冲区极小,VACUUM 尽量不扰动缓存
调大至百 MB —— 速度优先:维护窗口内跑批,更多数据常驻缓冲区减少重复读
显式关闭环形 —— 不支持"关闭",但调大到超过共享缓冲区即等效全速扫描取值范围 128kB ~ 16GB,语句级选项覆盖服务级参数,服务级参数同时管 autovacuum。实践建议:
- 业务高峰的 autovacuum:保持默认 256kB,用小缓冲换业务缓存稳定;
- 维护窗口的
vacuumdb批处理:--buffer-usage-limit=1GB起步,配合--jobs并行多个表; - 验证收益:
pg_stat_io里对比context = vacuum的hits/reads比例——调大 limit 后 hits 占比应明显上升(重复读减少)。
PROCESS_MAIN / PROCESS_TOAST 与 SKIP_DATABASE_STATS
三个新选项解决的都是「VACUUM 的固定开销」问题:
PROCESS_MAIN = false:跳过主表,只处理 TOAST。大字段(text/bytea/JSONB 大文档)频繁更新的表,死元组集中在 TOAST 表,主表扫描纯属浪费。vacuumdb --no-process-main是其批量形态。SKIP_DATABASE_STATS:跳过 VACUUM 结束时对全库冻结页统计(pg_database.datfrozenxid)的更新。批量逐表 VACUUM 时,每张表都更新一次库级统计是重复开销——全部处理完后跑一次VACUUM (ONLY_DATABASE_STATS)收尾即可。ONLY_DATABASE_STATS:只做上述库级统计更新,几乎瞬间完成。
典型组合(晚间批量冻结):
vacuumdb -d app --freeze --jobs=8 --skip-database-stats \
&& psql -d app -c "VACUUM (ONLY_DATABASE_STATS);"autovacuum 延迟设置的响应改进(新增于 PG16)
autovacuum 的工作节奏由 autovacuum_vacuum_cost_delay / autovacuum_vacuum_cost_limit 控制。PG15 及以前,这些设置的变更要等当前 autovacuum 工作进程「下一次平衡点」才被感知——在线调参后可能观察十几分钟才见效。PG16 让 autovacuum 更频繁地重新读取并遵循延迟设置的变化:紧急业务高峰时把 delay 调大限流、低谷时调小加速,效果接近即时。运维含义是把「调 autovacuum 参数」从「重启级动作」降级为「在线可调」。
仅 BRIN 列更新的 HOT 放宽(新增于 PG16)
HOT(Heap-Only Tuple)更新——新元组仍在同页、且所有索引列都没变时不写任何索引新条目——是 PostgreSQL 更新性能的关键通道(机制见HOT 与页内更新)。PG16 放宽了「索引列」的判定:只更新 BRIN 索引覆盖的列时,依然可以 HOT。BRIN 本来就是「块范围摘要」,不指向具体行,旧元组的索引摘要依然正确,无需更新。对时序/日志类表(brin(created_at) + 常更新 status 列)这是数量级的差别:更新从「写主键 B-tree 新条目」变成纯堆内操作。BRIN 的原理与建索引见BRIN 索引。
堆与索引页追加的效率提升(新增于 PG16)
高频 INSERT/追加(append-only)场景每次都要从 FSM(空闲空间映射)或页尾找可写位置。PG16 改进了堆页与索引页的追加路径,降低并发插入下的争用与查找开销。对「持续写入的事件表、消息队列表」是透明加速;写路径的另一个透明改进是 xid/subxid 搜索与 ASCII 检测的向量化(对 COPY FROM 文本处理有帮助)。
监控细节的补齐(新增于 PG16)
pg_stat_all_tables.n_tup_newpage_upd:记录「更新后迁移到新页」的行数——HOT 失败且跨页的更新(产生索引膨胀的主力)第一次有了独立计数;pg_locks投机插入锁信息:唯一约束冲突探测(speculative insertion)期间的锁在transactionid/objid列可见,排查「唯一索引上的插入等待」有了证据链;pg_stat_io温度关系 I/O 修正:临时表写入的 I/O 计数归入正确对象;- 等待事件
SpinDelay/DSMAllocate:自旋锁睡眠与动态共享内存分配等待可观测; pg_stat_*_tables/pg_stat_*_indexes的 last_seq_scan / last_idx_scan:见聚合与窗口函数增强的展开(两页共用,此处不重复)。
pg_stat_io 字段速查
pg_stat_io 的列比传统 pg_stat_database 细一个维度,常用口径如下(全集以官方 monitoring-stats 文档为准):
| 列 | 含义 | 典型用法 |
|---|---|---|
backend_type | 后端类型(client backend / autovacuum worker / parallel worker 等) | 分离业务与维护的 I/O |
object | I/O 对象(relation / temp relation) | 排查临时文件滥用 |
context | 访问上下文(normal / vacuum / bulkread / bulkwrite) | 维护 I/O 单列观测 |
reads / read_time | 读页数 / 读耗时(需 track_io_timing = on) | 判断「次数多」还是「单次慢」 |
hits | 缓冲区命中页数 | 命中率 = hits / (hits + reads) |
writes / write_time | 脏页写回数 / 耗时 | 与 checkpoint 压力关联分析 |
extends | 追加新块次数 | 表高速膨胀的旁证 |
evictions | 缓冲区淘汰次数 | 环形缓冲区扰动的直接证据 |
reuses | 批量读的缓冲区复用次数 | bulkread 上下文特有 |
fsyncs / fsync_time | fsync 次数 / 耗时 | 存储性能基线 |
op_bytes | 单次操作的字节数(通常 8kB) | 换算绝对流量 |
stats_reset | 最近一次统计重置时间 | 判断读数的累计口径 |
命中率必须按 backend_type / context 分组计算,不要对全表求平均——autovacuum worker 的环形缓冲命中率天然偏低,混入全库均值会掩盖业务缓存的真实状态。
VACUUM 的执行阶段与进度观测
VACUUM 的内部阶段在 pg_stat_progress_vacuum 里实时可见(该视图自 PG14 引入,PG16 沿用并受益于前述新选项):
| 阶段(phase) | 在做什么 |
|---|---|
initializing | 准备阶段 |
scanning heap | 扫描堆页、收集死元组 |
vacuuming indexes | 清理各索引中的死元组条目 |
vacuuming heap | 回收堆页空间 |
truncating heap | 截断表尾空页 |
final cleanup | 收尾 |
SELECT p.pid,
c.relname,
p.phase,
p.heap_blks_total,
p.heap_blks_scanned,
round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total, 0), 1) AS scan_pct,
p.num_dead_tuples,
p.memory_used
FROM pg_stat_progress_vacuum p
JOIN pg_class c ON c.oid = p.relid;num_dead_tuples 逼近上限意味着即将进入索引清理阶段;长时间停在 vacuuming indexes 的 VACUUM,瓶颈几乎都在索引数量与死元组规模上——这是「该不该给表减索引」的直接证据。维护窗口里的批量任务用这张表判断「还在干活还是卡住了」,比翻日志直观得多。
PG16 移除 vacuum_defer_cleanup_age
PG16 移除了服务参数 vacuum_defer_cleanup_age。它曾被用来「延迟清理以保护备库查询」——但自 hot_standby_feedback 与复制槽普及后早已过时。升级检查清单:postgresql.conf、巡检脚本、Ansible 模板里若还有该参数,PG16 启动会因未知参数报错,直接删除并确认备库保护改用 hot_standby_feedback 或复制槽。
例行维护的参数速查表
PG16 的 VACUUM 调优落在几组参数上。下表按「日常够用」的口径列出高频项(全集见官方 autovacuum 配置文档):
| 参数 | 默认值 | 说明 |
|---|---|---|
autovacuum | on | 总开关,生产环境必须保持开启 |
autovacuum_vacuum_scale_factor | 0.2 | 死元组占表行数的比例阈值 |
autovacuum_vacuum_threshold | 50 | 触发清理的最小死元组数 |
autovacuum_analyze_scale_factor | 0.1 | 触发 ANALYZE 的变更比例 |
autovacuum_naptime | 1min | 两轮自动维护的间隔 |
autovacuum_max_workers | 3 | 并发 worker 数(重启生效) |
autovacuum_vacuum_cost_delay | 2ms | 成本限流的休眠时长 |
autovacuum_vacuum_cost_limit | -1 | 跟随 vacuum_cost_limit(默认 200) |
autovacuum_freeze_max_age | 200000000 | 触发反回绕 aggressive vacuum 的 XID 龄期 |
autovacuum_work_mem | -1 | 每个 worker 可用的最大内存(-1 跟随 maintenance_work_mem) |
vacuum_failsafe_age | 1600000000 | failsafe 冻结的兜底龄期(PG14+) |
vacuum_buffer_usage_limit | 256kB | 新增于 PG16:VACUUM/autovacuum 的缓冲区用量 |
log_autovacuum_min_duration | 10min | 超过该时长的 autovacuum 记录日志 |
autovacuum_vacuum_insert_scale_factor | 0.2 | 只追加表的插入量触发比例(PG13+) |
autovacuum_vacuum_insert_threshold | 1000 | 只追加表的插入量触发阈值(PG13+) |
vacuum_cost_limit | 200 | 手工 VACUUM 的成本上限 |
track_io_timing | off | 打开后 pg_stat_io 才有 read_time 等耗时 |
表级 autovacuum 参数:大表的分而治之
全局参数是「一张皮」,而表与表的行数、更新率可能差几个数量级。按表覆盖(存储参数)是治理混杂负载的主要工具:
-- 高频写入的大表:1% 死元组即触发,且不限流
ALTER TABLE hot_events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_cost_delay = 0
);
-- 只读归档表:关闭常规 autovacuum
ALTER TABLE cold_archive SET (autovacuum_enabled = false);
-- 查看哪些表带了自定义参数
SELECT c.oid::regclass AS table_name, c.reloptions
FROM pg_class c
WHERE c.reloptions IS NOT NULL
ORDER BY 1;两个必须知道的边界:其一,autovacuum_enabled = false 挡不住反回绕的 aggressive VACUUM——XID 龄期到达 autovacuum_freeze_max_age 时照样全表冻结,只读归档表因此更应保持龄期低位(页级冻结的日常化在此收益最大);其二,表级参数即时生效、无需重启,是线上灰度调 autovacuum 的首选路径。
XID 回绕防护的监控清单
页级冻结降低了全表 freeze 的频率,但 wraparound 防护的监控义务不变。三条核心 SQL 构成例行巡检:
-- 1) 数据库级 XID 龄期(超过 10 亿即需立即处理)
SELECT datname, age(datfrozenxid) AS dat_age
FROM pg_database
ORDER BY dat_age DESC;
-- 2) 表级 XID 龄期 Top 10(aggressive vacuum 的直接目标)
SELECT c.oid::regclass AS table_name,
age(c.relfrozenxid) AS rel_age,
pg_size_pretty(pg_table_size(c.oid)) AS size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 10;
-- 3) 长事务(冻结推进的头号障碍)
SELECT pid, usename, state,
now() - xact_start AS xact_age,
left(query, 60) AS query_head
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 5;判读基准:dat_age 常态应远低于 2 亿(autovacuum_freeze_max_age 的默认触发线);rel_age 持续爬升的表,检查它的 autovacuum 触发参数与长事务;多事务 ID(MXID)侧对照 relminmxid 与 pg_multixact 做同样巡检。任何一条超阈值,处置顺序永远是「先清长事务、再查触发参数,最后才考虑手工 VACUUM (FREEZE)」,而不是直接重启。
BUFFER_USAGE_LIMIT 与 work_mem 的关系辨析
BUFFER_USAGE_LIMIT 常被误解为「VACUUM 版的 work_mem」,两者的定位完全不同:
| 维度 | work_mem | BUFFER_USAGE_LIMIT |
|---|---|---|
| 控制对象 | 排序/哈希算子的私有内存 | 缓冲池的共享访问配额 |
| 单位与上限 | kB/MB/GB,无硬性上限 | kB/MB/GB,上限 16GB |
| 分配方式 | 每个算子独立分配 | 借用的缓冲环 |
| 过大的后果 | 内存压力、swap | 挤占业务缓冲命中率 |
| 过小的后果 | 排序外排落盘 | 更多 eviction,I/O 上升 |
便于记忆的类比:work_mem 是「这道菜用多大的案板」,BUFFER_USAGE_LIMIT 是「这道菜允许占用厨房台面的比例」。VACUUM 的死元组收集结构仍受内存约束(其上限由 maintenance_work_mem 推导,pg_stat_progress_vacuum.memory_used 可见),但那是内部结构的增长,不是 BUFFER_USAGE_LIMIT 的语义——调参时不要拿 work_mem 的直觉套用。
长事务与冻结的相互作用
页级冻结改变的是「冻结动作的成本分布」,不改变 xmin 可见性地平线的规则:
- 长事务(以及持有
xmin的其他来源:备库的hot_standby_feedback、逻辑槽的catalog_xmin、pg_dump的快照)会让 VACUUM 无法把relfrozenxid推进到当前 XID——新数据即使被扫描到也冻不了; - 换句话说,只要长事务在,页级冻结的「日常化」收益就被截断——表龄期停在最老事务的 XID 上,最终仍会逼近 aggressive 阈值;
- 因此 PG16 的冻结改进与「长事务治理」是乘法关系而非替代关系:前者让日常 vacuum 顺手做冻结,后者决定冻结能推进多远。
巡检里把「当前最老的 xmin 来源」查出来(pg_stat_activity 的 backend_xmin、pg_replication_slots 的 xmin / catalog_xmin),它就是本集簇冻结水位的实际天花板。三类来源各对应一种处置:
| 来源 | 典型元凶 | 处置 |
|---|---|---|
| 本地事务 | 遗漏提交的交互式会话、代码里的 BEGIN 泄漏 | 会话级终止 + 代码层事务边界评审 |
| 备库反馈 | 备库长报表、hot_standby_feedback 常开 | 报表限流或按库拆分反馈 |
| 复制槽 | 长期离线的订阅端、废弃未删的槽 | 恢复消费或删槽;max_slot_wal_keep_size 兜底 |
备库上的 VACUUM 图景
备库(hot standby)不执行自己的 VACUUM——清理动作全部来自主库并随 WAL 重放。由此有三点运维含义:
- 备库的清理滞后于主库:应用延迟、
recovery_min_apply_delay都会放大这个差距,延迟备库上的死元组空间占用持续更久; - 备库查询对主库 vacuum 的反作用:
hot_standby_feedback开启后,备库的长查询会把 xmin 反馈回主库,阻止主库清理——「备库报表拖慢主库膨胀」的经典链条; - 备库上的逻辑槽是保留源:PG16 备库逻辑解码落地后,备库上的逻辑槽同样保留 WAL 与
catalog_xmin,主库的 vacuum 无法清理这些目录元组。
排查主库膨胀时,把三类「xmin 持有者」(本地长事务、备库反馈、复制槽)用 pg_stat_activity 与 pg_replication_slots 交叉排查,比只看 vacuum 日志有效得多。
一句话归纳:主库负责「做」清理,备库负责「配合」清理——前者靠参数与长事务治理,后者靠反馈与延迟控制;两端的责任清单都要写进运维手册,缺一边都会让膨胀在看不见的角落发生。
实战示例
示例一:升级前后 autovacuum 行为对比
-- 升级前基线:记录 aggressive vacuum 频率
ALTER SYSTEM SET log_autovacuum_min_duration = 0;
SELECT pg_reload_conf();
-- 运行一周,统计日志:
-- grep -c "vacuum for wraparound prevention" postgresql.log-- 升级 PG16 后:观察日常 vacuum 是否顺路冻结
SELECT relname,
n_dead_tup,
n_mod_since_analyze,
last_autovacuum,
last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;理想状态:n_dead_tup 长期低位平稳,wraparound 日志接近消失;pg_stat_io 中 context = vacuum 的 reads 占比稳定且不打满。
示例二:TOAST 密集表的精准清理
-- 找出「TOAST 死元组占比高」的表
SELECT c.relname AS main_table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size,
s.n_dead_tup AS main_dead
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.reltoastrelid <> 0
ORDER BY pg_relation_size(c.reltoastrelid) DESC
LIMIT 5;# 只清理这些表的 TOAST,避开主表扫描
vacuumdb -d app --no-process-main \
--table="large_documents" --buffer-usage-limit=512MB --verbose配合应用侧把「大 JSONB 字段的整体覆盖更新」改为「只更新变化的小字段」(把大字段拆到独立表),TOAST 死元组的产生速率本身就能降一个量级。
示例三:pg_stat_io 定位维护窗口 I/O 争用
-- 维护窗口执行 vacuumdb --buffer-usage-limit=1GB 前后各拍一次快照
CREATE TEMP VIEW io_snap AS
SELECT backend_type, object, context, reads, hits, extends, writes
FROM pg_stat_io;
-- 维护窗口结束后的对比查询(示例)
SELECT context,
sum(reads) AS reads_total,
sum(hits) AS hits_total,
round(100.0 * sum(hits) / NULLIF(sum(hits) + sum(reads), 0), 1) AS hit_pct
FROM pg_stat_io
WHERE context IN ('vacuum', 'normal')
GROUP BY context; context | reads_total | hits_total | hit_pct
---------+-------------+------------+---------
normal | 120431 | 9812023 | 98.8 -- 业务命中率高,未被挤占
vacuum | 314005 | 871200 | 73.5 -- limit=1GB 后命中率显著上升若 normal 的 hit_pct 在维护期间明显低于常态,说明 1GB 的 limit 对这台机器仍偏大,回退到 256MB 重试;反之可以继续放宽换取更短窗口。
示例四:PG16 形态的例行维护脚本
把本页的新选项串成一条维护流水线(周末维护窗口执行):
#!/usr/bin/env bash
# vacuum_maintenance.sh:PG16 形态的周末维护
set -euo pipefail
export PGHOST=db1.prod.internal PGDATABASE=app
# 1) 高写入 schema 常规清理 + 统计刷新(PG16:--schema 精准圈定范围)
vacuumdb --schema=public --analyze --jobs=4 \
--buffer-usage-limit=1GB --skip-locked --quiet
# 2) TOAST 密集表专项:只清 TOAST 不扫主表(PG16:--no-process-main)
vacuumdb --table=large_documents --no-process-main \
--buffer-usage-limit=512MB --quiet
# 3) 批量冻结收尾时跳过库级统计,最后一次统一更新(PG16:SKIP/ONLY_DATABASE_STATS)
vacuumdb --freeze --jobs=8 --skip-database-stats --quiet
psql -c "VACUUM (ONLY_DATABASE_STATS);"
# 4) 冻结水位报告(写入巡检日志)
psql -Atc "SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;"逐段对应本页特性:--buffer-usage-limit 让批处理在维护窗口内吃满缓冲区换速度;--schema / --table 把范围收敛到真正需要处理的表;--skip-database-stats + ONLY_DATABASE_STATS 把库级统计更新从「每表一次」降为「全局一次」;最后的龄期报告让 wraparound 防护有迹可循。同样的流水线在 PG15 上要写成更粗粒度的形态——这正是 PG16 维护工效的直接体现。
示例五:大表 autovacuum 参数的灰度与验证
线上大表的 autovacuum 调参要「先灰度、后验证」:
-- 1) 选出膨胀嫌疑表:死元组比例高
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY dead_pct DESC
LIMIT 5;
-- 2) 灰度:仅对嫌疑表收紧触发与限流
ALTER TABLE hot_events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 1
);
-- 3) 验证:观察一个 naptime 周期内的行为
SELECT relname, n_dead_tup, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'hot_events';-- 4) 对照:清理前后的表尺寸(膨胀回收的直接证据)
SELECT pg_size_pretty(pg_table_size('hot_events')) AS before;
-- 等待 autovacuum,或手动执行 VACUUM (VERBOSE, BUFFER_USAGE_LIMIT '512MB');
SELECT pg_size_pretty(pg_table_size('hot_events')) AS after;验证通过(尺寸回收、last_autovacuum 推进)后再推广到同类表;autovacuum_count 长期不涨说明触发条件还没被满足,回头检查 naptime 与 worker 占用。整个过程不重启、不锁表,随时 ALTER TABLE ... RESET 回滚到全局默认。
示例六:维护窗口的 pg_stat_io 复盘
维护窗口结束后,用 pg_stat_io 对比「维护 I/O」与「业务 I/O」的实际比例:
SELECT backend_type,
context,
reads,
pg_size_pretty(reads * op_bytes) AS read_bytes,
round(100.0 * hits / NULLIF(hits + reads, 0), 1) AS hit_pct
FROM pg_stat_io
WHERE backend_type = 'autovacuum worker'
OR context IN ('vacuum', 'bulkread')
ORDER BY reads DESC;复盘要点:
context = 'vacuum'的读取量占全库读的比例,就是 VACUUM 对业务的缓冲扰动程度——evictions显著增长说明环形缓冲在反复冲刷业务页;- 若 vacuum 上下文的
hit_pct反而高于 normal 上下文,说明被清理的表本身比缓冲池小,BUFFER_USAGE_LIMIT维持默认即可; - 对比要在维护前后各取一次快照(记录时间戳做差或
pg_stat_reset),单点读数没有意义。
把这个查询放进维护手册的「窗口后动作」,与 autovacuum 日志(log_autovacuum_min_duration)互为印证——前者看 I/O 形态,后者看每张表的清理时长与死元组数。
示例七:冻结专项的表级巡检脚本
「哪些表该做冻结专项」的回答脚本(低峰期巡检用):
SELECT c.oid::regclass AS table_name,
age(c.relfrozenxid) AS xid_age,
mxid_age(c.relminmxid) AS mxid_age,
pg_size_pretty(pg_table_size(c.oid)) AS size,
s.last_autovacuum,
s.n_tup_upd + s.n_tup_del AS churn
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND (age(c.relfrozenxid) > 500000000
OR mxid_age(c.relminmxid) > 500000000)
ORDER BY age(c.relfrozenxid) DESC;阈值定在 5 亿(aggressive 触发线 2 亿的 2.5 倍)意味着筛出的是「已经 aggressive 过但水位仍高」的表——它们通常被长事务或复制槽压着,优先处置 xmin 来源而不是无脑手工 freeze。手工冻结的执行模板:
-- 只冻结、不清理死元组(冻结专项的精准动作)
VACUUM (FREEZE, SKIP_LOCKED, BUFFER_USAGE_LIMIT '1GB') hot_events;执行后复跑巡检确认 xid_age 归零;连续两轮都推不动的表,回到长事务排查——这是本页「冻结收益 × 长事务治理」乘法关系在实战里的直接体现。巡检与手工冻结两段脚本合并进 cron,就是小型团队够用的 wraparound 防护方案。
注意事项
vacuum_defer_cleanup_age 已被移除。 升级 PG16 前全局搜索配置与自动化脚本,残留参数会导致实例启动失败;备库保护一律改用 hot_standby_feedback 或(逻辑槽/物理槽的)WAL 保留机制。
页级冻结不是「免费」的。 普通 VACUUM 顺路冻结会让单次 vacuum 的耗时略增(多写了冻结标记);这是用「日常小额」换「偶发巨款」的划算交易,但 autovacuum_vacuum_cost_delay 偏大的系统会感觉 vacuum 变慢了,要同步评估 cost 参数。
BUFFER_USAGE_LIMIT 调大前先看业务缓存水位。 共享缓冲区有限时把 limit 拉到 1GB 等于亲手把业务热数据挤出去;pg_stat_io 的 normal 上下文命中率是唯一的裁判。
SKIP_DATABASE_STATS 与 ONLY_DATABASE_STATS 要成对使用。 只用前者会让库级冻结统计长时间不更新(影响 wraparound 判定的时间轴);批量维护结束后必须补一次 ONLY_DATABASE_STATS。
PROCESS_MAIN = false 不清主表死元组。 它是「只清 TOAST」的专用开关,不是「跳过死元组清理」的加速器;主表死元组照常需要常规 VACUUM。
autovacuum 参数在线调整依赖新机制。 PG16 起 delay 设置更快生效,但 autovacuum_max_workers 等结构性参数仍需重启;不要混淆「响应更快」与「全部热更」。
n_tup_newpage_upd 是诊断信号不是告警阈值。 该计数高说明 HOT 失败且跨页更新频繁,先检查「是否更新了索引列、fillfactor 是否过满」,再考虑重建表调整页填充率。
升级后关键表要重新 EXPLAIN。 与聚合页同理:BRIN-HOT 放宽与 GIN 代价修正会改变行为与计划,写密集表的压测要在升级后重跑。
PG16 的 VACUUM 不是「免调优」。 新选项提供了更好的旋钮,但死元组产生的根因治理(长事务、索引膨胀、fillfactor、更新模式)依然是 VACUUM 运维的主体,见VACUUM 基础的注意事项清单。
表级参数优先于全局参数。 混杂负载下全局调参是零和博弈——把 hot 表的 scale_factor 调小必然让全库 vacuum 更频繁;先用表级存储参数隔离差异,全局参数保持保守。
pg_stat_io 与进度视图都是本地实例视角。 巡检脚本要覆盖主库与各备库(备库上的 vacuum 同样消耗 I/O);统计计数可用 pg_stat_reset() 清零重建基线,重置动作本身要纳入变更管理。
BUFFER_USAGE_LIMIT 在内存紧张的机器上是双刃剑。 内存充裕的报表库可以放心加大;小实例上加大它等于把业务缓存换成 vacuum 专用缓存,pg_stat_io 的命中率曲线会立刻告诉你代价。
xmin 持有者决定冻结水位的天花板。 长事务、备库反馈、复制槽三类来源压住的 XID 地平线,页级冻结再高效也推不动;「VACUUM 变快了」与「龄期下不来」完全可以同时发生。
最佳实践
- 升级 PG16 的第一周建立 I/O 与冻结基线。 打开
log_autovacuum_min_duration、track_io_timing,用pg_stat_io快照建立vacuum/normal上下文的水位,此后所有调参都有对照。 - autovacuum 保持默认
vacuum_buffer_usage_limit,维护窗口用 vacuumdb 放开。 「业务时间小缓冲、窗口时间大缓冲」是这条参数的正确用法,全局改大 limit 是常见反模式。 - 批量冻结用
--freeze --skip-database-stats+ 收尾ONLY_DATABASE_STATS。 既省掉逐表库级统计的重复开销,又不漏掉统计更新。 - TOAST 密集表纳入例行
--no-process-main巡检。 大字段表的死元组大多藏在 TOAST,按表精准清理比全库 VACUUM 更快更省。 - 时序/日志表优先 BRIN + 列级更新设计。 PG16 的 BRIN-HOT 放宽让「时间列建 BRIN、状态列频繁更新」的模式近乎零索引写放大;建表阶段就按这个形态设计。
- autovacuum 延迟参数纳入在线调参手册。 高峰限流、低谷加速都在线操作;把「改参数等生效」的旧运维习惯换成「改参数看 pg_stat_io 反应」。
- 长事务治理优先于一切 VACUUM 调参。
pg_stat_activity里超过分钟的idle in transaction是死元组堆积的头号原因,任何版本的 VACUUM 改进都替代不了清理它。 - 监控
n_tup_newpage_upd占更新比例。 该比例持续偏高(>30%)的表要 review 更新模式与 fillfactor;它是 PG16 里最直接的「HOT 失败」仪表。 pg_locks的投机锁信息用于唯一约束争用排查。 插入型高并发 + 唯一索引的场景出现等待时,先看投机锁的持有者分布,再决定是改并发模式还是改键生成策略。xid向量化与页追加改进作为「升级收益」写进评审记录。 这些透明加速无需动作,但把它们列入 PG16 升级收益清单有助于说服团队按计划升级。- 为 PG17 的 VACUUM 内存管理预留观察项。 PG17 重构了 VACUUM 的死元组内存管理(radix tree 存储,大幅降低大表 VACUUM 的内存峰值),PG16 上受内存限制的大型 VACUUM 计划,值得在 PG17 后重新评估(届时参考 PG17 章节页面)。
- 校验和策略按版本正确理解。 PG16 的
initdb默认仍不开启数据校验和;需要校验和的集簇继续用--data-checksums显式创建(或在后续版本按该版本的默认行为规划)。 - 冻结巡检与 xmin 持有者巡检合并成同一个脚本。 表龄期超限的下一步永远是「查最老事务」,两段查询写在一起、一起出报告,wraparound 应急才不会在深夜里从零排障。
下一节
继续学习:PG17:EXPLAIN 增强
参考链接
- PostgreSQL 16 Release Notes
- PostgreSQL 16 官方文档:VACUUM
- PostgreSQL 16 官方文档:routine vacuuming(冻结与 autovacuum)
- PostgreSQL 16 官方文档:pg_stat_io
- PostgreSQL 16 官方文档:vacuum_buffer_usage_limit
- PostgreSQL 16 官方文档:vacuumdb
- PostgreSQL 16 官方文档:autovacuum 配置
- PostgreSQL 16 官方文档:HOT(Heap-Only Tuples)
- PostgreSQL 16 官方文档:pg_stat_all_tables(n_tup_newpage_upd 等)
- PostgreSQL 16 官方文档:监控等待事件