第八篇:《VACUUM、自动清理与膨胀治理》

在上一篇文章中,我们理解了 MVCC 的核心机制------UPDATE 不是原地修改,而是插入新版本、将旧版本标记为死元组。这带来了"读不阻塞写、写不阻塞读"的并发优势,但代价同样明确:死元组不会自动消失,它们占据的磁盘空间必须由 VACUUM 回收。一个从未执行过 UPDATE 的表,也可能因为回滚的批量插入积累 20% 的死空间。膨胀不会突然爆发,它表现为"p95 延迟每周上涨 3ms"、"每天的 VACUUM 越跑越久",等到你把它当成事故来处理时,表已经大到只能 VACUUM FULL 重写或者迁移。 本文从死元组的生命周期讲起,深入拆解 VACUUM 的两种形态、自动清理的触发公式与调优参数,最后给出膨胀诊断的完整查询和治理路径。

一、死元组的生命周期:从"看不见"到"可回收"

一个元组从"死"到"空间可复用",需要经过两个条件同时满足:

条件一:没有任何活跃事务还需要看见它。 VACUUM 判断死元组能否回收的依据是当前数据库中最老的活跃事务 ID(xmin) 。比这个 xmin 更老的死元组可以被回收;比它新的死元组一律不能碰------因为可能还有某个旧快照需要看见这个"历史版本"。

条件二:VACUUM 真正执行了。 条件一满足只是"可回收",还需要 VACUUM 实际扫描到这些元组,才能将它们的空间标记为可复用。

一个经常被忽视的事实:VACUUM 的回收范围是数据库级的,不是表级。任何一个表上的长事务,都会阻止同一个数据库中所有表的新于该事务的死元组被回收。一个在 orders 表上跑了 12 小时的长事务,会让 users 表在过去 12 小时内产生的所有死元组都无法回收------即使 users 表本身根本没有长事务。

二、VACUUM 的两种形态:逻辑回收 vs 物理重写

2.1 标准 VACUUM:逻辑回收,不归还操作系统

标准 VACUUM 移除表和索引中的死行版本,将空间标记为可供未来复用。但关键限制在于:它不会把空间归还给操作系统,除非表末尾的一个或多个页面完全空闲、并且能轻松获得排他锁。

换句话说,标准 VACUUM 让表在内部可以复用空间,但表的文件大小不会缩小。这是设计上的权衡:频繁的 VACUUM 让空间在表内部循环使用,避免文件持续膨胀;但如果表已经严重膨胀,标准 VACUUM 无法"瘦身"。

2.2 VACUUM FULL:物理重写,归还空间,但锁表

VACUUM FULL 主动压缩表------写入一个全新的、没有死空间的表文件,然后将旧文件替换掉。它最小化表的大小,并将空间归还操作系统,但代价是:需要很长时间,并且需要额外的磁盘空间来存放新副本,直到操作完成。

Autovacuum 永远不会执行 VACUUM FULL 。日常维护的目标不是"让表保持最小",而是维持磁盘空间的稳态使用------每个表占据的空间等于它的最小尺寸加上两次 VACUUM 之间累积的用量。频繁的标准 VACUUM 比偶发的 VACUUM FULL 更可持续。

三、Autovacuum:触发条件与调优

3.1 触发公式

Autovacuum 的核心触发逻辑是一个带阈值的比例公式:

text

当 dead_tuples > (total_tuples × autovacuum_vacuum_scale_factor) + autovacuum_vacuum_threshold 时,触发 VACUUM

PostgreSQL 默认 autovacuum_vacuum_scale_factor = 0.2,即死元组占比达到 20% 才触发。对于一个 1 亿行的表,这意味着要积累 2000 万个死元组才开始清理------在清理之前,表已经膨胀得非常严重。

3.2 关键参数与推荐值

GitLab 的实践:极狐 GitLab 将 autovacuum_vacuum_scale_factor 从默认的 0.2 降低到 0.02(2%) ,并对大型、频繁更新的表进一步降低。低 scale_factor 意味着更早触发清理,代价是更多的 VACUUM 开销------对于高频更新的核心表,这个代价是值得的。

3.3 插入触发的 Autovacuum(PostgreSQL 13+)

从 PostgreSQL 13 开始,autovacuum 不仅由死元组触发,也由插入量触发。当插入行数超过 autovacuum_vacuum_insert_threshold(默认 1000)+ autovacuum_vacuum_insert_scale_factor(默认 0.2)× 行数时,autovacuum 会运行以冻结元组和更新 visibility map,而不是等待死元组积累。

这个机制对 append-only 表至关重要:即使没有 UPDATE 和 DELETE,表也会因为事务 ID 冻结的需要而被定期清理。如果冻结跟不上,就会逼近 XID 回卷的边界。

四、膨胀诊断:从"感觉慢"到"量化证据"

4.1 第一层:pg_stat_user_tables 快速排序

sql 复制代码
SELECT
    schemaname || '.' || relname AS table_name,
    n_live_tup AS live_tuples,
    n_dead_tup AS dead_tuples,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
    n_ins_since_vacuum,
    last_autovacuum,
    last_vacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

dead_pct 是死元组占比,last_autovacuum 是上次自动清理的时间。如果 last_autovacuum 已经过去数小时甚至数天,而 n_dead_tup 仍在增长,说明 autovacuum "跟不上"更新速度。

4.2 第二层:pgstattuple 精确测量

pg_stat_user_tables 是估算值。需要精确数据时,使用 pgstattuple 扩展:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('orders');

输出中的 dead_tuple_percent 是死元组占用的实际空间百分比。dead_tuple_percent 超过 20% 通常意味着显著膨胀;对于 TOAST 表,同样需要单独检查。

4.3 第三层:找出"谁在阻止回收"

sql 复制代码
SELECT pid, state, xact_start,
       age(backend_xmin) AS xmin_age,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xmin_age DESC;

backend_xmin 非空且 xmin_age 很大的会话,就是钉住回收位点的"元凶" 。常见的三类:

悬挂复制槽是最隐蔽的膨胀源:它可能由一次临时的逻辑复制实验创建,实验结束后槽被遗忘,但它持续阻止 WAL 清理和相关目录行的回收。表现是磁盘莫名增长,业务侧完全找不到原因。

五、膨胀治理:分场景的处置路径

5.1 表膨胀的治理

pg_repack 和 PG19 的 REPACK CONCURRENTLY 可以避免锁表

索引膨胀需要单独处理:标准 VACUUM 会清理索引中的死条目,但不会重建索引结构,索引的物理膨胀可能持续存在。REINDEX CONCURRENTLY 是重建索引的标准手段,它不阻塞读写,但需要额外的磁盘空间和更长的时间。

5.2 长事务和复制槽的治理

长事务:

sql 复制代码
-- 设置空闲事务超时(全局)
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';

-- 终止特定长事务
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE xact_start < now() - INTERVAL '30 minutes'
  AND pid <> pg_backend_pid();

悬挂复制槽:

sql 复制代码
-- 确认槽确实不再需要后,删除
SELECT pg_drop_replication_slot('slot_name');

六、PostgreSQL 18/19 的 VACUUM 改进

PostgreSQL 18 的 Eager Freezing:此前,防回卷的冻结操作会在某个时刻集中爆发,引发剧烈的 I/O 风暴。PG18 将冻结操作平摊到常规清理中,消除了这种周期性的 I/O 尖峰。

PostgreSQL 19 的并行 Autovacuum:Autovacuum 终于可以使用并行 worker,大表和多个索引的清理效率显著提升。

PostgreSQL 19 的 Autovacuum 优先级机制:不再按"发现顺序"处理表,而是引入优先级评分,频繁更新的核心表优先清理。pg_stat_autovacuum_scores 视图让自动清理的决策过程变得透明可观测。

七、小结

死元组的两个回收条件:没有任何活跃事务需要看见它(xmin 边界),并且 VACUUM 实际执行了扫描。长事务的影响是数据库级的,一个表上的长事务会阻止同库所有表的新死元组回收。

标准 VACUUM:逻辑回收空间供表内复用,不归还操作系统。VACUUM FULL:物理重写,归还空间,但AccessExclusiveLock 阻塞所有读写。Autovacuum 永远不会执行 VACUUM FULL。

Autovacuum 调优:scale_factor 默认 0.2 对大表过高,推荐 0.1-0.2,高频更新大表可降到 0.02;max_workers 3-6;maintenance_work_mem 总预算不超过 RAM 的 12.5%-25%。

膨胀诊断三层:pg_stat_user_tables 快速排序 → pgstattuple 精确测量 → pg_stat_activity 和 pg_replication_slots 找出阻塞源。

治理路径:轻度调低 scale_factor 预防;中度 REINDEX CONCURRENTLY 重建索引;重度 VACUUM FULL/pg_repack/PG19 REPACK CONCURRENTLY。

PG18/19 改进:Eager Freezing 消除冻结 I/O 风暴,并行 Autovacuum 解除大表清理瓶颈,优先级机制让核心表优先处理。

相关推荐
这个DBA有点耶1 小时前
主从切换报成功,业务却写不进去——数据层脑裂与应用层连接残留的两类假成功
数据库·架构·dba
SelectDB技术团队1 小时前
ClickHouse 支持存算分离吗?Doris 社区版原生存算分离怎么落地
数据库·clickhouse·云原生·开源·apache doris·技术选型·存算分层
DBA_G1 小时前
南大通用GBase 8a数据库可视化灾备解决工具GVR
数据库
JJJennie7771 小时前
MAI Gateway实战部署:大模型网关部署方案?从0到1上手指南
数据库·人工智能·gateway·ai网关
吹什么轩1 小时前
MySQL 内连接与外连接:从笛卡尔积到 LEFT JOIN,一篇讲透
数据库·mysql
涛思数据(TDengine)2 小时前
支撑药企全球化合规:TDengine 发布中美欧三地监管对照白皮书
大数据·数据库·人工智能·ai·tdengine
晴天¥2 小时前
记录Oracle11g数据库OGG同步实验
oracle·oracle ogg·oracle高可用架构
李兆龙的博客2 小时前
从一到无穷大 #106:Nimble 文件格式——超宽表的元数据、编码与索引设计
数据库·存储·parquet
boy快快长大2 小时前
MySQL 转 PostgreSQL 使用指南
数据库·mysql·postgresql