数据库 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 前先套用这个模式

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

看真实 Agent 跑这个案例

我们在 Claude Code(Opus 5.5)上把拆分 first_name / last_name 的任务跑了 9 次,并用每次的结果迁移一个仿生产数据库。其中一次在仓库没有书面规则的情况下,回填完全正确,然后删掉了 users.name。

查看迁移实测

编辑说明

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