当你在PostgreSQL中执行了一万次UPDATE操作后,可能会发现一个令人困惑的现象:表的数据行数没变,但磁盘占用却增长了好几倍。更奇怪的是,即使你执行了DELETE删除了大量数据,磁盘空间也没有减少。
这不是bug,而是PostgreSQL MVCC(多版本并发控制)机制的必然结果。而Vacuum,正是PostgreSQL为此设计的"垃圾回收器"——它的重要性远超大多数开发者的认知,不仅关系到磁盘空间,还决定了数据库能否持续运行。
一行UPDATE背后的秘密
要理解Vacuum存在的必要性,必须先理解PostgreSQL的MVCC是如何工作的。
当执行一条简单的UPDATE语句时:
UPDATE users SET name = 'Alice' WHERE id = 1;
大多数人想象中的过程是:数据库找到id=1的那一行,直接把name字段从旧值改成新值。但PostgreSQL的实际做法完全不同:它会保留旧行,然后插入一个全新的行版本。
每个元组(tuple,即行版本)的头部都包含几个关键字段:
- xmin: 创建该行版本的事务ID
- xmax: 删除或更新该行版本的事务ID(如果行仍然有效,则为空)
- ctid: 元组的物理位置(页号+偏移量)
- t_infomask: 一组标志位,记录事务状态
graph LR
subgraph "UPDATE操作前"
A1["元组 (id=1, name='Bob')<br/>xmin=100, xmax=NULL"]
end
subgraph "UPDATE操作后"
B1["旧元组 (id=1, name='Bob')<br/>xmin=100, xmax=101<br/>标记为'死元组'"]
B2["新元组 (id=1, name='Alice')<br/>xmin=101, xmax=NULL"]
B1 -->|"ctid指向"| B2
end
A1 -->|"事务ID=101执行UPDATE"| B1
A1 -->|"事务ID=101执行UPDATE"| B2
这种设计的精妙之处在于并发控制:事务100创建的旧版本对事务101之前的快照仍然可见,而事务101及其之后的快照则看到新版本。读取操作不需要获取任何锁,写操作也不会阻塞读操作——这是PostgreSQL高并发能力的基石。
但代价也是显而易见的:每次UPDATE都会产生一个"死元组"(dead tuple)——即旧版本的行,它对任何活跃事务都不再可见,却仍然占用着磁盘空间。DELETE操作同样如此:被删除的行并不会立即从磁盘消失,只是被标记为"已死"。
死元组的累积效应
死元组的问题远不止空间占用。当PostgreSQL执行顺序扫描时,它必须读取所有页面,包括那些充满了死元组的页面。即使这些元组对当前事务完全不可见,数据库仍然需要:
- 读取页面到内存
- 检查每个元组的可见性
- 跳过不可见的元组
这意味着一个100GB的表,如果积累了80GB的死元组,每次全表扫描都需要读取100GB的数据,即使最终只有20GB是有效的。
索引同样会受到波及。每个索引条目都指向一个特定的元组位置(ctid)。当UPDATE产生新元组时,所有索引都需要插入新的条目指向新位置。旧索引条目不会自动删除——它们和表中的死元组一一对应,共同构成"索引膨胀"。
-- 查看表的死元组统计
SELECT
schemaname,
tablename,
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_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC;
运行这个查询,你可能会惊讶地发现某些表的dead_ratio高达50%甚至更高——这意味着表的一半空间都被死元组占用。
Vacuum的工作原理
Vacuum的核心任务是回收死元组占用的空间。但它的设计远比简单的"删除死数据"要复杂得多。
并发Vacuum(Concurrent VACUUM)
普通的VACUUM命令(不带FULL选项)采用一种非阻塞的方式工作:
flowchart TD
A[扫描表页面] --> B{页面有死元组?}
B -->|是| C[移除死元组]
C --> D[更新FSM]
D --> E[清理索引]
E --> F[更新Visibility Map]
B -->|否| G[跳过该页面]
G --> H[继续下一页]
F --> H
H --> I{还有更多页面?}
I -->|是| A
I -->|否| J[更新统计信息]
J --> K[完成]
关键特点:
非阻塞操作:Vacuum不会锁定表,不会阻止读写操作。它以一小块一小块的方式处理表,每次只锁定少量页面。
空间重用而非归还:Vacuum清理出的空间会被标记为"可重用",但不会归还给操作系统。这些空间会在后续INSERT或UPDATE时被优先使用。这是故意的设计——避免频繁的文件大小调整带来的开销。
索引清理:Vacuum会遍历所有索引,删除指向死元组的索引条目。这通常是Vacuum最耗时的部分,特别是对于多索引的大表。
Visibility Map:Vacuum的加速器
PostgreSQL为每个表维护一个Visibility Map(可见性映射表)。这是一个位图,每两个bit对应表中的一个页面:
- all-visible bit: 表示该页面所有元组对所有事务都可见
- all-frozen bit: 表示该页面所有元组都已被"冻结"
Visibility Map的价值在于:Vacuum可以跳过那些已经完全清理过的页面。如果一个页面的all-visible bit已设置,Vacuum就不需要扫描它——所有元组都是活的,没有死元组需要清理。
graph TB
subgraph "Visibility Map结构"
VM["Visibility Map文件<br/>(每页2 bits)"]
P1["页面1: all-visible=1<br/>Vacuum跳过"]
P2["页面2: all-visible=0<br/>Vacuum扫描"]
P3["页面3: all-visible=1<br/>Vacuum跳过"]
P4["页面4: all-visible=0<br/>Vacuum扫描"]
end
VM --> P1
VM --> P2
VM --> P3
VM --> P4
对于几十GB甚至TB级别的表,Visibility Map可以将Vacuum的扫描范围缩小几个数量级。但要注意:任何INSERT、UPDATE、DELETE操作都会清除相关页面的visibility bits,迫使下一次Vacuum重新扫描这些页面。
Free Space Map:空间的账本
FSM(Free Space Map)记录了每个页面中可用的空闲空间。当执行INSERT或UPDATE时,PostgreSQL会先查询FSM,找到有足够空间的页面。
Vacuum清理死元组后,会更新FSM中相应页面的空闲空间大小。这确保了新数据能够优先使用已清理出的空间,而不是持续扩展表文件。
FSM的结构是一个树形索引——底层页面记录实际的空间大小,上层节点记录子节点的最大值。这样在查找空间时可以从根节点快速定位,时间复杂度为O(log n)。
graph TB
subgraph "FSM树形结构"
ROOT["根节点<br/>max=8KB"]
L1A["中间节点<br/>max=8KB"]
L1B["中间节点<br/>max=6KB"]
L2A["叶子节点<br/>页1: 8KB空闲"]
L2B["叶子节点<br/>页2: 4KB空闲"]
L2C["叶子节点<br/>页3: 6KB空闲"]
L2D["叶子节点<br/>页4: 2KB空闲"]
end
ROOT --> L1A
ROOT --> L1B
L1A --> L2A
L1A --> L2B
L1B --> L2C
L1B --> L2D
当INSERT需要5KB空间时,从根节点开始:根节点的max=8KB表示有足够空间,查看左子树L1A的max=8KB也满足,继续到叶子节点L2A,发现页1有8KB空闲空间,即可使用。
Autovacuum:自动化的守护进程
手动执行VACUUM在很多场景下是不现实的——你不可能24小时盯着数据库,在死元组累积到一定程度时手动清理。Autovacuum正是为此而生。
进程架构
Autovacuum由两类进程组成:
autovacuum launcher(启动器):一个常驻后台进程,每隔autovacuum_naptime(默认1分钟)醒来一次,检查是否有表需要清理。它不执行实际的清理工作,只负责调度。
autovacuum worker(工作进程):由launcher按需启动的实际执行VACUUM的进程。每个worker处理一个数据库中的一个表,完成后退出。
sequenceDiagram
participant L as autovacuum launcher
participant S as pg_stat系统表
participant W as autovacuum worker
participant T as 目标表
loop 每隔autovacuum_naptime
L->>S: 检查各表的统计信息
S-->>L: 返回死元组数量等
L->>L: 计算是否达到触发阈值
alt 达到阈值
L->>W: 启动worker进程
W->>T: 执行VACUUM
T-->>W: 清理完成
W-->>L: 进程退出
end
end
触发机制
Autovacuum的触发条件基于一个简单而有效的公式:
$$\text{threshold} = \text{vacuum\_threshold} + \text{vacuum\_scale\_factor} \times \text{pg\_class.reltuples}$$默认值:
autovacuum_vacuum_threshold = 50autovacuum_vacuum_scale_factor = 0.2
这意味着一个有100万行的表,需要累积超过50 + 0.2 × 1,000,000 = 200,050个死元组才会触发autovacuum。对于大表,20%的死元组比例是一个相当宽松的阈值——这正是很多生产环境遇到表膨胀问题的根源。
针对大表,一个常见的调优策略是降低scale_factor:
-- 为特定大表设置更激进的清理阈值
ALTER TABLE large_table SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_threshold = 1000
);
这样100万行的表只需要累积51,000个死元组就会触发清理。
I/O节流机制
Vacuum是一个I/O密集型操作——它需要读取大量页面,同时产生WAL日志和更新FSM。如果让Vacuum全速运行,很容易拖垮整个系统的I/O性能。
PostgreSQL通过基于成本的节流机制来解决这个问题:
每个Vacuum操作都被赋予一个"成本":
| 操作 | 成本 |
|---|---|
| 读取一个页面 | vacuum_cost_page_hit(内存命中)或 vacuum_cost_page_miss(磁盘读取) |
| 清理一个死元组 | vacuum_cost_page_dirty |
| Vacuum的基准成本 | vacuum_cost_limit(默认200) |
当累计成本达到vacuum_cost_limit时,Vacuum会休眠vacuum_cost_delay(默认2ms),然后重置成本继续工作。
默认配置相当保守,这对于保护I/O是好事,但对于需要快速清理的大表可能太慢。在高I/O能力的存储系统上,可以提高限制:
-- 提高autovacuum的工作速度(针对SSD或高性能存储)
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 1000;
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '2ms';
事务ID环绕:被忽视的致命威胁
死元组占用空间是可见的问题,但还有一个更隐蔽却更致命的问题:事务ID环绕。
32位事务ID的限制
PostgreSQL使用32位无符号整数作为事务ID(XID),范围是0到约42亿。当XID达到最大值后,会从3开始重新计数(0-2是保留值)。
问题在于:PostgreSQL通过比较XID大小来判断事务的先后顺序。假设当前XID是100,那么XID=90的元组是"旧的",XID=110的元组是"新的"。但如果当前XID是100,而某个元组的XID是2^31 + 90(约21亿+90),直接比较就会得出错误的结论——那个21亿+90的元组反而会被认为是"旧的"。
这就是"事务ID环绕"问题。当它发生时,数据可能变得不可见,或者本该不可见的数据变得可见——这是无法接受的数据损坏。
Freeze机制
为了防止环绕,PostgreSQL引入了"冻结"(freeze)机制:将旧的元组标记为"对所有事务可见",这样它们的XID就不再重要了。
冻结的具体实现是:将元组的xmin设置为一个特殊的"冻结XID"(FrozenXID,值为2),并在Visibility Map中设置all-frozen bit。
Vacuum在清理死元组的同时,也会对足够老的元组执行冻结。判断标准是:
- 元组的XID比
vacuum_freeze_min_age(默认5000万)更老 - 元组的XID比当前表的
pg_class.relfrozenxid更老
relfrozenxid记录了表中所有元组的最老未被冻结的XID。当这个值与当前XID的差距接近autovacuum_freeze_max_age(默认2亿)时,即使死元组数量未达阈值,autovacuum也会强制启动——这就是"anti-wraparound vacuum"。
当环绕真的发生时
如果anti-wraparound vacuum也来不及处理(比如被长事务阻塞),PostgreSQL会在XID距离最大值只剩100万时进入"紧急模式":
ERROR: database is not accepting commands to avoid wraparound data loss in database "mydb"
HINT: Stop the postmaster and vacuum that database in single-user mode.
此时数据库会拒绝所有写操作,只允许读取。唯一的恢复方法是启动单用户模式执行VACUUM FREEZE。
这不是理论上的风险。2015年,Sentry团队就遭遇了这个问题——一个长时间运行的事务阻止了freeze操作,最终导致数据库进入紧急模式。
为什么VACUUM有时不生效
很多DBA都遇到过这种情况:明明执行了VACUUM,死元组数量却没有减少,表仍然膨胀。原因通常有以下几种:
长事务阻塞
Vacuum只能清理"对所有活跃事务都不可见"的死元组。如果存在一个长事务(比如运行了几小时的报表查询),它可能持有一个很老的快照,导致之后的死元组都无法被Vacuum清理。
-- 查找可能阻塞Vacuum的长事务
SELECT pid,
now() - pg_stat_activity.query_start AS duration,
query,
state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes'
AND state != 'idle'
ORDER BY duration DESC;
复制槽(Replication Slot)
逻辑复制或物理复制槽会阻止Vacuum清理某些元组,因为这些元组可能还需要发送给从库。被遗弃的复制槽是Vacuum失灵的常见原因:
-- 检查复制槽
SELECT slot_name,
slot_type,
active,
xmin,
catalog_xmin
FROM pg_replication_slots;
预备事务(Prepared Transaction)
两阶段提交的预备事务如果没有正确提交或回滚,也会阻止Vacuum。
-- 检查预备事务
SELECT gid,
prepared,
owner,
database
FROM pg_prepared_xacts;
VACUUM FULL:双刃剑
普通的VACUUM只能让空间在表内部重用,不能归还给操作系统。如果表已经严重膨胀,想要回收磁盘空间,就需要VACUUM FULL。
VACUUM FULL的工作方式完全不同:
- 获取表的AccessExclusiveLock,阻塞所有读写
- 创建一个新的表文件
- 将所有活元组复制到新文件,紧凑排列
- 重建所有索引
- 用新文件替换旧文件
- 释放旧文件的空间给操作系统
显而易见的问题:它需要大约等于表大小的额外磁盘空间。对于一个大表,这可能意味着需要数百GB的临时空间。如果磁盘空间不足,VACUUM FULL会失败,而你可能陷入进退两难的境地——表已经锁住,没有空间完成操作。
VACUUM FULL的第二个问题是长时间持锁。对于一个繁忙的表,获取AccessExclusiveLock可能需要等待所有现有事务完成,而这个等待期间,新的请求会被阻塞。
更安全的替代方案
pg_repack是一个广泛使用的扩展,它可以在不持锁的情况下重建表:
-- 安装扩展
CREATE EXTENSION pg_repack;
-- 重建表(不阻塞读写)
SELECT pg_repack.repack_table('bloated_table');
pg_repack的工作原理:
- 创建一个新的"影子表"
- 将原表数据复制到影子表
- 在复制期间,通过触发器捕获对原表的修改,同步到影子表
- 短暂锁定表,完成最后的同步和切换
整个过程只在最后切换阶段需要短暂的排他锁,对生产环境影响极小。
pg_squeeze是另一个选择,它完全在数据库内部运行,不需要外部命令行工具。
HOT优化:减少Vacuum的负担
理解了Vacuum的工作原理后,一个自然的想法是:能否减少死元组的产生?HOT(Heap-Only Tuple)更新正是为此设计。
当UPDATE满足以下条件时,PostgreSQL会使用HOT优化:
- 更新的列不是索引列(新元组不需要新索引条目)
- 新元组可以放在同一个页面(页面有足够空间)
HOT更新时,旧元组不需要新的索引条目。PostgreSQL在旧元组中存储一个指向新元组的指针。当索引扫描找到旧元组时,会自动跟随指针找到新元组。
graph LR
subgraph "非HOT更新"
I1["索引条目1<br/>ctid=(0,1)"]
I2["索引条目2<br/>ctid=(0,2)"]
T1["旧元组<br/>已死"]
T2["新元组"]
I1 --> T1
I2 --> T2
end
subgraph "HOT更新"
I3["索引条目<br/>ctid=(0,1)"]
T3["旧元组<br/>已死+HOT指针"]
T4["新元组"]
I3 --> T3
T3 -.->|"HOT链"| T4
end
HOT的优势:
- 减少索引膨胀:不需要为每次更新创建新的索引条目
- 加速Vacuum:同一个页面的死元组可以批量清理,不需要扫描索引
- 更好的空间利用率:新元组放在同一页面,提高局部性
为了让HOT更有效,需要:
- 设置合适的fillfactor:较低的fillfactor(如70-80%)为HOT更新预留空间
ALTER TABLE hot_frequent_table SET (fillfactor = 70);
- 避免索引频繁更新的列:如果一个列经常被更新,考虑是否真的需要为它建索引
并行Vacuum:利用多核加速
从PostgreSQL 13开始,VACUUM支持并行处理索引。对于有多个索引的大表,这可以显著减少Vacuum时间。
-- 使用4个并行worker清理索引
VACUUM (PARALLEL 4) large_table;
并行Vacuum只加速索引清理阶段——这是Vacuum最耗时的部分。表扫描仍然由单个进程执行。
参数max_parallel_maintenance_workers控制了可用的并行worker数量上限。需要注意的是,每个worker都会消耗maintenance_work_mem大小的内存,并行度越高,内存消耗越大。
PostgreSQL 17的Vacuum改进
PostgreSQL 17对Vacuum进行了重大优化:
新的内存管理:Vacuum不再需要为整个表维护一个死元组数组。新的实现使用了一种更高效的内存结构,内存消耗降低高达20倍。这意味着Vacuum可以处理更大的表而不会耗尽内存。
增量Vacuum:Vacuum可以更高效地处理那些只有少量页面被修改的表,避免不必要的全表扫描。
这些改进让Vacuum在处理TB级表时表现更好,减少了维护窗口的需求。
监控Vacuum状态
pg_stat_progress_vacuum
从PostgreSQL 9.6开始,可以使用pg_stat_progress_vacuum视图监控正在运行的Vacuum进度:
SELECT
pid,
datname,
relid::regclass AS table_name,
phase,
heap_blks_total,
heap_blks_scanned,
heap_blks_vacuumed,
index_vacuum_count,
num_dead_tuples
FROM pg_stat_progress_vacuum;
phase字段显示了Vacuum当前所处的阶段:
| 阶段 | 含义 |
|---|---|
| initializing | 初始化 |
| scanning heap | 扫描表页面 |
| vacuuming indexes | 清理索引 |
| vacuuming heap | 清理表 |
| cleaning up indexes | 清理索引 |
| truncating heap | 截断表尾部的空页面 |
pg_stat_user_tables
日常监控的关键指标:
SELECT
schemaname,
tablename,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count,
n_dead_tup,
n_live_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS bloat_ratio
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
事务ID监控
监控事务ID使用情况,提前预警环绕风险:
SELECT
datname,
age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age')::bigint AS max_age,
ROUND(100.0 * age(datfrozenxid) /
current_setting('autovacuum_freeze_max_age')::bigint, 2) AS percent_towards_emergency
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
当percent_towards_emergency接近100%时,需要立即关注。
调优最佳实践
1. 合理设置内存
-- autovacuum专用内存(PostgreSQL 12+)
ALTER SYSTEM SET autovacuum_work_mem = '256MB';
-- 或使用全局维护内存
ALTER SYSTEM SET maintenance_work_mem = '512MB';
更大的内存允许Vacuum一次性记录更多死元组,减少索引扫描次数。
2. 调整触发阈值
根据表的更新频率,设置不同的阈值:
-- 高频更新表:更激进的清理
ALTER TABLE hot_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 500
);
-- 低频更新表:节省资源
ALTER TABLE cold_table SET (
autovacuum_vacuum_scale_factor = 0.3,
autovacuum_vacuum_threshold = 1000
);
3. 增加worker数量
默认只有3个autovacuum worker:
ALTER SYSTEM SET autovacuum_max_workers = 6;
但要注意,更多worker意味着更多的I/O竞争。
4. 设置空闲超时
防止长事务阻塞Vacuum:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min';
5. 定期维护计划
对于特别大的表,考虑在低峰期手动调度:
-- 每周日凌晨执行
VACUUM (VERBOSE, ANALYZE) critical_large_table;
写在最后
PostgreSQL的Vacuum机制是MVCC设计的必然产物——你不可能在不产生垃圾的情况下实现无锁的并发控制。理解Vacuum不仅仅是DBA的职责,对于开发者来说,知道UPDATE和DELETE的真实成本,才能在设计数据库操作时做出正确的权衡。
一个健康的PostgreSQL数据库,需要:
- 合理配置的autovacuum参数
- 对长事务的监控和限制
- 定期的表膨胀检测
- 必要时的手动干预
Vacuum不是数据库的"缺陷",而是PostgreSQL为了并发性能所做的精心权衡。理解它的工作原理,才能让数据库在长期运行中保持健康。
参考资料
- PostgreSQL Documentation. “Routine Vacuuming”. https://www.postgresql.org/docs/current/routine-vacuuming.html
- PostgreSQL Documentation. “VACUUM Command”. https://www.postgresql.org/docs/current/sql-vacuum.html
- Hironobu Suzuki. “PostgreSQL Internals”. https://www.interdb.jp/pg/
- EDB Blog. “PostgreSQL VACUUM and Analyze Best Practice Tips”. https://www.enterprisedb.com/blog/postgresql-vacuum-and-analyze-best-practice-tips
- Percona Blog. “Tuning Autovacuum in PostgreSQL and Autovacuum Internals”. https://www.percona.com/blog/tuning-autovacuum-in-postgresql-and-autovacuum-internals/
- Sentry Blog. “Transaction ID Wraparound in Postgres”. https://blog.sentry.io/transaction-id-wraparound-in-postgres/
- Google Cloud Blog. “Deep dive into PostgreSQL VACUUM garbage collector”. https://cloud.google.com/blog/products/databases/deep-dive-into-postgresql-vacuum-garbage-collector
- Citus Data Blog. “Debugging Postgres autovacuum problems: 13 tips”. https://www.citusdata.com/blog/2022/07/28/debugging-postgres-autovacuum-problems-13-tips/
- Postgres Professional. “MVCC in PostgreSQL Series”. https://postgrespro.com/blog/pgsql/5967910
- pganalyze. “5mins of Postgres: The basics of tuning VACUUM and autovacuum”. https://pganalyze.com/blog/5mins-postgres-tuning-vacuum-autovacuum
- AWS Database Blog. “Understanding autovacuum in Amazon RDS for PostgreSQL”. https://aws.amazon.com/blogs/database/understanding-autovacuum-in-amazon-rds-for-postgresql-environments/
- Cybertec PostgreSQL. “Reasons why VACUUM won’t remove dead rows”. https://www.cybertec-postgresql.com/en/reasons-why-vacuum-wont-remove-dead-rows/
- PostgreSQL Documentation. “Visibility Map”. https://www.postgresql.org/docs/current/storage-vm.html
- PostgreSQL Documentation. “Free Space Map”. https://www.postgresql.org/docs/current/storage-fsm.html
- Crunchy Data Blog. “Postgres TOAST: The Greatest Thing Since Sliced Bread?”. https://www.crunchydata.com/blog/postgres-toast-the-greatest-thing-sliced-bread