跳转到主要内容

28 除旧布新:VACUUM、冻结与膨胀治理

PostgreSQL 的写入并不在提交时把旧世界擦掉。

UPDATE 写出新行版本,DELETE 标记旧版本失效;旧版本必须继续存在,直到所有可能看见 它的快照都离开。这个选择换来了读写并发,也把空间、统计、事务年龄和索引维护变成一套 持续运行的生命周期:

write
  -> create obsolete row versions
      -> wait until no snapshot can see them
          -> prune / VACUUM
              -> make page space reusable
                  -> update FSM / VM / statistics
                      -> freeze old XID / MXID

这条链上任一环被阻断,表现都可能是“膨胀”,但处理方法完全不同:

autovacuum 尚未触发         -> 触发参数与变化速率
worker 已触发但排队         -> worker / I/O / cost budget
VACUUM 扫过却没清掉         -> 长快照、槽、预备事务
页内空间已经可重用          -> 不一定需要缩小文件
数据稳定但 XID 很老         -> aggressive vacuum / freeze
heap 正常但 index 膨胀      -> index diagnosis / reindex
过期数据天然按时间成片      -> detach partition,不做海量 DELETE

所以本章不把 VACUUM 当作一个“清垃圾命令”,而把它放回四条相互关联的控制回路:

控制回路 主要问题 关键证据
MVCC 回收 哪些旧版本已经无人可见 backend_xminpgstattuple、dead tuple
空间与访问 空间能否复用,VM/FSM 是否更新 relation size、FSM、VM、HOT
事务年龄 离回卷保护线还有多远 relfrozenxiddatfrozenxidrelminmxid
结构与生命周期 是否需要重建或整片退役 amcheckpg_index、partition manifest

三个必须分开的结论

“不可见”不等于“已经移除”

一条旧版本对当前事务不可见,不代表它对所有事务不可见。决定是否可回收的是全局清理 边界,而不是当前查询的快照。

“已经回收”不等于“文件已经缩小”

普通 VACUUM 主要把空间登记为关系内部可复用。它在特定条件下可能截断关系尾部,但 不承诺把散落在文件中间的空洞交还操作系统。VACUUM FULL 会重写表并取得 ACCESS EXCLUSIVE 锁,不能作为日常扫尾。

“dead tuple 很多”不等于“表已经异常膨胀”

n_dead_tup 是累计统计系统的估计;一次写入尖峰、健康的稳态 churn、被长事务阻断、 真正的 heap bloat,可能给出相似的瞬时数字。至少要联合:

change rate
last vacuum / autovacuum
dead tuple estimate and physical sample
relation growth
free space
oldest xmin holders
workload reuse behavior

再决定“等下一轮、手工 VACUUM、解除保留者、在线重建,还是安排离线重写”。

本章实验:让旧快照亲自阻断回收

正式实验在已确认的 Pigsty pg-test 沙箱创建一次性数据库:

database   pg36_maintenance
role       dbuser_pg36maint
PostgreSQL 18.6
data       synthetic only
risk       L2 bounded disposable fixture

夹具初始有 60,000 行。实验先开启一个 REPEATABLE READ 事务并确认它持有非空 backend_xmin,然后:

UPDATE 40,000 rows
DELETE 10,000 rows
VACUUM while old snapshot remains

结果:

时点 当前行 pgstattuple dead tuple heap bytes FSM 可用空间
初始 60,000 0 61,440,000 19,680,000
churn 后 50,000 50,000 87,040,000 27,877,376
旧快照仍在,普通 VACUUM 后 50,000 50,000 87,040,000 17,480,000
精确释放旧快照并 VACUUM FREEZE 50,000 0 87,040,000 51,920,000

这个结果同时证明两件事:

  1. 扫过并不等于能清;旧快照仍需要那些版本时,普通 VACUUM 必须保留它们;
  2. 回收并不等于缩文件;最后 dead tuple 为零、FSM 可用约 51.9 MB,heap 文件仍是 87.04 MB。

最后一次维护还得到:

all-visible pages   10,625
all-frozen pages    10,625
relfrozenxid age    14 -> 2

注意:这不是在声称普通 VACUUM 永远不会截断文件。本夹具只观察到“文件未缩、空间 可复用”;生产结论必须保留普通 vacuum 有条件截断尾部的例外。

同一条证据链中的索引与分区

实验没有在 heap 回收后停止。

完整性与重建

bt_index_check(... heapallindexed=true, checkunique=true)      pass
bt_index_parent_check(... rootdescend=true, ...)               pass
REINDEX INDEX CONCURRENTLY                                     pass
invalid index after                                            0
_ccnew / _ccold artifact after                                 0

被重建的二级索引从 3,227,648 bytes 降到 1,589,248 bytes;relfilenode 改变, 索引仍只有一个、indisready/indisvalid/indislive 全为真。这个数据只说明夹具中的 重建完成,不能外推生产窗口的耗时、锁等待或空间余量。

分区退役

10,000 行过期分区采用:

logical manifest
  -> DETACH PARTITION CONCURRENTLY
      -> CSV export + SHA-256
          -> independent restore table
              -> row count / range / sum / digest equality
                  -> drop detached partition

导出文件为 547,894 bytes。只有在回灌后的 10,000 行逻辑摘要完全一致后,实验才删除 独立分区;父表保留 5,000 行当前数据。

公开结果见 maintenance-run.json,安全边界见 lab-contract.md

本章学习成果

完成本章后,你应该能:

  1. 从 MVCC 快照解释 UPDATEDELETE 为什么留下旧版本;
  2. 区分 tuple visibility、deadness、removability 与 reusable space;
  3. 解释 page pruning、HOT、regular vacuum、aggressive vacuum 的分工;
  4. 用 FSM、VM、relation size 和 physical tuple evidence 分别回答不同问题;
  5. 正确计算 PostgreSQL 18 的 autovacuum update/delete 与 insert 触发阈值;
  6. 用表级 storage parameter 做定点治理,同时避免把关闭 autovacuum 当调优;
  7. 从 worker、cost delay、memory、I/O 和 workload 联合判断维护竞争;
  8. 读取 pg_stat_progress_vacuum,但不把块比例冒充 ETA;
  9. backend_xmin、复制槽 xmin/catalog_xmin、两阶段事务找出保留者;
  10. 监控 relfrozenxid/datfrozenxidrelminmxid,避免只看数据库总年龄;
  11. 在回卷紧急态按安装版本的官方流程解除保留者并让普通 VACUUM 完成;
  12. 区分 stable-state bloat、transient churn、index bloat 和 statistics error;
  13. 评估 VACUUM FULL、在线重写和并发索引重建的锁、空间、WAL 与失败残留;
  14. DETACH ... CONCURRENTLY、归档清单和回灌验证完成分区退役;
  15. amcheck 建立分层完整性检查,而不把它当页校验或恢复演练的替代品;
  16. 把 Pigsty 历史指标、日志和 dashboard 与 PostgreSQL 原生视图交叉验证;
  17. 输出日常、每周、每月和事件驱动的维护清单;
  18. 在过载与疑似损坏时分别安全路由到第 34、35 章。

本章目录

28.1 死元组与可见性

28.2 autovacuum 的触发与资源

28.3 冻结、XID 与保留者

28.4 膨胀与重建

28.5 分区生命周期

28.6 amcheck 与例行完整性检查

28.7 实战:建立维护节奏

阅读路线

应用开发者:

28.1 -> 28.2.1 -> 28.3.2 -> 28.5 -> 28.7

重点是事务生命周期、长事务边界、表级写入特征和分区保留策略。

平台工程师:

28.1 -> 28.2 -> 28.3 -> 28.4 -> 28.6 -> 28.7

重点是触发、资源、回卷安全、重建窗口和完整性检查。

两条路线必须合流:应用定义事务与数据生命周期,平台维护全局清理边界和资源预算。 应用留下无限事务,平台无法“调快 VACUUM”;平台盲目重写,应用也无法获得可预测服务。

版本与证据权威

本章命令以 PostgreSQL 18 为基线,正式 run 使用 18.6;平台示例以 Pigsty 4.5 为 参考实现。维护与紧急恢复语义应按实际安装 major/minor 的 PostgreSQL 官方文档 执行,不把旧版本博客或平台二次说明覆盖到新版本。

特别是事务 ID 即将耗尽的处置,PostgreSQL 18 官方流程要求先处理 prepared xact、 长事务和旧复制槽,再运行普通 VACUUM;它明确不建议在该状态使用 VACUUM FULLVACUUM FREEZE,一般也不需要 single-user mode。本章 28.3.4 按这个版本事实展开。

核心资料:

实验文件

static/labs/ch28/
├── requirements.json
├── maintenance-contract.json
├── negative-cases.json
├── topology.mmd
├── lab-contract.md
├── capture.py
├── remote_experiment.py
├── exercise.py
├── validate.py
├── review.py
├── task.sh
└── maintenance-run.json

执行:

static/labs/ch28/task.sh lint

export PG36_EVIDENCE_DIR=/private/path/to/new-empty-dir
static/labs/ch28/task.sh capture
static/labs/ch28/task.sh exercise
static/labs/ch28/task.sh verify
static/labs/ch28/task.sh review

all 会按同一顺序执行。exercise 会产生真实 I/O、WAL、锁和短暂维护负载,只能在 已确认的一次性开发/测试环境运行。

本章验收

只有当你能交付以下证据,才算掌握本章:

trigger calculation
holder inventory
progress and blocker evidence
before/after physical + cumulative statistics
XID and MXID headroom
lock / space / WAL budget
maintenance command and rollback
index validity after rebuild
partition archive and restore manifest
exact cleanup or production change record

“我跑了 VACUUM,没有报错”不是验收。


上一章:精益求精:参数调优与资源治理 · 返回下卷导读 · 下一章:移花接木:逻辑复制、迁移与异构同步 · 查看全书目录 · 查看索引中心

28.1 死元组与可见性

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

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

这条旧版本对我还可见吗?

而要问:

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

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

28.1.1 UPDATE/DELETE 如何产生旧版本

UPDATE 不是原地覆盖

概念上,一次更新经历:

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

四个不同状态

不要把以下词混成一个 dead

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

例如:

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 看不见旧版本不等于旧版本可删。

xminxmaxctid 是诊断入口,不是业务 API

在教学夹具可以观察:

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 的兄弟子语句共享同一个命令快照:

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 章实验最初正是在这里被验收器拒绝:

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 是估计,不是验尸报告

常用视图:

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

受控诊断可补:

CREATE EXTENSION pgstattuple;

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

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

谁决定“仍可能可见”

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

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 与可见性图

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

page pruning:局部、机会式

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

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

它的作用域是当前页,不会:

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

因此出现:

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

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

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

监控:

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 行时观察到:

n_tup_hot_upd      15,000
n_tup_newpage_upd  25,000

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

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 回卷。

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

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 找到可复用页。

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

VM:哪些页可以被安全跳过

heap relation 的 Visibility Map 每页两位:

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

VM 是保守结构:

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

修改页面会清位,只有 vacuum 置位。因此:

all_visible = 0

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

观察:

CREATE EXTENSION pg_visibility;

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

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

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 Mappg_visibility

一张图看职责

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 回收可重用空间不等于归还文件系统

普通 VACUUM 的 steady-state 目标

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

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

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

普通 VACUUM 也可能截断尾部

两个绝对命题都错:

normal VACUUM always shrinks files     false
normal VACUUM never shrinks files      false

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

因此正确表述是:

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

用三类 size,不用一个数字

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;

再联合:

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 的反直觉结果

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 存在时:

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

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

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

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

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

先回答:

Will the table reuse the space within the retention horizon?

若会:

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

若不会,且空间有现实价值:

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

才评估重写。

重写决策必须有预算

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 节处理分区退役。

停止线

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

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 章:数据抢救与工程取证

本节检查清单

你应能对一张表给出:

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 是否健康、是否被阻断、是否需要重写才是可回答的问题。

延伸阅读


返回本章目录 · 下一节:autovacuum 的触发与资源 · 查看全书目录 · 查看索引中心

28.2 autovacuum 的触发与资源

autovacuum 不是“每隔一分钟把所有表 vacuum 一遍”。

它是一套按数据库调度、按表判断资格、按 worker 执行、按 cost budget 限速的后台系统:

launcher
  -> choose database
      -> worker examines relations
          -> trigger decision
              -> VACUUM / ANALYZE / both
                  -> resource and lock interaction

排障必须分开问:

  1. 表有没有达到触发条件?
  2. 有 worker 能接活吗?
  3. worker 启动后在做什么?
  4. 为什么扫完仍留下旧版本?

“把 scale factor 调小”最多回答第一个问题的一部分。

28.2.1 阈值、比例、插入触发与表级覆盖

UPDATE/DELETE 触发公式

PostgreSQL 18 对普通 vacuum 的变化量先计算为:

Traw=Tbase+fvacuum×Ntable T_{\text{raw}} = T_{\text{base}} + f_{\text{vacuum}} \times N_{\text{table}}

对应:

T_base   autovacuum_vacuum_threshold
f        autovacuum_vacuum_scale_factor
N_table  pg_class.reltuples
T_max    autovacuum_vacuum_max_threshold

当 $T_{\max}\ge 0$ 时,最终阈值为:

Tvacuum=min(Tmax,Traw) T_{\text{vacuum}} = \min(T_{\max}, T_{\text{raw}})

autovacuum_vacuum_max_threshold=-1 时,表示禁用最大阈值,最终阈值就是 $T_{\text{raw}}$,不能把 -1 直接代入 min()

当自上次 vacuum 以来被 UPDATE/DELETE 变旧的 tuple 估计数超过这个阈值,表取得 vacuum 资格。

PostgreSQL 18 引入/使用 autovacuum_vacuum_max_threshold 作为上限:

base=500
scale=0.08
max=100,000,000
reltuples=1,000,000,000

base + scale * rows = 80,000,500
effective threshold = 80,000,500

若表为 10 billion rows:

base + scale * rows = 800,000,500
effective threshold = 100,000,000

版本低于 PostgreSQL 18 时,不要照抄这个公式中的 max 项;先查对应 major 文档和 pg_settings 是否存在。

INSERT-only 也需要 vacuum

只插不删的表没有 dead tuple,却仍需要:

  • 更新 visibility map;
  • 让 index-only scan 受益;
  • 冻结旧 XID;
  • 降低以后 aggressive vacuum 的工作。

插入触发公式为:

Tinsert=Tinsert-base+finsert×Ntable×(1relallfrozenrelpages) T_{\text{insert}} = T_{\text{insert-base}} + f_{\text{insert}} \times N_{\text{table}} \times \left(1 - \frac{\text{relallfrozen}}{\text{relpages}}\right)

对应:

autovacuum_vacuum_insert_threshold
autovacuum_vacuum_insert_scale_factor
pg_class.reltuples
unfrozen page fraction

这不是简单的:

1000 + 0.2 * rows

它还乘以“未冻结页面比例”。当表逐步 all-frozen,insert-based 触发的 scale 部分也会 变化。

ANALYZE 有自己的阈值

Tanalyze=Tanalyze-base+fanalyze×Ntable T_{\text{analyze}} = T_{\text{analyze-base}} + f_{\text{analyze}} \times N_{\text{table}}

变化量包括 insert/update/delete。vacuum 和 analyze 可能:

only vacuum
only analyze
vacuum then analyze

不要把 last_autovacuumlast_autoanalyze

freeze 资格优先于普通变化量

relfrozenxid 年龄超过 autovacuum_freeze_max_age,系统会强制 vacuum,即使:

  • 普通 autovacuum GUC 为 off;
  • 表级 autovacuum_enabled=false
  • dead tuple 没达到普通阈值。

同理,multixact 有独立的:

relminmxid
autovacuum_multixact_freeze_max_age

因此“关 autovacuum”既不安全,也不能保证后台永远不出现 worker;防回卷维护是正确性 机制,不是可选性能功能。

reltuples 和 change count 都不是精确实时值

触发器依赖:

  • pg_class.reltuples 估计;
  • cumulative statistics 的变化计数;
  • 最近 vacuum/analyze 更新;
  • stats flush 的最终一致性。

边界附近出现几秒或一轮调度差异是正常的。排障时先查看实际输入:

WITH p AS (
  SELECT
    current_setting('autovacuum_vacuum_threshold')::numeric AS base,
    current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale,
    current_setting('autovacuum_vacuum_max_threshold')::numeric AS max_t
)
SELECT
  s.schemaname,
  s.relname,
  c.reltuples,
  s.n_dead_tup,
  CASE
    WHEN p.max_t < 0 THEN p.base + p.scale * c.reltuples
    ELSE least(p.max_t, p.base + p.scale * c.reltuples)
  END AS estimated_trigger,
  s.last_autovacuum
FROM pg_stat_user_tables AS s
JOIN pg_class AS c ON c.oid = s.relid
CROSS JOIN p
ORDER BY
  s.n_dead_tup
  / nullif(
      CASE
        WHEN p.max_t < 0 THEN p.base + p.scale * c.reltuples
        ELSE least(p.max_t, p.base + p.scale * c.reltuples)
      END,
      0
    )
  DESC NULLS LAST;

这段查询仍没处理表级覆盖,生产版需要把 reloptions 合并进来。

表级覆盖:治疗特殊表,不复制全局配置

高 churn 大表、append-only 表和小型 catalog-like 表,触发策略可能不同:

ALTER TABLE app.hot_orders SET (
  autovacuum_vacuum_threshold = 1000,
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_analyze_scale_factor = 0.02
);

查看:

SELECT
  n.nspname,
  c.relname,
  c.reloptions
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.reloptions IS NOT NULL
ORDER BY 1, 2;

恢复继承全局值:

ALTER TABLE app.hot_orders RESET (
  autovacuum_vacuum_threshold,
  autovacuum_vacuum_scale_factor,
  autovacuum_analyze_scale_factor
);

表级覆盖适合:

one relation demonstrably misses its maintenance window
its write pattern differs materially from cluster norm
change rate and resource budget are measured
override is in schema/IaC and reviewed

不适合:

copy every global GUC to every table
set autovacuum_enabled=false as tuning
hide a long-transaction blocker
raise freeze age to silence alerts

第 28 章实验为了让手工 vacuum 不被后台抢跑,在一次性夹具表上临时设置 autovacuum_enabled=false;数据库清理后该设置随表消失。公开结果明确将其列为 fixture_table_autovacuum_enabled=false,不是生产建议。

计算后还要看时间

一个表达到阈值只表示“有资格”,不表示立刻开始。launcher 要轮询数据库,worker 要 可用,其他 relation 可能排在前面。

评估维护能力,应比较:

dead tuple arrival ratevsvacuum reclamation rate \text{dead tuple arrival rate} \quad \text{vs} \quad \text{vacuum reclamation rate}

若每小时产生 500 million obsolete tuples,而可用 worker 每小时只能处理 300 million, 调低触发阈值只会更早开始积压,不能解决服务率不足。

28.2.2 worker、cost delay、I/O 与业务竞争

launcher、worker slot 和 worker 上限

PostgreSQL 18 需要同时理解:

autovacuum_worker_slots
autovacuum_max_workers
autovacuum_naptime
number of databases

autovacuum_worker_slots 在启动时为 worker 预留 backend slot; autovacuum_max_workers 是可同时运行 worker 的上限。把后者设得高于前者没有效果。

launcher 尝试把工作分散到各数据库;有 $N$ 个数据库时,会试图约每 autovacuum_naptime / N 启动一个 worker。它不是每个数据库独立一套无限 worker。

查询:

SELECT name, setting, unit, context, source, pending_restart
FROM pg_settings
WHERE name IN (
  'autovacuum',
  'autovacuum_worker_slots',
  'autovacuum_max_workers',
  'autovacuum_naptime'
)
ORDER BY name;

worker 数只是并发上限

增加 worker 可能:

  • 减少多个数据库/表的排队;
  • 让更多表并发扫描;
  • 同时增加 I/O、CPU、buffer churn;
  • 放大 autovacuum_work_mem 总预算;
  • 与 foreground query、checkpoint、backup、replay 竞争。

若瓶颈是单块磁盘,三个 worker 已把设备打满,再加三个只会提高 queue depth 和业务 tail latency。

先看:

eligible tables waiting
active autovacuum workers
worker phase
disk latency / queue
CPU busy / run queue
buffer and cache effect
business p95/p99
replica and archive lag

memory 按 worker 放大

autovacuum_work_mem 控制每个 autovacuum worker 可用的 maintenance memory;设为 -1 时回退到 maintenance_work_mem。粗略预算:

MautoWactive×Mper-worker M_{\text{auto}} \le W_{\text{active}} \times M_{\text{per-worker}}

这仍是上界近似,不是每个 worker 永远一次性占满。它用于预留最坏并发,而不是预测 RSS 精确值。

内存主要影响:

  • 收集 dead item identifiers;
  • index vacuum cycle 频率;
  • maintenance 内部结构。

它不会让一个被旧 snapshot 保留的 tuple 突然可删。

PostgreSQL 18 的 pg_stat_progress_vacuum 暴露:

max_dead_tuple_bytes
dead_tuple_bytes
num_dead_item_ids
index_vacuum_count

可以判断是否因为维护内存限制而反复做 index vacuum cycle。

cost delay 是 I/O 影响控制,不是带宽保证

vacuum 给页面操作累计抽象 cost:

page hit
page miss
page dirty

达到 vacuum_cost_limit 后,sleep vacuum_cost_delay 再继续。

autovacuum 对应:

autovacuum_vacuum_cost_delay
autovacuum_vacuum_cost_limit

若 autovacuum cost limit 非 -1,PostgreSQL 会在并行 worker 之间按比例分配,使各 worker limit 合计不超过该值。这意味着:

worker 变多,不等于每个 worker 都拿到完整 limit。

另外:

  • 手工 VACUUM 的 cost delay 默认关闭,除非显式设置非零;
  • 持有关键锁的操作段不会照常 sleep;
  • failsafe 触发后会停止 cost delay,并跳过非必要工作来优先防回卷;
  • cost unit 不是 IOPS 或 MB/s,必须用 OS/Pigsty I/O 指标校准。

正式实验为了可靠抓取进度,只在 vacuum session 设置:

SET vacuum_cost_delay = '10ms';
SET vacuum_cost_limit = 20;
SET track_cost_delay_timing = on;

session 结束即回退;没有修改集群配置。10 ms 是教学限速,不是推荐生产值。官方文档 指出正常配置通常应使用很小的 delay,大延迟并不理想。

I/O 不是唯一竞争

vacuum 还会:

read heap
dirty heap / VM / FSM
read and update indexes
generate WAL for maintenance changes
use CPU to evaluate tuple visibility
acquire relation/page locks
evict useful shared/OS cache pages

所以 “iowait 不高” 不能证明 vacuum 无影响。可能:

  • 数据在 cache,竞争表现为 CPU 和 buffer churn;
  • device 很快,竞争表现为 foreground tail;
  • cloud storage queue 未映射为 host iowait;
  • cost delay 让 worker 大量 sleep;
  • checkpoint/backup 与 vacuum 交织。

维护优先级不是一刀切

可把对象分三层:

P0 correctness:
  XID/MXID danger
  suspected corruption

P1 service health:
  dead tuple backlog accelerating
  index cleanup not completing
  table growth threatens disk/SLO

P2 efficiency:
  moderate bloat
  stale statistics
  low HOT ratio

P0 不能为了降低业务 I/O 无限限速;P2 不应在业务峰值争抢资源。

28.2.3 进度、阻塞与“为什么没清掉”

先确认 worker 身份

SELECT
  pid,
  datname,
  usename,
  backend_type,
  application_name,
  state,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  query
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker'
   OR query LIKE 'autovacuum:%'
ORDER BY query_start;

防回卷 worker 的 query 文本会带 (to prevent wraparound)。它与普通 autovacuum 的 取消策略不同:冲突锁通常可中断普通 autovacuum,但防回卷 worker 不会被自动中断。

读取原生 progress

SELECT
  p.pid,
  p.datname,
  p.relid::regclass AS relation,
  p.phase,
  p.heap_blks_total,
  p.heap_blks_scanned,
  p.heap_blks_vacuumed,
  p.index_vacuum_count,
  p.dead_tuple_bytes,
  p.num_dead_item_ids,
  p.indexes_total,
  p.indexes_processed,
  p.delay_time
FROM pg_stat_progress_vacuum AS p
ORDER BY p.pid;

PostgreSQL 18 的主要 phase:

initializing
scanning heap
vacuuming indexes
vacuuming heap
cleaning up indexes
truncating heap
performing final cleanup

解释时注意:

  • heap_blks_total 是开始扫描时的规模;
  • VM 跳过的块仍会计入 scanned 的推进;
  • heap_blks_vacuumed 可能跳跃;
  • index 可能有多个 cycle;
  • truncation、锁等待和 index cleanup 的耗时不由 heap 扫描百分比线性预测。

所以:

heap_blks_scanned / heap_blks_total

是 scan progress,不是可靠 ETA。

“没清掉”的决策树

Did VACUUM run?
├─ no
│  ├─ below threshold
│  ├─ worker unavailable
│  ├─ autovacuum/table option disabled
│  ├─ statistics not updating
│  └─ permissions/manual command skipped relation
└─ yes
   ├─ old snapshot still needs tuples
   ├─ replication slot xmin/catalog_xmin retains them
   ├─ prepared transaction retains horizon/locks
   ├─ index cleanup skipped/deferred
   ├─ pages skipped to avoid waits
   ├─ only estimate is stale
   ├─ space became reusable but file did not shrink
   └─ new churn arrived as fast as cleanup

blocker inventory

SELECT
  pid,
  usename,
  application_name,
  state,
  xact_start,
  state_change,
  backend_xid,
  backend_xmin,
  age(backend_xid)  AS xid_age,
  age(backend_xmin) AS xmin_age,
  wait_event_type,
  wait_event
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
   OR backend_xmin IS NOT NULL
   OR state LIKE 'idle in transaction%'
ORDER BY age(backend_xmin) DESC NULLS LAST;

再查:

SELECT
  slot_name,
  slot_type,
  database,
  active,
  age(xmin) AS xmin_age,
  age(catalog_xmin) AS catalog_xmin_age,
  restart_lsn,
  wal_status,
  inactive_since,
  invalidation_reason
FROM pg_replication_slots;

SELECT
  transaction,
  age(transaction) AS xid_age,
  gid,
  prepared,
  owner,
  database
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;

不要在第一条查询里直接拼 pg_terminate_backend。先确认:

owner
application
business transaction semantics
retry behavior
prepared transaction coordinator
replication consumer
HA/failover impact

然后才能决定 cancel、terminate、commit、rollback 或 drop slot。

累计结果

SELECT
  relid::regclass,
  n_live_tup,
  n_dead_tup,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count,
  total_vacuum_time,
  total_autovacuum_time
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

PostgreSQL 18 增加/提供 vacuum/analyze 累计耗时列;部署跨版本查询时要先检查列存在。 累计 view 会 reset,必须联合 stats_reset 和 Pigsty 时序数据,不要把 reset 后的 “低计数”解释成改善。

Pigsty:历史趋势与原生瞬时事实互补

Pigsty 的监控栈把 PostgreSQL、PgBouncer、Patroni、主机和日志放到同一组 cls/ins/ip 标签下。对维护问题,常用:

页面 看什么
PGSQL Tables / Table dead/live、scan、vacuum、relation trend
PGCAT Table 当前 catalog、size、bloat 类诊断
PGSQL Persist XID、WAL、checkpoint、archive、持久性
PGSQL Activity / Session backend、wait、长事务
PGSQL Replication slot、replica、replay/retention
PGCAT Locks blocker/waiter
PGLOG autovacuum verbose、warning、cancel/failsafe
NODE Instance disk latency、queue、space、CPU、memory

Dashboard 回答:

when did it start?
is it accelerating?
which instance/table changed?
what else happened at the same time?

原生 SQL 回答:

which PID and phase now?
which exact xmin/slot/prepared xact retains horizon?
which reloption and effective GUC applies?

两者必须互证。Grafana panel 不是另一个数据库真相层。

处置顺序

1. classify correctness vs service vs efficiency
2. verify actual trigger inputs and table overrides
3. locate active/queued workers and progress
4. inventory holders
5. compare cleanup rate with churn rate
6. check I/O/CPU/memory/WAL/replica side effects
7. choose smallest reversible intervention
8. validate dead/reusable/age outcome
9. record desired state and rollback

跳过第 4 步直接“手工再 vacuum 一次”,通常只会重复同一失败。

本节检查清单

effective update/delete threshold
effective insert threshold
analyze threshold
freeze/MXID age
table reloptions
eligible backlog
worker slots / max workers
per-worker and total memory budget
cost limit distribution
progress phase and cycle
old snapshot/slot/2PC holders
foreground tail and device pressure
cleanup rate vs churn rate

延伸阅读


上一节:死元组与可见性 · 返回本章目录 · 下一节:冻结、XID 与保留者 · 查看全书目录 · 查看索引中心

28.3 冻结、XID 与保留者

空间膨胀会让系统越来越慢;事务 ID 回卷可能让系统为了保护数据而拒绝写入。

两者都由 VACUUM 参与治理,却不是同一个风险:

space debt:
  obsolete tuple -> reusable page space

age debt:
  old XID/MXID -> frozen/advanced horizon

一张几乎不更新的静态表可能没有 dead tuple,却必须周期性冻结;一张高 churn 表可能 每天 vacuum,仍被一个旧 snapshot 阻止回收。成熟运维要同时看“垃圾速度”和“年龄 安全线”。

28.3.1 XID 年龄、冻结与回卷保护

XID 是全局 32 位循环空间

普通内部 xid 为 32 位,约每 42.9 亿次分配回卷一次。PostgreSQL 用模 $2^{32}$ 比较:

relative to current XID:
  about 2 billion are in the past
  about 2 billion are in the future

若一个行版本保留超过约 20 亿个事务,它原本的插入 XID 会从“过去”落到“未来”的 比较区间。冻结的目的,是把已确定对所有当前与未来事务可见的老版本标成永久过去。

注意:

  • XID 在事务第一次需要写入时才分配,不一定等于 BEGIN 时间顺序;
  • txid_current() 等旧接口与 xid8/epoch 语义不同;
  • 不能用 32 位 xmin 做长期业务序列或跨回卷排序。

事务标识内部说明见 Transactions and Identifiers

冻结是 flag,不是把 xmin 改成 2

PostgreSQL 9.4 以后,冻结通常设置 tuple header flag,并保留原 xmin 供取证;不能 因为查询还看到原 xmin 就断言“没冻结”。老版本升级数据库可能仍见 FrozenTransactionId=2

正确证据是:

relfrozenxid advance
VM all-frozen pages
VACUUM VERBOSE freeze output
pg_visibility checks

而不是:

SELECT count(*) WHERE xmin <> '2';

关系、数据库和 TOAST 三层年龄

每个普通表/物化视图:

pg_class.relfrozenxid
pg_class.relminmxid

每个数据库:

pg_database.datfrozenxid = minimum relation relfrozenxid
pg_database.datminmxid   = minimum relation relminmxid

database 值用于 cluster-level 预警,relation 值用于定位。表的 TOAST relation 也可能 最老,不能漏。

数据库总览:

SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  mxid_age(datminmxid) AS mxid_age,
  datallowconn
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

当前数据库按关系下钻:

SELECT
  c.oid::regclass AS relation,
  c.relkind,
  age(c.relfrozenxid) AS heap_xid_age,
  age(t.relfrozenxid) AS toast_xid_age,
  greatest(
    age(c.relfrozenxid),
    coalesce(age(t.relfrozenxid), 0)
  ) AS effective_xid_age,
  mxid_age(c.relminmxid) AS heap_mxid_age,
  mxid_age(t.relminmxid) AS toast_mxid_age,
  pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid
WHERE c.relkind IN ('r', 'm')
ORDER BY effective_xid_age DESC
LIMIT 50;

relfrozenxid 是最近一次成功推进该边界的 vacuum 结果,不是“最老一行的精确插入 时间”。age() 是相对于当前 XID 的事务数量,不是秒。

把年龄换成时间余量

同样 100 million age:

100 TPS XID allocation       about 11.6 days
10,000 TPS XID allocation    about 2.8 hours

因此告警要看:

headroom secondsconfigured thresholdcurrent agerecent XID allocation rate \text{headroom seconds} \approx \frac{\text{configured threshold} - \text{current age}} {\text{recent XID allocation rate}}

并给 maintenance duration、业务尖峰和失败重试留余量。仅用固定 age percentage, 无法表达突然增长的 XID burn rate。

获取近似分配速率可对固定间隔的 pg_current_xact_id()/监控计数做差,但读取函数是否 分配 XID、采样事务本身的影响要按接口语义处理。Pigsty 的 PGSQL Persist 历史曲线更 适合看持续速率,原生 catalog 负责当前边界。

regular、aggressive 和 failsafe

regular VACUUM
  scans pages likely needing work
  may skip all-visible pages

aggressive VACUUM
  visits every page that might contain unfrozen XID/MXID
  advances relfrozenxid / relminmxid when full necessary coverage achieved

failsafe
  last-resort anti-wraparound mode
  removes cost delay
  may bypass non-essential index maintenance
  prioritizes age correctness

相关门槛:

vacuum_freeze_min_age
vacuum_freeze_table_age
autovacuum_freeze_max_age
vacuum_failsafe_age

vacuum_multixact_freeze_min_age
vacuum_multixact_freeze_table_age
autovacuum_multixact_freeze_max_age
vacuum_multixact_failsafe_age

PostgreSQL 会限制部分有效值,例如 vacuum_freeze_table_age 不会有效超过 autovacuum_freeze_max_age 的 95%。不要只读配置文件;读 pg_settings.setting 确认实际值。

eager freeze

PostgreSQL 18 的普通 vacuum 可能主动扫描一部分 all-visible 但未 all-frozen 的页面, 提前冻结,减少以后 aggressive vacuum 的工作;相关行为可用 vacuum_max_eager_freeze_failure_rate 调整。

这意味着:

regular vacuum

不再等价于“绝不扫描可跳过页”,但它仍不保证每次全表 aggressive coverage。

实验中的年龄证据

第 28 章夹具不是回卷压力测试;它绝不消耗数十亿 XID。它只验证:

baseline relfrozenxid age         14
post VACUUM (FREEZE) age          2
all-frozen pages                  10,625

这些值证明 freeze 路径在一次性表上生效,不证明生产 threshold、maintenance duration 或 headroom 合理。

28.3.2 长事务、backend_xmin 与 idle in transaction

事务久不等于一定持有旧 snapshot,反之亦然

xact_start 告诉你事务开始时间;backend_xmin 告诉你该 backend 对 vacuum horizon 的贡献。它们相关,但不等价:

old xact_start + backend_xmin       likely reclamation holder
old xact_start + no backend_xmin    still examine locks/XID/state
recent xact_start + old xmin        possible imported/exported snapshot
idle outside transaction            usually no MVCC snapshot retention
idle in transaction                 dangerous candidate

查询:

SELECT
  pid,
  datname,
  usename,
  application_name,
  client_addr,
  state,
  xact_start,
  query_start,
  state_change,
  backend_xid,
  backend_xmin,
  age(backend_xid) AS xid_age,
  age(backend_xmin) AS xmin_age,
  wait_event_type,
  wait_event,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND (
    backend_xid IS NOT NULL
    OR backend_xmin IS NOT NULL
    OR state LIKE 'idle in transaction%'
  )
ORDER BY age(backend_xmin) DESC NULLS LAST, xact_start;

idle in transaction 为什么危险

应用执行:

BEGIN
SELECT ...
client pauses / forgets COMMIT

backend 不用 CPU,却可能:

  • 保留 snapshot,阻止 dead tuple 回收;
  • 持有 relation/row/advisory locks;
  • 占用 backend 与连接池槽;
  • 让 DDL、vacuum、reindex 等等待;
  • 让 table/index/WAL 间接增长。

所以“CPU 为 0”不是无害。

timeouts 要按角色与协议设计

PostgreSQL 18 提供:

idle_in_transaction_session_timeout
transaction_timeout
statement_timeout
lock_timeout
idle_session_timeout

其中:

  • idle_in_transaction_session_timeout 专门终止在开放事务中 idle 过久的 session;
  • transaction_timeout 限制整个事务跨度;
  • 普通 idle session 不持有开放事务,危害与 idle-in-xact 不同;
  • pool/middleware 可能无法优雅处理被服务端突然关闭的连接。

更稳妥的做法:

ALTER ROLE app_rw IN DATABASE appdb
  SET idle_in_transaction_session_timeout = '2min';

ALTER ROLE analyst IN DATABASE appdb
  SET transaction_timeout = '30min';

示例值不是通用推荐。应用必须:

  • 正确 rollback/retry;
  • 不在事务中等待用户输入或远程 API;
  • 流式读取时理解 cursor/snapshot 生命周期;
  • 为 migration、batch、backup 使用独立角色和窗口。

精确处置,不批量杀 idle

处置协议:

1. identify PID + role + database + application + client
2. verify backend_xmin/xid/locks and business transaction
3. contact owner or follow pre-approved runbook
4. prefer graceful commit/rollback
5. cancel statement if only statement must stop
6. terminate session only when necessary and retry-safe
7. verify holder disappeared
8. rerun/observe vacuum and business correctness

正式实验只终止:

database          pg36_maintenance
role              dbuser_pg36maint
application       pg36-ch28-old-snapshot
PID               exact observed PID
backend_xmin      non-null
matched sessions  exactly 1

结果:

terminated sessions       1
remaining holder sessions 0
unrelated terminated      0

没有使用“杀掉所有 idle in transaction”。

读副本也可能把 horizon 反馈到主库

hot_standby_feedback 可降低 standby query cancellation,但会把所需 xmin 反馈到 primary,导致 primary 保留 dead tuple。取舍是:

cancel long replica query
vs
retain primary heap versions

有 replication slot 时,还要联合 pg_replication_slots.xmin。只在 standby 查 pg_stat_activity 可能找不到 primary 膨胀的完整原因。

28.3.3 复制槽 xmin 与孤儿 pg_prepared_xacts

复制槽有两类保留线

pg_replication_slots 中:

字段 保留什么
xmin 数据库必须保留的最老事务;更晚删除的 tuple 不能被 vacuum 移除
catalog_xmin 逻辑解码所需系统目录 tuple
restart_lsn consumer 仍可能需要的最老 WAL
confirmed_flush_lsn 逻辑 consumer 已确认接收的位置
wal_status/safe_wal_size WAL 保留状态与走向 lost 的余量

空间问题要分开:

xmin/catalog_xmin -> heap/catalog bloat and vacuum horizon
restart_lsn       -> pg_wal retention

“复制槽只会撑大 WAL”是错的。

查询:

SELECT
  slot_name,
  slot_type,
  database,
  active,
  active_pid,
  age(xmin) AS xmin_age,
  age(catalog_xmin) AS catalog_xmin_age,
  restart_lsn,
  confirmed_flush_lsn,
  wal_status,
  safe_wal_size,
  inactive_since,
  conflicting,
  invalidation_reason,
  failover,
  synced
FROM pg_replication_slots
ORDER BY
  greatest(
    coalesce(age(xmin), 0),
    coalesce(age(catalog_xmin), 0)
  ) DESC,
  slot_name;

inactive 不等于 orphan

一个 inactive slot 可能是:

  • 正常短暂断线;
  • 灾备系统等待窗口;
  • CDC consumer 故障;
  • 切换后遗留;
  • 已废弃对象;
  • standby 同步 slot。

drop slot 前必须确认:

consumer owner
expected reconnect
replica/CDC resume semantics
required WAL/rows already lost?
will replica need rebuild?
failover/synced restrictions

官方文档明确提示:若删除仍会回来使用的 slot,对应 replica 可能需要重建。

因此:

SELECT pg_drop_replication_slot('unknown_slot');

不是发现 inactive 后的第一步。

prepared transaction 没有客户端也能继续持有状态

两阶段提交:

BEGIN
changes
PREPARE TRANSACTION 'gid'
-- original session may leave
COMMIT PREPARED / ROLLBACK PREPARED later

进入 prepared 后,它仍可持有:

  • 已分配 XID;
  • 行/表锁;
  • 可见性和清理边界影响;
  • 未决业务结果。

查询:

SELECT
  transaction,
  age(transaction) AS xid_age,
  gid,
  prepared,
  clock_timestamp() - prepared AS prepared_for,
  owner,
  database
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;

不要因为 pg_stat_activity 没有对应客户端,就判断“事务已消失”。

orphan 处置需要业务协调器事实

GID maps to which distributed transaction?
coordinator decision is commit or rollback?
did participants commit elsewhere?
is retry idempotent?
what locks and rows are affected?

没有这些事实时,随意 ROLLBACK PREPARED 可能破坏跨系统一致性,随意 COMMIT PREPARED 也可能提交应回滚的业务。

正确 runbook:

inventory
  -> coordinator/ledger lookup
      -> peer participant status
          -> explicit decision
              -> COMMIT/ROLLBACK PREPARED
                  -> verify age/locks/business invariants

若业务并不需要 2PC,应让 max_prepared_transactions=0 保持禁用,而不是启用后希望 “没人会忘”。

统一保留者清单

一张事故表至少包括:

source identifier age owner needed by action proof
backend PID/app xmin age team transaction commit/terminate holder gone
slot slot name xmin/catalog age CDC/DBA consumer resume/drop consumer state
prepared GID xid age coordinator distributed tx commit/rollback business ledger

只有 owner 和 action 明确,才进入执行。

28.3.4 紧急态先解除保留并让 VACUUM 完成

这一目的标题刻意没有写“立刻 VACUUM FREEZE”。

对 PostgreSQL 18,必须区分:

proactive maintenance
warning / shrinking headroom
write-refusal protection state

它们的正确动作不同。

第一阶段:预防与早期告警

正常系统应在远离危险线时:

monitor xid/mxid age and burn rate
keep autovacuum healthy
let aggressive vacuum complete
remove long-lived holders
schedule targeted VACUUM where needed
validate relfrozenxid advancement

在这个阶段,针对静态大表使用 VACUUM (FREEZE) 可以是有计划的维护动作:

VACUUM (FREEZE, VERBOSE) app.archive_2020;

前提是:

  • 已评估 I/O、WAL、锁和 replica;
  • 不是为了掩盖未知 blocker;
  • 目标表与 TOAST 均被验收;
  • 生产窗口已批准。

Pigsty 的:

pig pg freeze mydb

封装的是 freeze vacuum;适合明确的计划动作,不应脱离 PostgreSQL 版本语义当作所有 XID 事故的万能按钮。

第二阶段:系统已接近或进入拒绝新 XID

PostgreSQL 18 官方文档说明:

  • 临近回卷点会先产生必须 vacuum 的 warning;
  • 剩余不足约 3 million XID 时,系统拒绝分配新 XID以保护数据;
  • 已在运行的事务可继续,新的只读事务可启动;
  • 普通 VACUUM 仍可执行。

此时的优先顺序是:

1. resolve old prepared transactions
2. end long-running open transactions
3. remove only confirmed obsolete replication slots
4. run ordinary VACUUM in affected database / oldest relations
5. restore normal operation
6. repair autovacuum/root cause

官方文档对 PostgreSQL 18 还明确说:

do not use VACUUM FULL
do not use VACUUM FREEZE
single-user mode is normally unnecessary and undesirable

原因是 hard-stop 状态要做恢复正常所需的最小工作

  • VACUUM FULL 自身需要/消耗 XID、强锁且重写;
  • VACUUM FREEZE 做超过最小恢复所需的工作;
  • single-user mode 会绕开保护并引入停机风险。

这与某些旧版本文章或旧 runbook 不同。执行时以安装版本官方文档为准。

“让冻结完成”的正确含义

在日常或 early warning 阶段:

不要为了降低 I/O 不断 cancel 防回卷 vacuum;解除 blocker,让 aggressive freeze 推进并验收年龄。

在已经拒绝 XID 的 hard-stop 阶段:

不要把 FREEZE option 当口号;按 PostgreSQL 18 最小恢复流程先解除 prepared xact、长事务和旧 slot,再让普通 VACUUM 完成。

两者共同反对:

raise autovacuum_freeze_max_age to silence alert
disable autovacuum
cancel every anti-wraparound worker
restart hoping age disappears
delete pg_xact files
reset xid counters by hand

这些都没有安全地冻结旧 tuple,可能把正确性风险推向灾难。

紧急 runbook 的 fail-closed 门

incident:
  postgresql_version: 18.x
  primary_identity: verified
  xid_or_mxid: xid
  oldest_database: ...
  oldest_relations: [...]
  burn_rate_per_second: ...
  estimated_headroom: ...

holders:
  prepared: [...]
  backends: [...]
  slots: [...]
  ownership_confirmed: false

execution:
  ordinary_vacuum_only: true
  vacuum_full: forbidden
  vacuum_freeze_in_hard_stop: forbidden
  force_drop: forbidden
  single_user: not_planned

validation:
  write_assignment_restored: ...
  datfrozenxid_advanced: ...
  oldest_relation_advanced: ...
  warnings_stopped: ...
  autovacuum_root_cause: ...

ownership_confirmed=false 时,不能自动 drop slot 或 resolve prepared transaction; 需要事故指挥者和业务 owner 决策。

XID 与 MXID 要分案

MXID 用于多事务共同锁行,拥有独立:

pg_multixact storage
relminmxid / datminmxid
freeze thresholds
failsafe thresholds
exhaustion effects

XID 耗尽会阻断所有需要新 XID 的写;MXID 耗尽主要阻断需要创建新 multixact 的锁类 写入。事故名称、监控和 runbook 不能只写“wraparound”。

恢复写入不等于事故闭环

普通 vacuum 让系统重新接受写入,只是止血。根因可能仍是:

application transaction leak
stale logical slot
2PC coordinator failure
worker starvation
I/O insufficient
autovacuum misconfiguration
huge database never covered
maintenance repeatedly canceled

事故关闭条件:

headroom restored
burn rate understood
all holder ownership recorded
oldest tables and TOAST advancing
autovacuum completion observable
alerts and capacity model corrected
recurrence test passed

否则下一次只是时间问题。

本节检查清单

installed PostgreSQL major/minor
database xid/mxid age
top relation + TOAST age
configured and effective thresholds
XID/MXID burn rate and headroom time
backend_xmin holders
idle-in-transaction sessions
slot xmin/catalog_xmin/restart_lsn
prepared transaction GID/owner/decision
anti-wraparound worker phase
ordinary vs aggressive vs failsafe state
version-correct emergency procedure
post-recovery root-cause action

延伸阅读


上一节:autovacuum 的触发与资源 · 返回本章目录 · 下一节:膨胀与重建 · 查看全书目录 · 查看索引中心

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

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

同时:

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 与保留者 · 返回本章目录 · 下一节:分区生命周期 · 查看全书目录 · 查看索引中心

28.5 分区生命周期

如果数据天然按时间或租户整片到期,最好的 vacuum 往往是不要制造那些 dead tuple

DELETE 500 million expired rows
  -> row locks / WAL / dead tuples / index cleanup / vacuum / replica replay

DETACH one expired partition
  -> catalog and lock operation
      -> standalone table
          -> archive / validate / drop

分区不是免费的性能开关;它是把数据生命周期编码进物理边界。只有 partition key、 retention unit、query pruning 和发布流程一致时,整片退役才成立。

28.5.1 新分区预建、约束和父表显式 ANALYZE

从生命周期单位反推边界

先定义:

event_time_semantics: UTC timestamptz
retention: 400 days
retirement_unit: month
late_arrival: 7 days
future_precreate: 3 months
archive_retention: 7 years

再决定:

partition key
range bounds
timezone
default partition policy
precreate horizon
detach cadence

月分区不一定最好:

单位 优点 风险
退役粒度细 partition 数、planning/catalog 开销
常见折中 大月仍可能过大
季/年 对象少 退役和维护粒度粗
tenant hash/list 隔离租户 retention 可能仍需二级时间分区

目标不是最多 partition,而是让:

query predicate
retention cut
maintenance unit

落在同一边界。

range 上界是排他的

CREATE TABLE app.events (
  event_id bigint NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload jsonb NOT NULL
) PARTITION BY RANGE (occurred_at);

CREATE TABLE app.events_2026_08
  PARTITION OF app.events
  FOR VALUES FROM ('2026-08-01 00:00:00+00')
             TO   ('2026-09-01 00:00:00+00');

边界:

[2026-08-01 00:00Z, 2026-09-01 00:00Z)

共享的 2026-09-01 属于下一个 partition。若应用按本地日历月保留,必须明确 DST 和 timezone;不要让 session TimeZone 隐式决定 DDL literal。

预建,不等 insert error 报警

写入没有匹配 partition 会失败。生产应提前:

generate future partitions
validate exact non-overlapping bounds
create local indexes
apply owner/grants/comments/storage parameters
ANALYZE when populated
alert on last future boundary

例如维护表:

SELECT
  parent.relname AS parent,
  child.relname AS partition,
  pg_get_expr(child.relpartbound, child.oid) AS bound
FROM pg_inherits AS i
JOIN pg_class AS parent ON parent.oid = i.inhparent
JOIN pg_class AS child ON child.oid = i.inhrelid
WHERE parent.oid = 'app.events'::regclass
ORDER BY child.relname;

pg_partition_tree() 适合多层结构:

SELECT *
FROM pg_partition_tree('app.events');

离线装载后 ATTACH

大分区可先作为普通表准备:

CREATE TABLE app.events_2026_08_stage
  (LIKE app.events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);

ALTER TABLE app.events_2026_08_stage
  ADD CONSTRAINT events_2026_08_bound
  CHECK (
    occurred_at >= TIMESTAMPTZ '2026-08-01 00:00:00+00'
    AND occurred_at < TIMESTAMPTZ '2026-09-01 00:00:00+00'
  );

-- load, cleanse, build indexes, validate

ALTER TABLE app.events
  ATTACH PARTITION app.events_2026_08_stage
  FOR VALUES FROM ('2026-08-01 00:00:00+00')
             TO   ('2026-09-01 00:00:00+00');

若已有一个有效且与 partition bound 匹配CHECK constraint,PostgreSQL 可避免 在持有 partition ACCESS EXCLUSIVE 时扫描全表验证。attach 完成后,这个重复 constraint 可在评审后删除。

若 parent 有 default partition,还应给 default 添加排除新范围的 CHECK;否则 attach 可能扫描 default,且持有其强锁。

注意:

  • expression partition key 有额外限制;
  • list partition 是否接受 NULL 影响 constraint;
  • subpartition 可能递归锁/扫到 leaf;
  • parent 是 virtual structure,实际 index 在 leaf;
  • attach 前要验证 index/constraint 与 parent 模板一致。

default partition 是缓冲区,不是垃圾桶

default 可避免未知 key 直接失败,但会带来:

silent routing of bad/future data
attach scan/lock cost
DETACH CONCURRENTLY restriction on that parent
data migration before new range attach

若使用 default:

  • 监控 row count;
  • bad key 立即告警;
  • 定期清空到正确 partition;
  • 在 attach/detach runbook 中显式处理;
  • 不把它当永久无限分区。

parent 必须显式 ANALYZE

partition leaf 的变化不会触发 parent auto-analyze;partitioned table 自身不直接存 tuple, autovacuum 不会在 parent 上运行 ANALYZE。当首次装载或分布显著变化时:

ANALYZE app.events_2026_08;
ANALYZE app.events;

parent-level statistics 会影响引用 partitioned table 的 plan。生命周期动作完成而漏掉 parent analyze,可能导致:

row estimate drift
join order change
partition-wise plan quality loss

这不是物理 bloat,却常被误归因成“分区太多”。

分区模板是 schema release

创建脚本应来自同一 desired state:

columns / generated expressions
constraints
indexes / INCLUDE / predicates
storage parameters / fillfactor
tablespace
owner / grants / RLS
publication policy
comments
autovacuum overrides

不能靠:

CREATE TABLE child (LIKE parent);

就假设复制了所有业务语义。LIKEINCLUDING ... 选项、partitioned parent 的虚拟 对象、trigger/RLS/publication 行为都要按版本验证。

28.5.2 DETACH、归档、验证后删除

detach 不是 drop

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01;

结果:

parent no longer routes/scans it
child remains as standalone table
attached child indexes detach from parent indexes
cloned triggers are removed
data remains queryable by standalone name

这是理想的 quarantine point:

online dataset
  -> detached immutable dataset
      -> archive
          -> restore validation
              -> deletion

普通与 CONCURRENTLY

普通 detach 对 parent 取得 ACCESS EXCLUSIVE

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01 CONCURRENTLY;

PostgreSQL 18 的 concurrent 形式不是“零锁”,而是内部两个 transaction:

  1. 对 parent 和 partition 取得 SHARE UPDATE EXCLUSIVE,标记 pending detach 并提交;
  2. 等所有使用 partitioned table 的旧 transaction 离开;
  3. 再对 parent 取 SHARE UPDATE EXCLUSIVE、对 partition 取 ACCESS EXCLUSIVE
  4. 完成 detach,并给 standalone table 添加等价 CHECK constraint。

限制:

cannot run inside transaction block
not allowed when parent has a default partition
only one partition per parent may be pending detach
foreign-key related tables may acquire SHARE locks
old transactions can prolong the wait

中断后:

ALTER TABLE app.events
  DETACH PARTITION app.events_2024_01 FINALIZE;

用于完成先前被取消/中断的 concurrent detach。自动化不能看到命令失败就直接重跑或 drop;先查 pending state,再决定 FINALIZE

先关闭边界写入竞争

在 detach 前确认:

retention cutoff immutable
late-arrival window closed
backfill jobs stopped
application routes no new row to old range
timezone/cutoff reviewed
no open transaction still writes old partition

否则 detach 后:

  • 新写入可能失败;
  • 被路由到 default;
  • 被误写到 archive standalone table;
  • 数据清单在导出期间变化。

一种做法是先把旧 partition 业务状态标成 sealed,再等 maximum transaction duration 过去,最后 detach。真正的控制点在应用与数据产品,不只在 DDL。

归档清单至少有四层

  1. 对象清单
database/schema/table
partition bound
owner/grants
columns/types/collations
constraints/indexes
row-level security
  1. 逻辑清单
SELECT
  count(*) AS rows,
  min(event_id) AS min_id,
  max(event_id) AS max_id,
  min(occurred_at) AS min_time,
  max(occurred_at) AS max_time,
  sum(amount) AS amount_sum
FROM app.events_2024_01;
  1. 归档文件清单
format/version
compression/encryption
object URI
bytes
cryptographic hash
created_at
retention/legal hold
  1. 恢复清单
restore target
row/type/constraint verification
logical aggregates/digests
query spot checks
elapsed time
tool versions

只有 file hash 相同,不能证明文件可以被当前工具恢复;只有 row count 相同,也不能证明 金额、范围和编码正确。

COPY 与 pg_dump 的选择

COPY
  simple data stream
  schema/privileges not included
  explicit order and format needed

pg_dump table
  schema/data options
  dependency-aware archive
  restore tooling and version policy needed

base backup
  cluster physical recovery
  not a per-partition logical archive

对于大型表,不要用:

md5(string_agg(all_rows...))

在 server 端聚合整个数据集;第 28 章夹具只有 10,000 行,才用它作为教学逻辑摘要。 生产可用有序 chunk hash、COPY/Parquet manifest、业务聚合和独立 restore 合并证明。

正式实验的顺序

events_2024 rows         10,000
parent total             15,000

manifest
  rows                   10,000
  id range               1..10,000
  amount sum             499,950.00
  logical digest         recorded

DETACH CONCURRENTLY      pass
parent after detach      5,000
standalone               10,000

CSV bytes                547,894
CSV SHA-256              cd7d54e4...a9a41d0d

restore check rows       10,000
restore digest           equal

drop standalone          only after equality

清理 validator 会拒绝:

drop before restore validation
row mismatch
digest mismatch
empty archive
parent count mismatch
force cleanup

删除后还要验 backup policy

partition 从在线库删除后:

  • PITR 仍可在 retention window 内恢复历史 cluster;
  • 逻辑 archive 负责更长期访问;
  • backup retention 和 archive legal retention 可能不同;
  • GDPR/删除义务也可能要求从 archive 到期清除;
  • catalog/monitoring 应记录在线与归档位置的转换。

生命周期不是 DROP TABLE 结束,而是 ownership 从 online service 转到 archive service。

28.5.3 用分区退役替代大批量 DELETE

大 DELETE 的债

DELETE FROM app.events
WHERE occurred_at < now() - interval '400 days';

可能产生:

row locks
large transaction / long snapshot
WAL and archive volume
replica replay
dead heap tuples
dead index tuples
autovacuum backlog
relation growth before reuse
rollback/retry cost

分批 delete 可控制 transaction:

WITH victim AS (
  SELECT ctid
  FROM app.events
  WHERE occurred_at < $1
  ORDER BY occurred_at
  LIMIT 10000
  FOR UPDATE SKIP LOCKED
)
DELETE FROM app.events AS e
USING victim AS v
WHERE e.ctid = v.ctid;

但它仍逐行处理,且 ctid 只用于当次短事务。批处理适合选择性删除,不如整片 partition 退役。

detach 的收益来自事前设计

若 expired predicate 恰好覆盖完整 partition:

row-by-row physical change
  -> partition metadata change

它避免制造海量 obsolete tuples。随后 drop standalone table 删除 relation files, 也不需要 vacuum 每一行。

但不能夸大为 $O(1)$、瞬间、无 WAL、无锁:

  • catalog 要更新;
  • parent/partition/FK 有锁;
  • concurrent 形式要等旧 transaction;
  • replica 要 replay DDL;
  • drop 仍要处理 dependency 和文件;
  • archive copy 仍按数据量花费 I/O;
  • planning/catalog 对 partition 数敏感。

准确说:

对生命周期与 partition bound 对齐的数据,detach/drop 把逐行淘汰的核心成本转换成 受控 DDL 与归档成本。

何时不能替代

predicate cuts through every partition
legal hold retains arbitrary rows
tenant records mixed in same partition
foreign keys prevent independent detach
late arrivals keep changing old ranges
application directly names leaf tables
default partition contains mixed data

这时选择:

  • 更合适的 partition key/subpartition;
  • selective batch delete;
  • logical archive+copy;
  • tenant migration;
  • schema redesign。

不要为了这次清理临时创建数千 partition;partitioning 是长期模型。

FK 与全局唯一性

partitioned table 的 unique/primary key 通常必须包含 partition key,才能由每个 leaf 的 局部索引共同保证全局逻辑唯一。跨 partition FK、引用 leaf、触发器和 publication 也会影响 detach。

设计前问:

Can an event_id be unique without time?
Who references expired rows?
Should archive preserve FK graph?
Can referenced partitions retire independently?

若答案不清晰,retention 不是一个 DBA 单表任务。

DELETE 与 detach 的选择矩阵

条件 batch DELETE partition detach
任意 predicate 支持 不支持
整片时间范围 可做但昂贵 优先候选
在线逐步释放 支持 按 partition 粒度
归档后保留 standalone 需 copy 天然
dead tuple/vacuum 债 不制造逐行债
前置建模 必须
FK/依赖 行级处理 DDL 级约束

28.5.4 将 ch04/ch07/ch11/ch16/ch28 串成能力索引

分区生命周期不是本章孤立技巧,而是贯穿设计、查询、发布和运维的能力。

第 4 章:数据表达决定边界

第 4 章:数据类型、约束与可靠数据表达 负责:

timestamptz vs timestamp
UTC and business timezone
NOT NULL / CHECK
identity and uniqueness
retention metadata

若时间语义错,partition boundary 再精确也会错删。

能力交付:

partition_key:
  column: occurred_at
  type: timestamptz
  canonical_zone: UTC
  null_allowed: false
retention_cutoff_semantics: event_time

第 7 章:统计与 pruning 证明查询受益

第 7 章:执行计划与统计信息 负责:

partition pruning
parameterized plan behavior
parent/leaf statistics
join estimate
EXPLAIN evidence

能力交付:

representative predicates prune expected leaves
generic/custom plans both reviewed
parent ANALYZE after distribution change
planning time acceptable at partition count

分区多但查询不带 partition key,可能同时扫描大量 leaf;那不是 vacuum 能修的。

第 11 章:生命周期 DDL 是安全发布

第 11 章:模式变更与安全发布 负责:

lock compatibility
lock_timeout
preflight holders
expand/contract
canary and rollback
DDL queue behavior

能力交付:

change:
  attach_or_detach: ...
  lock_budget: ...
  transaction_block: false
  old_snapshot_gate: ...
  interrupted_state: ...
  finalize_or_rollback: ...

DETACH CONCURRENTLY 仍是 production DDL。

第 16 章:时序/时空 workload 定义生命周期

第 16 章:时序、空间与时空查询 负责:

time bucketing
late/out-of-order events
rollup/downsample
spatial/temporal retention
hot/warm/cold tiers

能力交付:

late arrival horizon
immutable cutoff
rollup completion watermark
archive query contract

只有 watermark 越过 partition end + late-arrival allowance,才可 seal/detach。

第 28 章:运营闭环

本章负责:

precreate
explicit analyze
seal
detach
archive manifest
restore validation
drop
monitor and audit

合起来:

type/constraint semantics          ch04
  -> plan/pruning/statistics       ch07
      -> safe DDL release          ch11
          -> time-domain policy    ch16
              -> retirement SOP    ch28

可执行能力索引

能力 输入 证据 失败路由
create future partition calendar + schema bound/tree diff ch11
attach loaded partition valid CHECK + manifest no scan/lock plan + row proof ch11
analyze hierarchy distribution change parent/leaf stats ch07
seal range watermark + late window no new writes ch16
detach dependency + lock budget topology diff ch11/ch28
archive object + logical manifest hash + restore ch28/ch35
drop approved retention archive proof + audit ch28
query cold data archive contract result/SLO test ch16

生命周期控制表

可在 control plane 维护:

CREATE TABLE ops.partition_lifecycle (
  parent_table regclass NOT NULL,
  partition_table regclass,
  range_start timestamptz NOT NULL,
  range_end timestamptz NOT NULL,
  state text NOT NULL CHECK (
    state IN (
      'planned','attached','sealed','detached',
      'archived','restore_verified','dropped'
    )
  ),
  manifest_uri text,
  manifest_sha256 text,
  approved_by text,
  changed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  PRIMARY KEY (parent_table, range_start)
);

但不要让这张表成为未经核验的自动 drop 开关。状态转换必须读取数据库 catalog、归档 系统和审批事实;control row 只是审计协调,不是单独真相。

本节检查清单

partition key and timezone semantics
retention and late-arrival horizon
future partitions precreated
bound gaps/overlaps checked
leaf schema/index/grant consistency
attach CHECK avoids scan where possible
default partition policy
parent explicit ANALYZE
dependency/FK inventory
old snapshot and lock budget
DETACH CONCURRENTLY restrictions
interrupted FINALIZE plan
object/logical/file/restore manifest
drop only after restore proof
online/archive ownership transition

延伸阅读


上一节:膨胀与重建 · 返回本章目录 · 下一节:amcheck 与例行完整性检查 · 查看全书目录 · 查看索引中心

28.6 `amcheck` 与例行完整性检查

备份没有报错,不表示每个 B-tree 都满足搜索不变量;checksum 开启,也不表示 heap 与 index 逻辑一致。

完整性不是一个布尔值,而是多层故障面:

page bytes
  -> relation structure
      -> heap/index logical correspondence
          -> constraints and business invariants
              -> backup artifacts
                  -> actual recoverability

amcheck 覆盖其中重要的一段:heap 与若干 index access method 的结构/逻辑检查。它是 检测工具,不是修复器,更不是 backup 或 restore 的替代品。

28.6.1 bt_index_check 与更深检查的成本

bt_index_check:日常 B-tree 基线

CREATE EXTENSION amcheck;

SELECT bt_index_check(
  index => 'app.orders_pkey'::regclass,
  heapallindexed => false,
  checkunique => true
);

它检查多种 B-tree 不变量。若发现逻辑不一致,通常抛错;无输出/无错意味着:

在本次检查覆盖的范围内没有发现问题。

不意味着:

这个索引、heap、存储设备和所有备份绝对无损坏。

锁:

index  AccessShareLock
heap   AccessShareLock

与普通 SELECT 使用的 relation lock mode 相同。官方文档把它视为 live production 日常轻量检查的较好折中。

先筛合法对象

批量检查不要把 temp、invalid、非 B-tree 对象误传:

SELECT
  bt_index_check(
    index => c.oid,
    heapallindexed => i.indisunique,
    checkunique => i.indisunique
  ),
  n.nspname,
  c.relname,
  c.relpages
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
JOIN pg_am AS am ON am.oid = c.relam
WHERE am.amname = 'btree'
  AND c.relkind = 'i'
  AND c.relpersistence <> 't'
  AND i.indisready
  AND i.indisvalid
ORDER BY c.relpages DESC;

对 unique index,checkunique=true 检查重复 entry 中不应有多个可见版本;这是额外 工作,但更贴合 uniqueness 语义。

bt_index_parent_check:更强锁、更深结构

SELECT bt_index_parent_check(
  index => 'app.orders_pkey'::regclass,
  heapallindexed => true,
  rootdescend => true,
  checkunique => true
);

它是 bt_index_check 的超集:

  • 检查 parent/child relationship;
  • 检查缺失 downlink;
  • rootdescend=true 为每个 leaf tuple 从 root 重新搜索;
  • 可结合 heapallindexed/checkunique。

代价:

index  ShareLock
heap   ShareLock

这会阻止 concurrent INSERT/UPDATE/DELETE,也阻止 relation 的 VACUUM 和其他 utility command。锁只在函数运行期间持有,不是整个外部 transaction 都持有;但大型 索引检查时间可能很长,所以仍需要窗口。

它不能在 hot standby 上执行;bt_index_check 可以。不要在 replica 迁移脚本里把两者 当同义函数。

rootdescend 不一定是最有价值的生产检查

rootdescend 最初也服务于 B-tree feature development。它可能显著增加资源和时间, 但对现实中某些损坏类型的额外检出价值有限。

分层策略:

routine:
  bt_index_check

selected stronger check:
  bt_index_check + heapallindexed + checkunique

maintenance window / incident:
  bt_index_parent_check
  optional rootdescend

不要因为参数叫“更彻底”就每天全库打开。

不止 B-tree

PostgreSQL 18 amcheck 还提供:

gin_index_check
verify_heapam

verify_heapam 检查 table/sequence/materialized view 的物理格式和逻辑结构,返回每个 发现问题的 block/offset/attribute/message。

SELECT *
FROM verify_heapam(
  relation => 'app.orders'::regclass,
  on_error_stop => false,
  check_toast => false,
  skip => 'none',
  startblock => NULL,
  endblock => NULL
);

边界:

  • check_toast=true 很慢;
  • TOAST 或其 index 损坏时,检查 toast value 理论上可能导致 server crash,很多情况 会只报错,但不能当无风险;
  • skip=all-visible/all-frozen 降低成本,也降低覆盖;
  • startblock/endblock 可做 chunk;
  • 依赖的内部设施本身损坏时,函数可能无法继续。

对疑似 heap corruption,先按事故窗口、备份和证据保全运行,不要把全表 verify_heapam(check_toast=true) 当 cron。

调试日志

交互诊断可:

SET client_min_messages = DEBUG1;
SELECT bt_index_check('app.orders_pkey', true, true);

会显示更多检查上下文。生产自动化默认不应把 DEBUG 细节写入公开日志;错误信息可能 泄漏数据结构或可推断内容。

权限不是“能执行即可”

amcheck function 可授权给非超级用户,但官方文档提示安全与隐私风险。独立维护角色应:

no application writes
no broad role membership
function execute only where needed
secure log/evidence destination
audited schedule
no public error payload

Pigsty managed cluster 可用专门运维角色和私有日志采集;不要让普通应用角色在任意表上 调用结构取证函数。

28.6.2 heapallindexed、锁与业务窗口

heapallindexed 回答更强的问题

普通 B-tree structure check 主要从 index 看 index。heapallindexed=true 增加:

每一个应该有 index entry 的 heap tuple,是否都能在目标 index 的摘要结构中找到?

内部类似一次“dummy CREATE INDEX CONCURRENTLY”:

scan target index
  -> build in-memory fingerprint summary
      -> scan heap as hypothetical index input
          -> verify expected entries are represented

这能发现 heap/index 不一致,而这种 cross-check 不会在普通 index scan 中自动完成。

它是概率摘要

摘要受 maintenance_work_mem 限制。PostgreSQL 官方说明,为使每个应被索引的 heap tuple 漏检不一致的概率不超过约 2%,近似需要每 tuple 2 bytes memory;更少内存时, 漏检概率缓慢上升。

因此:

heapallindexed passed once

仍不是数学上的 100% 证明。例行重复检查会给单个缺失/畸形 tuple 新的发现机会。

计划 memory:

Mfingerprint2×Ntuples M_{\text{fingerprint}} \approx 2 \times N_{\text{tuples}}

只是质量量级,不是固定 allocation。对于 1 billion tuples,2 GB 量级已超过许多默认 maintenance_work_mem;不能以为 boolean 参数零成本。

heapallindexed 不改变 relation lock mode

对同一个函数:

bt_index_check(heapallindexed=false/true)
  -> AccessShareLock remains

bt_index_parent_check(heapallindexed=false/true)
  -> ShareLock remains

但运行时间和 I/O 通常增加数倍,锁持有时长随之增加。锁 mode 没变,不等于业务 影响没变。

业务窗口要看四个预算

scope:
  relations: [...]
  total_bytes: ...
  total_tuples: ...

locks:
  function: bt_index_check
  mode: AccessShareLock
  lock_timeout: ...

resources:
  maintenance_work_mem: ...
  jobs: ...
  io_budget: ...
  cpu_budget: ...

service:
  p95_budget: ...
  replica_lag_budget: ...
  abort_threshold: ...

若使用 parent check,再明确:

write blocking expected
maximum check duration
application retry behavior
DDL/vacuum exclusion

pg_amcheck 批量编排

PostgreSQL client utility:

pg_amcheck \
  --database=appdb \
  --schema=app \
  --heapallindexed \
  --checkunique \
  --progress \
  --jobs=2

更深:

pg_amcheck \
  --database=appdb \
  --table=app.critical_orders \
  --parent-check \
  --heapallindexed \
  --checkunique \
  --jobs=1

注意:

  • --parent-check/--rootdescend 会用更强 relation locks;
  • --rootdescend 隐式选择 parent check;
  • --jobs 是并发连接,直接放大 server I/O/CPU/lock impact;
  • 默认选 table 时也检查 dependent B-tree indexes 和 TOAST;
  • --install-missing 会改变数据库,应纳入 extension change,而不是巡检时隐式执行;
  • PostgreSQL 18 的 pg_amcheck 文档说明该工具面向 PostgreSQL 14+ server;
  • client/server 版本和 option 集必须在 automation 中记录。

不要一次扫全库最大并发

较稳妥:

catalog + small critical indexes
  -> largest/highest-risk index batches
      -> rotating coverage
          -> periodic deep window

对象优先级:

constraint/primary indexes
high write volume
recent crash/storage incident
collation version change
replica divergence suspicion
large/old/rarely read objects
previous invalid/reindex artifact

同一时段避免叠加:

backup full scan
vacuum freeze
reindex
scrub
bulk load
major analytical scan

结果模型

每个对象至少记录:

{
  "database": "appdb",
  "relation": "app.orders_pkey",
  "function": "bt_index_check",
  "heapallindexed": true,
  "checkunique": true,
  "parent_check": false,
  "rootdescend": false,
  "started_at": "...",
  "ended_at": "...",
  "server_version": "18.x",
  "maintenance_work_mem": "...",
  "result": "passed",
  "error_sqlstate": null
}

“cron exit 0”没有对象级 coverage,无法证明哪些 relation 被跳过。

正式实验

一次性 churn_pkey

bt_index_check(
  heapallindexed=true,
  checkunique=true
)                                      pass, 0.0597 s

bt_index_parent_check(
  heapallindexed=true,
  rootdescend=true,
  checkunique=true
)                                      pass, 0.0789 s

表仅 50,000 live rows、无 concurrent business writes,时间只用于证明流程,不能作为 生产吞吐基准。实验在检查后还执行 concurrent reindex,并验证:

one final named index
indisready=true
indisvalid=true
indislive=true
invalid fixture indexes=0
cc artifacts=0

完整性检查与重建后 catalog 状态形成闭环。

28.6.3 amcheck 不替代数据页 checksum 和备份恢复

四种证明覆盖不同问题

机制 主要回答 不能回答
data checksum 读到的数据页 bytes 是否匹配上次写入 checksum B-tree 排序/heap-index 逻辑、可恢复性
amcheck relation/access-method 结构与部分逻辑对应是否一致 所有磁盘 bytes、所有业务语义、备份可用
backup manifest verification 备份文件/size/checksum/所需 WAL 是否匹配 manifest server 一定能恢复、业务结果正确
restore drill 能否启动、replay、打开并验证数据/SLO 所有未来故障都可恢复

这四层是相加,不是互相替代。

data checksum 的边界

PostgreSQL 18 默认启用 page checksum,但可被 cluster-level 禁用。确认:

SHOW data_checksums;

启用时:

  • data page 写入时更新 checksum;
  • 每次读取 page 时验证;
  • 只保护 data pages;
  • 不覆盖内部数据结构和 temporary files;
  • cluster 级启停,不是 per-table。

page 已在 shared buffer 时,amcheck 可能检查的是 buffer 中版本,不一定在该时刻重新读 filesystem;它若触发磁盘读且 checksum 失败,也可能报 checksum error。

第 28 章 run 记录:

data_checksums = on

但这仍不意味着 storage、RAM 或所有 page 被本次实验读过。

amcheck 能发现 checksum 看不到的东西

例如:

operator class violates ordering rules
OS collation behavior changes
primary/standby collation environment differs
heap tuple missing corresponding index entry
access method implementation bug
logical structure valid bytes but wrong links/order

checksum 只知道 bytes 是否与写入时一致;错误逻辑也可以被“正确”地写入并拥有有效 checksum。

备份验证也不是恢复

pg_verifybackup 可:

  • 读 backup manifest;
  • 检查 system identifier/manifest checksum;
  • 对比缺失、额外、size 不同文件;
  • 比较 file checksum;
  • 对 plain backup 解析恢复所需 WAL。

官方文档同样明确:

即使 verify 通过,也应做 test restore,并验证数据库可运行、数据正确。

原因:

manifest cannot prove every server recovery check
valid WAL checksum can still encode nonsensical action due to bug
archive access/KMS/network may fail
recovery config/timeline/target may be wrong
application schema/semantic checks may fail
RTO may exceed objective

Pigsty 的 pgBackRest backup/PITR 流程应同时产出 repository check、restore drill 和业务 验收;第 31 章会完整展开恢复证明。

amcheck 通过不等于“备份健康”

可能:

live primary amcheck pass
backup missing WAL
restore impossible

也可能:

backup files verify pass
live index logically inconsistent

还可能:

primary index inconsistent
standby has different corruption state

需要按 failure domain 分别检查。

发现 corruption 后不要立即“修”

第一反应不应是:

REINDEX everything
ignore_checksum_failure=on
zero damaged page
delete relation file
fail over blindly

先:

1. preserve error, SQLSTATE, block/index identity and logs
2. identify primary/replica/timeline/version/storage
3. stop avoidable writes to affected scope
4. check whether error reproduces on another copy
5. verify checksums, storage/kernel events and recent changes
6. validate backup and recovery points
7. classify heap vs index vs VM vs catalog vs hardware
8. choose repair/rebuild/restore with evidence

如果确认只有可重建 secondary index 损坏,reindex 可能合理;如果 heap、TOAST、system catalog 或 multiple copies 损坏,简单 reindex 可能失败或掩盖证据。

转入:

第 35 章:数据抢救与工程取证

不同结果的安全路由

结果 动作
check pass 记录 coverage/time/version;继续其他层
lock timeout 本轮 inconclusive;换窗口,不算 pass
query canceled/resource gate inconclusive;缩 scope/jobs
checksum failure corruption incident;保全证据
amcheck invariant error corruption incident;定位 heap/index
server crash during deep check highest severity;停止重复触发
invalid index only 依赖/heap 验证后评估 concurrent reindex
backup verify pass 仍做 restore drill

例行计划

continuous:
  checksum/log/storage alerts

daily:
  metadata inventory, invalid indexes, recent error correlation

weekly rotating:
  bt_index_check on critical/high-change B-trees

monthly/window:
  heapallindexed selected objects
  backup manifest verification

quarterly / after incident:
  parent-check/deep checks where justified
  full restore drill + business validation

after upgrade/collation/storage event:
  targeted expanded coverage

频率应按数据变化量和 failure risk,不按“每月一号”机械套用。

完整性 SLO

coverage:
  critical_btree_days: 7
  all_btree_days: 30
  deep_selected_days: 90

backup:
  manifest_verify_each_backup: true
  restore_drill_days: 30

response:
  checksum_or_amcheck_error_page_minutes: 5
  preserve_evidence: true
  automatic_repair: false

这样“例行检查”才是可审计服务,不是散落脚本。

本节检查清单

object type / validity / persistence
bt_index_check vs parent-check selection
heapallindexed/checkunique/rootdescend flags
relation lock mode and duration
maintenance_work_mem and jobs
I/O/CPU/service budget
object-level coverage record
private error evidence
data_checksums state
backup manifest verification
restore drill recency
corruption response route
no automatic destructive repair

延伸阅读


上一节:分区生命周期 · 返回本章目录 · 下一节:实战:建立维护节奏 · 查看全书目录 · 查看索引中心

28.7 实战:建立维护节奏

前六节分别讨论了旧版本、autovacuum、冻结、膨胀、分区生命周期和物理完整性。 这些知识如果只停在若干 SQL 和参数上,仍然很容易变成“告警来了就跑一次 VACUUM”的被动运维。本节把它们收束成一条可重复的维护闭环:

发现信号
  -> 确认对象与保留者
      -> 判断正确性、服务和效率优先级
          -> 选择最小动作并预算锁 / 空间 / WAL
              -> 执行、旁路观测、验收
                  -> 留档、复盘、进入下一周期

本节同时提供一套可复现实验。它不是把固定阈值塞给读者,而是让读者亲眼验证四件事:

  1. 旧快照怎样改变普通 VACUUM 的清理结果;
  2. “空间已经可复用”和“文件已经缩小”为什么是两个结论;
  3. 索引检查、并发重建与分区退役怎样分别验收;
  4. 哪些异常仍属于日常维护,哪些必须立即转入资源事故或数据救援。

28.7.1 制造膨胀、长事务与分区到期

先读实验合同

本章实验只允许在已经确认的 Pigsty pg-test 开发沙箱执行,正式参考环境为:

target        pg36-l2-vagrant/pg-test
server        PostgreSQL 18.6
database      pg36_maintenance
role          dbuser_pg36maint
data          synthetic only
capture       L0 read-only
exercise      L2 bounded disposable fixture
production    forbidden

完整合同在 static/labs/ch28/lab-contract.md,机器可校验的要求与动作白名单 分别在 requirements.jsonmaintenance-contract.json

实验只创建带随机 run_id 注释的一次性数据库和角色。为避免 exporter 在建库与授权之间 抢先连接,runner 依次执行:

CREATE DATABASE ... ALLOW_CONNECTIONS false
  -> REVOKE CONNECT FROM PUBLIC
      -> GRANT CONNECT TO exact fixture role
          -> ALTER DATABASE ... ALLOW_CONNECTIONS true

所有表都位于夹具数据库。只有 maint.churn 的表级 autovacuum 被临时关闭,以便让手工 VACUUM 的因果关系可复现;这不是生产建议。实验明确禁止:

ALTER SYSTEM
修改 Pigsty inventory 或 Patroni DCS
reload / restart PostgreSQL
全局关闭 autovacuum
VACUUM FULL
DROP DATABASE ... WITH (FORCE)
终止不相关会话
未验证归档就删除分区
接触生产数据或流量

换言之,这里制造的是可丢弃夹具上的现象,不是在真实库中“先破坏再学习”。

先锁定输入与上游证据

capture 在任何写入前验证:

  • 第 19 章部署证据仍指向同一个非生产沙箱;
  • 第 25 章确认目标为主库,并保留观测基线;
  • 第 27 章没有把试验参数持久化;
  • 数据库与角色在起点均不存在;
  • amcheckpg_freespacemappg_visibilitypgstattuple 可安装;
  • PostgreSQL 设置、复制槽、prepared transaction、文件系统空间和校验和状态可读;
  • 11 个实验源文件的 SHA-256 与随后执行的版本一致。

这一步解决一个经常被忽略的问题:如果运行期间脚本、目标或前置状态发生变化,最终数字 即使“看起来正确”,也不能归到当前实验设计上。runner 因此在 capture 之后再次计算源文件 散列,不一致就失败关闭。

创建三组现象

夹具包含两类表。

第一类是一个 fillfactor = 70 的 heap,共写入 60,000 行,并建立主键和 (status, id) B-tree:

CREATE TABLE maint.churn (
  id      bigint PRIMARY KEY,
  status  text NOT NULL,
  payload text NOT NULL
) WITH (
  fillfactor = 70,
  autovacuum_enabled = false
);

INSERT INTO maint.churn (id, status, payload)
SELECT i, 'new', repeat(md5(i::text), 20)
FROM generate_series(1, 60000) AS g(i);

CREATE INDEX churn_status_idx
  ON maint.churn (status, id);

VACUUM (FREEZE, ANALYZE) maint.churn;

第二类是按日期范围分区的事件表:

events_2024    10,000 rows   expired
events_2025     5,000 rows   retained
parent total   15,000 rows

数据内容、行数和边界都是确定的,因此归档前后可以比较:

row_count
min(id) / max(id)
min(event_date) / max(event_date)
sum(amount)
ordered row digest

仅比较文件大小或只执行一次 count(*) 都不够:前者不能证明逻辑内容,后者无法发现 同样行数下的值篡改。

建立一个可识别的旧快照

实验另开一个连接:

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM maint.churn;

连接的 application_name 固定为 pg36-ch28-old-snapshot。runner 必须从 pg_stat_activity 同时看到:

datname          = pg36_maintenance
usename          = dbuser_pg36maint
application_name = pg36-ch28-old-snapshot
pid              = recorded holder pid
backend_xmin     IS NOT NULL

只有这五个条件全匹配,后续才允许释放该会话。脚本不使用模糊的 query 文本、不按用户名 批量杀连接,也不把所有 idle in transaction 一锅端。

接下来在另一个事务中制造 churn:

WITH updated AS (
  UPDATE maint.churn
     SET status = 'changed',
         payload = payload || '-u'
   WHERE id <= 40000
   RETURNING 1
),
deleted AS (
  DELETE FROM maint.churn
   WHERE id > 40000
     AND id <= 50000
   RETURNING 1
)
SELECT
  (SELECT count(*) FROM updated) AS updated_rows,
  (SELECT count(*) FROM deleted) AS deleted_rows;

结果应为:

updated_rows    40,000
deleted_rows    10,000
remaining_rows  50,000

remaining_rows 必须在下一条 SQL 命令中读取。PostgreSQL 的 data-modifying CTE 共享同一个命令级快照;若在同一条语句里再次 count(*),读到的仍可能是修改前的 60,000 行。这不是数据库“少提交了一次”,而是命令快照语义。类似地,不能让 UPDATEDELETE 命中同一批行,再假设两个子语句会按书写顺序串行处理。

为什么这组夹具有教学价值

这组实验同时保留了四条互不替代的证据线:

现象 主要证据 回答的问题
旧快照 backend_xmin、精确 holder identity 谁还需要旧版本
heap churn pgstattuple、FSM、VM、关系大小 旧版本是否清掉、空间去哪里
索引维护 amcheck、catalog、relfilenode 结构是否通过检查、重建是否完成
分区到期 分区拓扑、CSV、manifest、回灌 数据是否先可恢复、再退出热表

任何一列都不能替代其他列。n_dead_tup 是估计值;文件大小不是可见性;amcheck 不是备份;CSV 存在也不等于可恢复。

28.7.2 从指标与原生视图判定维护优先级

先按风险排序,不按表大小排序

维护队列应该先回答“拖延会造成什么”,而不是“哪个数字最大”。一个实用的三层优先级是:

P0 correctness
  XID / MXID headroom、校验和或 amcheck 错误、无法推进的冻结

P1 service continuity
  replication slot / prepared xact / 长事务保留、磁盘逼近红线、
  autovacuum backlog、锁等待、持续延迟与 WAL 压力

P2 efficiency
  可复用空间不足、索引低效、统计陈旧、计划可控的重组与分区退役

因此,一个 relfrozenxid 年龄危险但只有 2 GB 的表,可能比一个 2 TB、膨胀 20%、 仍有充足空间且正常被 vacuum 的表更紧急。维护分数可以帮助排序,但不能把 P0 平均进 一个漂亮的加权总分:

priority=lexicographic(correctness, service, efficiency) priority = \operatorname{lexicographic} \left( correctness,\ service,\ efficiency \right)

同层内再用 headroom、增长速率、业务关键度、预计锁时间和维护成本排序。

第一屏:全库安全边界

先看数据库年龄、活动快照、prepared transaction 和复制槽:

SELECT datname,
       age(datfrozenxid)        AS xid_age,
       mxid_age(datminmxid)     AS mxid_age
FROM pg_database
WHERE datallowconn
ORDER BY age(datfrozenxid) DESC;

SELECT pid, datname, usename, application_name,
       state, xact_start, backend_xid, backend_xmin,
       wait_event_type, wait_event
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
   OR state = 'idle in transaction'
ORDER BY xact_start NULLS LAST;

SELECT transaction, gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

SELECT slot_name, slot_type, active,
       xmin, catalog_xmin,
       restart_lsn, confirmed_flush_lsn,
       wal_status, safe_wal_size
FROM pg_replication_slots
ORDER BY slot_name;

不要把“最老连接”自动等同于“清理阻塞者”。真正相关的是它是否持有旧 backend_xmin、prepared XID 或 slot xmin/catalog_xmin,以及时间线是否与问题吻合。 也不要看到 age() 大就立即运行一条万能命令;先按 28.3 的版本化流程判断处于常规态、 迫近 failsafe,还是已经进入事务 ID 硬停机状态。

第二屏:对象触发与进度

候选表至少需要这些原生信息:

SELECT relid::regclass AS relation,
       n_live_tup,
       n_dead_tup,
       n_tup_ins,
       n_tup_upd,
       n_tup_hot_upd,
       n_tup_del,
       last_autovacuum,
       autovacuum_count,
       last_autoanalyze,
       autoanalyze_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

SELECT c.oid::regclass AS relation,
       c.reltuples::bigint,
       c.relpages,
       age(c.relfrozenxid)    AS xid_age,
       mxid_age(c.relminmxid) AS mxid_age,
       pg_relation_size(c.oid)       AS heap_bytes,
       pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC;

SELECT pid, datname, relid::regclass AS relation,
       phase,
       heap_blks_total,
       heap_blks_scanned,
       heap_blks_vacuumed,
       index_vacuum_count,
       max_dead_tuple_bytes,
       dead_tuple_bytes,
       num_dead_item_ids,
       indexes_total,
       indexes_processed
FROM pg_stat_progress_vacuum;

其中:

  • pg_stat_user_tables 是累计统计和估计,适合发现趋势,不是物理真值;
  • pg_class 给出 catalog 估计、年龄和大小,仍不能直接证明 dead tuple 百分比;
  • pg_stat_progress_vacuum 只能描述正在运行的进度,phase 切换不是线性 ETA;
  • 需要高成本物理确认时,再在已选对象上使用 pgstattuple,不要全库高频扫描;
  • pg_visibility_map_summarypg_freespace 分别观察 VM 与 FSM,但不要把它们 解释成业务行正确性。

例如对单个已获批候选:

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

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

SELECT sum(avail) AS fsm_available_bytes
FROM pg_freespace('app.orders'::regclass);

将 Pigsty 看板与 SQL 对齐

Pigsty 的 PostgreSQL 监控提供数据库、实例、表、查询、复制、WAL、磁盘和 autovacuum 等多层视图。看板负责快速发现关联:

dead tuple ratio rises
  + autovacuum duration / backlog rises
  + disk free falls
  + latency or I/O pressure rises

原生视图负责复核对象、持有者、年龄、命令 phase 和 catalog 状态。正确的工作方式是:

dashboard finds pattern
  -> SQL proves identity and mechanism
      -> maintenance ticket freezes target and budget
          -> dashboard + SQL watch the operation

不要从一张图直接跳到 VACUUM FULLREINDEX 或终止会话。图表的采样、标签聚合和 保留周期都可能掩盖瞬态事实;反过来,单次 SQL 也无法替代时间序列。

本次实验如何判定

正式 run 的基线与 churn 后快照同时记录:

logical row count
pgstattuple live/dead tuple counts
pg_stat_user_tables counters
heap and total relation bytes
FSM available bytes
VM all-visible / all-frozen page counts
relfrozenxid age
holder backend_xmin
VACUUM progress samples and phases

优先级判断如下:

  1. 没有 XID/MXID 或完整性 P0 信号;
  2. 人工旧快照明确保留 50,000 个物理 dead tuples,先解除精确保留者;
  3. 普通 vacuum 之后确认空间回收语义,再决定是否需要文件重写;
  4. 索引检查、并发重建和分区到期作为独立、可验收的维护动作执行。

这里的重要判断不是“dead tuple 多,所以 vacuum”,而是“先证明谁让 vacuum 不能完成, 再移除那个精确原因”。

28.7.3 执行清理、检查和分区退役并验证副作用

运行完整闭环

先做不接触数据库的合同检查:

static/labs/ch28/task.sh lint

然后为每次正式实验使用一个不存在或为空的私密绝对目录:

export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch28-run
static/labs/ch28/task.sh all

all 的顺序固定为:

capture L0 read-only
  -> exercise L2 bounded fixture
      -> verify L0
          -> review L0

也可以逐步执行:

static/labs/ch28/task.sh capture
static/labs/ch28/task.sh exercise
static/labs/ch28/task.sh verify
static/labs/ch28/task.sh review

captureall 拒绝覆盖非空证据目录。私有目录包含原始 SQL 输出、进度采样、 归档 CSV、清理记录和 source hashes;公开仓库只保存字段白名单后的 maintenance-run.json,不保存口令、连接串、SSH 材料或原始业务数据。

第一步:旧快照存在时只运行普通 VACUUM

在 holder 仍有非空 backend_xmin 时,runner 对夹具表运行普通 VACUUM,并以较低的 session-local cost 设置放慢它,以便旁路采样:

old snapshot remains
  -> VACUUM maint.churn
      -> sample pg_stat_progress_vacuum
          -> wait for completion
              -> take physical/statistical snapshot

正式 run 结果:

项目 结果
初始行数 60,000
更新行数 40,000
删除行数 10,000
当前行数 50,000
观察到 holder backend_xmin
vacuum 后物理 dead tuples 50,000
进度采样 128
观察到 phase initializingscanning heap

这不是“VACUUM 失效”。它遵守 MVCC,不能移除那个旧快照仍可能读取的版本。若此时反复 提高 cost limit、增加 worker 或改用 VACUUM FULL,都没有解决保留边界,反而会把 问题扩大成资源或锁事故。

第二步:只释放精确 holder,再完成冻结与统计

runner 只允许终止同时匹配数据库、角色、application、记录 PID 和非空 backend_xmin 的那一个夹具会话。任一属性改变都拒绝动作。释放后执行:

VACUUM (FREEZE, ANALYZE) maint.churn;

这里的 FREEZE 是为了在可控夹具上展示冻结与 VM 变化,不是声称所有日常 vacuum 都 必须加 FREEZE,也不是 PostgreSQL 18 已进入 XID 硬停机后的万能命令。

正式结果:

证据 基线 / 旧快照阶段 释放后
物理 dead tuples 50,000(旧快照下 vacuum 后) 0
heap bytes 61,440,000(初始) 87,040,000
FSM 可用字节 51,920,000
all-visible pages 10,625
all-frozen pages 10,625
age(relfrozenxid) 14 2
进度采样 128 171
释放后 phase scan heap、vacuum indexes、vacuum heap

结论需要精确措辞:

dead tuples became removable
free space became reusable inside the relation
visibility/freeze metadata advanced
heap file did not shrink

初始 heap 为 61,440,000 字节,churn 后最终仍为 87,040,000 字节。普通 VACUUM 成功清理,并不承诺把中间空洞归还操作系统。它在适当条件下可能截断文件末尾的空页, 但这次证据不能推导出“普通 vacuum 永不缩文件”,也不能推导出“文件没缩,所以 vacuum 没用”。

第三步:检查索引,再验证并发重建

runner 先对主键执行两层 amcheck

SELECT bt_index_check(
  'maint.churn_pkey'::regclass,
  heapallindexed => true,
  checkunique    => true
);

SELECT bt_index_parent_check(
  'maint.churn_pkey'::regclass,
  heapallindexed => true,
  rootdescend    => true,
  checkunique    => true
);

正式 run:

bt_index_check             pass   0.0597 s
bt_index_parent_check      pass   0.0789 s

第二种检查更强,但会取得 ShareLock,阻塞并发 DML 与 VACUUM;不能因为本次只需 0.08 秒就假设大表也能随时运行。生产中应根据表大小、缓存状态、业务窗口和副本角色 安排,必要时先用较轻的 bt_index_checkpg_amcheck 分批巡检。

然后对次级索引执行:

REINDEX INDEX CONCURRENTLY maint.churn_status_idx;

验收不止是“命令返回 0”:

条件 正式结果
relfilenode 改变
原大小 3,227,648 bytes
新大小 1,589,248 bytes
同名有效索引 1
indisvalid / indisready / indislive 全部为真
无效夹具索引 0
_ccnew / _ccold 残留 0

尺寸变小是这次数据分布的结果,不是并发重建的固定收益。真正的最低验收是新索引有效、 唯一目标明确、没有异常残留,并且写放大、临时空间、WAL、复制延迟和锁等待仍在预算内。

第四步:先分离、归档与回灌,再删除

过期分区执行:

ALTER TABLE maint.events
  DETACH PARTITION maint.events_2024 CONCURRENTLY;

DETACH ... CONCURRENTLY 不能放在显式事务块中;它分两次事务完成,存在 default partition 时也不能直接使用。若中途留下 pending detach,应该检查 catalog 与作业状态, 按实际版本使用 FINALIZE,而不是盲目再次执行或直接 drop。

正式 run 的顺序和结果:

parent before             15,000 rows
expired partition         10,000 rows
DETACH CONCURRENTLY       passed
parent after               5,000 rows
standalone relation       10,000 rows
CSV bytes                 547,894
CSV SHA-256               cd7d54e4781db3e18deb3fd6d49d5e23...
independent restore       matched
detached partition drop   after validation only

SHA-256 完整值在公开结果文件中为:

cd7d54e4781db3e18deb3fd6d49d5e23daf54496da14e41e5daed009a9a41d0d

独立回灌表的行数、日期范围、金额合计和有序 digest 必须与 detach 前 manifest 一致。 只有 round_trip_validated = true 后,runner 才允许删除原分区。这条门槛把“对象已经 离开热路径”和“数据已经可以销毁”明确分开。

第五步:证明副作用已经收束

正式 run 最终证明:

fixture database absent         true
fixture role absent             true
ordinary drop                   true
DROP ... FORCE used             false
unrelated sessions terminated   0
remote temporary path absent    true
persistent config changed       false
production data/traffic touched false

验证器拒绝 28 个预声明反例,并对 14 个现场证据 mutant 验证失败关闭。review 校验私有 证据文件、归档、源散列和公开摘要。这样的负向验证很重要:只证明“正确文件能通过” 无法证明校验器真的会拦住数据库未清理、索引无效、digest 不一致或误用 VACUUM FULL 等失败状态。

一次沙箱成功只说明合同内流程可执行。它没有证明:

  • 同样动作在生产表上的锁时间和 I/O 成本;
  • 当前备份真的可恢复;
  • amcheck 能替代 checksums、pg_verifybackup 或恢复演练;
  • 生产窗口已经获批;
  • 任何固定 dead tuple 比例适合所有表。

28.7.4 输出维护清单及 ch34/ch35 的安全路由

把“维护”拆成不同频率

健康维护不是每月一次“大扫除”,而是多种频率的闭环。

频率 观察与动作 必须留下的证据
连续 XID/MXID headroom、磁盘、autovacuum backlog、复制槽、长事务、错误日志 告警身份、开始时间、当前 headroom
每日 top dead/churn 表、长事务、slot/prepared xact、失败 vacuum、分区边界 排序快照、owner、处置状态
每周 autovacuum 覆盖、统计陈旧、HOT 比例、FSM/VM 抽查、索引增长 趋势、候选对象、是否需要实验
每月 表级 override 审计、冻结策略、空间与 WAL 预算、归档回灌抽检 参数来源、容量预算、恢复证据
每季度 checksums/pg_amcheck 策略、备份 manifest、整库恢复演练 完整性报告、恢复 RTO/RPO
事件驱动 大批量导入/更新/删除、版本升级、分区切换、schema change 后 前后统计、对象状态、回退结果

频率不是硬编码。写入速率、表大小、SLO、冻结 headroom 和恢复要求决定实际周期。 关键是每项都有 owner、阈值来源、验收和升级路线。

一张可执行的生产工单

任何主动维护动作至少填写:

identity:
  cluster: ...
  instance_role: primary
  database: ...
  relation: schema.table
  relation_oid: ...
  owner: ...

reason:
  signal: ...
  first_seen: ...
  trend_window: ...
  mechanism_evidence: ...
  priority: P0 | P1 | P2

action:
  exact_command: ...
  expected_effect: ...
  alternatives_rejected: ...
  version_authority: PostgreSQL <major.minor> official docs

budget:
  lock_timeout: ...
  statement_timeout: ...
  estimated_runtime: ...
  free_space_required: ...
  wal_budget: ...
  replica_lag_budget: ...
  io_cpu_budget: ...

observation:
  dashboard: ...
  sql_views: ...
  sample_interval: ...
  success_conditions: ...
  abort_conditions: ...

recovery:
  cancel_or_rollback: ...
  invalid_index_cleanup: ...
  pending_detach_finalize: ...
  backup_restore_evidence: ...

approval:
  application_owner: ...
  database_owner: ...
  platform_owner: ...
  window: ...

没有 target OID、精确命令、预算和停手条件的工单,不应进入生产。

Pigsty 命令是入口,不是审批

在目标节点上,Pigsty 提供本地 PostgreSQL 维护封装:

pig pg vacuum  mydb -t app.orders
pig pg analyze mydb -t app.orders
pig pg freeze  mydb -t app.orders
pig pg repack  mydb --plan

它们分别封装 vacuumdbpg_repack,降低日常操作摩擦,但不改变 PostgreSQL 本身的锁、WAL、磁盘、MVCC 和版本语义。特别注意:

  • 不要把 pig pg vacuum mydb --full 当作常规清理;VACUUM FULL 需要 ACCESS EXCLUSIVE 并重写表;
  • pig pg freeze 适用于明确的冻结任务,不是 PostgreSQL 18 事务 ID 硬停机状态下 可以无条件执行的急救口诀;
  • pig pg repack --plan 先列出计划对象,但正式执行仍要预算额外空间、写放大、 WAL、复制延迟和最终锁;
  • pig pg kill 默认 dry-run 是好习惯;即使加 -x,也必须先用 PID、数据库、角色、 application、事务时间和保留证据锁定目标。

平台让命令一致,证据链才决定命令是否应该执行。

明确停手条件

以下任何条件出现,都应停止扩大动作,保留现场并重新判断:

target OID / role / primary identity changed
lock wait exceeds budget
free space falls below action + rollback reserve
WAL rate or replica lag exceeds budget
autovacuum / user workload begins mutual starvation
dead tuple or XID result contradicts hypothesis
REINDEX leaves invalid or _ccnew/_ccold artifacts
DETACH remains pending or archive manifest mismatches
checksum / amcheck / page read reports structural error
unrelated sessions would need termination
only force-drop could clean the fixture

“命令还在跑”不是继续等待的充分理由;“已经跑了很久”也不是取消的充分理由。应根据 预先声明的预算、progress phase、阻塞图和副作用趋势决定。

分流到第 34 章:资源与过载事故

如果数据结构没有明确损坏,但维护动作或 backlog 正在威胁服务,应转入 第 34 章:过载保护与资源故障判型

disk free / inode rapidly falling
I/O queue and latency rise together
VACUUM workers or maintenance I/O crowd out foreground traffic
WAL burst causes archive or replica lag
lock queue expands
connection / worker / memory pressure
multiple maintenance jobs overlap without budget

第 34 章回答的是“如何止血、保护前台、恢复资源 headroom,并在容量与并发边界内重排 维护”,不是在压力中继续加大 vacuum 或 reindex 并发。

分流到第 35 章:数据救援与取证

出现以下证据时,应停止把问题称为“普通膨胀”,转入 第 35 章:数据抢救与工程取证

checksum failure
amcheck reports structural inconsistency
heap / index / VM relation produces impossible invariant
page read or WAL replay exposes corruption
REINDEX cannot establish a trustworthy valid index
backup manifest or restore validation fails
primary and replica disagree in ways normal MVCC cannot explain

进入救援路线后,优先保护证据和可恢复性:

freeze the incident timeline
identify exact relation / fork / block
preserve logs, checksums, WAL and backup manifests
avoid destructive repair on the only copy
restore or clone into an isolated environment
compare independent evidence
then decide rebuild, logical extraction or failover

不要在唯一生产副本上反复尝试 zero_damaged_pages、手工删文件或未经验证的 catalog 修改。这些动作可能把可调查的局部损坏变成不可逆的数据丢失。

本章最终验收清单

完成第 28 章后,读者应该能逐项回答:

  • 我能用触发公式、表级 override 与版本参数解释 autovacuum 为什么启动;
  • 我能区分统计估计、物理抽查、VM、FSM 和关系大小各自证明什么;
  • 我能找到实际保留旧版本的事务、prepared xact 或 replication slot;
  • 我能同时检查 XID 与 MXID headroom,而不是只背一个 wraparound 数字;
  • 我知道普通 VACUUMVACUUM FULLREINDEX CONCURRENTLYpg_repack 的锁、空间和 WAL 边界;
  • 我能把分区 detach、归档、回灌验证和最终删除拆成独立门槛;
  • 我能为 amcheck 选择检查层级与窗口,并知道它不能替代备份恢复;
  • 我能写出含目标、假设、预算、停手、验收和恢复的生产维护工单;
  • 我能判断事件应留在日常维护,还是升级到第 34 或第 35 章;
  • 我不会把一次沙箱成功描述成生产安全证明。

本章参考实验的最终判定为:

maintenance loop demonstrated in sandbox
exact cleanup verified
production_ch28_gate = pending

这三个结论缺一不可:流程已经跑通,副作用已经收束,但生产变更仍未获授权。

进一步阅读:


上一节:amcheck 与例行完整性检查 · 返回本章目录 · 下一章:移花接木:逻辑复制、迁移与异构同步 · 查看全书目录 · 查看索引中心