# Expand–Migrate–Contract

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

---

Expand–Migrate–Contract 的价值不在三个英文词，而在于它强迫团队承认：数据库 schema 与 application fleet 不能原子切换。

本书把它细化为六个可查询阶段：

```text
legacy
  → expanded
  → backfilling
  → migrated
  → validated
  → switched
  → contract
```

每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串，而是 release protocol。

## 11.2.1 先扩展兼容结构 {#item-11-2-1}

### Expand 的判定标准

一个 expand 变更应满足：

```text
old application can still read
old application can still write
new application can discover/use the new shape
existing rows need not already satisfy the final invariant
new writes cannot create unbounded new migration debt
operation fits a measured lock budget
```

常见 expand：

- 添加 nullable column；
- 添加新表或新 relation；
- 添加不改变旧调用结果的新函数参数/overload；
- 添加兼容 view；
- 增加 `NOT VALID` 的 CHECK/FK；
- 建立 concurrent index；
- 安装临时 bridge trigger；
- 扩展 enum-like catalog，而非立即删除旧值。

常见非 expand：

- rename/drop old column；
- 直接 `SET NOT NULL`；
- 收窄 type/length/range；
- 删除旧 enum/catalog value；
- 改变函数返回 shape；
- 改变旧字段语义但保留同名；
- 让旧 writer 因新约束立即失败。

“DDL 能在旧代码旁边执行”不等于逻辑兼容；要实际运行最旧受支持版本的 read/write contract。

### 本章的兼容扩展

初始表只有：

```sql
shipping_method text NOT NULL
```

expand 事务做：

```sql
ALTER TABLE shop_private.ch11_order
    ADD COLUMN shipping_code text;

CREATE TRIGGER ch11_order_shipping_bridge
BEFORE INSERT OR UPDATE
ON shop_private.ch11_order
FOR EACH ROW
EXECUTE FUNCTION shop_private.ch11_sync_shipping_code();

ALTER TABLE shop_private.ch11_order
    ADD CONSTRAINT ch11_order_shipping_pair_consistent
    CHECK (...)
    NOT VALID;
```

这三部分承担不同职责：

| 组件 | 职责 |
|---|---|
| nullable new column | 让历史行暂时可表示 |
| bridge trigger | 让旧 writer 不再制造 NULL，并集中映射规则 |
| pair CHECK NOT VALID | 拒绝新/更新行的表示不一致，不扫描旧行 |

`NOT VALID` 不是“约束关闭”。约束加入后，新插入或更新的行仍被检查；只有已有行暂时没做全表验证。

### bridge 必须是临时且单一的 authority

本例映射：

```text
standard ↔ STD
express  ↔ EXP
pickup   ↔ PUP
```

旧 application 只提供 `shipping_method`，trigger 派生 code。新 application 在共存期 dual-write；若 pair 不一致，trigger/constraint 以命名 `23514` 拒绝。

为什么不让 application 连续执行：

```sql
UPDATE ... SET shipping_method = ...;
UPDATE ... SET shipping_code = ...;
```

因为两个 statement 之间可能：

- transaction 被取消；
- 进程断连；
- 第二条被 retry/skip；
- 另一个 writer 介入；
- 第一条提交而第二条未提交。

若两条在同一 transaction，可以保证原子性，但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority；长期则应收缩回一个 canonical representation，避免永久双写。

### Expand 也需要版本 identity

本章 state row：

```text
migration_id=shipping-code-v1
phase=legacy
```

expand 在同一事务中：

```text
add objects
  + install compatibility
  + update phase=expanded
```

若 DDL rollback，phase 也 rollback。下一次执行会先检查：

- migration identity 是否正确；
- 当前 phase 是否恰为 `legacy`；
- new column 是否确实不存在；
- table marker 是否匹配。

它选择“前置拒绝”而不是无条件 `IF NOT EXISTS`。`IF NOT EXISTS` 只能证明同名对象存在，不能证明 type、default、owner、constraint 与 function body 是期望版本。

## 11.2.2 分批迁移、双读校验与切换 {#item-11-2-2}

### Migrate 不等于一条 UPDATE

迁移阶段包含三个并行事实：

```text
new writes remain compatible and complete
historical debt monotonically decreases
read comparison proves semantic equivalence
```

若只做 backfill，而旧 writer 继续写 NULL，remaining count 永远追不上；若只保护新写入，却不做 shadow comparison，可能把错误映射完整填满全表。

本章的次序：

```text
expanded:
  old/new application probes
  build temporary partial index for unresolved rows

backfilling:
  keyset batches + atomic checkpoint
  controlled stop and resume

migrated:
  remaining NULL=0
  mapping mismatch=0

validated:
  pair CHECK validated
  non-null CHECK validated
  column SET NOT NULL

switched:
  new reads authoritative
  old column and bridge retained for rollback window
```

### 双读不是向用户返回两个结果

shadow read 的基本结构：

```text
primary result = currently trusted representation
shadow result  = candidate representation
compare normalized semantics
emit mismatch metric/log with stable identity
return only primary result
```

它要回答：

- 全量还是采样；
- 采样是否覆盖 tenant/value/time buckets；
- 如何归一化 NULL、时区、排序、rounding；
- mismatch 是否含敏感数据；
- 谁处理 mismatch；
- mismatch=0 要持续多久；
- shadow query 的额外负载预算。

本例可以在数据库内做精确比较：

```sql
SELECT count(*) AS mismatches
FROM shop_private.ch11_order
WHERE shipping_code IS DISTINCT FROM
      CASE shipping_method
          WHEN 'standard' THEN 'STD'
          WHEN 'express'  THEN 'EXP'
          WHEN 'pickup'   THEN 'PUP'
      END;
```

`IS DISTINCT FROM` 让 NULL 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则，不能只比较文本。

### 先切写还是先切读

常见安全次序是：

```text
1 protect new writes at database boundary
2 deploy code capable of reading both
3 turn on new/dual write
4 backfill and validate
5 shadow new read
6 switch primary read
7 observe
8 disable old write compatibility
9 contract old representation
```

“先切写再切读”让 new representation 逐步变新鲜，便于读比较；但具体顺序仍取决于：

- 新值能否从旧值无损派生；
- old writer 是否仍可能运行；
- new writer 是否能继续提供 old representation；
- read fallback 是否会掩盖 migration debt；
- rollback 时旧应用能否理解新写入。

不要把模式当教条，应把每个箭头写进兼容矩阵。

### Switch 是流量动作，不是 DDL

本章 `switch.sql` 在数据库内只能模拟：

- mismatch=0；
- 新表示可作为 read authority；
- 切换后旧 writer 仍能写；
- 新 writer 继续 dual-write；
- state 进入 `switched`。

真实 switch 通常是 application flag、deployment、routing 或 query version 的改变。它需要自己的：

```text
release identity
owner
start/end time
traffic percentage
SLI guard
rollback command
database migration identity
```

不要用 `schema_version=42` 代替 application rollout 证据，也不要用“应用已发布”代替数据库 catalog postcheck。

## 11.2.3 观察稳定后再收缩旧结构 {#item-11-2-3}

### Contract 是新的独立发布

Contract 删除的是兼容空间：

- drop old column/table/function；
- drop bridge trigger；
- remove fallback read；
- tighten type/range；
- remove old index/API；
- revoke old privilege；
- delete old catalog values。

它不应和 expand 放在同一个 maintenance window。否则旧 application 一旦仍在运行，expand 提供的兼容立刻被 contract 撤销，整个模式失去意义。

contract 的入口条件至少包括：

```text
database phase=switched
new representation complete and validated
old read traffic=0
old write traffic=0
offline/BI/ETL dependency inventory cleared
old prepared statements/connections aged out
rollback observation window elapsed
backup/PITR posture current
exact target and owner approved
forward repair documented
```

### “观察一周”必须可验证

时间长度本身不够。需要观测对象：

```text
old column read counter or query family
old write path/application version
bridge trigger invocation count
fallback-read count
mismatch count
old deployment replica count
offline job last success and next schedule
database errors for unknown old/new columns
```

如果没有区分旧/新路径的 telemetry，“观察一周无报警”不能证明旧依赖为零。

同时要考虑低频 consumer。一个月只跑一次的财务作业不会在七天窗口出现；依赖 inventory 和 owner 确认仍不可省略。

### 本地 suite 为什么拒绝 contract

[contract-gate.sql](/labs/ch11/contract-gate.sql) 要求三个独立输入：

```text
action token:
  CONTRACT_CH11_AFTER_OBSERVATION

exact target:
  pg36_shop/shop_private/ch11_order/shipping_method

external observation evidence:
  legacy-readers=0;legacy-writers=0;rollback-window=elapsed
```

`task.sh all` 故意不提供。稳定结果：

```text
psql exit=3
SQLSTATE=P3612
phase=switched
shipping_method exists
bridge exists
```

这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate，可以演练真正 DROP；生产审批、证据和权限仍是另一条边界。

### Contract 后没有免费回滚

删除旧列之后：

```text
application rollback to old binary
```

往往已不再可行。可选恢复：

- 前滚部署兼容修复；
- 从 new representation 重建 old 值（仅当转换可逆且规则仍在）；
- 从外部权威源 reconciliation；
- 从 backup/PITR 恢复到另一个环境并提取数据；
- 全库恢复，接受明确 RPO/RTO 与其他数据影响。

所以 contract 是 destructive semantic change，即使 `DROP COLUMN` 物理上很快。

### 状态机的单调性

本章不提供 `phase=validated → phase=expanded` 的数据库 down path。回退流量时：

```text
database stays expanded/validated/switched-compatible
application read path returns to old representation
new writer may continue dual-write
issue is repaired forward
```

这种“应用回退、数据库不倒退”通常比反向 DDL 更可靠。数据库状态可以暂时更宽松，只要：

- 两种表示继续一致；
- 新写入不积累债务；
- owner 和 expiry 明确；
- 后续 forward path 仍可执行。

## 发布状态表

可把每阶段写成以下审查表：

| Phase | 允许版本 | 写入 authority | 完成证据 | 失败后 |
|---|---|---|---|---|
| legacy | old | old | baseline checksum | redesign |
| expanded | old + new | bridge/dual | catalog + compatibility cases | retry/forward repair |
| backfilling | old + new | bridge/dual | checkpoint + watermarks | pause/resume |
| migrated | old + new | bridge/dual | remaining=0, mismatch=0 | repair anomalies |
| validated | old + new | constraints | `convalidated`, `attnotnull` | fix and revalidate |
| switched | old rollback + new | dual | SLI + shadow match | route reads back |
| contract | new only | new | dependency zero + observation | forward repair/restore |

阶段必须由事实推动，不由“脚本跑到了第几行”推动。

## 本节验收问题

1. expand 是否对最旧受支持 writer/readers 真正兼容；
2. new write protection 是否在 backfill 前建立；
3. mapping/dual-write 是否只有一个一致性 authority；
4. migration identity 与 phase 是否同 DDL 原子提交；
5. shadow comparison 的语义、采样和 owner 是否明确；
6. switch 的 application release identity 是否独立留证；
7. rollback 是流量回退还是数据库反向 DDL；
8. contract 是否是独立发布、独立授权和独立窗口；
9. 低频/offline consumer 是否进入依赖清单；
10. contract 后丢失语义时是否诚实声明 forward repair/restore。

Expand–Migrate–Contract 不是让发布变慢；它是把原本隐含、同时发生且不可诊断的风险，拆成可以停止和验证的阶段。

## 参考资料

- [PostgreSQL 18：ALTER TABLE](https://www.postgresql.org/docs/18/sql-altertable.html)
- [PostgreSQL 18：DDL Alter](https://www.postgresql.org/docs/18/ddl-alter.html)

---

[上一节：识别 DDL 的四类风险](../01/) · [返回本章目录](../) · [下一节：索引与约束的在线化路径](../03/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
