← Back to Blog

数据库迁移不是一条 ALTER:讲透安全变更的三个阶段

95 阅读 点赞

做过后端的人大概都经历过这样的时刻:深夜上线,一条 ALTER TABLE 发出去,然后盯着监控屏幕,心跳跟着数据库的连接数一起上下起伏。表越大,这条命令就越像一次走钢丝——顺利的话几秒钟,不顺利的话就是锁表、超时、业务雪崩。

数据库迁移之所以让人紧张,不是因为它难,而是因为它几乎不可逆。代码写错了可以回滚,配置改错了可以恢复,但一条执行到一半的 ALTER TABLE,往往既没成功也没失败,留下一个半新半旧的结构,让你连”重来一次”都不敢。

这篇文章想聊的是:把数据库迁移当成一件需要设计、需要分步、需要留退路的工程问题,而不是一条命令的事。

数据库结构迁移的抽象示意图
一次数据库迁移,本质上是让数据结构在两个状态之间安全地穿行

一、为什么直接 ALTER 是危险的

很多人对数据库迁移的第一印象是”改结构”,但真正致命的从来不是改结构本身,而是**锁**。

以 MySQL 为例,早期版本执行 ALTER TABLE 时会拿一个表级的元数据锁(MDL),并且需要重建整张表。一张 5000 万行的表,重建意味着要把数据全部拷一遍,期间原表被写锁住,所有写入请求排队等待。连接池在几秒钟内被塞满,接着是接口超时、上游重试、流量放大——一场事故就这么从一条看似无害的 DDL 开始了。

后来有了 Online DDL、有了 ALGORITHM=INPLACE,情况好了一些,但并没有根本解决。加索引可以是 inplace 的,加列可以是 instant 的,但改列类型、改字符集、加大字段这类操作,依然可能触发全表重建。而且 Online DDL 也不是免费的午餐——它需要额外的磁盘空间来存放临时日志,需要更高的 IO 开销,在业务高峰期执行依然可能拖垮实例。

更隐蔽的问题是:**迁移脚本和代码发布是两个不同步的时钟**。你在 t0 时刻发布了新代码,它开始写入一个还不存在的列;或者你在 t0 时刻加了新列,但老代码还在用旧结构查询。只要这两个动作之间存在时间窗口,就一定存在一个短暂的”谁都不完全适配”的状态。如果这个窗口恰好落在高并发时刻,报错就会像雪片一样飞回来。

所以第一层认知是:**迁移的风险不在 SQL 本身,而在它与锁、与代码版本、与线上流量之间的耦合关系**。想清楚这一点,后面的所有设计才有了出发点。

迁移三阶段的分层数据可视化
Expand-Contract:先做加法扩展,再双写回填,最后收缩删除,每一步都可回退

二、把一次迁移拆成可回头的几步

既然风险来自耦合,那解法自然就是**解耦**:把一个原子的结构变更,拆成一串每一步都安全、每一步都可中断、每一步都可以停下来的小变更。

业界把这种做法叫 Expand-Contract(扩展-收缩)模式,它大概是数据库迁移里最值得记住的一个方法论。核心思想是:**任何一次变更,都先做加法,再做迁移,最后才做减法**。

具体到一个”把 user_name 拆成 first_name 和 last_name“的例子,它会变成三个阶段。

第一阶段:Expand(扩展)。 先加新列,并且允许为空,不设任何约束。这一步是纯粹的加法,对老代码完全无感——它照旧读写老列,新列躺在那儿没人理。这一步可以随时执行,随时回滚。

第二阶段:Migrate(双写与回填)。 发布新代码,让它同时写老列和新列(读仍然读老列)。然后起一个后台任务,把历史数据分批回填到新列——每次几千行、每批 sleep 一小会儿,避免把 IO 打满。等双写稳定、回填完成、两边数据一致之后,就可以放心地让读也切到新列。整个过程中,任何一刻都可以退回去:因为老列还在,数据还是完整的。

第三阶段:Contract(收缩)。 等到确定再没有代码依赖老列(通常要观察一个完整的发布周期),才把双写去掉,最后删掉老列。到这一步,变更才真正结束。

这套流程看起来啰嗦,可能要跨好几个发布周期才完成一次”简单”的字段拆分。但它换来了什么?换来了**任何一步出错都只需要回滚一步,而不是回滚整个变更**。数据库迁移最贵的成本从来不是执行时间,而是出事之后无法回头。

配合这个思路,还有几个实践细节值得固化下来:迁移脚本必须**幂等**——重复执行不产生副作用,因为线上重试是常态;每个脚本都要写清楚它的**回滚方案**,哪怕只是”重新加回那一列”;DDL 和大数据量的 DML 尽量不要放在同一个脚本里,前者需要的是快速的元数据变更,后者需要的是缓慢的、可限速的批量处理。

三、让迁移这件事变得可验证、可追溯

拆分解决了执行过程的安全问题,但还有一个问题没解决:**你怎么知道这次迁移真的成功了?**

很多团队的迁移是”发起者自己心里有数”的状态:谁改的、什么时候改的、改完有没有验证,全靠口口相传和 Slack 记录。这在人少的时候没问题,一旦团队变大、发布变频繁,就会变成一团浆糊。你会遇到”这个列是干嘛的”、”为什么这里有两个相似的字段”这类没人答得上来的问题。

解法是把迁移纳入正式的工程管理,让它像代码一样有版本、有记录、有校验。

第一件事是**工具化**。无论是 Flyway、Liquibase 还是 Alembic,核心价值都不是”帮你执行 SQL”,而是**维护一张迁移状态表**——记录哪些脚本已经应用、在什么时间、校验和是多少。有了这张表,环境之间就不会漂移:测试环境跑过的脚本,生产环境一个都不会漏;生产环境多出来的东西,也能被立刻发现。手工执行的迁移永远无法做到这一点,因为人脑记不住版本号。

第二件事是**前置校验和灰度验证**。正式在生产执行之前,先在预发环境用**和生产同等规模的数据**跑一遍——注意是同等规模,不是同等结构。一个在小表上 50 毫秒完成的 DDL,在大表上可能是 50 分钟。执行时最好记录耗时、锁等待时间、影响行数,把它们变成可以回看的数据。执行之后,用一组数据一致性校验语句(新老列比对、行数核对、抽样比对)来确认结果,而不是凭”命令没报错”就认为成功。

第三件事是**让变更可观测、可回滚**。在迁移执行的窗口期,把数据库的连接数、慢查询、锁等待、主从延迟这几个指标盯起来,最好配上告警。同时准备好一键回滚脚本——如果 Expand-Contract 做得规范,回滚往往只是”把读切回老列”这么简单,但前提是你提前准备好了这个开关。

深夜机房中的工程师工作台
好的迁移设计,是为了让深夜上线的人能睡个安稳觉

写在最后

数据库迁移这件事,本质上是在回答一个更普遍的问题:**当你必须去动一个正在运行中的系统时,你该如何控制风险?**

答案通常不是”更小心一点”或者”挑个凌晨”,而是设计。把不可逆的操作拆成可逆的步骤,把隐式的依赖变成显式的契约,把”我觉得应该没问题”变成”我能验证它没问题”。这样做的代价是流程更长、脚本更多、跨的发布周期更久,但换来的是——你终于可以在发完那条命令之后,安稳地睡一觉。

下次再面对一条 ALTER TABLE 的时候,不妨先问自己三个问题:这一步如果失败,我能不能只回滚它?这期间新旧代码能不能同时活着?我怎么知道它真的成了?能把这三个问题答清楚,迁移就不再是走钢丝了。

💬 发表评论