# 实战：前滚、回退与发布决策

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

---

前六节已经把升级拆成版本决策、数据迁移、扩展、排序规则、验收和 Pigsty 平台动作。
本节再把它们收束为一条可重复的状态机：

```text
preflight
  -> compatibility blocked
  -> compatibility repaired
  -> pg_upgrade --check
  -> pg_upgrade --copy
  -> validate
  -> rollback proof
  -> forward commit
  -> cleanup
```

实验会在 Pigsty 开发沙箱的 `pg-meta` 主机上，用 PostgreSQL 17 与 18 的二进制创建两个
Unix-socket-only 私有临时集群。它不会升级 Pigsty 管理的集群，也不会修改 inventory、
Patroni、HAProxy、PgBouncer 或真实路由。目标不是测一个漂亮的停机秒数，而是证明：
不兼容能在发布前阻断，修复顺序有证据，第一笔目标独占写之前可以回退，之后则必须先
对账。

## 30.7.1 注入一个扩展或排序规则兼容问题 {#item-30-7-1}

### 先固定二进制与实验边界

完整边界见
[`lab-contract.md`](/labs/ch30/lab-contract.md)，主机、版本、对象与验收条件见
[`requirements.json`](/labs/ch30/requirements.json)，状态转移和禁止动作见
[`upgrade-contract.json`](/labs/ch30/upgrade-contract.json)，拓扑见
[`topology.mmd`](/labs/ch30/topology.mmd)。

runner 不联网下载，也不安装系统软件。调用者需要提前准备：

```text
PG36_CH30_OLD_BIN    PostgreSQL 17 bin 目录
PG36_CH30_OLD_SHARE  与该 17 版二进制匹配的 share 目录
PG36_CH30_NEW_BIN    PostgreSQL 18 bin 目录
```

先做不连接数据库的静态合同检查：

```bash
static/labs/ch30/task.sh lint
```

完整实验只应在已确认的开发沙箱中运行，并使用新的私有证据目录：

```bash
export PG36_CH30_OLD_BIN=/path/to/postgresql-17/bin
export PG36_CH30_OLD_SHARE=/path/to/postgresql-17/share
export PG36_CH30_NEW_BIN=/path/to/postgresql-18/bin
export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch30.XXXXXX"
)"

static/labs/ch30/task.sh all
```

若要分步评审，可以依次执行：

```bash
static/labs/ch30/task.sh capture
static/labs/ch30/task.sh exercise
static/labs/ch30/task.sh verify
static/labs/ch30/task.sh review
```

`capture` 先读取执行主机身份、文件系统与二进制版本，校验确为相邻的 17→18 major
升级，并记录可执行文件 SHA-256。`exercise` 才会在随机 marker 约束的临时目录中
初始化集群；`listen_addresses` 为空，所有连接都走权限为 `0700` 的 Unix socket。
实验只有 `pg36_upgrade.app.orders` 中 10,000 行确定性合成数据。

正式参考 run 使用 PostgreSQL 17.10 和 18.6；minor 号只是那次证据的事实，并不是读者
环境要硬编码的常量。升级前基线为：

```text
rows             10,000
ordered digest   7c1f9a24b7a7ac4aef59e9b482bb1374
data checksums   enabled
custom collation app.en_numeric
dependent index  app.orders_order_code_key
```

### 制造一个可控的排序规则失配

为了让阻断逻辑可重复，runner 只在一次性 fixture 中，把
`app.en_numeric` 对应 `pg_collation` 行的 `collversion` 精确改为
`pg36-injected-stale`。这是**教学故障注入**，绝不是生产修复方法；修改其他 catalog
行或在托管集群中照做都被合同禁止。

PostgreSQL 读取到的实际 ICU 版本为 `153.121`，门禁因而得到：

```text
recorded version  pg36-injected-stale
actual version    153.121
mismatch          true
affected index    app.orders_order_code_key
release           blocked
```

修复必须遵守第 30.4 节建立的顺序：

```sql
REINDEX INDEX app.orders_order_code_key;
ALTER COLLATION app.en_numeric REFRESH VERSION;
```

先重建依赖对象，是让索引按当前排序语义重新物化；后刷新版本，只是承认依赖已经处理。
如果反过来执行，告警可能消失，但旧索引仍可能保留旧排序语义。参考 run 在重建后重新
验证 10,000 行 manifest 与查询结果，确认 mismatch 消失，才允许进入停库阶段。

### 让 `pg_upgrade --check` 拒绝真正不兼容的目标

源集群启用了 data checksums。runner 先故意以 `--no-data-checksums` 初始化一个 PG18
目标，再用**计划采用的 PG18 `pg_upgrade` 二进制**执行检查：

```text
return code  1
reason       old cluster uses data checksums but the new one does not
decision     blocked
```

失败目标会被精确删除。随后重新初始化 checksums 一致的目标，`pg_upgrade --check`
通过，正式执行：

```bash
pg_upgrade --copy ...
```

实验固定使用默认 copy 模式，禁止 link、clone、copy-file-range、swap 与 `--no-sync`。
copy 会多占磁盘和复制时间，但旧集群文件保持独立，适合演示“新集群尚未写入前”的软件
回退。`pg_upgrade` 生成的 `delete_old_cluster.sh` 被记录但从不执行。

## 30.7.2 在业务写入恢复前验证回退路径 {#item-30-7-2}

### 先证明升级结果，不急着开放写入

升级完成不等于可以切流。runner 启动 PG18 后，依次验证：

- 数据库、schema、table、index 与 extension manifest；
- 10,000 行的有序摘要和业务查询结果；
- 两个 B-tree 索引的 `amcheck`；
- 升级后统计信息补采与 `ANALYZE`；
- 排序规则版本不再失配；
- 临时实例仍只监听私有 Unix socket。

参考 run 的升级前后摘要完全相等：

```text
source rows / digest  10,000 / 7c1f9a24b7a7ac4aef59e9b482bb1374
target rows / digest  10,000 / 7c1f9a24b7a7ac4aef59e9b482bb1374
extension manifest    equal
query result          equal
amcheck               passed
post-upgrade analyze  completed
```

前后查询都使用了 index scan，但 validator 没把“执行计划文本必须相同”当成通过条件。
新版本优化器可以合法地选择不同计划；真正要验收的是结果等价、业务时延与资源预算，而
不是把旧计划冻结成正确答案。

### 在第一笔目标独占写之前实际回退

完成上述验证后，实验仍不向 PG18 写入。它停止新集群，重新启动旧 PG17，再用旧端读取
同一 manifest：

```text
new cluster stopped                 true
old cluster restarted               true
target-only writes before proof     0
old manifest equals baseline        true
rollback proven                     true
```

这不是纸面命令，也不是“旧目录还在”的推断，而是一次真实启动与查询。它成立有三个必要
前提：

1. 使用 copy 模式，旧目录没有与新目录共享或交换文件；
2. 新集群还没有接受任何独占写入；
3. 应用、连接池和路由仍被写围栏挡在外面。

若用 link 模式，新集群一旦启动就可能修改旧集群共用的数据页，官方明确警告旧集群此后
不再安全；swap 更会交换文件，不能套用本节回退步骤。clone 是否可回退还取决于文件系统
和后续写入边界，也不能只凭模式名假设。

### 第一笔新写入改变了问题

回退证明完成后，runner 再次停止 PG17、启动 PG18，并提交一条明确 canary：

```text
order_id                         10001
target-only canary rows              1
final target rows               10001
direct rollback remains safe    false
```

从这一刻开始，旧 PG17 不包含 `order_id = 10001`。若立即把路由切回旧集群，数据会在
用户视角消失；真实系统还可能已经发送邮件、扣款或调用外部服务。此后的“回退”不再只是
启动旧二进制，而是数据迁移和业务补偿：

```text
停止或围住新写入
  -> 确定目标独占提交边界
  -> 反向同步或逐项对账
  -> 补偿外部副作用
  -> 再次校验
  -> 才能决定回旧，或继续前滚
```

因此升级 runbook 必须把两种回退写成不同状态：

| 状态 | 新版本独占写 | 可以采取的动作 |
|---|---:|---|
| 验证窗口 | 0 | 停新、启旧、验证旧端后恢复旧路由 |
| 已恢复写入 | > 0 或未知 | 先写围栏与对账；默认优先修复后前滚 |

所谓“回退窗口”不是维护开始后的固定分钟数，而是**第一笔无法在旧端重现的提交之前**。
时间阈值仍然有用，但它不能替代数据边界。

## 30.7.3 输出升级 runbook、决策门和观察窗口 {#item-30-7-3}

### 证据包必须能识别“看起来成功”

私有证据目录保存：

```text
preflight-evidence.json
remote/upgrade-evidence.json
remote/pg_upgrade-check-bad.log
remote/pg_upgrade-check-good.log
remote/pg_upgrade.log
remote/rollback-evidence.json
remote/cleanup-evidence.json
negative-report.json
validation-report.json
public-summary.json
review.txt
source file hashes
```

公开参考摘要在
[`upgrade-run.json`](/labs/ch30/upgrade-run.json)。它保留版本、状态和验收结论，
但删除本地路径、连接信息与私有原始日志。对已完成的同一个证据包，可重复执行：

```bash
static/labs/ch30/task.sh verify
static/labs/ch30/task.sh review
```

validator 不只检查成功字段。正式 run 要求：

```text
30 declared counterexamples rejected
20 live evidence mutants rejected
11 source files hash-bound
```

也就是说，伪造版本关系、二进制散列、checksum 拒绝原因、collation 依赖、修复顺序、
manifest、`amcheck`、回退前写入数、forward canary、平台边界或清理结果，都不能继续
得到 pass。

### 清理不是附属动作

实验结束时逐项证明：

```text
temporary postmasters stopped       true
fixture run root absent             true
remote root absent                  true
unrelated processes terminated         0
system packages changed            false
Pigsty managed data touched        false
Pigsty inventory changed           false
Patroni configuration changed      false
external listener created          false
```

清理只匹配当前随机 marker 所有的临时路径；它不使用宽泛进程匹配，也不终止无关
postmaster。任一临时实例仍在运行、目录 marker 不符或 Pigsty 平台身份发生变化，整次
run 都失败。

### 把实验提升为可执行 runbook

生产升级票据至少要有以下字段：

| 类别 | 必填内容 |
|---|---|
| 变更身份 | change ID、集群、数据库、版本、窗口、指挥人与每个动作 owner |
| 软件清单 | server/client、OS、驱动、连接池、扩展、动态库、collation provider |
| 数据基线 | system identifier、checksum、对象/行数/摘要、容量、备份与恢复演练 |
| 预检 | release notes、`pg_upgrade --check`、扩展升级路径、失效对象、长事务、slot |
| 平台动作 | Pigsty 配置、服务身份、连接端点、路由、pool drain 与监控 dashboard |
| 状态机 | 每次停写、停库、升级、启库、切流、恢复写入的前置条件与证据 |
| 阻断阈值 | 校验失败、延迟、错误率、锁等待、CPU/IO、业务不变量的 stop condition |
| 回退协议 | 第一笔目标独占写边界、旧端启动命令、反向对账和不可补偿副作用 |
| 退出条件 | 观察窗口、交接人、旧目录保留期、何时允许执行旧集群清理 |

执行时不要靠“大家觉得可以了”推进，而要逐门签字：

| 决策门 | 通过证据 | 不通过动作 |
|---|---|---|
| 软件门 | 目标包、扩展库、驱动与配置已冻结并散列 | 不进窗口 |
| 备份门 | 可验证备份且完成目标版本试恢复 | 不停源库 |
| 兼容门 | extension/collation/DDL/catalog 清单无未决项 | 修复或改迁移路线 |
| 检查门 | 以正式参数执行的 `pg_upgrade --check` 通过 | 保持停写，修复后重检 |
| 数据门 | manifest、业务不变量、sequence/identity 一致 | 不切流 |
| 完整性门 | `amcheck`、checksum 与备份校验各自通过 | 隔离并诊断 |
| 应用门 | 驱动、连接池、关键读写与影子流量通过 | 回旧或继续阻断 |
| 服务门 | 端点、TLS、HBA、路由和连接 drain 可观测 | 不开放流量 |
| 回退门 | 旧端已实际启动，且目标独占写为 0 | 不恢复写入 |
| 发布门 | 指挥人确认全部门禁与 owner | 不切换状态 |

其中“备份可验证”不等于执行过一次 `pg_verifybackup`；它还要包含新环境中的恢复、启动
和业务读取。`amcheck`、data checksum 与备份恢复也分别回答逻辑结构、存储页和灾难
恢复问题，不能互相替代。

### 观察窗口要有阈值和退出动作

切流后建议按三层观察：

```text
0–15 min   连接失败、认证、panic/crash、错误率、关键写入、锁与复制异常
15–60 min  p95/p99、CPU/IO、cache、autovacuum、长事务、队列和业务漏斗
1 个业务周期以上  batch、报表、备份、归档、故障转移与低频路径
```

具体时长必须由业务周期决定。每一项都要写基线、阈值、查询或 dashboard、owner 和超阈值
动作。例如“观察延迟”不够，至少应写成：

```text
signal       checkout p99
baseline     previous seven comparable periods
threshold    > baseline × 1.5 for 5 consecutive minutes
owner        application on-call
action       keep write fence / forward fix / invoke reconciliation plan
```

参考沙箱最终得到的是：

```text
isolated-pg17-to-pg18-state-machine-demonstrated
production_ch30_gate = pending
```

它证明升级合同可执行，却没有证明真实扩展 ABI、生产数据量、停机预算、HA 拓扑、应用
驱动、备份恢复和业务峰值。只有这些生产证据补齐并由 owner 批准，`pending` 才能转为
可发布。

第 31 章将把这种门禁和状态机带入更不友好的情形：系统已经发生故障时，怎样先止血、
再取证、恢复服务并留下可复盘的事件时间线。

---

[上一节：用隔离环境完成升级彩排](../06/) · [返回本章目录](../) · [下一章：事件分级、现场保护与应急决策——枕戈待旦](/incident-response/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
