11.2 Expand–Migrate–Contract
Expand–Migrate–Contract 的价值不在三个英文词,而在于它强迫团队承认:数据库 schema 与 application fleet 不能原子切换。
本书把它细化为六个可查询阶段:
每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串,而是 release protocol。
11.2.1 先扩展兼容结构
Expand 的判定标准
一个 expand 变更应满足:
常见 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。
本章的兼容扩展
初始表只有:
expand 事务做:
这三部分承担不同职责:
| 组件 | 职责 |
|---|---|
| nullable new column | 让历史行暂时可表示 |
| bridge trigger | 让旧 writer 不再制造 NULL,并集中映射规则 |
| pair CHECK NOT VALID | 拒绝新/更新行的表示不一致,不扫描旧行 |
NOT VALID 不是“约束关闭”。约束加入后,新插入或更新的行仍被检查;只有已有行暂时没做全表验证。
bridge 必须是临时且单一的 authority
本例映射:
旧 application 只提供 shipping_method,trigger 派生 code。新 application 在共存期 dual-write;若 pair 不一致,trigger/constraint 以命名 23514 拒绝。
为什么不让 application 连续执行:
因为两个 statement 之间可能:
- transaction 被取消;
- 进程断连;
- 第二条被 retry/skip;
- 另一个 writer 介入;
- 第一条提交而第二条未提交。
若两条在同一 transaction,可以保证原子性,但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority;长期则应收缩回一个 canonical representation,避免永久双写。
Expand 也需要版本 identity
本章 state row:
expand 在同一事务中:
若 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
迁移阶段包含三个并行事实:
若只做 backfill,而旧 writer 继续写 NULL,remaining count 永远追不上;若只保护新写入,却不做 shadow comparison,可能把错误映射完整填满全表。
本章的次序:
双读不是向用户返回两个结果
shadow read 的基本结构:
它要回答:
- 全量还是采样;
- 采样是否覆盖 tenant/value/time buckets;
- 如何归一化 NULL、时区、排序、rounding;
- mismatch 是否含敏感数据;
- 谁处理 mismatch;
- mismatch=0 要持续多久;
- shadow query 的额外负载预算。
本例可以在数据库内做精确比较:
IS DISTINCT FROM 让 NULL 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则,不能只比较文本。
先切写还是先切读
常见安全次序是:
“先切写再切读”让 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 的改变。它需要自己的:
不要用 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 的入口条件至少包括:
“观察一周”必须可验证
时间长度本身不够。需要观测对象:
如果没有区分旧/新路径的 telemetry,“观察一周无报警”不能证明旧依赖为零。
同时要考虑低频 consumer。一个月只跑一次的财务作业不会在七天窗口出现;依赖 inventory 和 owner 确认仍不可省略。
本地 suite 为什么拒绝 contract
contract-gate.sql 要求三个独立输入:
task.sh all 故意不提供。稳定结果:
这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate,可以演练真正 DROP;生产审批、证据和权限仍是另一条边界。
Contract 后没有免费回滚
删除旧列之后:
往往已不再可行。可选恢复:
- 前滚部署兼容修复;
- 从 new representation 重建 old 值(仅当转换可逆且规则仍在);
- 从外部权威源 reconciliation;
- 从 backup/PITR 恢复到另一个环境并提取数据;
- 全库恢复,接受明确 RPO/RTO 与其他数据影响。
所以 contract 是 destructive semantic change,即使 DROP COLUMN 物理上很快。
状态机的单调性
本章不提供 phase=validated → phase=expanded 的数据库 down path。回退流量时:
这种“应用回退、数据库不倒退”通常比反向 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 的四类风险 · 返回本章目录 · 下一节:索引与约束的在线化路径 · 查看全书目录 · 查看索引中心