跳至内容

28.4 膨胀与重建

“膨胀”不是一个 catalog flag。

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

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 表膨胀、索引膨胀与统计误判

先分 heap、TOAST 和 index

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;

这些层次不同:

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

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

表膨胀的四个常见来源

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 更直接,但不是瞬时原子快照

CREATE EXTENSION pgstattuple;

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

返回:

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

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

较轻量的候选:

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。

观察:

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

重点:

index_size
tree_level
leaf_pages
empty_pages
deleted_pages
avg_leaf_density
leaf_fragmentation

但仍不能写:

avg_leaf_density < 70% -> must reindex

原因:

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

还要看:

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

常见误判:

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

先确认:

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';

必要时:

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 决定可执行性。三者缺一不可。

诊断报告模板

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: ...

报告结论应是:

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

而不是一个没有依据的 bloat_pct

28.4.2 VACUUM FULL、在线重建与额外空间

VACUUM FULL 做了什么

PostgreSQL 18:

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 等物理标识。

命令:

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

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

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

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

预算至少覆盖:

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

不要用:

reclaimable bytes = operation free-space requirement

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

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

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

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

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

生产执行要有:

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

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

在线重写不是免费重写

pg_repack 一类工具通常通过:

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 提供:

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

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

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过期数据整片淘汰需预先按生命周期分区

“在线”应写成:

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

而不是一个布尔标签。

预执行 canary

对可复制数据:

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 下降”

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 的版本和失败处理

先问为什么重建

合理原因:

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

不充分:

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 语义为准:

SHOW server_version;
SHOW server_version_num;

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

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

普通与并发模式

普通:

REINDEX INDEX app.orders_created_at_idx;

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

并发:

REINDEX INDEX CONCURRENTLY app.orders_created_at_idx;

允许正常 insert/update/delete 继续,但需要:

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

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

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

观察进度和等待

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 包括:

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。

检查:

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;

不要用:

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。
SELECT
  conname,
  contype,
  conindid::regclass,
  convalidated
FROM pg_constraint
WHERE conrelid = 'app.orders'::regclass
  AND conindid <> 0;

正式实验

夹具的 churn_status_idx

状态bytesvalid/ready/liveinvalid artifact
before3,227,648true/true/true0
after1,589,248true/true/true0

同时:

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 清理边界。维护任务不能互相独立排程:

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

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

生产完成条件

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

本节检查清单

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

延伸阅读


上一节:冻结、XID 与保留者 · 返回本章目录 · 下一节:分区生命周期 · 查看全书目录 · 查看索引中心

最后更新于