# 清理不可省

> 清理如何处理你的页、自动清理何时触发、什么会阻止它工作，以及如何在膨胀演变成停机事故前测量并修复它。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-10"></span>

迟早你会看到一张表不断变大，而 `pg_stat_user_tables` 却显示自动清理一小时前才运行过。你已经知道增长从哪里来：每次更新和删除都会留下一个死行版本（[更新就是插入](/06-mvcc/#basics-chapter-6)）。本章讲的是本该清理这些遗留物的进程，以及它为什么有时做不到。

清理跟得上时，没有人会注意。表不断变化，却保持原来的大小。清理还会防止事务 ID 耗尽，让[仅索引扫描](/43-glossary/#index-only-scan)正常工作，并保持统计信息新鲜。清理落后时，表会膨胀，查询会读取不该读取的页；最糟糕的情况下，数据库会停止接受写入。

清理是 Postgres 中唯一一个所有人都会忽略、直到它成为所有人唯一谈论的事情的部分。趁还没到那一步，先弄懂它。

## 用 pgstattuple 测量死元组 {#measure-dead-tuples-with-pgstattuple}

先测量，从同一个 `pgstattuple` 开始；它曾观察到 [清理释放表内空间、而不是磁盘空间](/06-mvcc/#vacuum-frees-space-inside-the-table-not-on-disk)中的 `users` 变成两倍大。这次的表是 `vac_demo`，它是前 500,000 行 `events` 的副本，并且关闭了自动清理。更新每两行中的一行，然后查看：

```sql
update vac_demo set payload = payload || '{"seen": true}' where id % 2 = 0;

select tuple_count, dead_tuple_count, dead_tuple_percent, free_percent
from pgstattuple('vac_demo');
```

```text
 tuple_count | dead_tuple_count | dead_tuple_percent | free_percent
-------------+------------------+--------------------+--------------
      500000 |           250000 |              31.13 |         0.59
```

同样是 500,000 个活行，另外还有占表 31% 的 250,000 个死行。原本紧密排列的页没有空间容纳新版本，所以新版本被写到末尾的 4,807 个新页中。现在每次顺序扫描都会多读取 55% 的页。

`pgstattuple` 很精确，但会读取整张表。对于大型生产表，使用会跳过全可见页的 `pgstattuple_approx()`，或者使用 `n_live_tup` 和 `n_dead_tup` 这两个低成本估算值，它们位于 `pg_stat_user_tables` 中。

> [!EXAMPLE]- 动手验证 · lab `09-vacuum`
>
> ```sql
> select lab.prove(
>   'updating every second row leaves 250,000 dead tuples, about 31% of the table',
>   tuple_count = 500000 and dead_tuple_count = 250000
>   and dead_tuple_percent between 29 and 33)
> from pgstattuple('vac_demo');
>
> select lab.prove(
>   'the table grew about 55% (to about 13,600 pages) with nothing inserted',
>   pg_relation_size('vac_demo') / 8192 between 13400 and 13800);
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

## 清理要做五件事 {#vacuum-does-five-jobs}

清除这 250,000 个死行只是清理工作的第一项。普通 `VACUUM`（不是 `VACUUM FULL`）会在原地工作，只取得一个 `SHARE UPDATE EXCLUSIVE` [锁](/43-glossary/#lock-modes)，既不会阻塞读取，也不会阻塞写入。它沿着固定路线运行：遍历表，然后遍历每个索引，再次遍历表。

```text
 heap pass 1               index pass             heap pass 2
 -----------               ----------             -----------
 for each page:            for each index:        for each page with
  - prune dead versions     - delete entries       dead line pointers:
  - remember dead TIDs        pointing at the       - mark them unused
  - freeze old tuples         dead TIDs             - update FSM
  - set VM bits                                     - set VM bits
                                                  then: truncate empty
                                                  pages at the end
```

> [!NOTE] 来自 MySQL 的读者
>
> InnoDB 的后台清理线程会从撤销日志中清除旧行版本。Postgres 的旧版本存在表中，因此清理要在堆和每个索引上完成清除工作；还有一个 InnoDB 没有的风险：事务 ID 回卷（[Postgres 不是 MySQL](/11-not-mysql/#basics-chapter-11)）。

<span id="basics-10-1-remove-dead-row-versions"></span>

### 1. 移除死行版本 {#1-remove-dead-row-versions}

当删除或替换该行的事务已经提交，并且没有仍在运行的事务可能还需要它时，一个行版本就是**死亡的**。清理会计算这个边界，也就是[只有没有快照能看到版本时，版本才算死亡](/06-mvcc/#a-version-is-dead-only-when-no-snapshot-can-see-it)中介绍的 xmin 清理界限（xmin horizon）；清理输出把它称为**可移除截点**（removable cutoff）：任何当前快照可能还会关心的最老事务 ID。记住“已死亡但尚不可移除”这句话。这是清理输出中最重要的一行。

索引按位置指向堆元组（TID，即一个 `(page, slot)` 地址），所以清理会分阶段移除死版本。它先剪枝每个堆页，并把死 TID 收集到内存中；然后完整扫描每个索引，删除指向这些 TID 的条目；最后释放堆槽位。每个索引都会放大清理成本。（普通查询经过页时也会剪枝页：[页内剪枝要等到页接近填满](/07-hot/#pruning-waits-for-a-nearly-full-page)。）

<span id="when-vacuum-scans-every-index-twice" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-when-vacuum-scans-every-index-twice" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：清理何时会把每个索引扫描两遍
>
> 死 TID 存放在 `maintenance_work_mem`（或 `autovacuum_work_mem`）中。如果放不下，清理就执行一次索引遍历，清空这份列表后继续，因此内存太少意味着要对每个索引执行多次完整扫描。详细输出中的 `index scans: 1` 表示扫描一次。PostgreSQL 17 用紧凑得多的结构替换了 TID 数组，并取消了 1 GB 上限，所以在 17+ 上这种情况很少见。在更早的版本中，要给清理分配足够的内存，让 `index scans:` 保持为 1。

<span id="basics-10-2-record-the-free-space"></span>

### 2. 记录空闲空间 {#2-record-the-free-space}

释放的空间会进入**空闲空间映射**（FSM），这样后续写入会填充已有页，而不是扩展文件（[插入会找到空间，而不用扫描表](/04-storage/#inserts-find-room-without-scanning-the-table)）。清理让空间变得*可复用*，而不会让文件变小。

清理之后，`vac_demo` 仍然有 13,601 页，但死元组变成了零，空闲空间从 0.6% 上升到 31.7%。随后我们插入了 100,000 行。一页都没有增加；空闲空间降到了 19.3%。

这就是你想要的稳态：表不断变化，却保持相同的大小。这个循环一旦断开，得到的就是膨胀。

> [!EXAMPLE]- 动手验证 · lab `09-vacuum`
>
> ```sql
> select lab.prove(
>   'the table is the same size, and the dead space is now about 32% free space',
>   pg_relation_size('vac_demo') / 8192 = :pages_after_update
>   and free_percent between 29 and 34)
> from pgstattuple('vac_demo');
>
> select lab.prove(
>   '100,000 new rows land in that free space: not one page added',
>   pg_relation_size('vac_demo') / 8192 = :pages_after_update);
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

<span id="basics-10-3-truncate-empty-pages-at-the-end"></span>

### 3. 截断末尾的空页 {#3-truncate-empty-pages-at-the-end}

只有在末尾的整页都为空时，普通清理才会缩小文件；它会短暂取得一个 `ACCESS EXCLUSIVE` 锁，把这些页截掉。删除按时间排序的表中最新的一半行，再运行清理，文件会缩小一半。删除最老的一半行时，空间会留在文件前部：可以复用，却永远不会归还给操作系统。

<span id="which-half-you-delete-decides-whether-the-file-shrinks" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-which-half-you-delete-decides-whether-the-file-shrinks" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：删除哪一半决定文件是否缩小
>
> ```sql
> create table trunc_demo with (autovacuum_enabled = off) as
>   select * from events where id <= 100000;
> delete from trunc_demo where id > 50000;
> vacuum (verbose) trunc_demo;
> ```
>
> ```text
> INFO:  table "trunc_demo": truncated 1792 to 880 pages
> ```
>
> `events` 按时间排序，因此最新的一半位于文件末尾。截断锁持续时间很短，但副本会重放它，这可能造成可见的停顿。可以按表通过存储参数 `vacuum_truncate` 禁用，也可以在单次运行中使用 `vacuum (truncate false)` 禁用；PostgreSQL 18 还新增了服务器级别的 `vacuum_truncate` 设置。
>
> > [!EXAMPLE]- 动手验证 · lab `09-vacuum`
> >
> > ```sql
> > select lab.prove(
> >   'deleting the newest half and vacuuming cuts the file roughly in half (about 1,790 to 880 pages)',
> >   :trunc_before between 1750 and 1850
> >   and pg_relation_size('trunc_demo') / 8192 between 860 and 900);
> >
> > select lab.prove(
> >   'deleting the oldest half instead frees the space at the front, and the file does not shrink',
> >   pg_relation_size('front_demo') / 8192 = :trunc_before);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

<span id="basics-10-4-freeze-old-tuples"></span>

### 4. 冻结旧元组 {#4-freeze-old-tuples}

事务 ID（XID）存储在 32 位中，大约有 43 亿个值，计数器会像里程表一样回卷。因此 Postgres 会在一个圆环上比较 XID：对于任意一个 XID，大约 21 亿个在“过去”，21 亿个在“未来”。如果某个元组的插入 XID 比计数器落后超过 21 亿，它突然就会看起来像来自未来，并从所有查询中消失。这就是**事务 ID 回卷**。

**冻结**可以防止它。一旦元组的插入事务老到每个快照都能看到它已经提交，清理就会把元组标记为**已冻结**：对所有人可见，永久如此，不受计数器当前值影响。每张表的 `relfrozenxid` 表示其中所有更老的 XID 都已被冻结，而 `age(relfrozenxid)` 表示它落后了多少个 XID，这个值绝不能接近 20 亿。清理不会费心冻结比 `vacuum_freeze_min_age` 更年轻的行（5,000 万个事务）；`vacuum (freeze)` 会立即冻结当前能冻结的一切。元组全部冻结的页会在可见性映射中标记为全冻结，因此后续清理再也不用读取它。

<span id="frozen-is-two-hint-bits-that-cant-otherwise-coexist" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-frozen-is-two-hint-bits-that-cant-otherwise-coexist" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：冻结是两个本来无法共存的提示位
>
> 这是 [更新就是插入](/06-mvcc/#basics-chapter-6)中那个三行的 `account_usage` 表，只是这次显式冻结了它：
>
> ```sql
> vacuum (freeze) account_usage;
>
> select lp, t_xmin, combined_flags
> from heap_page_items(get_raw_page('account_usage', 0)),
>      heap_tuple_infomask_flags(t_infomask, t_infomask2)
> where lp_flags = 1;
> ```
>
> ```text
>  lp | t_xmin | combined_flags
> ----+--------+--------------------
>   4 | 869586 | {HEAP_XMIN_FROZEN}
>   5 | 869588 | {HEAP_XMIN_FROZEN}
>   6 | 869580 | {HEAP_XMIN_FROZEN}
> ```
>
> `HEAP_XMIN_FROZEN` 是“已提交”和“无效”两个提示位同时设置的状态，这种组合在其他情况下没有意义。原始 `t_xmin` 仍然保留，便于调试，但之后不再查询它；`relfrozenxid` 跳到当前值，可见性映射把该页标记为全可见和全冻结。正常运行时永远不需要 `vacuum (freeze)`；下面的设置会决定自动清理何时完成冻结。

<span id="basics-10-5-maintain-the-visibility-map"></span>

### 5. 维护可见性映射 {#5-maintain-the-visibility-map}

**可见性映射**（VM）为每个堆页保留两位：*全可见*和*全冻结*（[每页两位，让扫描和清理跳过工作](/04-storage/#two-bits-per-page-let-scans-and-vacuum-skip-work)）。清理设置它们；对页的任何修改都会清除它们。仅索引扫描会跳过全可见页的堆，清理会跳过全可见页，冻结清理会跳过全冻结页。

所以只追加的表也需要清理。它们不会有行死亡，但没有清理，它们的页就永远不会变成全可见（无法进行仅索引扫描），也不会变成全冻结（于是某天一次巨大的防回卷清理会同时冻结整张表）。而且 `vacuum (analyze)` 会在这里顺便刷新[规划器统计信息](/20-planner/#basics-chapter-20)。

<span id="what-vacuum-verbose-says-about-each-job" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-what-vacuum-verbose-says-about-each-job" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：vacuum (verbose) 如何说明每项工作
>
> ```sql
> vacuum (verbose) vac_demo;
> ```
>
> ```text
> pages: 0 removed, 13601 remain, 13601 scanned (100.00% of total), 0 eagerly scanned
> tuples: 250000 removed, 500000 remain, 0 are dead but not yet removable
> removable cutoff: 11733, which was 5 XIDs old when operation ended
> frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
> visibility map: 13601 pages set all-visible, 0 pages set all-frozen ...
> index scan needed: 8794 pages from table (64.66% of total) had 250000 dead
>   item identifiers removed
> ...
> ```
>
> `tuples:` 是工作 1；其中 `dead but not yet removable` 的数量是需要关注的那一行；`index scan needed` 是工作 1 的索引遍历；`pages: 0 removed` 表示工作 3 没有找到可截断的内容；`frozen:` 是工作 4；`visibility map:` 是工作 5。工作 2 的空闲空间只会出现在 `pgstattuple` 中。

## 自动清理会因死行、插入或年龄而触发 {#autovacuum-fires-on-dead-rows-inserts-or-age}

自动清理启动器每分钟唤醒一次（`autovacuum_naptime`），并在任何一张表超过以下三个阈值之一时启动一个工作进程；工作进程最多为 `autovacuum_max_workers` 个（默认 3 个）：

- **死元组。** `n_dead_tup` 超过 `autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples`：50 加上活行的 20%。PostgreSQL 18 会把它限制在 `autovacuum_vacuum_max_threshold`（1 亿）以内。
- **插入**（PostgreSQL 13+）。`n_ins_since_vacuum` 超过 1,000 加上表的 20%。这会让只追加的表以小剂量获得可见性位和冻结。在 PostgreSQL 18 中，按比例计算的部分只统计尚未冻结的那部分。
- **年龄。** `age(relfrozenxid)` 超过 `autovacuum_freeze_max_age`（2 亿）。这是**防回卷**清理，即使关闭了自动清理也会运行。

当变更行数超过 50 加上表的 10% 时，ANALYZE 会触发。

### 对大表来说，默认值不对 {#the-defaults-are-wrong-for-big-tables}

0.2 的比例因子意味着“表中有五分之一的行死亡时才清理”：

| 活行数 | 自动清理前的死行数 | 含义 |
|---------------|-----------------------------|---|
| 1,000         | 250                         | 尚可 |
| 2,000,000     | 400,050                     | 今天的 `events`：先积累 400k 个死行 |
| 100,000,000   | 20,000,050                  | 20m 个死行造成的膨胀 |
| 1,000,000,000 | 100,000,000 (PostgreSQL 18 cap) | 没有上限时为 200m |

<span id="tbl-10-1"></span>

**Table 10-1.** 对大表来说，默认值不对


对大表来说，20% 的死行意味着每个查询都要扫描数 GB 的死行，最终启动的清理还要运行数小时。ANALYZE 也有同样的问题：比例为 10% 时，一张像 `events` 这样的年度时间序列表可能一个多月都不分析，因此统计信息会漏掉最新的行，而查询最常问到的正是这些行。修复方法是设置表级参数：

```sql
alter table events set (
  autovacuum_vacuum_scale_factor        = 0.01,
  autovacuum_vacuum_threshold           = 1000,
  autovacuum_vacuum_insert_scale_factor = 0.01,
  autovacuum_analyze_scale_factor       = 0.02,
  autovacuum_vacuum_cost_limit          = 1000
);
```

现在 `events` 大约积累 21,000 个死行就会清理，而不是等到 400,000 个。频繁的小清理在各方面都胜过罕见的大清理。我们的规则是：超过几百万行后，使用 0.01 到 0.05 的比例因子，或者使用 0 和固定阈值（[自动清理的默认值适合小表和慢磁盘](/30-configuration/#autovacuums-defaults-suit-small-tables-and-slow-disks)介绍全局设置）。

> [!EXAMPLE]- 动手验证 · lab `09-vacuum`
>
> ```sql
> select lab.prove(
>   'for events (2,000,000 rows), the default trigger is 400,050 dead rows',
>   (select count(*) from events) = 2000000
>   and least(100000000, 50 + 0.2 * (select count(*) from events)) = 400050);
>
> select lab.prove(
>   'with scale factor 0.01 and threshold 1000, events is vacuumed at about 21,000 dead rows',
>   1000 + 0.01 * (select count(*) from events) = 21000);
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

> [!WARNING] 永远不要通过关闭自动清理来解决问题
>
> 关闭自动清理从来不能修复任何问题：死元组会堆积，统计信息会腐烂，而防回卷清理仍然会在表膨胀到最糟时才到来。如果自动清理成本很高，就让它更频繁地运行，每次少做一些工作，并增加 I/O 预算，让它能够完成。

## 自动清理按预算运行，而默认预算很小 {#autovacuum-runs-on-a-budget-and-the-default-is-small}

自动清理用点数预算限制自身：在共享缓冲区中找到的每页 1 点，从外部读取的每页 2 点，使其变脏的每页 20 点。当一个工作进程花完 `vacuum_cost_limit`（200）点时，就会休眠 `autovacuum_vacuum_cost_delay`（2 ms）。手动执行的 `VACUUM` 不受此限制。

这相当于每秒 100,000 点：读取 50,000 页（约 390 MB/s，绰绰有余），但只能弄脏 5,000 页（约 39 MB/s），而且这笔预算由三个工作进程共享。在真正有大量写入的 SSD 上，清理会落后，膨胀会增长，无论比例因子设得多低。把 `autovacuum_vacuum_cost_limit` 提高到 1,000～2,000，是最有效的改动之一。（清理还会通过一个小型缓冲区环读取，大小由 `vacuum_buffer_usage_limit` 决定：[大操作会获得一个环](/05-buffers/#big-operations-get-a-ring)。）

## 清理无法移除旧快照可能需要的内容 {#vacuum-cant-remove-what-an-old-snapshot-might-need}

仍可能需要旧版本的最老对象决定了可移除截点。[长事务会拖住一切](/06-mvcc/#long-transactions-hold-everything-back)已经说明了原因：一个从未接触这张表的空闲 repeatable-read 会话，在提交前一直让其中 60,000 个死行无法被移除。下面从清理的角度看看这是什么样，以及如何找出每种持有者。

症状是 `vacuum (verbose)` 的一行：`0 removed`，以及一个很大的**已死亡但尚不可移除**数量；除了持有截点的快照之外，所有人都看不到这些行。清理会运行、消耗 I/O，却什么也移除不了，这通常就是“自动清理在运行，但表仍然不断膨胀”的答案。持有截点的会话会在 `pg_stat_activity` 中以非空的 `backend_xmin` 出现，通常状态是 `idle in transaction`。

代价有多大，取决于它背后的写入量。在安静的表上，几秒钟没有代价；但在每秒接收数千次更新的表上，几秒钟就足以让更新失去 HOT，并永久扩大表（[长事务会关闭 HOT](/07-hot/#a-long-transaction-turns-hot-off)）。繁忙的表被拖住三小时，就会产生数百万个死行，而膨胀会比事务活得更久。

<span id="an-open-snapshot-makes-vacuum-remove-nothing" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-an-open-snapshot-makes-vacuum-remove-nothing" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：打开的快照会让清理什么也移除不了
>
> 在更新了 100,000 行的 `vac_demo` 上：
>
> ```text
> tuples: 0 removed, 654564 remain, 100000 are dead but not yet removable
> ```
>
> `remain` 数量（654,564）是清理根据它扫描的 60% 页估算出来的；由于可见性映射把其余页标记为全可见，它跳过了那些页。表中有 600,000 个活行。
>
> > [!EXAMPLE]- 动手验证 · lab `09-vacuum`
> >
> > ```sql
> > select lab.prove(
> >   'vacuum removes nothing: 100,000 rows are dead but not yet removable',
> >   dead_tuple_count = 100000) from pgstattuple('vac_demo');
> >
> > select lab.prove(
> >   'once that transaction ends, the next vacuum removes all 100,000',
> >   dead_tuple_count = 0) from pgstattuple('vac_demo');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)
>
> > [!EXAMPLE]- 动手验证 · lab `09-vacuum`
> >
> > ```sql
> > select lab.prove(
> >   'the culprit shows up in pg_stat_activity with a non-null backend_xmin',
> >   count(*) = 1)
> > from pg_stat_activity
> > where datname = current_database() and pid <> pg_backend_pid()
> >   and backend_xmin is not null and state = 'idle in transaction';
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

会拖住截点的对象包括：

- **长时间运行的事务和查询**，包括只读事务和查询。
- **处于事务中空闲的会话**：应用运行了 `BEGIN`，随后等待网络调用，或泄漏了连接。这是现实中最常见的原因；`idle_in_transaction_session_timeout` 是解决办法（[连接并非免费](/28-connections/#basics-chapter-28)）。
- **[复制槽](/43-glossary/#replication-slot)**。备库启用了 `hot_standby_feedback` 的物理槽会为每张表保持截点，即使备库消失后仍会继续保持。逻辑槽只为系统目录保持截点。两者都会保留 WAL。
- **启用 `hot_standby_feedback = on` 的副本**，其中运行时间很长的副本查询会拖住主库。
- **被遗弃的预备事务**（[两阶段提交很少是答案](/09-acid/#two-phase-commit-is-rarely-the-answer)）。

复制槽和预备事务永远不会出现在 `pg_stat_activity` 中，而备库反馈只会在那里显示为 walsender 的 `backend_xmin`（或者显示在它的槽上），所以还要检查 `pg_replication_slots` 和 `pg_prepared_xacts`（[四个查询告诉你清理是否跟得上](#four-queries-tell-you-whether-vacuum-is-keeping-up)）。

> [!DANGER] 一个被遗忘的事务会让一切膨胀
>
> 一个会话整个周末都处于 `idle in transaction`，就可能拖住整个数据库的清理；一个被遗弃的物理复制槽，则可能拖住整个集簇。为应用角色设置 `idle_in_transaction_session_timeout`，对最老的 `backend_xmin` 和不活跃槽设置告警，并设置 `max_slot_wal_keep_size`，这样一个死亡的槽就不会顺便填满磁盘。

<span id="vacuum-also-loses-lock-fights" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-vacuum-also-loses-lock-fights" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：清理也会输掉锁竞争
>
> 清理的 `SHARE UPDATE EXCLUSIVE` 锁会与大多数 DDL 冲突，包括 `ALTER TABLE`、`CREATE INDEX`（甚至 `CONCURRENTLY`）以及另一次清理。普通自动清理如果阻塞了冲突的锁请求，会在 `deadlock_timeout` 之后自行取消，因此频繁的 DDL 或 `LOCK TABLE` 可能让一张表永远无法完成一次清理（`log_autovacuum_min_duration` 会记录这些取消）。防回卷清理不会让步；这也是一次迁移最终排在它后面的原因之一。

## 索引膨胀通常比表膨胀代价更大 {#index-bloat-often-costs-more-than-table-bloat}

查找会在索引中逐页进行，因此膨胀的索引会拖慢所有经过它的查询。**膨胀**是既不是活数据、近期也不会被复用的空间；一些空闲空间是健康的，因为下一次更新会落在那里。对于表，`pgstattuple` 会给出 `dead_tuple_percent` 和 `free_percent`。对于 B-树索引，`pgstatindex` 会报告叶子密度。

新的 B-树会把叶子打包到大约 90%。本章的更新每次都会为新行版本添加一个主键条目，因为没有任何一次更新能让新版本留在原来的页上；之后 `vac_demo` 的主键密度降到了 49%。`REINDEX CONCURRENTLY`（PostgreSQL 12+）会在线重建索引：创建一份新副本，再把它交换进来：

```sql
select index_size, avg_leaf_density from pgstatindex('vac_demo_pkey');
reindex index concurrently vac_demo_pkey;
select index_size, avg_leaf_density from pgstatindex('vac_demo_pkey');
```

```text
 index_size | avg_leaf_density
------------+------------------
   24731648 |            49.22

 index_size | avg_leaf_density
------------+------------------
   13500416 |               90
```

从 23.6 MB 降到 12.9 MB，小了 45%，而且没有阻塞写入。无需读取每一页的更便宜的膨胀估算见[观察它运行](/31-observability/#basics-chapter-31)。

> [!EXAMPLE]- 动手验证 · lab `09-vacuum`
>
> ```sql
> select lab.prove(
>   'after all those non-HOT updates, the primary key''s leaves are only about half full',
>   avg_leaf_density between 40 and 65) from idx_before;
>
> select lab.prove(
>   'REINDEX CONCURRENTLY packs the leaves back to about 90%',
>   avg_leaf_density between 88 and 92) from pgstatindex('vac_demo_pkey');
>
> select lab.prove(
>   'and the index is over a third smaller',
>   (select index_size from pgstatindex('vac_demo_pkey'))
>     < 0.67 * (select index_size from idx_before));
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

### 大表执行 VACUUM FULL 会造成停机 {#vacuum-full-is-an-outage-on-a-big-table}

`VACUUM FULL` 会把表重写到一份只包含活行的新文件中，然后重建所有索引。在 `vac_demo` 上，它从 13,601 页降到了 10,961 页。代价是：整个重写期间都要持有 `ACCESS EXCLUSIVE` 锁，阻塞所有读取和写入，还要额外占用一份副本大小的磁盘空间。对大表来说，这就是一次事故。（`CLUSTER` 是同一种操作，只是按索引对行排序。）把它留给小表和维护窗口；大表要在线重建（[无锁重建膨胀的表](/16-extensions/#rebuild-bloated-tables-without-locking-them)）。

但首先要问，表为什么膨胀。如果清理被阻塞或太慢，重建只是把时钟拨回去。修复原因后，新写入会像插入测试所展示的那样复用已有的空闲空间。

<span id="vacuum-full-writes-a-brand-new-file" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-vacuum-full-writes-a-brand-new-file" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：VACUUM FULL 会写出一份全新的文件
>
> ```sql
> select pg_relation_filenode('vac_demo');   -- 18189
> vacuum full vac_demo;
> select pg_relation_filenode('vac_demo');   -- 19527
> ```
>
> filenode 发生了变化：这是一个全新的文件，旧文件会在事务提交时删除。
>
> > [!EXAMPLE]- 动手验证 · lab `09-vacuum`
> >
> > ```sql
> > select lab.prove(
> >   'VACUUM FULL writes a new file (the filenode changes)',
> >   pg_relation_filenode('vac_demo') <> :filenode_before);
> >
> > select lab.prove(
> >   'compacted from about 13,600 to about 11,000 pages, under 1% free',
> >   table_len / 8192 between 10700 and 11200 and tuple_count = 600000 and free_percent < 1)
> > from pgstattuple('vac_demo');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/09-vacuum.sql)

## 回卷会在任何内容损坏前停止写入 {#wraparound-stops-writes-before-anything-is-corrupted}

数据库会拒绝新的写入，日志会说它这样做是为了防止数据丢失。没有任何东西损坏。你已经用掉了大部分 XID 空间，Postgres 在计数器回卷前停了下来。

每个写事务都会消耗一个 XID（只读事务不会）。每秒 1,000 个写事务就是每天 8,600 万个，因此繁忙系统每月会消耗数亿个。防线分成几层：

| XID 年龄 | 设置或限制 | 发生什么 |
|---|---|---|
| 150 million | `vacuum_freeze_table_age` | 清理变得激进：处理所有不是全冻结的页 |
| 200 million | `autovacuum_freeze_max_age` | 防回卷自动清理，即使自动清理已关闭 |
| 1.6 billion | `vacuum_failsafe_age`（PostgreSQL 14+） | 清理放弃节流和索引清理，以便完成 |
| 距离末尾 40 百万 | | 在日志和客户端处发出警告 |
| 距离末尾 3 百万 | | 不再有新的 XID：完全不能写入 |

<span id="tbl-10-2"></span>

**Table 10-2.** 回卷会在任何内容损坏前停止写入


“末尾”就是回卷点，大约是 21 亿个 XID。

通常，防回卷清理每隔几亿个事务运行一次，没有人会注意。只有当某些东西阻止它完成时，才会出现紧急情况：有阻塞者拖住截点（清理不能冻结比它更新的元组）、节流太慢导致大表永远做不完，或者一次运行反复被阻塞或杀掉。

这发生在真实公司身上：[Sentry](https://blog.sentry.io/transaction-id-wraparound-in-postgres/) 在 2015 年停机了大半个工作日，[Mandrill](https://mailchimp.com/what-we-learned-from-the-recent-mandrill-outage/) 在 2019 年停机了大约 40 小时。两次事故的模式相同：大而繁忙的表早就让清理落后了，只是没人查看。

<span id="how-the-two-outages-ended" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-how-the-two-outages-ended" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：两次事故是如何结束的
>
> 两起事故都以相同方式结束。Sentry 的高写入数据库在自动清理跟不上时停止接受写入；经过数小时清理后，截断一张巨大且不重要的表恢复了写入。Mandrill 在一个分片上发生了回卷，因为哈希让它比其他分片更热；清理的最高估计时间是 40 天，而截断最大的表（以 TB 计，其中大多数表只有 MB）让它在大约一小时后完成。

要避免这种情况：

- **监控每个数据库的 `age(datfrozenxid)`，以及最大表的 `age(relfrozenxid)`。** 要在防故障保护阈值之前很久就告警，比如到 5 亿至 10 亿时。
- **控制清理阻塞者。** 几乎每起回卷事故背后都有它们。
- **让由插入驱动的自动清理以小剂量冻结只追加的表。**
- **不要为了让迁移通过而杀掉防回卷清理。** 只有一次运行完成时 `relfrozenxid` 才会前进，因此杀掉它会丢失已经做的工作，而自动清理会立即再启动一次。

如果写入已经被拒绝：移除阻塞者（终止旧会话、处理预备事务、删除死亡的槽），然后在最老的表上不节流地运行普通 `VACUUM`（不是需要 XID 的 `VACUUM FULL`）。当前文档建议使用普通多用户模式，而不是旧的单用户步骤；关于你的版本的确切步骤，见 [Routine Vacuuming](https://www.postgresql.org/docs/18/routine-vacuuming.html)。

<span id="multixacts-wrap-too-and-postgresql-18-freezes-earlier" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-multixacts-wrap-too-and-postgresql-18-freezes-earlier" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：多事务 ID 也会回卷，而 PostgreSQL 18 会更早冻结
>
> **多事务 ID**用于多个事务锁住同一行的情况（例如，许多会话的外键检查取得 `FOR KEY SHARE`），它们有自己的计数器、年龄（`mxid_age(relminmxid)`）和冻结设置，也可能以同样方式回卷（[共享行锁会产生多事务 ID，多事务 ID 也会回卷](/25-concurrency/#shared-row-locks-make-multixacts-and-multixacts-wrap-too)）。
>
> PostgreSQL 18 还会在普通清理期间**提前冻结**一小部分全可见页（详细输出中的 `eagerly scanned` 数量，由 `vacuum_max_eager_freeze_failure_rate` 调整），把原本会由激进清理一次性承担的成本摊开。

## 四个查询告诉你清理是否跟得上 {#four-queries-tell-you-whether-vacuum-is-keeping-up}

调查问题时运行下面这些查询，并把重要的结果做成图表和告警（[观察它运行](/31-observability/#basics-chapter-31)会把它们和其他指标放在一起）。它们回答四个问题：

1. **哪些表最接近触发自动清理，它最近运行过吗？** 如果一张表的 `n_dead_tup` 长期远高于阈值，它要么被阻塞，要么跟不上。
2. **每张表距离强制冻结还有多远？** 比较 `age(relfrozenxid)` 和 `autovacuum_freeze_max_age`。
3. **是什么拖住了截点？** 会话（`pg_stat_activity`）、槽（`pg_replication_slots`）和预备事务（`pg_prepared_xacts`）：三者都要检查。
4. **清理现在正在做什么？** 看 `pg_stat_progress_vacuum`。

还要让自动清理报告自身：`log_autovacuum_min_duration`（默认 10 分钟）会为每次更长的运行记录包含 `VACUUM VERBOSE` 细节的日志。设为 `1s` 后，你就会得到清理历史，知道清理了什么、用了多长时间，以及哪些内容已死亡但尚不可移除。

<span id="four-queries-for-the-vacuum-dashboard" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-10-four-queries-for-the-vacuum-dashboard" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：清理仪表盘的四个查询
>
> **哪些表最接近触发自动清理，它最近运行过吗？** 在更新了 6,000 个事件和 300 个账户的新样本数据副本上：
>
> ```sql
> select s.relname,
>        s.n_dead_tup,
>        round(current_setting('autovacuum_vacuum_threshold')::int
>              + current_setting('autovacuum_vacuum_scale_factor')::float8
>                * c.reltuples) as vacuum_at,
>        s.last_autovacuum
> from pg_stat_user_tables s
> join pg_class c on c.oid = s.relid
> order by s.n_dead_tup desc
> limit 10;
> ```
>
> ```text
>  relname  | n_dead_tup | vacuum_at |       last_autovacuum
> ----------+------------+-----------+------------------------------
>  events   |       6000 |    400052 |
>  accounts |          0 |       250 | 2026-10-06 05:57:21.08907+00
> ...
> ```
>
> 300 个变更跨过了 `accounts` 的 250 行阈值，自动清理在一分钟内就把它们清掉了。`events` 的 6,000 个死行离 400,052 还远得很。这个查询使用全局设置，因此有表级覆盖设置的表会比 `vacuum_at` 显示的时间更早触发。
>
> **每张表距离强制冻结还有多远？**
>
> ```sql
> select c.oid::regclass as table_name,
>        age(c.relfrozenxid) as xid_age,
>        round(100.0 * age(c.relfrozenxid)
>              / current_setting('autovacuum_freeze_max_age')::int, 1)
>          as pct_of_freeze_max,
>        pg_size_pretty(pg_total_relation_size(c.oid)) as size
> from pg_class c
> where c.relkind in ('r', 'm', 't')
> order by age(c.relfrozenxid) desc
> limit 10;
>
> select datname, age(datfrozenxid), mxid_age(datminmxid)
> from pg_database order by 2 desc;
> ```
>
> **是什么拖住了截点？**
>
> ```sql
> select pid, datname, usename, state,
>        now() - xact_start as xact_age,
>        backend_xmin, left(query, 60) as query
> from pg_stat_activity
> where backend_xmin is not null
> order by age(backend_xmin) desc
> limit 5;
>
> select slot_name, slot_type, active, xmin, catalog_xmin,
>        pg_size_pretty(
>          pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained_wal
> from pg_replication_slots;
>
> select gid, prepared, owner, database, age(transaction) as xid_age
> from pg_prepared_xacts;
> ```
>
> **清理现在正在做什么？**
>
> ```sql
> select p.pid, p.relid::regclass as table_name, p.phase,
>        p.heap_blks_scanned, p.heap_blks_total, p.index_vacuum_count,
>        now() - a.xact_start as running_for
> from pg_stat_progress_vacuum p
> join pg_stat_activity a using (pid);
> ```
>
> 上面 `index_vacuum_count` 在大表上如果超过 1，意味着清理存不下所有死 TID，因而不止一次扫描索引。第一个查询中的 `vacuum_at` 使用的是 `reltuples` 这个采样估计值，因此会接近精确 2,000,000 行得到的 400,050，而不会正好等于它。

## 本章要点 {#the-short-version}

- 更新和删除会留下死行版本；清理会移除它们，并记录空间以便复用。普通清理不会缩小文件，除非末尾存在空页。
- 用 `pgstattuple`（精确）或 `n_dead_tup`（便宜）测量；用 `pgstatindex` 测量索引，并用 `REINDEX CONCURRENTLY` 修复索引膨胀。
- 清理还会冻结旧元组并设置可见性映射位。只追加的表也需要清理。
- 默认情况下，自动清理在 50 + 20% 死行时触发。对于超过几百万行的表，把表级比例因子设为 0.01 到 0.05。
- 在 SSD 上把 `autovacuum_vacuum_cost_limit` 提高到远高于 200；默认预算由所有工作进程共享。
- “已死亡但尚不可移除”意味着有什么东西拖住了截点：长事务或处于事务中空闲的会话、复制槽、启用了反馈的副本，或预备事务。找出它并结束它。
- `VACUUM FULL` 会在整个重写期间阻塞表；在线重建大表，并先修复造成膨胀的原因。
- 在 16 亿的防故障保护阈值之前很久就对 `age(datfrozenxid)` 告警，永远不要关闭自动清理，也不要杀掉防回卷清理。

## 延伸阅读 {#further-reading}

- [Routine vacuuming](https://www.postgresql.org/docs/18/routine-vacuuming.html)，PostgreSQL 文档。关于冻结、回卷，以及服务器停止接受写入时恢复步骤的参考资料。
- [Vacuuming settings](https://www.postgresql.org/docs/18/runtime-config-vacuum.html)，PostgreSQL 文档。所有自动清理、成本和冻结设置，包括 PostgreSQL 18 的新增设置。
- [pgstattuple](https://www.postgresql.org/docs/18/pgstattuple.html)，PostgreSQL 文档。精确和近似的膨胀测量，以及每个输出列的说明。
- [src/backend/access/heap/vacuumlazy.c](https://github.com/postgres/postgres/blob/master/src/backend/access/heap/vacuumlazy.c)，PostgreSQL 源码。文件头注释介绍清理阶段以及 PostgreSQL 18 的提前冻结。
- [VACUUM won't remove dead rows: 4 reasons why](https://www.cybertec-postgresql.com/en/reasons-why-vacuum-wont-remove-dead-rows/)，Laurenz Albe，CYBERTEC。简明介绍会拖住截点的所有因素。
- [Transaction ID Wraparound in Postgres](https://blog.sentry.io/transaction-id-wraparound-in-postgres/)，Sentry；以及 [What We Learned from the Recent Mandrill Outage](https://mailchimp.com/what-we-learned-from-the-recent-mandrill-outage/)，Mailchimp。两篇事务 ID 回卷事故复盘，最好在你需要它们之前读完。
