数据模型与迁移:三十四张表与一次只能动一处的改动

三十四张表、六十二个迁移,其中十八次改动不可逆。本篇把迁移拆成 expand、migrate、contract 三步,一次只动一处:先加列双写,再分批回填校验,最后切读删旧,并给回滚脚本、停机成本与 14.2 小时的回滚节省算一笔账。

迁移的危险不在写错 SQL,而在一次改了太多东西。

一、一次同时改三件事的迁移

2026 年 7 月的一个周二,陆远要给订单状态加一个 refunded 值。他觉得顺手:改字段类型、回填历史数据、加非空约束,三件事写进同一个迁移文件。本地跑了两秒,生产跑到四十秒还没结束,后台开始报超时。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

他按下回滚,才发现回滚脚本只写了改类型那一半,回填和约束没有对应的反向操作。最后他从备份恢复,再手动重放两天的增量数据,前后用了 3 小时。

问题不在于他不懂 SQL,而在于他把三个独立的风险绑成了一次发布。改结构会锁表,回填会长时间占用连接,加约束会校验全表;三件事叠在一起,任何一步失败,前两步的成果都留在库里,没有干净的退出点。

这个产品 14 个月里累积了 34 张表、62 个迁移文件,平均每月 4.4 个。前 12 个月只有 18 张表,最近两个月因为统计和权限需求加得最密。表变多之后,一次改动牵动的约束和外键也变多,同一个迁移文件的影响面不再是刚起步时的样子。

一个人做这件事时没有任何人拦你。团队里会有人在评审时说「这个迁移太大了」,而你既是作者又是评审,所以这句话只能靠日历和清单替你说出来。

一次迁移只做一步,是唯一能让回滚变简单的纪律。

破坏性迁移必须先把回滚脚本写出来,再写正向迁移。

回填是一次独立的数据作业,不是迁移文件末尾的附加语句。

二、expand、migrate、contract:三步走完一次结构变化

阶段做什么允许的改动回滚方式
expand加新列、新表、新索引,新旧并存只增不删,允许为空删除新增对象即可
migrate双写、分批回填、校验新旧差异只写数据,不改结构停止双写,保留旧列
contract切读新列,删旧列与旧约束只删不减,删前确认零引用恢复旧列定义并回填

expand 阶段的关键是「旧代码不改也能跑」。新列默认可空,旧代码插入时不写它,读取时也不看它,所以任何一次部署都能单独回退。

migrate 阶段要把双写打开一段时间。这段时间里新旧两列同时写,读仍然走旧列;回填作业按主键分批推进,每批结束记录进度,失败可以从断点继续,而不是从头再来。

双写的代码要新旧都能工作,不能出现「读新列但新列还没回填完」的中间状态。作业每次启动时先比对最近一批的旧值和新值,不一致就停下来报警,而不是继续覆盖。

校验不是「跑完没报错」,而是逐批比对:行数是否相等、空值比例是否接近零、新旧值在不该变化的字段上是否完全一致。任何一个条件不满足,就回到 migrate 阶段修,不进入 contract。

三步之间至少隔一次发布。一个人做迁移最常见的错误不是写错语句,而是把三步压进一个下午,用「反正本地跑得快」说服自己。这条纪律换个说法就是:不在同一个迁移里同时改结构和改数据。

contract 阶段才是真正不可逆的一步。切读之前先确认新列的读取路径已经覆盖所有入口,删列之前先确认旧列没有任何代码引用——包括定时任务和后台脚本,它们最容易漏。

删索引和删列不一样:索引可以先改成不被查询计划选中,观察一周确认没有性能回退,再真正删除。这一步多花一周,换的是不必回滚的确定性。

读路径的切换要用开关,不要用部署。开关放在配置里,切错了一分钟能切回来;写在代码里,切错了要再发一次版,而发布本身就是风险。

三步纪律真正省下的不是写 SQL 的时间,而是「出事时你还记得当初为什么这么改」的时间。迁移文件名里写清这一步属于哪个阶段,半年后的你会感谢现在的你。

三、把迁移分成四类,分别定价

类型例子可否在线回滚成本
附加型加可空列、加表、并发建索引可以低,删除对象
回填型给新列补历史值、合并字段可以,需分批中,停止作业并保留旧值
约束型加非空、加外键、加唯一索引视现有数据而定中,先校验再删除
收窄型改类型、改名、删列高风险高,需备份或反向迁移

四类里只有收窄型要按破坏性迁移对待。全库 62 个迁移文件中,18 个属于这一类,占 29.0%;剩下 44 个都是附加型、回填型,或可以「先加不校验的约束、校验通过再启用」的约束型。

对 18 个破坏性迁移,陆远给每个补了 20 分钟的回滚脚本,合计 6 小时。近 14 个月真实回滚 5 次:补脚本之前手动恢复平均 3 小时,合计 15 小时;补脚本之后每次 10 分钟,5 次共 50 分钟。前后差 850 分钟,约 14.2 小时。

如果当初直接改列、不做双写,18 次破坏性迁移平均每次停机 15 分钟,合计 270 分钟,也就是 4.5 小时。这还只是停机时间,用户看到的报错和重试带来的不一致不在账内。

约束型迁移有个便宜的中间态:先加上不校验历史数据的约束,它只对新写入生效,再在低峰期做一次校验。加和校验拆成两次,锁的时间从全表缩短到一次轻量操作,失败时也只需要删掉约束。

真正需要提前排期的只有收窄型。它要么有一个能反向执行的 down 脚本,要么有一份当天可恢复的备份;两样都没有的收窄型迁移,应该被拆成「先加新列、再切读、最后删旧」三次发布。

四、复算:六十二个迁移的时间账

迁移频率      62 ÷ 14 = 4.4 个/月
破坏性占比    18 ÷ 62 = 29.0%
迁移本身      62 × 25 = 1,550 分钟 = 25.8 小时
回滚脚本      18 × 20 = 360 分钟 = 6 小时
手动恢复      5 × 180 = 900 分钟 = 15 小时
脚本后恢复    5 × 10 = 50 分钟 = 0.83 小时
回滚节省      900 − 50 = 850 分钟 = 14.2 小时
直接改列停机  18 × 15 = 270 分钟 = 4.5 小时
回填批量      12,400 ÷ 500 = 24.8 → 25 批
回填耗时      25 × 2 = 50 秒

25.8 小时是 14 个月里花在写迁移上的全部时间,摊到每月不到 2 小时。相比之下,一次失败迁移的手动恢复就要 3 小时,等于 7 次正常迁移的成本。

回滚脚本的 6 小时投入,在 5 次真实回滚里换回 14.2 小时,净收益 8.2 小时;更重要的是它把「出事时现写脚本」换成了「出事时执行脚本」,后者不需要在压力下做判断。

分批回填的数字说明另一件事:12,400 行按每批 500 行切分是 25 批,每批 2 秒,总耗时 50 秒。这个量级不需要停机窗口,需要的是双写 3 天和一次 10 分钟的切换。

按月看,4.4 个迁移里大约 1.3 个是破坏性的。真正需要写回滚脚本、需要双写、需要排期的,就是这每月一个多一点;把纪律只加在这一类上,日常的附加型迁移仍然可以十分钟写完。

把 62 个迁移全部按三步走是浪费。附加型迁移没有回滚的必要,它本来就是「删掉新增对象」;三步纪律的适用对象是那 18 个会删、会改、会加约束的迁移,占 29.0%,也就是不到三分之一。

五、行动清单与复算验收

  • 把下一个迁移拆成 expand 与 contract 两个文件,数据回填单独成一个作业。
  • 给 18 个已有破坏性迁移逐个补 down 脚本,单次限时 20 分钟。
  • 在迁移文件名与文件头注释里给不可逆操作标上 IRREVERSIBLE。
  • 回填作业按主键每批 500 行,记录断点并支持从中断处继续。
  • 切读之前全库搜索确认旧列零引用,包括定时任务与后台脚本。
  • 双写期至少覆盖一个完整发布周期,本产品取 3 天。
  • 每次迁移在 staging 用生产规模的数据副本跑一遍,记录耗时与锁等待。
  • 把回滚脚本与正向迁移放在同一目录,成对命名。

验收标准:任取最近 10 个迁移,破坏性迁移 100% 有可执行的 down 脚本,且每个迁移文件只包含 expand、migrate、contract 中的一种动作。

迁移这件事的收益不在快,而在「错了能退」。一个人没有 DBA 兜底,回滚脚本就是你的 DBA。

如果只能做一件事,先给下一个破坏性迁移写 down 脚本。它花 20 分钟,而一次没有脚本的回滚平均是 3 小时,比例是 9 倍。

清单里的八条不需要一次做完。前四条属于「下一次迁移之前」,后四条属于「这个季度内」;分成两批执行,比排一个完整的改造计划更容易真的开始。

使用边界与实操验收

这套纪律不解决数据正确性。 三步走只保证结构变更可回退,不保证回填逻辑算得对;回填前后要各跑一次对账查询,比对行数与关键字段的分布。

它不适用于无法双写的场景。 如果新结构需要跨表重算且旧代码无法同时写两份,双写窗口就要缩短,改为停机迁移并提前公告,这时回滚脚本的要求只会更高。

与备份专题的分工要说清。 备份保证数据不丢,迁移纪律保证结构可退;两者不能互相替代,回滚脚本失败时仍然要回到备份。

什么时候该停下来。 当一个迁移需要同时改三张以上表,或回填涉及跨模块聚合时,先把它拆成多个发布,宁可多两次部署,也不要一次做完。

运维侧的验收动作。 在 staging 恢复一份生产副本,跑一次完整的三步迁移,记录各阶段耗时与锁等待,再执行 down 脚本确认数据回到起点。

关键参考与核验范围

核验日期:2026-10-26。以下文档用于核对概念与规则,本文所有数字均取自全组教学基线,不取自这些文档。

  • ALTER TABLE :用于核对加列、改类型与删列的锁行为差异。
  • Constraints :用于核对非空、外键与唯一约束的校验时机。
  • CREATE INDEX :用于核对并发建索引与普通建索引的差别。
  • Transaction Isolation :用于核对回填作业与在线读写之间的事务隔离。

四个入口都指向数据库本身的语法与锁语义,没有指向任何迁移框架。框架会换,锁的行为不会:加列是否重写表、约束是否全表扫描、建索引是否阻塞写入,这三件事决定了迁移能不能在线做。

引用口径。 上述文档只用于确认「某类操作会锁表、某类约束会全表校验」这类机制事实;迁移数量、耗时与节省均来自本文的教学基线。

核验范围。 上述四个入口在 2026-10-26 实测可公开访问(HTTP 200);未核验各项目的最新版本、价格与配额。

如何自行复核。 取自己最近 10 个迁移,数破坏性迁移的比例,再看有几个写了 down 脚本;两项与本篇的 29.0% 对照,差额就是你的缺口。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元