11.2 Expand–Migrate–Contract
Expand–Migrate–Contract 的价值不在三个英文词,而在于它强迫团队承认:数据库 schema 与 application fleet 不能原子切换。
本书把它细化为六个可查询阶段:
legacy
→ expanded
→ backfilling
→ migrated
→ validated
→ switched
→ contract每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串,而是 release protocol。
11.2.1 先扩展兼容结构
Expand 的判定标准
一个 expand 变更应满足:
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。
本章的兼容扩展
初始表只有:
shipping_method text NOT NULLexpand 事务做:
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
本例映射:
standard ↔ STD
express ↔ EXP
pickup ↔ PUP旧 application 只提供 shipping_method,trigger 派生 code。新 application 在共存期 dual-write;若 pair 不一致,trigger/constraint 以命名 23514 拒绝。
为什么不让 application 连续执行:
UPDATE ... SET shipping_method = ...;
UPDATE ... SET shipping_code = ...;因为两个 statement 之间可能:
- transaction 被取消;
- 进程断连;
- 第二条被 retry/skip;
- 另一个 writer 介入;
- 第一条提交而第二条未提交。
若两条在同一 transaction,可以保证原子性,但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority;长期则应收缩回一个 canonical representation,避免永久双写。
Expand 也需要版本 identity
本章 state row:
migration_id=shipping-code-v1
phase=legacyexpand 在同一事务中:
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 分批迁移、双读校验与切换
Migrate 不等于一条 UPDATE
迁移阶段包含三个并行事实:
new writes remain compatible and complete
historical debt monotonically decreases
read comparison proves semantic equivalence若只做 backfill,而旧 writer 继续写 NULL,remaining count 永远追不上;若只保护新写入,却不做 shadow comparison,可能把错误映射完整填满全表。
本章的次序:
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 的基本结构:
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 的额外负载预算。
本例可以在数据库内做精确比较:
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 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则,不能只比较文本。
先切写还是先切读
常见安全次序是:
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 的改变。它需要自己的:
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 观察稳定后再收缩旧结构
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 的入口条件至少包括:
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“观察一周”必须可验证
时间长度本身不够。需要观测对象:
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 要求三个独立输入:
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=elapsedtask.sh all 故意不提供。稳定结果:
psql exit=3
SQLSTATE=P3612
phase=switched
shipping_method exists
bridge exists这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate,可以演练真正 DROP;生产审批、证据和权限仍是另一条边界。
Contract 后没有免费回滚
删除旧列之后:
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。回退流量时:
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 |
阶段必须由事实推动,不由“脚本跑到了第几行”推动。
本节验收问题
- expand 是否对最旧受支持 writer/readers 真正兼容;
- new write protection 是否在 backfill 前建立;
- mapping/dual-write 是否只有一个一致性 authority;
- migration identity 与 phase 是否同 DDL 原子提交;
- shadow comparison 的语义、采样和 owner 是否明确;
- switch 的 application release identity 是否独立留证;
- rollback 是流量回退还是数据库反向 DDL;
- contract 是否是独立发布、独立授权和独立窗口;
- 低频/offline consumer 是否进入依赖清单;
- contract 后丢失语义时是否诚实声明 forward repair/restore。
Expand–Migrate–Contract 不是让发布变慢;它是把原本隐含、同时发生且不可诊断的风险,拆成可以停止和验证的阶段。
参考资料
上一节:识别 DDL 的四类风险 · 返回本章目录 · 下一节:索引与约束的在线化路径 · 查看全书目录 · 查看索引中心