在软件工程中,很少有任务能像更改生产数据库架构那样引发如此多的焦虑。应用程序部署可以在几秒内回滚。基础设施变更可以通过配置更新来撤销。但是,删除列、更改数据类型或重构表的数据库迁移可能导致真正不可逆的数据丢失。这种焦虑是健康的,它反映了数据库变更具有独特风险的现实。但是,没有系统方法的焦虑会导致瘫痪或鲁莽行事,这两种情况都无法很好地为用户服务。
在我的职业生涯中,我参与了从简单的列添加到核心表的多个月重构(这些表处理数百万行数据)等各种规模的数据库迁移。以下模式是从这些经验中提炼出来的,既包括成功也包括失败。如果你的应用程序需要在不使系统离线的情况下演进其数据模型,本指南将帮助你安全地完成这项工作。
为什么数据库迁移特别危险
在讨论解决方案之前,值得精确理解为什么数据库迁移比应用程序部署更困难。多种因素共同使得架构变更变得危险。
首先,数据库变更通常不是立即可逆的。如果你删除了一列,数据就消失了。如果你更改了列类型并丢失了精度,原始值就无法恢复。即使在大型数据集上,如果迁移长时间锁定表,添加列也可能成为问题。与应用程序代码不同(你可以部署先前的版本),回滚破坏性的架构变更需要从备份恢复,这意味着停机以及迁移发生后发生的任何写入操作的数据丢失风险。
其次,架构变更与正在运行的应用程序代码相互作用。在部署过程中,存在一个时间窗口,旧的应用程序代码可能针对新架构运行,或者新的应用程序代码可能针对旧架构运行。如果迁移移除了旧代码依赖的列,每个由旧应用程序实例处理的请求都将失败。这个部署窗口,即使只持续几秒钟,也是一个脆弱期。
第三,大型表的迁移可能需要大量时间并消耗大量数据库资源。为包含一亿行的表添加索引不是即时的。根据你的数据库引擎和操作的不同,迁移可能会锁定表,阻止读写操作长达数小时甚至更长时间。对于需要高可用性的应用程序来说,这是不可接受的。
扩展-收缩模式:你的主要工具
扩展-收缩模式,有时也称为并行变更,是零停机数据库迁移最重要的技术。核心思想很简单:不要一次性进行破坏性变更,而是将其分解为多个非破坏性步骤,并在每个步骤之间部署应用程序。
阶段1:扩展
在新结构旁边添加旧结构。如果要重命名列,则在保留旧列的同时添加新列。如果要更改数据类型,则添加具有所需类型的新列。如果要拆分表,则在保留旧表的同时创建新表。关键约束是这个阶段必须是纯添加性的。不能以会破坏现有代码的方式删除或更改任何内容。
阶段2:迁移应用程序代码
部署应用程序代码,使其同时写入新旧结构,并从新结构读取(如果新结构尚未填充,则回退到旧结构)。这种双写方法确保新数据同时写入两个位置,而旧数据仍然可以访问。
阶段3:回填
运行数据迁移,将现有数据从旧结构复制或转换到新结构。这应该分批进行,以避免压垮数据库。每批应该足够小,能够快速完成,并且应该是幂等的,以便在被打断时可以安全地重新运行。
阶段4:验证
验证所有数据是否已成功迁移。比较计数,抽查值,并运行任何确认数据完整性的领域特定验证。这一步不是可选的。我曾见过一些看似成功的迁移,但几周后留下了孤立或不一致的数据,导致了微妙的错误。
阶段5:收缩
一旦新结构完全填充,并且应用程序仅从新结构读取,就删除旧结构。部署不再引用旧列或表的应用程序代码,然后运行迁移来删除它。此时,迁移完成。
扩展-收缩模式将单个危险操作转变为多个安全操作。每个阶段可以独立验证。每个阶段可以独立回滚。在任何时候,数据库或应用程序都不会进入因故障导致数据丢失或停机的状态。
破坏性的架构变更及如何避免
并非所有的架构变更都具有同等风险。了解哪些操作是安全的,哪些是危险的,可以让你相应地进行规划。
安全操作(通常)
在所有主要数据库中添加可空列通常是安全的。该列的添加不会影响现有行(在 PostgreSQL 中,只要没有需要重写的默认值,无论表大小如何,这个过程几乎是即时的)。使用 PostgreSQL 中的 CONCURRENTLY 选项或其他数据库中的等效选项来并发添加索引,允许在不锁定表写入的情况下创建索引。创建新表本质上是安全的,因为没有现有代码引用它。
危险操作
删除列会破坏任何引用它的应用程序代码。重命名列的效果与删除旧名称并添加新名称相同。如果现有数据无法转换为新类型,更改列类型可能会失败,即使成功,也可能需要锁定表并进行完整的表重写。如果某列中的任何行包含 NULL 值,则为现有列添加 NOT NULL 约束会失败。在旧版本的 PostgreSQL 中添加具有非空默认值的列需要重写表中的每一行。
兼容性窗口
任何模式变更的基本问题是:旧的应用程序代码和新的应用程序代码能否同时使用此模式?如果可以,则该变更是部署兼容的,可以独立于应用程序部署应用。如果不能,则需要使用扩展-收缩模式来创建中间状态,使新旧代码都能正常工作。
我保持一个简单的规则:每次迁移必须与当前部署的应用程序代码向前兼容。这意味着我从不部署会破坏运行中应用程序的迁移。相反,我部署迁移,验证其成功,然后部署使用新模式的应用程序代码。这种顺序确保应用程序永远不会期望一个不存在的模式。
常见场景的实用模式
重命名列
不要使用 ALTER TABLE RENAME COLUMN。相反,应用扩展-收缩模式。添加新列。部署写入两列的应用程序代码。从旧列回填新列。部署仅从新列读取的应用程序代码。删除旧列。这需要多次部署而不是一次,但每个步骤都是单独安全和可逆的。
更改列类型
添加具有所需类型的新列。部署写入两列的应用程序代码,在写入新列时转换类型。通过批量转换现有数据进行回填。验证所有行的类型转换是否正确。将读取切换到新列。删除旧列。如果类型转换有损耗(例如,降低精度),在删除旧列之前进行广泛验证。
拆分表
创建具有所需结构的新表。部署同时写入新旧两个表的应用程序代码。分批从旧表回填数据到新表。验证行计数和数据完整性。将读取切换到新表。部署不再引用旧表中相关列的代码。从旧表中删除已迁移的列。
添加 NOT NULL 约束
首先,部署应用程序代码,确保所有新写入都包含该列的值。然后回填任何现有的 NULL 值。验证不再有 NULL 值存在。最后添加 NOT NULL 约束。在 PostgreSQL 中,你可以先添加一个 NOT VALID 的 CHECK 约束(这不会扫描表),然后单独 VALIDATE 它(这会扫描但不锁定),最后添加实际的 NOT NULL 约束,如果已经存在有效的 CHECK 约束,PostgreSQL 可以立即完成此操作。
安全迁移的工具
已经开发了几种专门工具,使数据库迁移更安全、更易于管理。
对于 PostgreSQL,pgroll 自动实现扩展-收缩模式。你描述所需的架构变更,pgroll 会创建中间状态,管理双写视图,并处理清理工作。这是一个相对较新的工具,但直接解决了核心挑战。
GitHub 的 gh-ost 通过创建影子表、复制数据并通过二进制日志捕获变更来处理 MySQL 的在线架构变更。它避免了 ALTER TABLE 对大型 MySQL 表导致的表锁定,并允许你随时暂停、限制或中止迁移。
Strong Migrations 是一个针对 Rails 迁移的 linter(其他框架也有类似工具),它能检测危险操作并建议安全的替代方案。它能捕获常见错误,比如在确保应用程序代码不再引用某列之前就删除该列,或者在没有 CONCURRENTLY 选项的情况下添加索引。
框架级别的迁移工具如 Alembic(Python)、Flyway(Java)和 Prisma Migrate(TypeScript)提供迁移脚本的版本跟踪和顺序执行。这些对于管理迁移的顺序和状态至关重要,但并不强制执行安全实践。你仍然需要自律地编写迁移脚本本身。
真正有效的回滚策略
每个迁移计划都应包含回滚策略。但回滚的性质取决于迁移的类型。
添加型迁移
对于纯增量更改(添加列、添加表、添加索引),回滚很简单:删除添加的内容。由于现有功能不依赖于新结构,删除它们会使系统恢复到之前的状态。应用程序继续正常运行,因为它从未依赖过新结构(这些结构是添加为尚未发生的未来应用程序部署做准备)。
破坏性迁移
对于破坏性更改(删除列、更改类型、删除表),回滚需要恢复数据。这就是为什么扩展-收缩模式会将旧结构保留在原位,直到迁移完全验证通过。如果在收缩阶段出现问题,你仍然拥有旧数据。回滚操作是将应用程序代码恢复为从旧结构读取数据,并放弃新结构。
数据回填回滚
如果回填产生不正确的数据,你需要能够重新运行它。这就是为什么回填应该是幂等的,并且理想情况下应该跟踪其进度,以便能够从中断的地方继续,而不是从头开始。对于大型表,从头重新运行回填可能需要数小时。进度跟踪和批量检查点使这一过程变得可管理。
无法回头点
在每个多步骤迁移中,都有一个无法回头点:即当你删除旧结构且无法轻松恢复的那一刻。在到达这个点之前,运行全面验证。验证数据完整性。让新结构为生产流量服务一个预定义的稳定期(我通常至少使用一个完整的业务周期,通常是一周)。只有在这个稳定期之后,才应该执行最终清理,删除旧结构。
蓝绿数据库模式
对于真正关键的迁移,蓝绿数据库方法提供了最大安全性,但需要额外的基础设施。这个概念与蓝绿应用程序部署类似:维护两个完整的数据库环境。将迁移应用到绿色(非活动)数据库。彻底验证它。然后将应用程序流量切换到绿色数据库。如果出现问题,切换回蓝色数据库,它仍然具有原始模式和数据。
蓝绿数据库的挑战是在切换过程中保持它们同步。你需要一种机制将活动数据库的写入复制到非活动数据库,并且切换必须与应用程序部署协调进行。PostgreSQL中的逻辑复制,或像Debezium这样的变更数据捕获工具可以促进这一点,但操作复杂性是显著的。
我仅建议对从根本上重构数据模型且无法安全分解为增量步骤的迁移使用蓝绿数据库模式。对于大多数模式更改,扩展-收缩模式提供了足够的安全性,且操作开销要小得多。
构建迁移文化
工具和模式是必要的,但还不够。安全的数据库迁移还需要文化实践,以防止错误到达生产环境。
迁移脚本的代码审查至少应与应用逻辑的代码审查一样严格。每个迁移都应由既了解数据库影响又了解受影响的应用代码的人员进行审查。审查者应验证该迁移是否与当前部署的代码向前兼容,并且回滚路径是否存在。
使用生产规模的数据测试迁移。在包含100行的开发数据库中运行200毫秒的迁移,在包含5000万行的生产数据库中可能需要45分钟。如果您的暂存环境没有生产规模的数据,请创建一个至少包含迁移相关表的生产规模数据的测试环境。
为重大变更记录迁移计划。一份书面计划,描述每个步骤、预期持续时间、验证标准和回滚程序,会迫使您在执行前完全思考整个过程。对于复杂的多步骤迁移,此文档在执行过程中充当操作手册。
结论:慢即是稳,稳即是快
无需停机的数据库迁移比强制系统离线并运行ALTER TABLE命令的方法需要更多的步骤、更多的部署和更多的耐心。扩展-收缩模式、安全的模式变更实践和彻底的验证为每个单独的迁移增加了开销。
但这种开销会得到数倍的回报。我合作过的采用这些实践的团队没有数据库相关的事件。他们没有清晨的紧急维护窗口。他们没有需要备份恢复的数据丢失事件。他们能够自信、频繁地部署模式变更,而不会中断用户。学习和应用这些模式的初始投资是巨大的。长期来看,风险、压力和停机时间的减少是巨大的。在数据库迁移中,正如在许多事情上一样,选择更慢、更有纪律的道路最终是到达目的地的最快方式。
