数据库迁移(Database Migrations)

更新于 2026-01-05

vadim kravcenko 2024-01-06

我认为数据库迁移是软件工程师职业生涯中最令人头疼的问题之一。不仅如此,一旦出错(而这种情况经常发生),人们往往会因任何涉及数据库结构变更的操作而产生焦虑。

那么,为什么它如此令人烦恼呢?乍一看似乎很简单,但当你深入思考时,就会发现它其实非常棘手。

我主要使用的框架是基于 Python 的 Django。Django 在十多年前就推出了 Schema 迁移功能,至今我仍认为它是我在各种语言、多个框架中使用过的最优秀的迁移引擎之一。

但即便拥有一个出色的框架——能够自动生成迁移文件、在数据库中记录迁移状态、并支持轻松回滚——在进行数据库迁移时,依然有大量细节需要考虑。

试想这样一个场景:你需要将用户表中的 name 字段拆分为 first_namelast_name 两个字段。一切看起来都很顺利:你执行了 Schema 迁移,运行了将旧数据拆分到两个新字段的脚本,并部署了适配这些变更的新版 API。然而,问题出现了:监控系统显示大量用户的保存操作失败,原因是某些字符未通过新字段的验证规则。这个问题被判定为严重故障,你和团队决定立即回滚。

听起来没问题吧?于是你们回滚了 Schema 变更,并将应用恢复至上一版本。但这时你突然意识到:在短暂的上线窗口期内,已有部分用户向新字段写入了数据。现在,这些用户的 name 字段变成了空值,导致数据不一致。

再叠加一个更复杂的要求:整个过程必须实现零停机(zero downtime)

🏄 上述例子是假设的,但我想表达的是:数据库迁移是一个复杂的问题,必须采用多阶段、谨慎规划的方式处理。我也完全理解那些不愿面对这类问题的人——这是一个繁琐的过程,需要持续不断地验证数据。


为什么数据迁移如此困难?没人愿意碰它?

以下是几个主要原因:

  1. 产品演进不可预测
    在开发产品时,你通常只能预见未来几个月、最多一年的发展方向。但一年后,产品经理可能会突然告诉你:“我们的金融应用不再基于‘交易’,而是全部改为‘订阅制’。”——这显然需要一次巨大的数据库迁移(或不得不打各种补丁)。

  2. 如同带电作业
    做迁移就像在不断电的情况下更换天花板上的吊灯。我昨天刚装了一盏灯,所以这个比喻立刻浮现在脑海。

  3. 每次迁移都必须考虑三种场景

    • 升级(Migrating up):新增功能,数据模型被添加/修改/删除。新旧版本的应用程序都必须能正常工作。
    • 降级(Migrating down):出现问题或数据不一致时,必须能以受控方式回退到之前的稳定状态——而不是手动修改数据库。
    • 中间状态(Everything in between):所有数据转换逻辑都必须妥善处理。如今,我们可以通过“双写(dual writes)”或 GitHub 开源的 gh-ost 工具所采用的“幽灵表(ghost tables)”等技术,在更长时间内完成数据转换(后文会详述)。
  4. 这不是一个人的战斗
    数据模型变更越大,参与的人就越多。部署时最好安排多人待命,随时准备介入——我说“当问题发生时”,是因为几乎可以肯定,至少有一个步骤不会按计划进行。


简单部署方案

图:Teamplify 提供的多实例应用与负载均衡器部署示意图

如果你规模尚小,且能接受几秒钟的停机时间,以下是最直接的部署方式:

  1. 将代码推送到 Bitbucket/GitHub/GitLab。
  2. 触发自动部署流程。
  3. 构建新的 Docker 镜像。
  4. 执行数据库迁移及相关脚本。
  5. 重启服务器上的容器。

很多人批评这种简单方式,但我认为:只要业务允许,它完全可行。部署不必总是如临大敌——除非你已达到“零停机是业务刚需”的规模。

✅ 此方案适用的情况:

  • 只有一个应用实例。
  • 能接受几秒停机。
  • 已在预发环境充分测试过迁移。

❌ 此方案不适用的情况:

  • 运行多个应用实例(可能导致迁移竞争条件,引发数据库状态异常)。
  • 数据量巨大,需长时间转换(会阻塞部署甚至超时)。
  • 不能有任何停机。

常见迁移场景

1. 添加新字段(Adding a New Field)

这是最简单的场景。从应用角度看,添加字段基本是“无操作”(no-op),不会影响现有逻辑。新字段仅在新版本应用部署后才会被使用。

操作建议:

  • 在 ORM 中添加字段后,生成不可变的迁移脚本
  • 本地测试正向和反向迁移。
  • 为新字段编写测试用例(fixtures)。
  • 若字段为非空(non-nullable),必须提供默认值。若默认值依赖其他字段(如聚合值),需在迁移脚本中实现逻辑。

⚠️ 注意:对于大型数据库(数十亿行),为新列设置默认值可能导致全表更新,耗时极长。

推荐采用两阶段部署:

  1. 先部署数据库变更(加字段)。由于不影响旧应用,可安全执行。
  2. 确认迁移成功后,再部署应用代码变更

为何大表加默认值很危险?

  • 大多数数据库会在添加带默认值的列时,立即更新所有行
  • ALTER TABLE 操作通常需要对表加排他锁(exclusive lock),期间禁止写入(PostgreSQL 新版本对“加列+默认值”已优化,但修改字段仍会锁表)。
  • 若有只读副本(replicas),它们会因主库长时间锁表而严重滞后。

图示:迁移期间的排他锁会阻塞所有后续查询,直到锁释放。


2. 删除字段(Removing a Field)

这比添加复杂,因为该字段当前正被应用使用。

正确做法是反向操作:

  1. 提前标记所有使用该字段的代码位置。
  2. 逐步废弃:先注释掉相关逻辑,确保应用不再依赖该字段。
  3. (可选)若字段数据未来可能有用,先归档到其他地方
  4. 先部署应用变更(移除对该字段的读写)。
  5. 确认稳定后,再执行数据库迁移删除字段

关键原则:绝不能让应用在字段删除后仍尝试访问它。如果部署后发现该字段仍在被更新,说明遗漏了某些调用点。


3. 修改带业务逻辑的字段(Changing Field with Business Logic)

这才是真正的挑战!例如:

  • 将一个字段拆分为多个字段或新表。
  • 将数据迁移到另一个数据库。

这类变更影响深远,常波及未知的代码模块,因此:

  • 必须团队协作
  • 必须采用双写(dual-write)策略
  • 必须分阶段部署,确保零数据不一致。

Stripe 的订阅迁移案例原文链接)提供了绝佳参考:

“重构所有涉及订阅变更的代码路径(如更新、按比例计费、续订)是迁移中最难的部分,相关逻辑横跨多个服务、数千行代码。
成功的关键在于渐进式改造:我们将每个代码路径隔离成最小单元,逐个安全替换。
两个表必须在每一步都保持一致。不能简单地用新记录替代旧记录——每段逻辑都需仔细审查。”

双写迁移的标准流程:

  1. 添加新字段(对运行中的应用无影响)。
  2. 部署新代码:开始同时写入新旧字段(在同一事务中),但读取仍走旧路径
  3. 验证数据一致性
  4. 编写迁移脚本,将旧数据批量转换至新格式(或使用 gh-ost)。
  5. 切换读路径到新字段,写仍双写。
  6. 再次验证
  7. 停止写入旧字段(此时读写均走新字段,旧字段仅存在但无写入)。
  8. 再次验证
  9. 清理旧字段相关代码
  10. 再次验证
  11. 执行数据库迁移,删除旧字段
  12. 与队友击掌庆祝!

这种分步回滚能力极大提升了稳定性,即使出错也能避免数据丢失或不一致。


移动端 + 数据库迁移

在研究此话题时,我发现 DoorDash 分享了他们将 PostgreSQL 拆分为多个小库的经验,并提出了另一种双写变体:

由于移动 App 的旧版本会长期存在,数据库迁移必须向后兼容来自多个旧版本的数据。

他们尝试了三种方案:

  1. API 层双写:API 同时调用新旧两个服务。
  2. 数据库层双写:同一 API 同时写入两个数据库。
  3. App 层双写(DoorDash 最终选择)
    • 新 App 版本使用新 API 端点
    • 根据请求来源(新/旧 App),路由到不同的数据库和逻辑。

图示:DoorDash 的第三种方案


零停机(Zero Downtime)

并非所有应用都能承受停机,尤其是面向全球用户的服务。对 Google、Facebook、LinkedIn、Netflix 而言,没有“低峰期”——太阳永不落山,任何停机都会严重影响用户体验和收入。一旦宕机,Hacker News 头条就是你。

有时我会惊讶于支付网关发邮件通知:“下周我们将进行维护,期间无法收款。”——这等于直接告诉客户:“这段时间我们不赚钱,抱歉。”

结论很明确:公司越大,服务越关键,维护窗口就越少。若无维护窗口,零停机是唯一选择

虽然零停机部署耗时更长(需多阶段发布以确保服务不中断),但这是保障服务连续性数据一致性的必要代价。

不过,大多数公司并非 Google 级别。其停机主因往往是:

  • 代码未保持向后兼容。
  • 数据库迁移耗时过长。

如果你没有庞大的 SRE 团队,可借助以下工具简化迁移:

  • MySQL

    • gh-ost(GitHub 开源,框架无关)
    • MySQL Online DDL
    • pt-online-schema-change
    • Facebook 的 OnlineSchemaChange(类似 gh-ost)
  • PostgreSQL + Django

  • 通用工具

    • SchemaHero:开源工具,将 Schema 定义自动转为迁移脚本。

图示:gh-ost 如何在主库+只读副本架构下工作


总结:最佳实践

  • 禁止手动修改数据库。始终生成不可变的迁移脚本
  • 数据库版本应存储在数据库内部(Django 自动实现)。
  • 若无维护窗口,优先采用双写策略
  • 设计重大数据库变更时,务必考虑向后兼容性和合理抽象
  • 善用现代工具减轻迁移负担