# 实战：迁移 `pg36_shop`

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

---

前六节建立了 logical replication、CDC、全量校验、迁移状态机、异构语义与多集群边界。
本节把它们压缩到一个可重复的 Pigsty 双集群实验：

```text
pg-test / pg36_shop_src
  -> publication + logical slot
      -> pg-meta / pg36_shop_dst
          -> subscription
```

实验不是“看见几行复制过去”就结束，而要主动制造 consumer 停滞、显式 apply conflict
和不会报错的 silent drift，再完成 sequence 校准、写围栏、模拟切流、条件回退与精确
清理。

## 29.7.1 完成全量加增量同步 {#item-29-7-1}

### 先读边界，再运行脚本

本章只允许在已确认的 Pigsty 四节点开发沙箱执行：

```text
source       pg-test / PostgreSQL 18
target       pg-meta / PostgreSQL 18
data         deterministic synthetic fixture
capture      L0 read-only preflight
exercise     L2 bounded two-cluster disposable fixture
production   forbidden
real route   never changed
```

完整合同在
[`lab-contract.md`](/labs/ch29/lab-contract.md)，机器可读的环境、对象、数量和验收条件在
[`requirements.json`](/labs/ch29/requirements.json)，迁移状态与允许动作在
[`migration-contract.json`](/labs/ch29/migration-contract.json)，拓扑图在
[`topology.mmd`](/labs/ch29/topology.mmd)。

实验只创建以下固定名称对象，并用随机 `run_id` marker 证明所有权：

```text
source database       pg36_shop_src
target database       pg36_shop_dst
publication           pg36_shop_pub
slot                  pg36_shop_slot
subscription          pg36_shop_sub
five exact fixture roles
```

它明确禁止读取现有业务表、修改 Pigsty inventory、修改 Patroni/持久参数、改变真实
HAProxy/PgBouncer/DNS/VIP、终止无关连接和 force-drop。marker、对象名或连接范围有一项
不匹配，runner 都失败关闭。

先做纯静态合同检查：

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

完整实验会创建和删除两个一次性数据库，必须在已确认沙箱中指定一个新的私有证据目录：

```bash
export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch29.XXXXXX"
)"

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

若希望分步审阅：

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

`capture` 在任何写入前检查：

- 两端 service、cluster、PostgreSQL major、primary 身份；
- 两个不同的 system identifier；
- source `wal_level = logical`；
- 目标 database、role、slot 和 subscription 起点不存在；
- 第 19、23、25、28 章上游证据存在且环境边界一致；
- 实验源文件散列与随后执行的版本一致；
- 远端临时目录和证据目录满足私有权限。

### 创建 schema 与逻辑复制对象

夹具包含：

```text
shop.customers     5,000 rows
shop.orders       20,000 rows
```

两表都有稳定主键，`orders.customer_id` 引用 customer；状态、金额和更新时间具有固定
业务约束。runner 在源端创建 publication 和 logical slot，在目标端创建相同 schema 与
subscription，然后等待 `pg_subscription_rel` 中两张表都从同步状态进入 `r`。

正式参考 run 证明：

```text
source system id  7668025967696967004
target system id  7668025945980641675
customers         5,000
orders            20,000
tables ready      2
logical manifest  equal
```

system identifier 是本次沙箱证据，不是读者环境中的预期常量。验收的是“两端不同且分别
绑定已声明 cluster”，不是数字本身。

### 让初始快照与持续变更汇合

初始复制完成后，源端执行：

```text
INSERT    500 orders
UPDATE    200 orders
DELETE    100 orders
COMMIT    one unique migration marker
```

流程等待目标读取 marker，再比较两端精确行数、有序摘要、金额合计、状态分布和业务
不变量。参考 run 的结果为：

```text
inserted / updated / deleted  500 / 200 / 100
marker acknowledged           true
logical manifest equal        true
```

这同时证明了 initial copy 与增量可以汇合，以及验证是在声明的 marker 边界之后执行。
它不证明生产大表所需时间，也不覆盖迁移期间的 DDL；后者仍需独立编排。

### 连接失败也是 preflight 结果

开发中的第一次候选 run 在 fixture 写入前被 HBA 拒绝，因为临时角色未匹配 Pigsty 的
group-role 分类。流程没有临时放宽认证，而是：

1. 停止实验；
2. 按 marker 清理两个 database、五个角色、slot/subscription；
3. 证明所有临时对象不存在；
4. 用 `INHERIT FALSE, SET FALSE` 的成员关系满足 HBA 分类；
5. 重新从新的 run 和空证据目录开始。

生产演练也应如此：preflight 失败说明合同不成立，不能在原 run 上一边改权限一边继续，
否则最终证据无法说明实际执行了哪套安全边界。

## 29.7.2 注入消费者停滞与数据差异 {#item-29-7-2}

### 反例一：consumer 停了，风险留在 source

runner 精确禁用 `pg36_shop_sub`，确认源端 `pg36_shop_slot` inactive，然后在源夹具中
生成固定 3,000 行变化。参考结果：

```text
confirmed_flush_lsn unchanged      true
retained WAL before                227,008 bytes
retained WAL after               2,867,128 bytes
retained WAL grew                  true
caught up after re-enable          true
```

禁用动作只匹配本 run 的 subscription；负载行数固定，不改变
`max_slot_wal_keep_size`，也不制造无限 WAL。数字取决于 tuple、full-page image、
checkpoint 和版本，教学结论是方向：

```text
consumer 不确认 -> slot 不能推进 -> source retained WAL 增长
```

重新启用并追平后必须再次校验 manifest，不能因 worker 恢复 running 就进入下一阶段。

### 反例二：显式冲突会停 apply

实验先在目标端插入：

```text
order_id = 900000, target payload
```

再在源端提交同一 key、不同值。目标 apply 命中唯一键，PostgreSQL 18 的
subscription 统计出现 `insert_exists`：

```text
confl_insert_exists  0 -> 1
apply_error_count    0 -> 1
```

此时 slot 仍可能存在，subscription 也仍是一个 catalog 对象，但 apply 已无法越过
冲突事务。修复流程必须先证明：

```text
冲突表与主键
源端权威值
目标端冲突值
错误计数和日志时间
允许采取的修复方向
```

实验删除精确的目标冲突 fixture 行，让源事务重放，再等待 marker 与 manifest 收敛。
生产环境不能把“删除目标所有冲突行后重试”写成通用脚本；不同冲突可能代表合法的
target-only write。

### 反例三：静默漂移不会停 apply

runner 在目标端直接修改 `order_id = 1`。该行之后没有新的源变化，因此：

```text
subscription remains healthy
no apply conflict is raised
target query succeeds
```

但 16 个稳定 hash bucket 中，只有 bucket 1 的行数/摘要不一致。实验处于切流前，
合同指定 source 为权威，因而按主键读取源行、记录修复前后摘要、修复目标并重跑所有
分桶，结果：

```text
mismatched buckets before  [1]
mismatched buckets after   []
```

这组反例区分了两种故障：

| 故障 | apply 是否报错 | 主要发现方式 |
|---|---:|---|
| 唯一键/缺行等显式 conflict | 通常会 | worker/log/`pg_stat_subscription_stats` |
| 目标手工写、错误 backfill 等 silent drift | 不一定 | 持续 manifest、分桶和业务不变量 |

运行状态与数据等价必须分别验收。

## 29.7.3 验证、切流、回退并输出迁移证据包 {#item-29-7-3}

### 切流前修正 sequence 并建立写围栏

逻辑复制已经把 `order_id = 900000` 复制到目标，但 sequence 本身没有随 DML 推进：

```text
target sequence before  1
source max order_id      900000
```

runner 按源端最大 ID 校准目标 sequence。随后目标 runtime canary 得到 `900001`，证明
不会立刻与已迁移主键碰撞。

源端则撤销 runtime 的 DML 能力，用同一凭据实际发起 `INSERT`：

```text
SQLSTATE       42501
INSERT         denied
UPDATE         denied
DELETE         denied
SELECT         retained
```

“执行过 revoke”不是证据，旧凭据的负向操作才是。生产还要盘点 owner、
`SECURITY DEFINER`、scheduler 和其他 writer。

### 只模拟路由，不碰真实平台

私有 `route-history.json` 记录：

```text
source -> target -> source
```

它只是一台状态机的模拟输入。runner 会检查：

```text
Pigsty inventory unchanged
Patroni configuration unchanged
real HAProxy/PgBouncer/DNS/VIP unchanged
actual_platform_route_changed = false
```

在模拟 target 阶段写入一条可识别 canary。回退前把这 1 条目标独占数据显式对账回源，
再切回 source 并写 rollback canary。最终参考结果：

```text
customers                      5,000
orders                        23,402
logical manifest equal          true
orphan orders                      0
negative amounts                   0
invalid statuses                   0
source retained through rollback   true
```

这里证明的是“在目标独占写可枚举时，条件回退协议可执行”。真实业务流量已经写入目标后，
是否回退仍取决于 reverse sync、对账能力和不可补偿副作用。

### 证据包必须能反驳伪成功

私有证据目录包含：

```text
preflight-evidence.json
remote/migration-evidence.json
remote/route-history.json
remote-cleanup.json
negative-report.json
validation-report.json
public-summary.json
review.txt
source file hashes
```

review 会检查证据权限、schema、交叉字段、源文件 hash、私密信息与 public/private
边界。validator 不只验证成功样本，还要求：

```text
29 declared counterexamples rejected
19 live evidence mutants rejected
11 source files hash-bound
```

也就是说，篡改 system identifier、初始行数、marker、retained WAL、conflict counter、
bucket repair、sequence、写围栏、路由边界或清理结论，都不能继续得到 pass。公开参考
摘要在
[`migration-run.json`](/labs/ch29/migration-run.json)；它不含密码、conninfo、主机密钥
或原始行。

完成实验后可以对同一私有证据包重复审计：

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

但不能把另一个 run 的证据目录与当前源文件拼接使用。

### 清理也是验收阶段

正常删除目标 subscription 时，PostgreSQL 同时删除远端 main slot。runner 随后分别在
两端确认：

```text
source database absent
target database absent
all fixture roles absent
source slot absent
target subscription absent
ordinary DROP used
force drop used = false
unrelated sessions terminated = 0
remote temp absent
```

任何一项不成立，实验都不算完成。尤其不能为了让 CI 变绿而终止所有连接或删除所有
inactive slot。

### 从沙箱证据到生产迁移票据

本实验的最终决策仍是：

```text
production_ch29_gate = pending
```

正式票据至少还要补：

- 真实 schema/type/DDL/sequence/large-object inventory；
- 数据量、写入峰值、全量时长与 slot WAL 容量压测；
- source primary failover 和 consumer restart 演练；
- TLS、HBA、secret rotation 与权限评审；
- 应用影子读、连接池 drain、真实路由 owner 和变更窗口；
- 业务不变量、允许语义损失与 reconciliation owner；
- target-only write 边界、回退/前滚矩阵、不可逆动作；
- 备份恢复、RPO/RTO、观察窗口和退出条件。

读者完成本节后，应能提交的不是一句“逻辑复制已同步”，而是一份可以回答**同步了什么、
在哪个边界相等、故障怎样暴露、切流由谁执行、何时还能回退、如何证明已清理**的迁移
证据包。

---

[上一节：多集群迁移环境](../06/) · [返回本章目录](../) · [下一章：推陈出新：版本升级与回滚策略](/version-upgrade/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
