VACUUM 与 Freezing:膨胀的根因和 Wraparound 危机
如果你用过 PostgreSQL,大概率听过这句话:“PG 需要 VACUUM。”但 VACUUM 到底做了什么?为什么 MVCC 的实现必然导致它存在?膨胀多快才算危险?“事务 ID 回卷(wraparound)”这个词为什么会让 DBA 半夜惊醒?
本文从源码路径拆解 VACUUM 的内部流程——从 heap scan 到
index cleanup 到 FSM/VM
更新,讲清楚可见性映射和空闲空间映射的结构设计,解析
Freezing 机制如何防御事务 ID 回卷,然后用一条从
n_tup_del
增长到数据库强制只读的完整危机时间线,告诉你 Anti-wraparound
VACUUM 的预警信号链和排查方法。
与本文强相关的前置阅读:MVCC 实现:CLOG、hint bit 与快照可扩展性(第 03 篇)讲了 xmin/xmax 的可见性判断、CLOG 结构和 hint bit 机制——本文默认你已经理解这些基础。
一、为什么 PG 的 MVCC 必然需要 VACUUM
PG 的 MVCC 采用”多版本元组”策略:UPDATE
不是原地修改,而是插入一个新版本元组,把旧版本标记为已删除(设置
xmax)。DELETE 同样不真正删除——只设置
xmax,旧元组留在页面里。
这种策略的优势很直接:读不阻塞写,写不阻塞读,ROLLBACK 几乎没有代价。但代价也明确:每次 UPDATE 和 DELETE 都会产生垃圾元组(dead tuple),它们占据磁盘空间、消耗 buffer、拖慢扫描。
用一个简单实验就能直观感受:
-- 创建测试表
CREATE TABLE t (id INT, val TEXT);
INSERT INTO t SELECT generate_series(1, 100000), repeat('x', 100);
-- 查看初始大小:约 13 MB
SELECT pg_size_pretty(pg_relation_size('t'));
-- 全表 UPDATE 10 次——每次产生 10 万 dead tuple
DO $$ BEGIN FOR i IN 1..10 LOOP UPDATE t SET val = repeat('y', 100); END LOOP; END $$;
-- 表现在约 143 MB——膨胀了约 10 倍
SELECT pg_size_pretty(pg_relation_size('t'));
```text
每次 UPDATE 都在页面里插入新版本,旧版本还在原地。10 轮 UPDATE 后,表里有 100 万 dead tuple 和 10 万 live tuple。如果你不做 VACUUM,这些 dead tuple 会永远占据空间。
这就是 VACUUM 存在的根本原因:**回收 dead tuple 占据的空间,标记为空闲空间供后续 INSERT/UPDATE 复用**。它不是可选优化——没有 VACUUM,PG 的 MVCC 会把磁盘撑爆。
---
## 二、VACUUM 的完整流程
VACUUM 分两种:`VACUUM`(不锁表,允许并发读写)和 `VACUUM FULL`(锁表,重写整个表,回收所有空间还给 OS)。日常运维只涉及前者,后者是紧急瘦身手段。本节聚焦 `VACUUM`。
核心实现在 `src/backend/access/heap/vacuumlazy.c`。函数 `heap_vacuum_rel()` 是主入口,内部调用 `lazy_scan_heap()` 完成主要工作。整个流程分六个阶段:
```mermaid
flowchart TD
S["lazy_scan_heap()"] --> P1["阶段 1: 扫描 heap<br/>读每个页面,标记 dead tuple"]
P1 --> P2["阶段 2: 清理 heap 页面<br/>移除 dead tuple,压缩页面"]
P2 --> P3["阶段 3: 清理索引<br/>移除指向 dead tuple 的 index entry"]
P3 --> P4["阶段 4: 更新 VM<br/>标记全可见页面"]
P4 --> P5["阶段 5: 更新 FSM<br/>记录每个页面的空闲空间"]
P5 --> P6["阶段 6: 截断尾部空页<br/>归还空间给 OS(可触发)"]阶段 1:扫描 heap,标记 dead tuple
lazy_scan_heap()
遍历表的所有页面。对每个页面,它获取 buffer
pin(共享锁,不阻塞读写),然后调用
heap_page_prune() 判断页面内有哪些元组是 dead
的。
判断逻辑在
HeapTupleSatisfiesVacuum()(src/backend/utils/time/tqual.c)中:
// 简化版 dead tuple 判断逻辑
bool HeapTupleSatisfiesVacuum(HeapTupleHeader tuple, ...) {
// 1. xmax 非空,且 xmax 对应的事务已提交 → tuple 被 DELETE/UPDATE 标记为 dead
// 2. xmin 对应的事务回滚 → tuple 是 aborted tuple,也应清理
// 3. 关键:xmax 必须对所有活跃快照都可见
// 如果还有旧快照能看到这个 tuple,就不能回收
}
```text
关键约束是"所有活跃快照都看不到这个 tuple"——如果存在一个长时间运行的查询,它持有的快照在 xid=100 时刻,那么 xmax=101 的 dead tuple 对它来说仍然是"活着"的。这就是长事务(idle in transaction)导致膨胀的根因:**长事务持有的旧快照阻止 VACUUM 回收它启动之后产生的 dead tuple**。
扫描阶段维护一个 `dead_tuples` 数组,收集所有可回收元组的行指针(`ItemPointer`)。同时更新 `t_infomask` 中的 hint bit,让后续可见性判断不需要查 CLOG。
### 阶段 2:清理 heap 页面
对每个包含 dead tuple 的页面,`lazy_vacuum_heap_page()` 物理移除 dead tuple。它调用 `PageRepairFragmentation()` 压缩页面:把 live tuple 紧凑排列,更新 ItemId 数组,重置页面内的空闲空间指针。
这里不会触发 I/O——页面已经在 shared_buffers 中被 pin 住。修改后标记为 dirty,由 bgwriter 或 checkpoint 写入磁盘。
### 阶段 3:清理索引
每个 dead tuple 后面都有一个对应的 index entry。VACUUM 调用 `lazy_vacuum_index()` 遍历索引,删除所有指向已回收 dead tuple 的 index entries。
```c
// src/backend/access/heap/vacuumlazy.c
// lazy_vacuum() 的索引清理部分(简化)
for (i = 0; i < nindexes; i++) {
IndexBulkDeleteResult *result;
result = index_bulk_delete(indrel, lazy_tid_reaped, dead_tuples);
// 更新每个索引的统计信息(删除行数、空页面数)
}这个阶段的代价很高——如果表上有多个索引(比如 5
个),每个索引都要做一次完整扫描来匹配
dead_tuples
数组。maintenance_work_mem 控制
dead_tuples 数组的大小:如果 dead tuple
太多,VACUUM
会分多趟(multi-pass)进行,每一趟都重新扫描所有索引。这是
VACUUM 慢的主要瓶颈之一。
阶段 4:更新可见性映射(VM)
清理完成后,visibilitymap_set()
标记哪些页面”全可见”(all-visible)——页面上所有 tuple
对所有活跃快照都可见。VM 是一个每页面 2 bit
的位图文件,下节详解。
阶段 5:更新空闲空间映射(FSM)
RecordPageWithFreeSpace()
把每个页面清理后释放的空闲空间量写入 FSM。后续 INSERT 和
UPDATE 通过 FSM 快速找到有足够空间容纳新 tuple
的页面,而不是从头顺序扫描。
阶段 6:截断尾部空页
如果表尾部的连续页面全部为空(page 上没有
tuple),lazy_truncate_heap()
会截断它们,把空间归还 OS。触发条件:尾部连续空页数达到
REL_TRUNCATE_MINIMUM(默认为 32 页,即 256
KB)。不想触发截断可以用
VACUUM (TRUNCATE false)。
三、可见性映射(Visibility Map)
可见性映射(Visibility Map,VM)是 PG 中最容易被低估的数据结构。它的核心价值一句话概括:“这个页面上还有没有可能被某个快照看不到的 tuple?” 答案只需 1 bit。
VM 的物理结构
VM 是一个独立的 fork
文件,与表的数据文件(main fork)和 FSM
文件(fsm fork)并列。每个 heap 页面在 VM 中占
2 bit:
| Bit | 名称 | 含义 |
|---|---|---|
| Bit 0 | VISIBILITYMAP_ALL_VISIBLE |
页面上所有 tuple 对所有活跃快照都可见 |
| Bit 1 | VISIBILITYMAP_ALL_FROZEN |
页面上所有 tuple 已被 freeze(xmin 被冻结为
FrozenTransactionId) |
代码实现在
src/backend/access/heap/visibilitymap.c。每个
VM 页面(8 KB)覆盖大约 \(8 \times
1024 \times 8 \times 2 = 131072\) 个 heap 页面的
bit(约 1 GB 数据)。VM
文件不是创建时就预分配的——按需增长。VACUUM
清理页面后设置对应 bit,INSERT 第一次插入 tuple 到页面时
clear 对应 bit。
// src/backend/access/heap/visibilitymap.c
void visibilitymap_set(Relation rel, BlockNumber heapBlk, Buffer heapBuf,
XLogRecPtr recptr, Buffer vmBuf, TransactionId cutoff_xid,
uint8 flags) {
// 1. 设置 VISIBILITYMAP_ALL_VISIBLE bit(如果 flags 包含该标记)
// 2. 设置 VISIBILITYMAP_ALL_FROZEN bit(如果 flags 包含该标记)
// 3. 写 WAL 记录(VM 的修改也需要 WAL 保护)
// 4. 标记 VM 页面 dirty
}
```bash
### VM 的两个消费者
1. **VACUUM 本身**:跳过全可见且全冻结的页面,大幅减少扫描量。`lazy_scan_heap()` 中调用 `visibilitymap_get_status()` 检查每个页面,跳过已经完全冻结的页面。
2. **Index-Only Scan**:这是 VM 对查询性能最大的贡献——下节展开。
---
## 四、空闲空间映射(Free Space Map)
空闲空间映射(Free Space Map,FSM)回答的问题是:"哪个页面有足够的空间放下这个 tuple?"
### FSM 的物理结构
FSM 也用独立的 fork 文件存储。核心数据结构是一棵多级二叉树(`src/backend/storage/freespace/freespace.c`):
- **级别 0(叶子页面)**:存储最多约 4000 个 heap 页面的空闲空间信息
- **级别 1**:每个内部节点对应一个级别 0 页面的最大值
- **级别 2(根页面)**:对应所有级别 1 页面的最大值
每个 FSM 页面内部是一棵完整的二叉树,叶子节点对应具体 heap 页面的空闲空间量(以 1/256 字节为单位),非叶子节点存储其子树中最大的可用空闲空间。查找路径:从根页面出发,沿着"左子树最大值大于等于请求值"的路径向下,最终到达叶子节点指向的 heap 页面。
```c
// src/include/storage/fsm_internals.h
// FSM 页面的内部结构(简化)
//
// 每个 FSM 页面是一个完整的二叉树
// [根: 128] ← 整个 FSM 页面最大空闲为 128 单位
// / \
// [L1: 64] [L1: 128]
// / \ / \
// [L2: 0] [L2: 64] [L2: 128] [L2: 32]
// 叶子节点中的值对应具体 heap 页面的空闲空间量FSM 的更新时机
VACUUM 清理页面后调用
RecordPageWithFreeSpace() 更新 FSM。INSERT
填充页面后也会更新 FSM。但 FSM
不保证精确——如果页面实际空闲空间比 FSM 记录的大,INSERT
会用自己的检查更新 FSM 记录。如果比记录的小,INSERT 会
report 给 FSM 并尝试下一个页面。
这种”近似值”设计是刻意的:FSM
只是加速查找的辅助结构,精确的空闲空间在页面本身的
PageHeaderData.pd_lower / pd_upper
中。
五、Index-Only Scan 与 VM
Index-Only Scan 是 PG 中最直接依赖 VM
的查询优化。原理:如果索引包含了查询需要的所有列,而且目标
tuple 所在的 heap 页面是”全可见”的(VM 中
ALL_VISIBLE bit =
1),那么可以直接从索引返回结果,不需要访问 heap 页面。
-- 把 index-only scan 的工作过程用 EXPLAIN 可视化
CREATE TABLE t (id INT PRIMARY KEY, val TEXT);
INSERT INTO t SELECT g, 'data' || g FROM generate_series(1, 100000) g;
-- 初始没有 VM 信息,index-only scan 仍需访问 heap
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM t;
-- 输出:Index Only Scan using t_pkey ... Heap Fetches: 100000
-- VACUUM 后设置 VM bit
VACUUM t;
-- 再次执行:Heap Fetches 应该大幅下降
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM t;
-- 输出:Index Only Scan using t_pkey ... Heap Fetches: 0
```text
`Heap Fetches` 就是 index-only scan 实际需要访问 heap 页面的次数。如果这个值接近返回行数,意味着 VM 信息过期或不存在——index-only scan 退化为普通的 index scan,失去了 I/O 节省的优势。
VM bit 被 clear 的场景:INSERT 插入新 tuple 到页面、UPDATE 在页面内产生新版本、VACUUM 还没跑到这个页面。如果你的核心 OLTP 查询依赖 index-only scan 加速,应该确保 `autovacuum` 足够频繁地维护 VM,或者对关键表手动 `VACUUM`。
---
## 六、Freezing:事务 ID 回卷防御
### 事务 ID 是 32 位的
PG 的事务 ID(`TransactionId`)是 32 位无符号整数,总共约 40 亿个值($2^{32}$)。PG 用模运算来比较事务 ID 大小——不是简单的 $a < b$,而是 `TransactionIdPrecedes(a, b)`——把 40 亿的环对折,从当前事务向后看最多 20 亿个 xid 视为"过去",向前看最多 20 亿个 xid 视为"未来"。
如果事务 ID 真的用完并回卷,老 tuple 的 `xmin` 可能落在"未来"半环里——本来应该对所有快照可见的老数据,突然变得不可见。这就是 PG 运维中最危险的故障之一:**wraparound 导致数据"消失"**。
### Freezing 的作用
不能等到事务 ID 真的用完时才处理。PG 的策略是"冻结"(freeze)足够老的 tuple:把 tuple 的 `xmin` 替换为特殊值 `FrozenTransactionId`(值为 2)。被冻结的 tuple 在可见性判断时无条件视为"对所有快照可见"——它已经足够老了,不需要再跟任何事务 ID 比较。
`t_infomask` 中的 `HEAP_XMIN_FROZEN` 标志表示这个 tuple 的 `xmin` 已被冻结。PG 9.4 之前,freeze 操作直接在 tuple header 里把 `xmin` 改为 `FrozenTransactionId`,这产生 WAL 记录。PG 9.4 开始引入 freeze map(就是 VM 中的 `ALL_FROZEN` bit),可以在不修改 tuple 自身的情况下完成 freeze——如果页面上所有 tuple 都满足冻结条件,设置 `ALL_FROZEN` bit 即可。
### 冻结的触发条件
两个关键参数控制何时触发冻结:
| 参数 | 含义 | 默认值 |
|------|------|--------|
| `vacuum_freeze_min_age` | VACUUM 周期中,tuple 的 `xmin` 距当前事务 ID 超过此值时被冻结 | `50000000`(5 千万) |
| `vacuum_freeze_table_age` | 表的 `pg_class.relfrozenxid` 距当前事务 ID 超过此值时触发 aggressive VACUUM | `150000000`(1.5 亿) |
源码中的冻结判断逻辑在 `heap_prepare_freeze_tuple()`(`src/backend/access/heap/heapam.c`):
```c
// 简化版冻结条件
bool heap_prepare_freeze_tuple(HeapTupleHeader tuple, ...) {
// 1. 计算 cutoff_xid = 当前事务ID - vacuum_freeze_min_age
// 2. 如果 tuple->xmin < cutoff_xid → 冻结 xmin
// 3. 如果 tuple->xmax != 0 且 tuple->xmax < cutoff_xid → 冻结 xmax
// 4. 冻结:设置 HEAP_XMIN_FROZEN flag(9.4+)
}冻结时同时更新
pg_class.relfrozenxid,记录该表中最老的未冻结
xmin。下次 VACUUM 根据这个值和当前事务 ID 的差值决定是否需要
aggressive VACUUM。
七、Autovacuum:自动化垃圾回收
Autovacuum 是 PG 中最重要的后台进程子系统之一。它不是一个进程,而是两个角色:
- Autovacuum Launcher:一个长期运行的进程,定期检查所有数据库,决定哪些表需要 VACUUM 或 ANALYZE,然后 fork Autovacuum Worker 去执行。
- Autovacuum Worker:临时进程,由 Launcher fork 出来,执行具体一个表的 VACUUM 或 ANALYZE,完成后退出。
触发阈值公式
Autovacuum Launcher 对每张表使用以下阈值公式判断是否触发
VACUUM(src/backend/postmaster/autovacuum.c
中的 relation_needs_vacanalyze() 逻辑):
\[ \text{触发条件} = \text{n\_dead\_tup} > \text{autovacuum\_vacuum\_threshold} + \text{autovacuum\_vacuum\_scale\_factor} \times \text{n\_live\_tup} \]
默认
autovacuum_vacuum_threshold = 50,autovacuum_vacuum_scale_factor = 0.2。具体例子:
- 小表 1000 行:dead tuple 超过 \(50 + 0.2 \times 1000 = 250\) 即触发
- 大表 1 亿行:dead tuple 超过 \(50 + 0.2 \times 10^8 = 2000\) 万才触发
这就是为什么大表的
autovacuum_vacuum_scale_factor
默认值在很多场景下根本不合适——2000 万 dead tuple
意味着表已经膨胀了 20%,对一张 100 GB 的表来说就是 20 GB
的垃圾。生产环境通常对大表单独调低这个值:
ALTER TABLE large_table SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000
);
```text
ANALYZE 的触发公式类似,使用 `autovacuum_analyze_threshold` 和 `autovacuum_analyze_scale_factor`。
### Anti-wraparound VACUUM 的触发条件
除了普通的 dead tuple 触发,还有一类更高级别的触发:**Anti-wraparound VACUUM**。触发条件不看 dead tuple 数量,只看事务 ID 年龄:当 age(relfrozenxid) > autovacuum_freeze_max_age 时触发 aggressive VACUUM 默认 autovacuum_freeze_max_age = 200000000(2 亿)
Aggressive VACUUM 和普通 VACUUM 的区别:它扫描**所有页面**,不跳过 VM 标记的全可见页面——因为它的目的是 freeze 老的 tuple,而不是回收 dead tuple。
触发优先级是 Anti-wraparound > 普通 VACUUM。当多个表同时满足条件时,优先级按事务 ID 年龄降序排列——最接近 wraparound 的表先处理。
### Cost-based Delay
VACUUM 有大量 I/O。为了防止 VACUUM 打满磁盘影响正常查询,PG 使用 cost-based delay 机制限速:
```c
// src/backend/commands/vacuum.c, vacuum_delay_point()
// cost-based delay 计算逻辑(简化)
void vacuum_delay_point(void) {
VacuumCostBalance += VacuumCostPageHit * hits_per_page
+ VacuumCostPageMiss * misses_per_page
+ VacuumCostPageDirty * dirty_per_page;
if (VacuumCostBalance >= VacuumCostLimit) {
// sleep autovacuum_vacuum_cost_delay 毫秒
pg_usleep(VacuumCostDelay * 1000);
VacuumCostBalance = 0;
}
}
关键 GUC 参数:
| 参数 | 含义 | 默认值 |
|---|---|---|
autovacuum_vacuum_cost_limit |
每个 sleep 周期前的 cost 积累上限 | 200 |
autovacuum_vacuum_cost_delay |
每次 sleep 的毫秒数 | 2ms |
vacuum_cost_page_miss |
从磁盘读一页的 cost | 10 |
vacuum_cost_page_dirty |
写脏一页的 cost | 20 |
vacuum_cost_page_hit |
从 shared_buffers 读一页的 cost | 1 |
默认配置下,autovacuum worker 每个 cost 周期处理约 200 cost 单位后 sleep 2ms。对于一个 I/O 密集的 VACUUM(大量 cold pages),每秒处理约 \(200/10 \times 500 = 10000\) pages 左右(约 80 MB/s),远低于现代 NVMe 磁盘的能力。
这个限制是保护机制,但对大表来说也可能成为瓶颈——如果 dead tuple 产生速度超过了 VACUUM 的回收速度,膨胀会持续积累。
八、膨胀量化与监控
pgstattuple
pgstattuple 扩展是量化膨胀的最直接工具:
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('large_table');
-- 输出示例:
-- table_len | 1572864000 (1.5 GB)
-- tuple_count | 1000000 (100 万 live tuple)
-- tuple_len | 120000000 (120 MB, live tuple 实际占用)
-- tuple_percent | 7.63 (只有 7.63% 的空间是活数据)
-- dead_tuple_count| 9000000 (900 万 dead tuple)
-- dead_tuple_len | 1080000000 (1.08 GB)
-- dead_tuple_percent | 68.66
-- free_space | 350000000 (350 MB)
-- free_percent | 22.25
```text
如果 `dead_tuple_percent` 超过 30%,说明表已经明显膨胀,需要关注 VACUUM 是否跟不上。
### pg_stat_user_tables
日常监控不需要跑 `pgstattuple`(它在大表上很慢,会全表扫描),应该用 `pg_stat_user_tables`:
```sql
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 20;核心指标:
n_dead_tup:统计信息中估算的 dead tuple 数量。注意这是统计值,VACUUM 运行后更新。n_live_tup:统计信息中估算的 live tuple 数量。last_autovacuum:最近一次 autovacuum 完成的时间。如果n_dead_tup很高且这个时间很久远,autovacuum 可能跟不上。n_tup_del和n_tup_upd:累计删除和更新的行数——它们的增长速度决定了 dead tuple 的产生速度。
膨胀率预警信号
以下信号组合意味着膨胀正在加速,VACUUM 跟不上:
n_dead_tup / (n_live_tup + n_dead_tup) > 20%——dead tuple 占比超过 20%last_autovacuum距离现在超过 1 天,且n_dead_tup在持续增长n_tup_del + n_tup_upd的每小时增长率显著高于n_live_tup * autovacuum_vacuum_scale_factor
pg_stat_progress_vacuum
PG 9.6 开始提供 pg_stat_progress_vacuum
视图,让你实时看到正在运行的 VACUUM 在做什么:
SELECT
p.relid::regclass AS table_name,
p.phase,
p.heap_blks_total,
p.heap_blks_scanned,
round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total, 0), 2) AS heap_scan_pct,
p.heap_blks_vacuumed,
p.index_vacuum_count,
p.max_dead_tuple_bytes,
p.num_dead_tuples
FROM pg_stat_progress_vacuum p;
```text
关键 `phase` 值:
| Phase | 含义 | 常见瓶颈 |
|-------|------|----------|
| `initializing` | 启动阶段 | 短暂 |
| `scanning heap` | 扫描 heap 页面 | 大表扫描慢,特别是 cold pages |
| `vacuuming indexes` | 清理索引 | 多个索引时,每个索引都需要完整扫描 |
| `vacuuming heap` | 清理 heap 页面 | 通常很快 |
| `cleaning up indexes` | 索引清理收尾 | 最后一次索引遍历 |
| `truncating heap` | 截断尾部空页 | 持有 AccessExclusiveLock |
如果你看到 VACUUM 在 `scanning heap` 阶段走得很慢,同时 `heap_blks_scanned` 接近 `heap_blks_total`——这是一张冷数据大表,大部分页面需要从磁盘读。如果卡在 `vacuuming indexes`,说明索引多或索引大——可以考虑减少不必要的索引,或者增大 `maintenance_work_mem` 让每次索引扫描处理更多 dead tuple。
---
## 九、Wraparound 危机时间线
这是整个 PG 运维中最危险的故障模式之一。事务 ID 回卷(wraparound)危机不是突然发生的——它有清晰的预警信号链。如果你识别了这些信号并采取措施,可以避免数据库变只读。如果没识别,数据库会强制 shutdown。
```mermaid
flowchart TD
S1["阶段 1: 正常运维<br/>n_tup_del 持续增长<br/>autovacuum 正常回收"]
-->|"dead tuple 积累"| S2["阶段 2: 延迟膨胀<br/>n_dead_tup 稳定增加<br/>last_autovacuum 间隔变长"]
S2 -->|"xid 年龄增长"| S3["阶段 3: 首次告警<br/>age(relfrozenxid) > 1 亿<br/>日志: 'aggressive vacuum'"]
S3 -->|"未能完成"| S4["阶段 4: 强制 autovacuum<br/>age(relfrozenxid) > autovacuum_freeze_max_age (2 亿)<br/>日志: 'to prevent wraparound'"]
S4 -->|"大表跑太慢"| S5["阶段 5: 写阻塞<br/>age(datfrozenxid) > 15 亿<br/>ForceTransactionId 拦截新事务 ID 分配<br/>新增/删除/更新全部报错"]
S5 -->|"不干预"| S6["阶段 6: 数据库只读<br/>age(datfrozenxid) > 20 亿<br/>PG 强制 shutdown<br/>启动即只读,唯一恢复: 单用户 VACUUM"]
style S5 fill:#f0883e,stroke:#d2752f,color:#fff
style S6 fill:#f85149,stroke:#cc3f3b,color:#fff阶段 1-2:信号在统计视图中
最早的信号在 pg_stat_user_tables
中。n_dead_tup
持续增长,last_autovacuum
时间离现在越来越远。如果你没设监控,你可能根本不知道问题在积累。
-- 监控 SQL 1: 各表的年龄和 dead tuple 比率
SELECT
c.oid::regclass AS table_name,
age(c.relfrozenxid) AS xid_age,
s.n_dead_tup,
s.n_live_tup,
round(100.0 * s.n_dead_tup / NULLIF(s.n_live_tup + s.n_dead_tup, 0), 2) AS dead_ratio,
s.last_autovacuum,
now() - s.last_autovacuum AS since_last_av
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relfrozenxid != '0'::xid
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
```bash
### 阶段 3:日志中出现 "aggressive vacuum"
当表的 `age(relfrozenxid)` 超过 `vacuum_freeze_table_age`(默认 1.5 亿),PG 会在日志中输出:automatic aggressive vacuum of table “mydb.public.large_table”: index scans: 1 pages: 0 removed, 123456 remain tuples: 0 removed, 5000000 remain, 200000 dead …
这行日志是**正常行为**——PG 在主动处理。但如果大型表(几百 GB)的 aggressive VACUUM 频繁触发且每次耗时很长,意味着 autovacuum 的调度压力在增大。
### 阶段 4:日志中的 "to prevent wraparound"
当年龄超过 `autovacuum_freeze_max_age`(默认 2 亿),日志会变成:
autovacuum: VACUUM public.large_table (to prevent wraparound)
关键变化:Autovacuum Worker 的进程名会显示 "(to prevent wraparound)"。这意味着该表的年龄已经到了必须处理的阈值。所有其他 autovacuum work(普通 VACUUM、ANALYZE)会被暂停,优先处理这个表。
```sql
-- 监控 SQL 2: 数据库级别的事务 ID 年龄(最关键的监控指标)
SELECT
datname,
age(datfrozenxid) AS xid_age,
datfrozenxid,
pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
age(datfrozenxid)
是整个数据库中最老的未冻结事务 ID
距当前的距离。这个值是你需要设置告警的核心指标:
- 小于 1 亿:正常
- 1 亿到 1.5 亿:关注,检查 autovacuum 是否正常
- 1.5 亿到 1.9 亿:告警,确认所有大表的 aggressive VACUUM 在跑
- 大于 1.9 亿:紧急,手动对大表
VACUUM FREEZE - 大于 2
亿(
autovacuum_freeze_max_age):已经触发强制 VACUUM,如果再增长就有风险
阶段 5:写阻塞(ForceTransactionId)
当 age(datfrozenxid) 超过 15
亿,GetNewTransactionId() 中的
ForceTransactionId 检查会将新事务 ID
的分配阻塞。此时应用程序会收到错误:
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.
You might also need to commit or roll back old prepared transactions,
or drop stale replication slots.
这还不是只读模式——已经持有事务 ID 的读写操作仍可继续,但新事务无法开始。此时你必须尽快完成 VACUUM FREEZE。
阶段 6:数据库强制只读 shutdown
如果 age(datfrozenxid) 达到 20 亿,PG 会直接
PANIC 并
shutdown。重启后数据库处于只读模式——你只能以单用户模式启动(postgres --single),手动执行
VACUUM FREEZE。
# 单用户模式 VACUUM——最后的手段
postgres --single -D /path/to/data mydb <<EOF
VACUUM FREEZE;
EOF
```text
恢复时间取决于表的大小和磁盘性能。对于 TB 级别的大表,这个 VACUUM 可能跑几个小时甚至几天——在此期间数据库不可用。
### 两种典型陷阱
**陷阱一:手动取消 autovacuum 导致 wraparound**
一个常见的错误操作——DBA 看到 autovacuum 跑得慢、占 I/O,心想"先取消吧,晚上手动跑"。于是 `SELECT pg_cancel_backend(pid)` 终止了 autovacuum worker。Autovacuum Launcher 会重新 schedule,但如果再次被取消,这个表就再也不会被 autovacuum 处理。
如果你需要临时让某个表不通过 autovacuum 被 freeze,至少要在表级别设置一个较高的 freeze age,而不是 kill worker:
```sql
ALTER TABLE large_table SET (autovacuum_freeze_max_age = 400000000);也绝对不要用
ALTER TABLE large_table SET (autovacuum_enabled = false)
来阻止 anti-wraparound VACUUM——即使 autovacuum
被表级禁用,PG 仍会触发 anti-wraparound 检查,但不会再分配
autovacuum worker 去处理,最终导致阶段 5/6。
陷阱二:autovacuum_vacuum_cost_delay
设太高,VACUUM 跟不上
当你在一张 500 GB 的大表上调高
autovacuum_vacuum_cost_delay 到
20ms(甚至是表级别的),看起来是在”保护生产负载”,但实际上
autovacuum 的处理速度降为原来的 1/10。如果这张表每天产生 5
GB 的 dead tuple,而 autovacuum 每天只能回收 2 GB,那么 3
GB/天 的积累会在几周内让年龄突破 2 亿。
正确做法是提高
autovacuum_vacuum_cost_limit(比如从 200 提到
2000),而不是提高 delay。或者利用 PG 13+ 的表级 cost
参数,为特定表设置独立的加速配置,不影响全局 autovacuum
行为。
十、排查路径与监控 SQL 汇总
日常监控三件套
-- 1. 数据库级别事务年龄(告警阈值建议小于 1.5 亿)
SELECT datname, age(datfrozenxid) AS age_xid,
pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- 2. 表级别 dead tuple 积累与 autovacuum 间隔
SELECT c.oid::regclass AS tbl,
age(c.relfrozenxid) AS xid_age,
s.n_dead_tup, s.n_live_tup,
round(100.0 * s.n_dead_tup / NULLIF(s.n_live_tup + s.n_dead_tup, 0), 2) AS dead_pct,
s.last_autovacuum,
now() - s.last_autovacuum AS since_last_av
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relfrozenxid != '0'::xid AND s.n_dead_tup > 0
ORDER BY age(c.relfrozenxid) DESC;
-- 3. 当前正在运行的 VACUUM 进度
SELECT p.relid::regclass, p.phase,
p.heap_blks_scanned, p.heap_blks_total,
round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total, 0), 2) AS pct,
p.num_dead_tuples, p.index_vacuum_count,
a.query_start, now() - a.query_start AS elapsed
FROM pg_stat_progress_vacuum p
JOIN pg_stat_activity a ON a.pid = p.pid;
```bash
### 膨胀速度与 VACUUM 能力匹配检查
```sql
-- 计算每个表的 dead tuple 日均增长速率(需要快照基线)
-- 第一步:创建基线表
CREATE TABLE dead_tup_snapshot AS
SELECT now() AS snap_ts, relid, n_dead_tup, n_tup_del, n_tup_upd
FROM pg_stat_user_tables;
-- 第二步:24 小时后查询增长量
SELECT
c.oid::regclass AS tbl,
s.n_dead_tup - d.n_dead_tup AS dead_tup_growth,
s.n_tup_del - d.n_tup_del AS del_24h,
s.n_tup_upd - d.n_tup_upd AS upd_24h,
s.last_autovacuum,
(s.n_tup_del - d.n_tup_del) / 24.0 AS del_per_hour
FROM pg_stat_user_tables s
JOIN dead_tup_snapshot d ON d.relid = s.relid
JOIN pg_class c ON c.oid = s.relid
WHERE s.n_dead_tup > d.n_dead_tup
ORDER BY dead_tup_growth DESC;
-- 清理基线
DROP TABLE dead_tup_snapshot;如果 del_per_hour 持续上升且
last_autovacuum 的间隔在拉长——说明 autovacuum
在追赶,但追不上。
autovacuum 配置审计
-- 检查所有表级别的 autovacuum 定制配置
SELECT
c.oid::regclass,
c.reloptions
FROM pg_class c
WHERE c.reloptions IS NOT NULL
AND c.relkind = 'r'
AND (array_to_string(c.reloptions, ',') LIKE '%autovacuum%');
```text
关注是否有表设置了过高的 `autovacuum_vacuum_cost_delay`(大于 10ms)或过低的 `autovacuum_vacuum_scale_factor`(但这通常是好事)。
### 长事务检查
长事务持有旧快照,阻止 VACUUM 回收其启动之后产生的 dead tuple:
```sql
SELECT
pid,
usename,
application_name,
state,
backend_start,
xact_start,
now() - xact_start AS xact_duration,
query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
OR (xact_start IS NOT NULL AND now() - xact_start > interval '5 minutes')
ORDER BY xact_start;任何一个 xact_duration
超过几分钟的事务都需要被审视——如果是一个遗留的、忘了 COMMIT
的 psql session,它会阻碍所有表上的 VACUUM 回收。
十、关键要点
- VACUUM 是 PG MVCC
的必然代价——UPDATE/DELETE 产生 dead
tuple,不回收就会膨胀;回收速度取决于
OldestXmin和 autovacuum 是否跟得上写入速率。 - lazy VACUUM 分六步:heap scan → heap prune → index cleanup → VM 更新 → FSM 更新 → 可选截断;瓶颈常在 index cleanup 或 cost-based delay 限速。
- VM 支撑 Index-Only Scan,FSM
指导插入位置——VM 标记全可见页可跳过 heap 读取;FSM
记录页内空闲空间,但 VACUUM
不收缩物理文件(
VACUUM FULL才还空间给 OS)。 - Freezing 是 wraparound 防御线——把老
tuple 的
xmin标记为 frozen,释放 XID 比较空间;age(relfrozenxid)和autovacuum_freeze_max_age是核心监控指标。 - Anti-wraparound VACUUM 有完整危机链——从
autovacuum 告警、
ForceTransactionId写阻塞,到数据库只读 shutdown;长事务和手动取消 autovacuum 是最常见的加速器。
参考资料
源码(PG 17)
src/backend/access/heap/vacuumlazy.c:VACUUM 主循环与 heap 清理,heap_vacuum_rel(),lazy_scan_heap(),lazy_vacuum_all_indexes(),lazy_vacuum_heap_rel()src/backend/commands/vacuum.c:VACUUM 命令入口与 cost-based delay,ExecVacuum(),vacuum_delay_point()src/backend/postmaster/autovacuum.c:Autovacuum Launcher 与 Worker 调度,AutoVacLauncherMain(),AutoVacWorkerMain(),relation_needs_vacanalyze()src/backend/access/heap/visibilitymap.c:可见性映射,visibilitymap_set(),visibilitymap_clear(),visibilitymap_get_status()src/backend/storage/freespace/freespace.c:空闲空间映射,fsm_search_avail(),RecordPageWithFreeSpace()src/backend/utils/time/tqual.c:HeapTupleSatisfiesVacuum()可见性判断src/backend/access/heap/heapam.c:heap_prepare_freeze_tuple()冻结逻辑src/backend/access/transam/varsup.c:GetNewTransactionId()与ForceTransactionId()wraparound 检查src/include/storage/fsm_internals.h:FSM 内部结构src/include/access/htup_details.h:HeapTupleHeaderData结构定义,t_infomask 标志位contrib/pgstattuple/:pgstattuple 扩展源码
官方文档
- PostgreSQL 17 Documentation, Chapter 25: Routine Database Maintenance Tasks(VACUUM、autovacuum 配置)
- PostgreSQL 17 Documentation, Section 25.1.5: Preventing Transaction ID Wraparound Failures(wraparound 防护策略)
- PostgreSQL 17 Documentation, Section 20.10: Automatic Vacuuming(autovacuum 触发公式与参数)
- PostgreSQL 17 Documentation, Section 68.4: Visibility Map(VM 的物理格式)
- PostgreSQL 17 Documentation, Section 68.5: Free Space Map(FSM 的物理格式)
- PostgreSQL 17 Documentation, Section 27.2.4:
pg_stat_progress_vacuum视图
论文
- Berenson, H. et al. A Critique of ANSI SQL Isolation Levels. SIGMOD 1995.(快照隔离的形式化定义,包含事务 ID 回卷的原始讨论)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性
在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。
【PG 内核】事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件
拆解 PostgreSQL 事务系统的三层结构:事务状态机 TransState 的状态转换路径、子事务(savepoint)的 TransactionState 栈与 ResourceOwner 嵌套管理、两阶段提交(2PC)的 WAL 记录与 pg_twophase 状态文件格式、事务 ID 分配的 xidStopLimit/xidWrapLimit 防线。附带 2PC 泄露的排查 SQL 和子事务栈过深的故障案例。
【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么
不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。