# 膨胀与重建

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

---

“膨胀”不是一个 catalog flag。

它是一个相对于**预期有效载荷和未来复用**的工程判断：

```text
allocated bytes
  - live payload
  - necessary page/index overhead
  - intentionally reserved fillfactor
  - space likely to be reused soon
  = potentially reclaimable waste
```

这几个减数没有一个能由 `n_dead_tup` 单独给出。重建又会产生锁、额外空间、WAL、
replica replay 和失败残留；误判膨胀，常常比接受一个稳定 plateau 更贵。

## 28.4.1 表膨胀、索引膨胀与统计误判 {#item-28-4-1}

### 先分 heap、TOAST 和 index

```sql
SELECT
  c.oid::regclass AS relation,
  pg_relation_size(c.oid) AS main_bytes,
  pg_table_size(c.oid) AS table_bytes,
  pg_indexes_size(c.oid) AS index_bytes,
  pg_total_relation_size(c.oid) AS total_bytes,
  c.reltuples::bigint AS planner_rows,
  c.relpages
FROM pg_class AS c
WHERE c.oid = 'app.orders'::regclass;
```

这些层次不同：

```text
main fork
FSM / VM / init forks
TOAST table and TOAST indexes
user indexes
partition children
```

“表 2 TB”必须说清是 heap、table size 还是 total size。

### 表膨胀的四个常见来源

```text
1. obsolete tuple not yet vacuumable
2. vacuumable tuple not yet processed
3. processed free space not reused by current workload
4. deliberately reserved page space / unavoidable overhead
```

对应动作：

| 来源 | 动作 |
|---|---|
| old snapshot/slot/2PC retains | 解除精确保留者 |
| vacuum service rate insufficient | 修触发/worker/I/O |
| retention permanently shrank | 评估 rewrite/partition |
| fillfactor/design overhead | 评估写放大与 scan tradeoff |

只有第三类天然指向“交还 OS”。

### `pgstattuple` 更直接，但不是瞬时原子快照

```sql
CREATE EXTENSION pgstattuple;

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

返回：

```text
table_len
tuple_count / tuple_len / tuple_percent
dead_tuple_count / dead_tuple_len / dead_tuple_percent
free_space / free_percent
```

它只拿 read lock 并逐页累计；并发写可在扫描期间发生，所以结果不是整张表同一时点的
原子 snapshot。大型表还会产生显著读取负载。

较轻量的候选：

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

它利用 VM 跳过部分 all-visible pages，换取估计。到底用哪一个要在 ticket 中声明
accuracy/cost。

### index bloat 不是 heap dead ratio

B-tree 页面分裂、删除和 key distribution 会造成：

- 半空 leaf page；
- deleted/empty page；
- logical adjacency 与 physical layout 分离；
- 低 leaf density；
- 大量仍需维护但查询很少使用的 index tuple。

观察：

```sql
SELECT *
FROM pgstatindex('app.orders_created_at_idx'::regclass);
```

重点：

```text
index_size
tree_level
leaf_pages
empty_pages
deleted_pages
avg_leaf_density
leaf_fragmentation
```

但仍不能写：

```text
avg_leaf_density < 70% -> must reindex
```

原因：

- index fillfactor 本来允许余量；
- 刚经历 page split；
- key 是随机、递增或时间窗口；
- 并发扫描期间数据在变化；
- 不同 access method 的空间模型不同；
- 低密度空间可能马上被写入复用。

还要看：

```sql
SELECT
  schemaname,
  relname,
  indexrelname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch,
  pg_relation_size(indexrelid) AS bytes
FROM pg_stat_user_indexes
WHERE relid = 'app.orders'::regclass
ORDER BY bytes DESC;
```

统计 reset 会影响 `idx_scan`；不能因为 reset 后为零就立即 drop index。

### statistics error 会伪装成 bloat

常见误判：

```text
reltuples stale
  -> rows-per-page estimate wrong
  -> SQL bloat formula says 80%

n_dead_tup delayed
  -> dashboard shows vacuum did nothing

partition parent never ANALYZE
  -> planner row estimate wrong
  -> blamed on physical bloat

stats reset
  -> usage looks zero
```

先确认：

```sql
SELECT
  stats_reset
FROM pg_stat_database
WHERE datname = current_database();

SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_analyze,
  last_autoanalyze,
  analyze_count,
  autoanalyze_count
FROM pg_stat_user_tables
WHERE relname = 'orders';
```

必要时：

```sql
ANALYZE (VERBOSE) app.orders;
```

然后再重算 estimate。

### “大”与“膨胀”分开

一个 4 TB 表可能：

- live data 就是 4 TB；
- page density 合理；
- 维护跟上；
- query 通过 partition pruning；
- 无需重写。

一个 20 GB 表可能：

- live data 只有 1 GB；
- retention 永久下降；
- 文件系统只剩 5 GB；
- 重写却需要超过当前 free space；
- 已成为事故风险。

大小决定操作成本，浪费比例决定收益，headroom 决定可执行性。三者缺一不可。

### 诊断报告模板

```yaml
object:
  table: app.orders
  access_method: heap
  partition: false

size:
  heap_bytes: ...
  toast_bytes: ...
  index_bytes: ...
  total_bytes: ...
  growth_7d: ...

contents:
  planner_rows: ...
  cumulative_live: ...
  cumulative_dead: ...
  physical_dead_bytes: ...
  physical_free_bytes: ...

behavior:
  updates_per_hour: ...
  hot_ratio: ...
  expected_reuse_days: ...
  retention_change: ...

holders:
  backend_xmin: ...
  slots: ...
  prepared: ...

confidence:
  stats_reset: ...
  physical_scan_scope: ...
  captured_at: ...
```

报告结论应是：

```text
healthy steady state
transient churn
vacuum blocked
vacuum underprovisioned
heap rewrite candidate
index-only rebuild candidate
inconclusive
```

而不是一个没有依据的 `bloat_pct`。

## 28.4.2 `VACUUM FULL`、在线重建与额外空间 {#item-28-4-2}

### `VACUUM FULL` 做了什么

PostgreSQL 18：

```text
write a new compact copy
keep old copy until operation completes
swap relation storage
return old storage after success
```

因此它：

- 需要 `ACCESS EXCLUSIVE`；
- 比普通 vacuum 慢；
- 需要额外磁盘容纳新副本；
- 重写 heap，并重建相关索引；
- 产生大量 I/O/WAL；
- 可推高 replica replay 和 archive backlog；
- 使缓存重新变冷；
- 改变 tuple `ctid` 等物理标识。

命令：

```sql
VACUUM (FULL, VERBOSE, ANALYZE) app.orders;
```

语法简单，变更本身不简单。

### 为什么不能“磁盘快满时就 FULL”

`VACUUM FULL` 在完成前不释放旧副本；磁盘已经接近满时，它可能最缺执行所需空间。

预算至少覆盖：

```text
new heap copy
new indexes
temporary sort/build space
WAL
archive spool
replica retention/replay
filesystem reserve
failure residue/headroom
```

不要用：

```text
reclaimable bytes = operation free-space requirement
```

推算。可回收 500 GB 不表示先有 500 GB 可用。

### 锁窗口不仅是命令运行时间

```text
wait for ACCESS EXCLUSIVE
  -> command execution
      -> dependent work / validation
          -> release
```

若先无限等待锁，队列会在它后面形成：

```text
VACUUM FULL waiting
  blocks later queries that conflict with its queued lock
```

生产执行要有：

```sql
SET lock_timeout = '5s';
SET statement_timeout = '2h';
```

示例值必须按对象预算；关键是 fail-fast 获取锁，而不是在峰值排队。

### 在线重写不是免费重写

`pg_repack` 一类工具通常通过：

```text
create shadow table/index
capture concurrent changes
copy base data
replay delta
short final lock/swap
cleanup
```

把长时间强锁缩短到最终切换，但代价仍在：

- shadow copy 空间；
- 索引和 WAL；
- trigger/delta capture 开销；
- 长事务等待；
- extension/client/server 版本兼容；
- unique key/对象类型限制；
- DDL 并发限制；
- 中断后的临时对象和恢复。

Pigsty 提供：

```bash
pig pg repack mydb --plan
pig pg repack mydb -t app.orders
pig pg repack mydb -j 2
```

它要求 `pg_repack` extension。`--plan` 只是先看计划，不是生产审批。执行前仍需：

```text
pg_repack version compatibility
extension installed in target database
eligible unique key
disk/WAL/replica budget
lock and timeout
DDL freeze
backup/restore proof
cleanup procedure
```

Pigsty 默认扩展目录可提供 pg_repack 包与数据库 extension 映射，但实际是否启用要用
`pg_extension` 检查。

### 其他重写路径

| 路径 | 适合 | 主要风险 |
|---|---|---|
| `VACUUM FULL` | 小表/可停写窗口/一次性大清理 | 长强锁 |
| `CLUSTER` | 需要按索引重排且可停写 | 长强锁，物理顺序会再漂移 |
| `pg_repack` | 需保持大部分读写 | 额外空间、delta、最终锁、扩展复杂度 |
| logical copy/swap | 迁移、类型/模型同时变更 | 双写/增量/切换验证 |
| partition detach | 过期数据整片淘汰 | 需预先按生命周期分区 |

“在线”应写成：

```text
which operations continue?
which lock still occurs?
for how long?
what happens under long transactions?
```

而不是一个布尔标签。

### 预执行 canary

对可复制数据：

```text
1. clone representative database/table
2. reproduce bloat and statistics
3. run exact tool/version/options
4. measure peak extra disk, WAL, CPU, I/O
5. replay concurrent workload
6. inject interruption before final swap
7. verify cleanup and retry
8. verify backup/restore after rewrite
```

canary 仍不能完全预测生产锁队列，但能排除明显容量和兼容性错误。

### 验收不只是“size 下降”

```text
logical row count/digest
constraints and triggers
indexes valid/ready
grants/owner/comments
replica caught up
archive backlog recovered
plans and latency
autovacuum reloptions
backup chain and restore
temporary objects absent
```

若 rewrite 改了 statistics，执行计划可能变化；第 7 章的 plan evidence 也应进入验收。

## 28.4.3 `REINDEX CONCURRENTLY` 的版本和失败处理 {#item-28-4-3}

### 先问为什么重建

合理原因：

```text
measured index bloat with low reuse
amcheck or incident evidence indicates index inconsistency
collation/operator-class change requires rebuild
index storage parameter must fully take effect
invalid index recovery
```

不充分：

```text
index is large
idx_scan is zero since yesterday's stats reset
query is slow
calendar says monthly reindex
```

索引疑似损坏时，先保存证据、确认 heap 和 backup，再决定重建；盲目重建可能覆盖关键
取证线索。

### 版本边界

`REINDEX CONCURRENTLY` 在 PostgreSQL 12 引入。本章以 PostgreSQL 18 语义为准：

```sql
SHOW server_version;
SHOW server_version_num;
```

跨版本自动化不能只看语法存在，还要检查：

- object kinds；
- partitioned relation 支持；
- progress view columns；
- exclusion/system catalog 限制；
- invalid-index recovery；
- minor release bug fixes。

### 普通与并发模式

普通：

```sql
REINDEX INDEX app.orders_created_at_idx;
```

会阻止 parent table 写入，并对索引本身拿强锁；planner 尝试锁表的各索引，所以读也可能
受到广泛影响。

并发：

```sql
REINDEX INDEX CONCURRENTLY app.orders_created_at_idx;
```

允许正常 insert/update/delete 继续，但需要：

```text
new transient index
first table scan
second catch-up scan
wait for old snapshots/readers
catalog validity swap
drop old index
```

它做更多总工作、花更久、需要额外空间和 CPU/memory/I/O，并不“无锁”。

### 不能放在 transaction block

```sql
BEGIN;
REINDEX INDEX CONCURRENTLY app.orders_created_at_idx;
-- ERROR
```

同一张表一次只能有一个 concurrent index build；并发 DDL 也受限制。自动化器要把
每个对象的状态作为可恢复 step，而不是把全库命令包成一个 transaction。

### 观察进度和等待

```sql
SELECT
  pid,
  datname,
  relid::regclass AS table_name,
  index_relid::regclass AS index_name,
  command,
  phase,
  lockers_total,
  lockers_done,
  current_locker_pid,
  blocks_total,
  blocks_done,
  tuples_total,
  tuples_done,
  partitions_total,
  partitions_done
FROM pg_stat_progress_create_index;
```

关键 phase 包括：

```text
building index
waiting for writers before build
waiting for writers before validation
index validation: scanning index
index validation: sorting tuples
index validation: scanning table
waiting for old snapshots
waiting for readers before marking dead
waiting for readers before dropping
```

如果卡在 old snapshots，回到 28.3.2，不是再启动第二个 reindex。

### 失败后会留下 INVALID

官方文档明确：

- `_ccnew`：新 transient index 未成功，应检查后 drop，再重试；
- `_ccold`：旧 index 在成功重建后未能 drop，通常应 drop 这个 old artifact；
- 后缀可能带数字；
- INVALID index 不供查询，但仍可能带来 update overhead。

检查：

```sql
SELECT
  n.nspname,
  c.relname,
  c.oid,
  i.indisready,
  i.indisvalid,
  i.indislive,
  pg_relation_size(c.oid) AS bytes
FROM pg_index AS i
JOIN pg_class AS c ON c.oid = i.indexrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE i.indrelid = 'app.orders'::regclass
ORDER BY c.relname;
```

不要用：

```sql
DROP INDEX app.orders_created_at_idx_ccnew;
```

作为通用 cleanup。先确认它的 `indisready/valid/live`、parent、constraint dependency
和本次 run marker；名称相似不是所有权证据。

### 约束索引需要额外验证

unique/primary/exclusion index 参与约束。并发重建会更新 constraint reference，但：

- exclusion constraint index 不能并发 reindex；
- unique violation 可让 build 失败；
- 不能只验 index name；
- 必须验 constraint 仍绑定正确有效 index。

```sql
SELECT
  conname,
  contype,
  conindid::regclass,
  convalidated
FROM pg_constraint
WHERE conrelid = 'app.orders'::regclass
  AND conindid <> 0;
```

### 正式实验

夹具的 `churn_status_idx`：

| 状态 | bytes | valid/ready/live | invalid artifact |
|---|---:|---|---:|
| before | 3,227,648 | true/true/true | 0 |
| after | 1,589,248 | true/true/true | 0 |

同时：

```text
REINDEX INDEX CONCURRENTLY return code  0
relfilenode changed                      true
same final name count                    1
_ccnew/_ccold artifacts                 0
elapsed on isolated fixture             0.089 s
```

0.089 秒只对这一张小型、无并发业务的夹具成立。正式 run 明确不外推生产 duration。

### reindex 也能影响 vacuum

并发 reindex 自身有多事务和 snapshot 等待。官方文档提醒：像任何长事务一样，它可能
影响其他表的 concurrent vacuum 清理边界。维护任务不能互相独立排程：

```text
reindex window
vacuum/freeze window
backup window
bulk load
schema migration
```

要在同一个资源与事务日历里编排。

### 生产完成条件

```yaml
before:
  reason: measured_bloat
  heap_integrity: verified
  index_integrity: ...
  free_disk: ...
  wal_replica_archive_headroom: ...
  long_snapshot_inventory: []
  lock_timeout: ...

after:
  command_success: true
  expected_index_count: ...
  indisready_valid_live: true
  constraints_bound: true
  cc_artifacts: []
  logical_queries_equal: true
  replica_caught_up: true
  size_and_plan_reviewed: true
```

## 本节检查清单

```text
heap / TOAST / index size separated
stats reset and estimate confidence
physical sample cost and timestamp
future reuse horizon
rewrite benefit in bytes
peak extra disk / WAL / replica budget
lock mode and fail-fast timeout
tool/extension/server compatibility
concurrent progress and snapshot blockers
invalid index cleanup plan
constraint binding
post-change logical and recovery validation
```

## 延伸阅读

- [PostgreSQL 18：Recovering Disk Space](https://www.postgresql.org/docs/18/routine-vacuuming.html#VACUUM-FOR-SPACE-RECOVERY)
- [PostgreSQL 18：VACUUM](https://www.postgresql.org/docs/18/sql-vacuum.html)
- [PostgreSQL 18：REINDEX](https://www.postgresql.org/docs/18/sql-reindex.html)
- [PostgreSQL 18：Routine Reindexing](https://www.postgresql.org/docs/18/routine-reindex.html)
- [PostgreSQL 18：`pgstattuple`](https://www.postgresql.org/docs/18/pgstattuple.html)
- [PostgreSQL 12 Release：REINDEX CONCURRENTLY](https://www.postgresql.org/docs/12/release-12.html)
- [Pigsty：`pig pg repack`](https://pigsty.io/docs/pig/pg/#pg-repack)
- [Pigsty：Default Extensions](https://pigsty.io/docs/pgsql/ext/extension/)

---

[上一节：冻结、XID 与保留者](../03/) · [返回本章目录](../) · [下一节：分区生命周期](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
