# 死元组与可见性

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

---

理解 `VACUUM` 的第一步，是放弃“表里只有当前行”的直觉。

PostgreSQL heap 存放的是**行版本**。一个逻辑主键在不同时间可能对应多条物理 tuple；
每个查询再用自己的 snapshot 判断哪一条可见。空间回收不能问：

> 这条旧版本对我还可见吗？

而要问：

> 集群清理边界之前，是否还存在任何合法快照可能看见它？

这两个问题之间的时间差，就是 MVCC 的空间债。

## 28.1.1 UPDATE/DELETE 如何产生旧版本 {#item-28-1-1}

### UPDATE 不是原地覆盖

概念上，一次更新经历：

```text
old tuple
  xmax <- updating transaction
  t_ctid -> new tuple location

new tuple
  xmin <- updating transaction
  values <- new values
```

事务提交后：

- 新快照通常看新版本；
- 更新前已经建立的旧快照仍可能看旧版本；
- rollback 则让更新产生的新版本不可见；
- vacuum 不能在旧快照离开前移除它仍可能访问的版本。

`DELETE` 不需要创建“空的新行”，而是在旧版本上记录删除事务；它同样要等到删除前的
快照离开，才可物理回收。

这就是 PostgreSQL 18 官方维护文档强调的边界：`UPDATE`/`DELETE` 不立即移除旧行，
因为它可能仍对并发事务可见。参见
[Routine Vacuuming](https://www.postgresql.org/docs/18/routine-vacuuming.html)。

### 四个不同状态

不要把以下词混成一个 `dead`：

| 状态 | 含义 | 能否立即物理移除 |
|---|---|---|
| 对当前 snapshot 不可见 | 本查询不应返回 | 未必 |
| 对所有可能 snapshot 都不可见 | 已跨过清理边界 | 通常可成为回收候选 |
| 已由 vacuum/prune 处理 | tuple/line pointer 已清理或重定向 | 页内空间可复用 |
| 文件系统已收回 | 关系文件缩小或重写完成 | 是另一项操作结果 |

例如：

```text
T1 BEGIN ISOLATION LEVEL REPEATABLE READ
T1 SELECT row                 -- snapshot S1

T2 UPDATE row
T2 COMMIT

T3 SELECT row                 -- sees new version
T1 SELECT row                 -- still sees old version
```

在 T1 结束前，T3 看不见旧版本不等于旧版本可删。

### `xmin`、`xmax`、`ctid` 是诊断入口，不是业务 API

在教学夹具可以观察：

```sql
SELECT
  ctid,
  xmin::text,
  xmax::text,
  id,
  revision
FROM maint.churn
WHERE id = 42;
```

但要保留三项边界：

1. 普通 SQL 只返回当前 snapshot 可见的版本，不会自动展示完整版本链；
2. `ctid` 会随 UPDATE、表重写和行移动变化，不能当持久业务键；
3. `xmin/xmax` 是内部事务标识，存在冻结、回卷和 multixact 语义，不能当无限增长的
   业务版本号。

若要检查页面内部，需要 `pageinspect` 等更侵入的诊断工具；它们适合受控故障分析，
不适合高频全库扫描。

### 一个容易忽略的命令级快照

数据修改 CTE 的兄弟子语句共享同一个命令快照：

```sql
WITH updated AS (
  UPDATE t SET payload = 'new'
  WHERE id <= 100
  RETURNING id
), deleted AS (
  DELETE FROM t
  WHERE id <= 100
  RETURNING id
)
SELECT ...;
```

不要依赖 `deleted` 再处理已经被 `updated` 修改的同一行，也不要用该命令末尾对原表的
`count(*)` 证明提交后状态。第 28 章实验最初正是在这里被验收器拒绝：

```text
UPDATE count       40,000
DELETE count       10,000
same-command count 60,000
next-command count 50,000
```

删除确实发生了；同命令读仍使用旧 command snapshot。正确证据是把修改计数和提交后
状态拆成两个 SQL 命令。这一例子也说明：没有明确 snapshot，所谓“当前行数”并不完整。

### `n_dead_tup` 是估计，不是验尸报告

常用视图：

```sql
SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  n_tup_ins,
  n_tup_upd,
  n_tup_del,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
```

`n_dead_tup` 来自累计统计系统，更新是最终一致的，且本来就是估计。它适合：

- 找趋势；
- 排优先级；
- 关联写入速率和维护时间；
- 发现“长期只增不降”的异常。

它不适合单独证明：

- 精确有多少物理旧版本；
- 多少版本已经可由 vacuum 移除；
- 表文件浪费了多少字节；
- 是否应该 `VACUUM FULL`。

受控诊断可补：

```sql
CREATE EXTENSION pgstattuple;

SELECT *
FROM pgstattuple('app.orders'::regclass);
```

`pgstattuple` 会扫描关系，能给更直接的 tuple/free-space 证据，但它也消耗 I/O；大型
生产表要先评估窗口，可考虑 `pgstattuple_approx` 或抽样型 bloat estimate。所谓
“更精确”不是“零成本”。

### 谁决定“仍可能可见”

清理边界受多类对象影响：

```text
running transaction snapshot
backend_xmin
idle in transaction
logical replication slot xmin/catalog_xmin
standby feedback
prepared transaction
```

因此 `VACUUM` 没清掉时，先找保留者，而不是先提高 vacuum worker。第 28.3 节会把
每一类对象拆开。

## 28.1.2 vacuum、prune、HOT 与可见性图 {#item-28-1-2}

`VACUUM` 不是唯一清理旧版本的地方，也不是所有清理都做同一件事。

### page pruning：局部、机会式

访问 heap page 时，如果页面上有可安全裁剪的版本链，PostgreSQL 可以做 page pruning：

```text
remove no-longer-needed intermediate tuple data
convert root line pointer to redirect
compact page free space
preserve chain reachability for indexes
```

它的作用域是当前页，不会：

- 扫全表；
- 清所有索引死条目；
- 更新全关系统计；
- 推进整个表的 `relfrozenxid`；
- 替代周期性 vacuum。

因此出现：

```text
n_dead_tup decreased before autovacuum
```

并不神秘，可能是热点页被访问时发生了 pruning。

### HOT：避免不必要的索引版本

PostgreSQL 18 的 HOT 条件是：

1. 更新没有修改任何被普通索引引用的列；核心中的 summarizing index 例外是 BRIN；
2. 原 tuple 所在页面有足够空间放新版本。

满足时：

- 新版本不需要给普通索引添加新 index tuple；
- 中间版本可由 page pruning 更便宜地移除；
- 索引仍通过原始 line pointer 沿 HOT chain 找到可见版本。

参见
[Heap-Only Tuples](https://www.postgresql.org/docs/18/storage-hot.html)。

HOT 不是 `UPDATE` 的固定属性。下面这些都会降低它：

```text
update indexed column
update expression-index referenced column
page has no room
wide row grows
fillfactor leaves too little reserve
write pattern moves working set to packed pages
```

监控：

```sql
SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  round(
    100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0),
    2
  ) AS hot_pct,
  round(
    100.0 * n_tup_newpage_upd / nullif(n_tup_upd, 0),
    2
  ) AS newpage_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;
```

实验表使用 `fillfactor=70`，更新 40,000 行时观察到：

```text
n_tup_hot_upd      15,000
n_tup_newpage_upd  25,000
```

这不是“70% fillfactor 应得到 37.5% HOT”的公式。它只是说明同样不改索引列的 update，
仍有 25,000 行因为页内空间条件转到新页。是否调整 fillfactor，要联合：

```text
HOT gain
base table footprint
cache residency
scan cost
insert density
rewrite cost
```

不能只追求 100% HOT。

### 普通 VACUUM 的四项工作

官方文档把日常 vacuum 目的分成：

1. 回收或复用 UPDATE/DELETE 占用的空间；
2. 更新 planner statistics；
3. 更新 visibility map，帮助 index-only scan；
4. 防止 XID/MXID 回卷。

一次命令不一定对每项做相同强度。例如：

```text
VACUUM table
VACUUM (ANALYZE) table
VACUUM (FREEZE) table
VACUUM (INDEX_CLEANUP OFF) table
```

语义不同。`INDEX_CLEANUP OFF` 在极端防回卷场景可减少工作，但若长期跳过，索引死条目
和 heap line pointer 会累积；PostgreSQL 18 还有 failsafe 机制可在危险年龄自动跳过
某些昂贵工作。不要把临时救险选项变成常规模板。

### FSM：哪里还有可放新 tuple 的空间

每个 heap 和除 hash 外的 index relation 都有 Free Space Map。它按页记录可用空间的
近似信息，帮助 insert/update 找到可复用页。

```sql
CREATE EXTENSION pg_freespacemap;

SELECT
  count(*) AS pages,
  sum(avail) AS reusable_bytes,
  max(avail) AS largest_page_free_bytes
FROM pg_freespace('app.orders'::regclass);
```

FSM 回答的是：

> 关系内部哪些页有空间可供后续写入？

它不回答：

> 操作系统现在多了多少 free bytes？

官方结构说明见
[Free Space Map](https://www.postgresql.org/docs/18/storage-fsm.html)。

### VM：哪些页可以被安全跳过

heap relation 的 Visibility Map 每页两位：

| bit | 含义 | 主要用途 |
|---|---|---|
| all-visible | 页内 tuple 对所有事务可见，没有 tuple 需要 vacuum | index-only scan 可跳 heap visibility check |
| all-frozen | 页内 tuple 已冻结 | anti-wraparound vacuum 可跳过 |

VM 是保守结构：

```text
bit = 1 -> 条件必须为真
bit = 0 -> 条件可能不真，也可能尚未被 vacuum 证明
```

修改页面会清位，只有 vacuum 置位。因此：

```text
all_visible = 0
```

不能直接推出页面里一定有 dead tuple。

观察：

```sql
CREATE EXTENSION pg_visibility;

SELECT *
FROM pg_visibility_map_summary('app.orders'::regclass);
```

需要进一步一致性检查时：

```sql
SELECT * FROM pg_check_visible('app.orders'::regclass);
SELECT * FROM pg_check_frozen('app.orders'::regclass);
```

非空结果意味着 VM 与 heap 的约束可能损坏，应停止普通维护、保全证据并进入第 35 章
的数据抢救流程，而不是“清空 VM 看看”。`pg_truncate_visibility_map` 是修复性、
超级用户操作，会迫使后续 vacuum 重建 VM，必须有明确故障证据和变更记录。

官方说明见
[Visibility Map](https://www.postgresql.org/docs/18/storage-vm.html) 与
[`pg_visibility`](https://www.postgresql.org/docs/18/pgvisibility.html)。

### 一张图看职责

```text
UPDATE / DELETE
  |
  v
old row versions --------> snapshot horizon
  |                             |
  | page-local                  | when safe
  v                             v
prune / HOT chain          VACUUM heap scan
  |                             |
  +------ reusable page space --+--> FSM
                                |
                                +--> index cleanup
                                +--> VM all-visible/all-frozen
                                +--> relfrozenxid / relminmxid
                                +--> optional ANALYZE
```

## 28.1.3 回收可重用空间不等于归还文件系统 {#item-28-1-3}

### 普通 VACUUM 的 steady-state 目标

高 churn 表最健康的状态通常不是“每晚回到最小文件”，而是：

```text
minimum live footprint
  + space consumed between vacuum cycles
  = stable relation plateau
```

后续更新和插入复用 plateau 内的空闲页，文件不再无限增长。PostgreSQL 官方文档建议
用较频繁的普通 vacuum 维持稳态，避免把 `VACUUM FULL` 当周期任务。

### 普通 VACUUM 也可能截断尾部

两个绝对命题都错：

```text
normal VACUUM always shrinks files     false
normal VACUUM never shrinks files      false
```

普通 vacuum 主要原地处理页面；若关系尾部形成连续空页且锁等条件允许，它可能截断尾部。
文件中间的空洞不能靠截尾交还操作系统，但仍可由关系复用。

因此正确表述是：

> 普通 `VACUUM` 不承诺按 dead tuple 数缩小文件；其主要产物是可重用空间，并可能在
> 条件满足时截断空闲尾部。

### 用三类 size，不用一个数字

```sql
SELECT
  pg_relation_size('app.orders')       AS heap_bytes,
  pg_indexes_size('app.orders')        AS index_bytes,
  pg_total_relation_size('app.orders') AS total_bytes;
```

再联合：

```sql
SELECT *
FROM pgstattuple('app.orders');

SELECT sum(avail)
FROM pg_freespace('app.orders');
```

这些值回答不同问题：

| 指标 | 回答 | 不回答 |
|---|---|---|
| heap bytes | 主 fork 当前文件规模 | 其中多少马上可移除 |
| index bytes | 全部索引文件规模 | 每个索引是否逻辑健康 |
| total bytes | heap + indexes + TOAST 等总体 | OS 会不会马上得到空间 |
| `free_space` | heap 扫描看到的自由空间 | 未来 workload 是否会复用 |
| FSM sum | allocator 已知的页内空间 | 精确物理空洞 |
| dead tuple | 旧版本数量/字节 | 是否被长快照保留 |

### 正式 run 的反直觉结果

```text
baseline heap                 61,440,000 bytes
after churn heap              87,040,000 bytes
after holder-blocked vacuum   87,040,000 bytes
after release + freeze        87,040,000 bytes

dead tuples:
  with old snapshot           50,000
  after release               0

FSM reusable:
  final                       51,920,000 bytes
```

表已经具备很大的内部复用空间，却没有缩小。这是普通 vacuum 的正常结果，不是失败。

更重要的是，第一次普通 vacuum 在旧 snapshot 存在时：

```text
progress samples   128
phases             initializing, scanning heap
dead tuples        still 50,000
```

它确实工作了；只是清理边界不允许移除那些版本。第二次在释放保留者后：

```text
progress samples   171
phases             scanning heap, vacuuming indexes, vacuuming heap
dead tuples        0
all-frozen pages   10,625
```

“命令成功”与“达成预期回收”必须分别验收。

### 什么时候才需要把空间交还 OS

先回答：

```text
Will the table reuse the space within the retention horizon?
```

若会：

- 保留稳定 plateau；
- 让 autovacuum 跟上；
- 调整 fillfactor/索引设计；
- 监控增长斜率。

若不会，且空间有现实价值：

```text
one-time purge
tenant offboarding
retention shortened
schema removed wide columns
index permanently overgrown
filesystem headroom endangered
```

才评估重写。

### 重写决策必须有预算

```yaml
object: app.orders
live_bytes: ...
estimated_rewrite_bytes: ...
extra_disk_required: ...
wal_generated_estimate: ...
replica_replay_headroom: ...
archive_headroom: ...
lock_mode: ...
long_transaction_wait: ...
duration_estimate: ...
rollback: ...
backup_and_restore_proof: ...
```

不同方法：

| 方法 | 主要效果 | 主要代价 |
|---|---|---|
| normal `VACUUM` | 页内复用、VM/freeze | 不整理中间空洞 |
| `VACUUM FULL` | 重写并缩 heap | `ACCESS EXCLUSIVE`、额外空间、WAL、长窗口 |
| `CLUSTER` | 按索引重写排序 | 强锁、额外空间、后续不会自动保持 |
| `pg_repack` | 较在线地重建 | extension、额外对象/空间、trigger/锁/失败治理 |
| logical copy/swap | 最大控制力 | 迁移与双写/切换复杂度 |
| partition detach | 整片退役 | 设计前提、DDL/依赖/归档流程 |

第 28.4 节展开前三类重建，第 28.5 节处理分区退役。

### 停止线

看到以下任一情况，不要继续“加大清理”：

```text
oldest backend_xmin is unexplained
replication slot consumer ownership unknown
prepared transaction ownership unknown
free disk cannot hold rewrite
backup exists but restore untested
replica/archive headroom insufficient
lock queue begins to grow
suspected structural corruption
```

前六项先补治理证据；最后一项转入
[第 35 章：数据抢救与工程取证](/data-rescue-forensics/)。

## 本节检查清单

你应能对一张表给出：

```text
logical live rows
cumulative dead estimate
physical tuple/free-space sample
heap / index / total bytes
HOT / new-page update ratio
VM all-visible / all-frozen
oldest holder
last vacuum / autovacuum
write and growth rate
expected future reuse
```

只有这些信息组合起来，`VACUUM` 是否健康、是否被阻断、是否需要重写才是可回答的问题。

## 延伸阅读

- [PostgreSQL 18：Routine Vacuuming](https://www.postgresql.org/docs/18/routine-vacuuming.html)
- [PostgreSQL 18：VACUUM](https://www.postgresql.org/docs/18/sql-vacuum.html)
- [PostgreSQL 18：Heap-Only Tuples](https://www.postgresql.org/docs/18/storage-hot.html)
- [PostgreSQL 18：Free Space Map](https://www.postgresql.org/docs/18/storage-fsm.html)
- [PostgreSQL 18：Visibility Map](https://www.postgresql.org/docs/18/storage-vm.html)
- [PostgreSQL 18：`pgstattuple`](https://www.postgresql.org/docs/18/pgstattuple.html)
- [PostgreSQL 18：`pg_visibility`](https://www.postgresql.org/docs/18/pgvisibility.html)

---

[返回本章目录](../) · [下一节：autovacuum 的触发与资源](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
