迁移的危险不在写错 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% 对照,差额就是你的缺口。
系列导航
- 上一篇:单体优先:九个模块、一条依赖方向与一次边界检查
- 下一篇:依赖管理:四十一个直接依赖与四个升级窗口
- 回到目录:一个人的架构:技术选型、模块边界与演进
- 相关阅读:组织、工作区、成员:三层模型与一次数据迁移 、数据与备份:3-2-1、加密、离线副本与恢复演练 、单体优先:九个模块、一条依赖方向
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。