数据库迁移:将毛毛虫变成蝴蝶

已发表: 2022-03-11

用户不关心他们使用的软件里面有什么。 只是它工作顺利,安全,不引人注目。 开发人员努力实现这一目标,他们试图解决的问题之一是如何确保数据存储处于适合当前产品版本的状态。 软件不断发展,其数据模型也可能随着时间而改变,例如,为了修复设计错误。 使问题进一步复杂化的是,您可能有许多测试环境或客户以不同的速度迁移到产品的较新版本。 您不能只从单一角度记录商店的结构以及使用闪亮的新版本需要哪些操作。

数据库迁移:将毛毛虫变成蝴蝶

我曾经加入一个项目,其中包含一些数据库,这些数据库的结构直接由开发人员按需更新。 这意味着没有明显的方法可以找出需要应用哪些更改才能将结构迁移到最新版本,并且根本没有版本控制的概念! 这是在 DevOps 之前的时代,现在被认为是一团糟。 我们决定开发一种工具,用于将每个更改应用到给定的数据库。 它进行了迁移,并将记录架构更改。 这使我们确信不会发生意外更改,并且模式状态是可预测的。

在本文中,我们将了解如何应用关系数据库模式迁移以及如何克服随之而来的问题。

首先,什么是数据库迁移? 在本文的上下文中,迁移是应应用于数据库的一组更改。 创建或删除表、列或索引是迁移的常见示例。 随着时间的推移,架构的形状可能会发生巨大变化,尤其是在需求仍然模糊的情况下开始开发时。 因此,在发布过程中的几个里程碑过程中,您的数据模型将不断发展,并且可能与最初的情况完全不同。 迁移只是到达目标状态的步骤。

首先,让我们探索一下我们工具箱中的内容,以避免重新发明已经做得好的东西。

工具

在每一种广泛使用的语言中,都有一些库可以帮助简化数据库迁移。 例如,对于 Java,流行的选项是 Liquibase 和 Flyway。 我们将在示例中更多地使用 Liquibase,但这些概念适用于其他解决方案并且与 Liquibase 无关。

如果某些 ORM 已经提供了自动升级模式并使其与映射类的结构匹配的选项,为什么还要使用单独的模式迁移库呢? 在实践中,这种自动迁移只做简单的模式改变,例如,创建表和列,而不能做潜在的破坏性事情,比如删除或重命名数据库对象。 因此,非自动(但仍然是自动化)解决方案通常是更好的选择,因为您被迫自己描述迁移逻辑,并且您知道数据库究竟会发生什么。

混合自动和手动模式修改也是一个非常糟糕的主意,因为如果手动更改以错误的顺序应用或根本不应用,即使需要手动更改,您可能会产生独特且不可预测的模式。 选择工具后,使用它来应用所有模式迁移。

典型的数据库迁移

典型的迁移包括创建序列、表、列、主键和外键、索引和其他数据库对象。 对于最常见的更改类型,Liquibase 提供了不同的声明性元素来描述应该做什么。 阅读 Liquibase 或其他类似工具支持的每一个微不足道的更改都太无聊了。 要了解变更集的外观,请考虑以下示例,其中我们创建了一个表(为简洁起见,省略了 XML 命名空间声明):

 <?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog>

如您所见,变更日志是一组变更集,变更集由变更组成。 可以组合像createTable这样的简单更改来实现更复杂的迁移; 例如,假设您需要更新所有产品的产品代码。 通过以下更改可以轻松实现:

 <sql>UPDATE product SET code = 'new_' || code</sql>

如果您拥有数以万计的产品,性能将会受到影响。 为了加快迁移速度,我们可以将其重写为以下步骤:

  1. 就像我们之前看到的那样,使用createTable为产品创建一个新表。 在这个阶段,最好创建尽可能少的约束。 让我们将新表命名为PRODUCT_TMP
  2. 使用sql change 以INSERT INTO ... SELECT ...的形式使用 SQL 填充PRODUCT_TMP
  3. 创建您需要的所有约束( addNotNullConstraintaddUniqueConstraintaddForeignKeyConstraint )和索引( createIndex )。
  4. PRODUCT表重命名为PRODUCT_BAK之类的名称。 Liquibase 可以使用renameTable来做到这一点。
  5. PRODUCT_TMP重命名为PRODUCT (再次使用renameTable )。
  6. 或者,使用dropTable删除PRODUCT_BAK

当然,最好避免此类迁移,但最好知道如何实现它们,以防遇到需要它的罕见情况之一。

如果您认为 XML、JSON 或 YAML 对于描述更改的任务来说太奇怪了,那么只需使用纯 SQL 并利用所有数据库供应商特定的功能。 此外,您可以在纯 Java 中实现任何自定义逻辑。

Liquibase 免除您编写实际特定于数据库的 SQL 的方式可能会导致过度自信,但您不应忘记目标数据库的怪癖; 例如,当您创建外键时,可能会或可能不会创建索引,具体取决于所使用的特定数据库管理系统。 结果,您可能会发现自己处于尴尬的境地。 Liquibase 允许您指定仅应为特定类型的数据库运行变更集,例如 PostgreSQL、Oracle 或 MySQL。 它使这成为可能,对不同的数据库使用相同的与供应商无关的变更集,对于其他变更集,使用供应商特定的语法和功能。 仅当使用 Oracle 数据库时才会执行以下变更集:

 <changeSet dbms="oracle" author="..."> ... </changeSet>

除了 Oracle,Liquibase 还支持其他一些开箱即用的数据库。

命名数据库对象

您创建的每个数据库对象都需要命名。 您不需要为某些类型的对象明确提供名称,例如,约束和索引。 但这并不意味着这些对象没有名字。 无论如何,它们的名称将由数据库生成。 当您需要引用该对象以删除或更改它时,就会出现问题。 所以最好给他们明确的名字。 但是对于起什么名字有什么规定吗? 答案很简单:保持一致; 例如,如果您决定将索引命名为: IDX_<table>_<columns> ,则上述CODE列的索引应命名为IDX_PRODUCT_CODE

命名约定是非常有争议的,所以我们不会假设在这里给出全面的说明。 保持一致,尊重您的团队或项目约定,或者如果没有,就自行发明它们。

组织变更集

首先要决定的是存储变更集的位置。 基本上有两种方法:

  1. 将变更集与应用程序代码一起保存。 这样做很方便,因为您可以一起提交和审查变更集和应用程序代码。
  2. 将变更集和应用程序代码分开,例如,在单独的 VCS 存储库中。 这种方法适用于数据模型在多个应用程序之间共享的情况,并且更方便地将所有变更集存储在专用存储库中,而不是将它们分散到应用程序代码所在的多个存储库中。

无论您将变更集存储在何处,通常都可以将它们分为以下几类:

  1. 不影响正在运行的系统的独立迁移。 如果当前部署的应用程序还不知道它们,那么创建新表、序列等通常是安全的。
  2. 改变存储结构的模式修改,例如,添加或删除列和索引。 当应用程序的旧版本仍在使用时,不应应用这些更改,因为这样做可能会由于架构的更改而导致锁定或奇怪的行为。
  3. 插入或更新少量数据的快速迁移。 如果正在部署多个应用程序,则可以同时执行此类别的变更集,而不会降低数据库性能。
  4. 插入或更新大量数据的迁移可能会很慢。 当没有其他类似的迁移正在执行时,最好应用这些更改。

四个类别的图形表示

这些迁移集应在部署较新版本的应用程序之前连续运行。 如果一个系统由几个独立的应用程序组成并且其中一些使用相同的数据库,这种方法会变得更加实用。 否则,值得只分离那些可以应用而不影响正在运行的应用程序的变更集,其余的变更集可以一起应用。

对于更简单的应用程序,可以在应用程序启动时应用全套必要的迁移。 在这种情况下,所有变更集都属于一个类别,并在应用程序初始化时运行。

无论选择在哪个阶段应用迁移,值得一提的是,对多个应用程序使用同一个数据库可能会在应用迁移时导致锁定。 Liquibase(像许多其他类似的解决方案一样)利用两个特殊的表来记录其元数据: DATABASECHANGELOGDATABASECHANGELOGLOCK 。 前者用于存储有关已应用变更集的信息,后者用于防止在同一数据库模式中进行并发迁移。 因此,如果多个应用程序出于某种原因必须使用相同的数据库模式,最好为元数据表使用非默认名称以避免锁定。

现在高层结构已经清晰,您需要决定如何组织每个类别中的变更集。

示例变更集组织

这在很大程度上取决于具体的应用要求,但以下几点通常是合理的:

  1. 保留按产品版本分组的变更日志。 为每个版本创建一个新目录并将相应的变更日志文件放入其中。 有一个根变更日志并包含与版本相对应的变更日志。 在版本变更日志中,包括包含此版本的其他变更日志。
  2. 对变更日志文件和变更集标识符有一个命名约定——当然要遵循它。
  3. 避免有大量更改的变更集。 首选多个变更集而不是单个长变更集。
  4. 如果您使用存储过程并需要更新它们,请考虑使用添加该存储过程的变更集的runOnChange="true"属性。 否则,每次更新时,您都需要使用新版本的存储过程创建新的变更集。 要求各不相同,但不跟踪此类历史记录通常是可以接受的。
  5. 考虑在合并功能分支之前压缩冗余更改。 有时,在特性分支(尤其是长期存在的分支)中,后来的变更集会细化在早期变更集中所做的更改。 例如,您可以创建一个表,然后决定向其中添加更多列。 如果此功能分支尚未合并到主分支,则值得将这些列添加到初始createTable更改中。
  6. 使用相同的更改日志来创建测试数据库。 如果您尝试这样做,您可能很快就会发现并非每个变更集都适用于测试环境,或者该特定测试环境需要额外的变更集。 使用 Liquibase,使用contexts可以轻松解决这个问题。 只需将context="test"属性添加到仅需要通过测试执行的变更集中,然后在启用test上下文的情况下初始化 Liquibase。

滚回来

与其他类似的解决方案一样,Liquibase 支持“向上”和“向下”迁移模式。 但请注意:撤消迁移可能并不容易,而且并不总是值得付出努力。 如果您决定为您的应用程序支持撤消迁移,那么请保持一致并针对需要撤消的每个变更集执行此操作。 使用 Liquibase,撤消变更集是通过添加包含执行回滚所需更改的rollback标签来完成的。 考虑以下示例:

 <changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet>

显式回滚在这里是多余的,因为 Liquibase 会执行相同的回滚操作。 Liquibase 能够自动回滚大多数受支持的更改类型,例如createTableaddColumncreateIndex

修复过去

没有人是完美的,我们都会犯错误。 当已经应用了损坏的更改时,可能会发现其中一些为时已晚。 让我们探索可以做些什么来挽救这一天。

手动更新数据库

它涉及通过以下方式弄乱DATABASECHANGELOG和您的数据库:

  1. 如果您想更正错误的变更集并再次执行它们:
    • DATABASECHANGELOG中删除与变更集相对应的行。
    • 删除变更集引入的所有副作用; 例如,如果表被删除,则恢复它。
    • 修复错误的变更集。
    • 再次运行迁移。
  2. 如果您想更正错误的变更集但跳过再次应用它们:
    • 通过将与错误变更集对应的行的MD5SUM字段值设置为NULL来更新DATABASECHANGELOG
    • 手动修复数据库中的错误。 例如,如果添加了错误类型的列,则发出查询以修改其类型。
    • 修复错误的变更集。
    • 再次运行迁移。 Liquibase 将计算新的校验和并将其保存到MD5SUM 。 更正的变更集不会再次运行。

显然,在开发过程中很容易做到这些技巧,但如果将更改应用于多个数据库,则变得更加困难。

编写纠正性变更集

在实践中,这种方法通常更合适。 您可能想知道,为什么不直接编辑原始变更集? 事实是,这取决于需要更改的内容。 Liquibase 为每个变更集计算校验和,如果校验和对于至少一个先前应用的变更集是新的,则拒绝应用新的更改。 通过指定runOnChange="true"属性,可以基于每个变更集自定义此行为。 如果您修改前置条件或可选变更集属性( contextrunOnChange等),校验和不会受到影响。

现在,您可能想知道,您最终如何纠正有错误的变更集?

  1. 如果您希望这些更改仍适用于新模式,则只需添加更正的更改集。 例如,如果添加了错误类型的列,则在新变更集中修改其类型。
  2. 如果您想假装那些糟糕的变更集从未存在,请执行以下操作:
    • 删除变更集或添加具有值的context属性,以保证您永远不会再次尝试使用此类上下文应用迁移,例如context="graveyard-changesets-never-run"
    • 添加新的变更集,这些变更集将恢复错误或修复错误。 只有在应用了错误的更改时才应应用这些更改。 它可以通过先决条件来实现,例如使用changeSetExecuted 。 不要忘记添加评论来解释您这样做的原因。
    • 添加以正确方式修改架构的新变更集。

如您所见,修复过去是可能的,尽管它可能并不总是那么简单。

减轻成长的痛苦

随着您的应用程序变老,它的更改日志也会增长,沿路径累积每个架构更改。 这是设计使然,这本身并没有错。 可以通过定期压缩迁移来缩短长的变更日志,例如,在发布产品的每个版本之后。 在某些情况下,它会使初始化新模式更快。

更改日志被压缩的插图

挤压并不总是微不足道的,并且可能会导致回归而不会带来很多好处。 另一个不错的选择是使用种子数据库来避免执行所有变更集。 如果您需要尽可能快地准备好数据库,甚至可能包含一些测试数据,它非常适合测试环境。 您可能会将其视为变更集的一种压缩形式:在某个时间点(例如,在发布另一个版本之后),您会转储模式。 恢复转储后,您照常应用迁移。 只会应用新的更改,因为在转储之前已经应用了旧的更改; 因此,它们从转储中恢复。

种子数据库的插图

结论

我们有意避免深入研究 Liquibase 的功能,以提供一篇简短而中肯的文章,重点关注总体上不断发展的模式。 希望数据库模式迁移的自动化应用带来了哪些好处和问题,以及它与 DevOps 文化的契合程度。 重要的是不要把好的想法变成教条。 要求各不相同,作为数据库工程师,我们的决定应该促进产品向前发展,而不仅仅是遵循互联网上某人的建议。