部署数据库迁移的策略

引言

修改数据库结构通常被称为“迁移”到新schema。虽然用于修改结构本身的操作通常相对简单,但确保所管理的数据保持可访问、一致且语义正确,需要谨慎和规划。

在本文,我们将介绍团队可用于更新数据库schema及相关代码库的一些策略,并讨论每种方案在解决潜在问题方面的效果。我们将探讨一些通用的应用程序部署模式,以及几种专门针对数据库场景设计的选项。

数据库相关部署策略面临的挑战

在包含数据库的环境中部署变更时,可能会出现一系列潜在问题。其中一些问题源于客户端代码库与数据库结构之间的不一致,另一些则源于对现有数据进行更新所带来的影响。

成功的迁移现有数据

修改数据库的实际结构时,通常需要对现有数据进行调整以符合新 schema。在某些情况下,这一步相对简单。例如,如果新增一张与现有数据无关的表,数据库中已有的任何数据都无需改动。

而在另一些场景下,比如拆分或合并列,就必须定义自定义的数据转换规则,明确说明应如何修改现有数据,以便填充到新的上下文中。对于存储了海量数据的结构,这一迁移过程可能会耗费大量时间。

使变更可回滚

根据你的部署策略,使变更可逆也可能颇具挑战。一旦数据结构被更新并由线上代码写入数据,回退到旧版本就可能造成数据丢失。

因此,当出现问题时,“向前修复”往往比“回滚”更具吸引力,因为即使行为被回退,更新后的代码仍能对新增数据进行兼容处理。

测试 schema 变更

数据库变更带来的另一个挑战是测试。要找到一种既能覆盖边界情况、又能验证新数据格式有效性的测试方法并不容易。真实数据往往会在意想不到的地方触碰约束边界,因此必须全面掌握所有可能的取值范围,才能编写出对应的测试用例。

此外,schema 变更还可能波及数据库的其他部分,例如存储过程、触发器及其他组件。每次修改结构后,都需要对这些部分进行测试,确保它们仍能按预期工作。

性能与可用性影响

最后,schema 变更之所以棘手,还在于它们可能对性能与可用性造成巨大冲击。这类问题在测试环境中往往难以复现,因为测试库的数据量、请求负载和访问模式通常无法一比一还原生产环境。

某些在技术层面完全合理的改动,却可能带来难以接受的性能开销,而这一点在预发布环境中很难提前察觉。如果部署流程还有波及系统可用性的风险,那就更是雪上加霜。

策略

考虑到上述问题,如何确定迁移到新 schema 的最佳方式?可根据你的需求、优先级以及应用环境,考虑多种方案。在某些情况下,组合使用多种策略,有助于抵御更广泛的潜在风险。

计划内停机:在停机窗口内升级 schema

实施 schema 迁移最古老、也最简单的策略之一,就是在整个迁移期间将数据库下线,并把这段停机时间视为可接受的成本。出于显而易见的原因,这种做法在许多场景下并不合适,因为对于绝大多数组织来说,服务的持续可用性往往是最核心的目标之一。

然而,在离线状态下执行 schema 变更与数据改写,仍然是一种可行且在某些情况下颇具价值的策略。如果条件允许,这种方式能带来诸多优势:

  • 可以在一个步骤中同时完成 schema 变更与客户端代码的更新。
  • 无需顾虑对运行中进程的性能影响,即可检查和测试 schema 及存量数据的改动。
  • 不存在“过渡期”,也就不会因条件代码路径或同一数据结构的多个版本而带来复杂性的短期激增。
  • 实施该方案所需的基础设施和系统最少。
  • 相比其他方案,更容易一次性引入大规模变更。

然而,其缺点同样不容忽视:

  • 服务中断会对 SLA、收入、声誉及其他关键指标造成巨大冲击。
  • 若在有限的“维护”窗口内出现意外问题,将直接影响恢复可用所需的时间,冲击更大。
  • 下游服务也会随之中断,导致所有依赖系统出现级联停机。
  • 计划内维护往往需“全员待命”,若这是主要部署方式,实施难度会进一步增加。

在实际操作中,停机期间部署数据库 schema 变更的流程相对简单。面向用户的应用或组件应提前充分告知计划内的维护时段,以便用户及下游服务根据自身需求做出安排。

维护窗口开始前,应制定详尽的执行计划或检查清单,明确每一步必须完成的操作。部署脚本应尽可能自动化,以降低人为失误并缩短操作时间。所有必需的资源与人员需在服务下线前全部就位。

维护期间,应用需更新响应内容,提示正在进行计划维护,并给出预计恢复时间。随后,将已验证的变更流程应用到生产环境,并对新代码与数据 schema 进行检查与测试。

部署完成后,重启应用,即可使用新代码与 schema 继续处理请求。

蓝绿部署

蓝绿部署是另一种常用于应用环境发布新代码的策略。它也可在一定程度上用于数据库 schema 变更,但存在一些明显的不足。

蓝绿部署的做法是为数据库客户端准备两套完全相同的基础设施,合计相当于生产流量所需资源的两倍。

一套基础设施承载当前的生产流量;另一套则用于部署下一个版本。当一切就绪后,负载均衡器或其他流量调度器将客户端请求从第一套切换到第二套,从而引入新变更。若出现问题,可随时切回原始基础设施。若一切顺利,原来的那套闲置设施就会成为下一次部署的预发布环境。

蓝绿部署之所以吸引人,是因为它允许你在不影响现有生产环境的前提下,把变更部署到已就绪的生产级基础设施上。通过将部署流程与变更“发布”解耦,开发者可以在真正承载流量的环境中、无需停机地测试改动。借助切换机制发布新代码和变更,也能轻松回退。

尽管蓝绿部署在许多场景下都很实用,但将其用于 schema 变更时会面临挑战。如果只改动应用代码(不涉及数据层面),回退问题代码只需把流量重新指向原始基础设施即可。然而,一旦数据 schema 发生变化,就可能出现不兼容。回退到旧基础设施可能导致数据丢失,因为新 schema 结构会被移除等。

在蓝绿部署中引入 schema 变更时,一种规避上述问题的方法是把发布流程设计为“扩展-收缩”模式(后文详述)。

功能开关

功能开关(Feature flags)是一种软件设计模式,它允许开发者在运行时根据应用外部设定的值来修改程序的控制流。当执行到某段代码路径时,应用会去查询一个众所周知的外部位置,读取当前的开关值,从而决定是否执行该路径,或在多条路径中选择其一。

功能开关本身并不是一种部署策略,而是一种技术手段,它能让你把“部署新功能”与“启用新功能”解耦,进而让其他策略更易落地。只要开关值没变,应用就会保持原有行为;一旦检测到新的开关值,就能立即切换到新功能。

在引入 schema 变更时,功能开关尤其有价值:你可以让应用同时兼容多版 schema。通过开关值指明当前部署的 schema 版本,应用即可自动选择与之匹配的代码路径。

功能开关的缺点通常较小,但仍需权衡:若团队尚未具备合适的键值存储,可能需要额外基础设施来存放开关值;此外,在开关生效期间,代码路径会因条件分支而变复杂。待开关完成使命后,及时清理并简化代码,才能把多余的逻辑减到最少。

金丝雀发布

另一种可与其他方案结合使用的策略是金丝雀发布。所谓金丝雀发布,就是先将变更部署到单个或少数几个客户端,再逐步推广到其余基础设施。

这样做的好处是,可以提前发现前期测试未能暴露的问题。运行新代码的这部分客户端就像“探路先锋”,其表现能预示代码在其余系统上的运行效果;随着对稳定性与功能的信心增强,可逐步扩大部署范围。

在数据库 schema 变更场景下,金丝雀发布通过降低变更风险,让你验证 schema 调整及其配套客户端代码是否适合生产环境。与其一次性影响所有客户端,不如先用小比例流量评估变更。若出现意外后果,可及早回滚;同时还能借助真实生产流量的一部分观察性能表现。金丝雀发布有助于最小化代码变更带来的影响,从而让 schema 迁移更加顺利。

扩展和收缩模式

引入 schema 变更的最佳方法或许是“扩展-收缩”模式。该模式允许你在保留原 schema 的同时引入新结构,将旧数据逐步迁移到新结构,并通过一系列计划阶段把生产流量平稳切换到新结构。它可与前面提到的多种策略和技术结合使用,为变更提供多重安全保障,一旦出现问题可及时应对。

扩展和收缩模式可按以下步骤实施:

  • 设计并部署目标 schema,与原有 schema 并存。
  • 修改客户端代码,使其同时向两套 schema 写入变更。
  • 将已有数据从原 schema 迁移到新 schema,必要时按新结构进行转换。
  • 对新 schema 进行测试,确保功能正确且数据迁移无误。
  • 调整客户端代码,开始从新 schema 读取数据。
  • 再次修改客户端代码,停止向原 schema 写入。
  • 删除原 schema。

按照上述阶段逐步推进,你的 schema 变更会被分散到更长的时间段内。然而,这恰恰让你能够在生产环境中逐步调整应用代码,以应对 schema 的变化。

该策略的一大优势在于,把“读新 schema”与“写新 schema”解耦。这意味着,在新 schema 真正影响到面向客户端的响应之前,应用就已经在使用它;同时,客户端代码会主动把新数据写入新 schema,而旧数据则可在后台慢慢迁移。

如需更深入地了解这一方法,可参考《使用扩展-收缩模式进行 schema 变更》的一文。

使用扩展-收缩模式与功能开关的示例

通常,部署 schema 变更的最佳方式是综合运用多种技术。为了演示一个与 schema 变更相关的例子,假设你正在尝试引入一项变更:将 names 表中的 first_namelast_name 两列合并为单个 full_name 列。

按照扩展-收缩模式的第一阶段,你已将新数据库 schema 与现有 schema 并行部署。现在,你有两张结构大体相同的表:names——原始结构,包含所有当前数据;以及 new_names——新的空表,代表期望的结构。

接下来,你希望修改应用程序代码,使其同时向新旧两种结构写入数据。为此,你在应用中新增一段逻辑:在修改 names 表时,先查询 Redis 实例中 DATABASE_NAMES_TABLE_WRITE 的值。该值是一个列表,指明需要写入哪些表。

你还知道,最终需要把读取操作从旧 schema 切换到新 schema。为此,你又加入一个开关:在读取时先检查 Redis 中 DATABASE_NAMES_TABLE_READ 的值,以决定从哪个结构读取数据。

你在 Redis 实例中设置值,以使用原始数据模式:

rpush DATABASE_NAMES_TABLE_WRITE names
set DATABASE_NAMES_TABLE_READ names

接下来,你将包含 Redis 检查的新代码部署到客户端,该检查作为决定读写位置的开关。

当你希望客户端同时向两套数据结构写入(即“扩展-收缩”模式第 2 步)时,只需更新 Redis 中的 DATABASE_NAMES_TABLE_WRITE 列表,把新表名追加进去即可:

rpush DATABASE_NAMES_TABLE_WRITE new_names

DATABASE_NAMES_TABLE_WRITE 列表现在包含了两个表名:namesnew_names

lrange DATABASE_NAMES_TABLE_WRITE 0 -1
1) names
2) new_names

如果你的功能开关代码依据这些值来决定写入位置,那么它现在会同时写入两张表。

既然应用已同时向两处写入,你便可开始在后台迁移数据。由于这次改动相当直接,你只需读取 names 表中的 first_namelast_name 值,用空格拼接后,把结果字符串写入 new_names 表的 full_name 列,即可填充新 schema。

此时,新表已包含全部当前数据。你可以在此阶段执行进一步测试,确保其运行正确,并可替代原表。

当你准备把读取操作从旧结构切换到新结构时,只需用新表名覆盖 DATABASE_NAMES_TABLE_READ 即可:

set DATABASE_NAMES_TABLE_READ new_names

下一次客户端在执行读取操作前检查该值时,就会收到新值并从新结构读取数据。

确认一切运行正常后,你就可以更新 DATABASE_NAMES_TABLE_WRITE 列表,将原表名移除:

lrem DATABASE_NAMES_TABLE_WRITE 0 "names"
(integer) 1

客户端的下一次写操作将触发一次查询,并收到仅包含 new_names 的新值。

此时,你可以安全地删除原始的 names 表,并移除要求客户端检查读写位置的功能开关脚手架。在此过程中,你可能还会想把 new_names 表重新命名为 names,以完成整个 schema 变更。

总结

虽然你可以使用多种部署与迁移策略来实施 schema 变更,但最简单的方法往往伴随着一些明显的缺陷。通过深入了解不同迁移策略带来的影响,并结合自身组织的需求与技术能力,你就能设计出一套既能最大限度减少停机时间、又能安全验证变更的迁移流程。