跳转至

迁移按 ID 幂等的盲区:测试全绿,上线全炸

一次很普通的"给表加两列",落地动作是把 ALTER 写进了一个已经发布过的旧迁移块里——语义等价,测试环境全新库一切正常。云端存量库升级后,所有读这张表的接口整批报错,说列不存在。这篇文章把这次事故的机制讲透:迁移框架的幂等按 ID 记账,它校验"这个 ID 跑过没有",不校验"这段 SQL 变没变"。

现象

全新测试库上,该加的列加上了、读写全通;只有经历过旧版本的真实库,升级后读该表即报缺列。测试永远绿,炸的只有线上。

发现过程(据修复提交 5af3a4f 与补回归提交 b22f05d 的记载):超时费功能发布当天,云端存量库升级,所有读在住单的接口整批报错——GET /checkins/{id} 在扫描新增的两条规则列时报 no such column。同一份代码在全新测试库上完全正常。升级前后唯一的差别,是那张库经历过旧版本。

我一开始的错误判断

怀疑链是三级跳:先怀疑升级包没传全——核对无误;再怀疑迁移没执行——查 schema_migrations 记账表,相关 ID 都在,它执行过;最后才意识到:执行过的是旧内容。ALTER 加进旧迁移块之后,框架看到 ID 已登记直接跳过——新 SQL 从未在任何存量库上运行过。

排查与关键证据

机制根源在迁移框架本身:迁移按 ID 记账,已应用的直接跳过(internal/mod/core/storage/storage.go)。这个设计单独看是对的——幂等、可重放;盲区在于它只认 ID 不认内容,于是"修改已发布迁移的 SQL"成为一个零反馈的危险动作:在全新库上零副作用(库里没有那条记录,全量照跑),只在存量库上生效(跳过)。

修复本身不难:把两条超时规则列移进一个新迁移 ID(frontdesk/0011_overtime_rule),问题立即消失。真正值钱的是随后补的回归测试(legacy_migrate_test.go 的 TestLegacyDBUpgradeOvertimeColumns,本仓库 go test 实测通过):它先应用"不含新迁移的旧迁移集"构造出升级前的存量库形态,插入一条老口径的在住行,再跑全量迁移;断言两条新迁移被补登记、当年报 500 的 getRaw 扫描不再报缺列;并且测试头注释写明——若把 ALTER 塞回旧迁移块、删掉新迁移条目,这个用例会在"新迁移未登记"断言处失败。把"只增不改"的纪律钉进了测试:违规写法不靠 review 提醒,靠 CI 直接红。

同根的旁证在仓库里不止一处:存量钟点单的类型修复(迁移前入住的钟点单缺标记、被当过夜房按天重复计价)走的是同一条"新迁移补旧账"的路;放开一房多卡时漏删旧唯一索引,也是用新迁移补刀(详见《把业务不变量钉进数据库》)。

根因

一句话:幂等按 ID 记账,不看 SQL 内容 → 改已发布迁移在全新库零副作用、只在存量库生效 → 测试环境对这类变更结构性失明。

这不是粗心,是部署顺序与状态机的耦合:全新库和存量库是两个不同的初始状态,而测试只覆盖了其中一个。任何只测全新库的迁移测试,都在系统性地放过这一类错误。

约束与取舍

  • 放弃了"热修已发布迁移"的便利。以前发现旧迁移写错,顺手改掉最省事;现在必须新开 ID 补刀,多一次发版;
  • 代价一:迁移条目只增不改,迁移文件持续增长,历史包袱显性化;
  • 代价二:修复动作变形——哪怕是"删掉一个写错的索引",也要走一条新迁移,而不是改旧的;
  • 代价三:多维护一套存量库回放测试,以及一条要写进 review 清单的纪律:"本次改动有没有 SQL 落进了已发布的迁移块"。

可复用检查清单

  1. 改表结构 = 新增迁移 ID,永不修改已发布迁移的 SQL,哪怕语义等价;
  2. 测试环境永远建全新库,所以必须另造"升级前 schema 快照"的回放用例,否则存量路径零覆盖;
  3. 发版自问:本次 diff 里有没有任何 SQL 落进了已发布过的迁移块——把迁移目录纳入发版检查项;
  4. 迁移与读该结构的代码同批发布,但迁移本身必须可独立重复执行;
  5. 排障第一入口是迁移记账表里"应用过哪些 ID",先对账再猜代码。

小结

"测试全绿"证明的只是全新库路径。存量系统里,绿色是最常见的安全错觉——真正的保障,是把状态机的盲区变成一条测试会红的规则。