土法炼钢兴趣小组的算法知识备份

【PG 内核】VACUUM 与 Freezing:膨胀的根因和 Wraparound 危机

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#vacuum#freezing#wraparound#autovacuum#visibility-map#free-space-map#index-only-scan#pgstattuple#bloat#pg-stat-progress-vacuum#transaction-id

目录

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 都在页面里插入新版本,旧版本还在原地。10UPDATE 后,表里有 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 回卷防御

### 事务 ID32 位的

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(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 = 50autovacuum_vacuum_scale_factor = 0.2。具体例子:

这就是为什么大表的 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;

核心指标:

膨胀率预警信号

以下信号组合意味着膨胀正在加速,VACUUM 跟不上:

  1. n_dead_tup / (n_live_tup + n_dead_tup) > 20%——dead tuple 占比超过 20%
  2. last_autovacuum 距离现在超过 1 天,且 n_dead_tup 在持续增长
  3. 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 距当前的距离。这个值是你需要设置告警的核心指标

阶段 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 回收。


十、关键要点

  1. VACUUM 是 PG MVCC 的必然代价——UPDATE/DELETE 产生 dead tuple,不回收就会膨胀;回收速度取决于 OldestXmin 和 autovacuum 是否跟得上写入速率。
  2. lazy VACUUM 分六步:heap scan → heap prune → index cleanup → VM 更新 → FSM 更新 → 可选截断;瓶颈常在 index cleanup 或 cost-based delay 限速。
  3. VM 支撑 Index-Only Scan,FSM 指导插入位置——VM 标记全可见页可跳过 heap 读取;FSM 记录页内空闲空间,但 VACUUM 不收缩物理文件(VACUUM FULL 才还空间给 OS)。
  4. Freezing 是 wraparound 防御线——把老 tuple 的 xmin 标记为 frozen,释放 XID 比较空间;age(relfrozenxid)autovacuum_freeze_max_age 是核心监控指标。
  5. Anti-wraparound VACUUM 有完整危机链——从 autovacuum 告警、ForceTransactionId 写阻塞,到数据库只读 shutdown;长事务和手动取消 autovacuum 是最常见的加速器。

上一章:事务与子事务 下一章:查询解析与重写


参考资料

源码(PG 17)

官方文档

论文

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。

2026-06-16 · database / kernel

【PG 内核】事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件

拆解 PostgreSQL 事务系统的三层结构:事务状态机 TransState 的状态转换路径、子事务(savepoint)的 TransactionState 栈与 ResourceOwner 嵌套管理、两阶段提交(2PC)的 WAL 记录与 pg_twophase 状态文件格式、事务 ID 分配的 xidStopLimit/xidWrapLimit 防线。附带 2PC 泄露的排查 SQL 和子事务栈过深的故障案例。

2026-06-16 · database / kernel

【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么

不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。


By .