数据库 Schema 变更:从高风险工单到迁移规格包

这个案例把“加 account_status 字段”这种模糊需求,改写成包含回填证据、双读行为、回滚边界和 AI 编码范围的分阶段迁移规格。

风险锁、回填、陈旧读取
边界Expand、Backfill、Switch、Cleanup
证据行数、计划、回滚说明

写规格前的需求

弱工单

给 accounts 加 account_status。

需要 active、paused 和 closed 状态。
更新 API,确保报表还能用。

Spec-First 改写

Feature: Account status schema migration
Owner: Platform Data
Status: Ready for review

Context:
- accounts 有 1800 万行。
- status 当前由 closed_at 和 billing_pause_until 推导。
- reporting 通过 nightly export 读取 accounts。

Goal:
- 添加 account_status,且不阻塞账号写入。
- 从现有字段回填 status。
- parity check 通过后再切换读取。

Non-goals:
- 不重设计 billing state。
- 本次不改 report schema。
- 暂不删除 closed_at 或 billing_pause_until。

迁移阶段

1. Expand

添加 nullable account_status 和必要索引,不改变应用读取逻辑。

2. Backfill

分批回填 status,并记录行数、mismatch 数量和运行时间。

3. Switch

只有 staging 和生产抽样 parity 通过后,才从 account_status 读取。

4. Cleanup

等 reporting 和回滚窗口结束后,再单独移除旧推导逻辑。

评审真正需要看的规格包

spec.md

Status rules:
- closed_at not null -> closed
- billing_pause_until future -> paused
- otherwise -> active

Compatibility:
- expand 和 backfill 期间现有读取继续使用旧推导。
- API response 只有在 switch flag 开启后才包含 account_status。
- 本次 release 中报表继续使用 nightly export 字段。

tasks.md

- [ ] 添加 nullable account_status 字段。
- [ ] 如 query plan 需要,添加非阻塞索引。
- [ ] 编写可重复运行的分批回填脚本。
- [ ] 添加对比新旧 status 的 parity check。
- [ ] 在 feature flag 后切换 API 读取。
- [ ] 记录 cleanup follow-up。

acceptance-criteria.md

- Given account 有 closed_at
  When backfill 运行
  Then account_status 是 closed。

- Given switch flag 关闭
  When API 读取 account status
  Then 使用现有推导逻辑。

- Given parity mismatch > 0.1%
  When 发布评审运行
  Then 阻止 switch。

evidence.md

Required:
- migration dry-run output
- batch size 和锁风险说明
- query plan before/after
- parity check result
- API flag test
- switch 后的回滚边界

评审时应该看什么

第一轮评审看迁移能否在不阻塞生产写入的情况下运行。代码生成器能添加字段和更新 model,但如果规格没有写表规模、锁行为、索引策略和批量大小,它并不知道这个 migration 是否适合线上执行。这些上下文必须先进入规格包,再生成迁移文件。

第二轮评审看新字段是否真的等价于旧行为。旧 status 可能是隐式的、混乱的、分散在多个字段里的。规格会把隐式规则变成可测试映射,再要求 parity check 通过后才切换读取。

第三轮评审看回滚边界。switch 之前,回滚通常是关闭新 reader,暂停或重跑 backfill。switch 之后,回滚就可能需要不同方案,因为下游消费者已经看到 account_status。规格包必须把这个边界写清楚。

范围检查

拒绝 billing 重设计、report schema 变更和旧字段删除。这些应在 parity 和消费者评审后单独处理。

证据检查

要求 backfill dry-run 输出、query plan、mismatch 数量,以及证明 feature flag 控制读取的测试。

发布检查

停止信号是写延迟升高、锁等待、回填错误率,或 parity mismatch 超过规格里写明的阈值。

AI 编码护栏

只实现 account_status 迁移规格包。

允许修改:
- nullable column 的 migration 文件
- 分批回填脚本
- status mapping 测试
- parity check
- API read flag
- 回滚边界文档

不要做:
- 删除 closed_at
- 删除 billing_pause_until
- 重设计 billing status
- 修改 reporting schema
- 新增 admin status editor
- 重写和 status 读取无关的 account model

这条护栏防止一个常见 AI 编码问题:把 schema 需求理解成“顺便清理领域模型”。更安全的迁移会让旧字段继续存在,直到新字段证明 parity,且下游消费者有时间适配。

如何把这个案例迁移到你的项目

当新字段代表某个已经隐式存在的行为时,可以套用这个案例:账号状态、订阅等级、权限状态、用量分类、 onboarding 阶段或支付风险。模式是先 expand,再安全 backfill,对比新旧逻辑,通过 flag 切换读取,最后等回滚风险降低后再 cleanup。

复制规格包前,要替换行数、锁风险说明、状态映射和消费者列表。行数重要,因为 5 万行安全的迁移不一定适合 1800 万行。消费者列表也重要,因为 reporting、export、search index 和客服工具经常用不同方式读取数据。

使用 AI 时,不要在规格写清允许文件和停止信号前就要求它“写迁移”。可以让 AI 分别生成 migration 和测试。如果它在 switch flag 存在前就改旧推导逻辑,应该退回规格,而不是接受 diff。

评审时应拒绝的反模式

第一天就加 required column

非空字段和立即写入可能锁表或破坏旧路径。除非表很小且隔离,否则先 nullable expand。

没有 parity 阈值

“看起来对”不够。规格必须写明阻止 switch 的 mismatch 阈值。

同一发布里 cleanup

下游消费者稳定前就删除旧字段,会让回滚更贵,也让事故响应变慢。

上线前最后复查

数据库迁移的风险通常不在“字段能不能加上”,而在“它上线后是否还能安全暂停”。提交发布前,评审者应该确认三件事:第一,migration 是否能重复运行或安全中断;第二,backfill 失败后是否能从最后一批继续;第三,switch flag 关闭后,API、报表和后台任务是否都会回到旧读取路径。

如果团队无法回答这些问题,说明规格还没准备好交给 AI 或人工实现。此时继续写代码只会把不确定性推到发布窗口里。更好的做法是先补 row count、batch size、query plan、消费者列表和停止信号,再拆任务。

上线后还要观察什么

Schema migration 合并后仍需要继续产出证据。最稳的团队会把首个发布窗口写进规格,而不是把监控当成临时提醒。

锁与延迟

backfill 执行时观察写入延迟、lock wait 和批次错误率,这些信号比“迁移已完成”更可靠。

Parity 漂移

switch 后继续跑新旧映射对比,尤其是旧字段在发布窗口内仍可能被写入时。

下一次 schema migration 前先套用这个模式

先生成规格包,补上行数和停止信号,再把实现任务拆到足够容易评审。

编辑说明

这个案例基于数据库迁移常见失败模式:锁风险、缺少回填数量、过早切换读取,以及 cleanup 太早导致回滚困难。