当你在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执行顺序扫描时,它必须读取所有页面,包括那些充满了死元组的页面。即使这些元组对当前事务完全不可见,数据库仍然需要:

  1. 读取页面到内存
  2. 检查每个元组的可见性
  3. 跳过不可见的元组

这意味着一个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 = 50
  • autovacuum_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的工作方式完全不同:

  1. 获取表的AccessExclusiveLock,阻塞所有读写
  2. 创建一个新的表文件
  3. 将所有活元组复制到新文件,紧凑排列
  4. 重建所有索引
  5. 用新文件替换旧文件
  6. 释放旧文件的空间给操作系统

显而易见的问题:它需要大约等于表大小的额外磁盘空间。对于一个大表,这可能意味着需要数百GB的临时空间。如果磁盘空间不足,VACUUM FULL会失败,而你可能陷入进退两难的境地——表已经锁住,没有空间完成操作。

VACUUM FULL的第二个问题是长时间持锁。对于一个繁忙的表,获取AccessExclusiveLock可能需要等待所有现有事务完成,而这个等待期间,新的请求会被阻塞。

更安全的替代方案

pg_repack是一个广泛使用的扩展,它可以在不持锁的情况下重建表:

-- 安装扩展
CREATE EXTENSION pg_repack;

-- 重建表(不阻塞读写)
SELECT pg_repack.repack_table('bloated_table');

pg_repack的工作原理:

  1. 创建一个新的"影子表"
  2. 将原表数据复制到影子表
  3. 在复制期间,通过触发器捕获对原表的修改,同步到影子表
  4. 短暂锁定表,完成最后的同步和切换

整个过程只在最后切换阶段需要短暂的排他锁,对生产环境影响极小。

pg_squeeze是另一个选择,它完全在数据库内部运行,不需要外部命令行工具。

HOT优化:减少Vacuum的负担

理解了Vacuum的工作原理后,一个自然的想法是:能否减少死元组的产生?HOT(Heap-Only Tuple)更新正是为此设计。

当UPDATE满足以下条件时,PostgreSQL会使用HOT优化:

  1. 更新的列不是索引列(新元组不需要新索引条目)
  2. 新元组可以放在同一个页面(页面有足够空间)

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更有效,需要:

  1. 设置合适的fillfactor:较低的fillfactor(如70-80%)为HOT更新预留空间
ALTER TABLE hot_frequent_table SET (fillfactor = 70);
  1. 避免索引频繁更新的列:如果一个列经常被更新,考虑是否真的需要为它建索引

并行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为了并发性能所做的精心权衡。理解它的工作原理,才能让数据库在长期运行中保持健康。


参考资料

  1. PostgreSQL Documentation. “Routine Vacuuming”. https://www.postgresql.org/docs/current/routine-vacuuming.html
  2. PostgreSQL Documentation. “VACUUM Command”. https://www.postgresql.org/docs/current/sql-vacuum.html
  3. Hironobu Suzuki. “PostgreSQL Internals”. https://www.interdb.jp/pg/
  4. EDB Blog. “PostgreSQL VACUUM and Analyze Best Practice Tips”. https://www.enterprisedb.com/blog/postgresql-vacuum-and-analyze-best-practice-tips
  5. Percona Blog. “Tuning Autovacuum in PostgreSQL and Autovacuum Internals”. https://www.percona.com/blog/tuning-autovacuum-in-postgresql-and-autovacuum-internals/
  6. Sentry Blog. “Transaction ID Wraparound in Postgres”. https://blog.sentry.io/transaction-id-wraparound-in-postgres/
  7. Google Cloud Blog. “Deep dive into PostgreSQL VACUUM garbage collector”. https://cloud.google.com/blog/products/databases/deep-dive-into-postgresql-vacuum-garbage-collector
  8. Citus Data Blog. “Debugging Postgres autovacuum problems: 13 tips”. https://www.citusdata.com/blog/2022/07/28/debugging-postgres-autovacuum-problems-13-tips/
  9. Postgres Professional. “MVCC in PostgreSQL Series”. https://postgrespro.com/blog/pgsql/5967910
  10. pganalyze. “5mins of Postgres: The basics of tuning VACUUM and autovacuum”. https://pganalyze.com/blog/5mins-postgres-tuning-vacuum-autovacuum
  11. AWS Database Blog. “Understanding autovacuum in Amazon RDS for PostgreSQL”. https://aws.amazon.com/blogs/database/understanding-autovacuum-in-amazon-rds-for-postgresql-environments/
  12. Cybertec PostgreSQL. “Reasons why VACUUM won’t remove dead rows”. https://www.cybertec-postgresql.com/en/reasons-why-vacuum-wont-remove-dead-rows/
  13. PostgreSQL Documentation. “Visibility Map”. https://www.postgresql.org/docs/current/storage-vm.html
  14. PostgreSQL Documentation. “Free Space Map”. https://www.postgresql.org/docs/current/storage-fsm.html
  15. Crunchy Data Blog. “Postgres TOAST: The Greatest Thing Since Sliced Bread?”. https://www.crunchydata.com/blog/postgres-toast-the-greatest-thing-sliced-bread