本文概述
用户不在乎所使用软件的内部内容;只是它可以平稳, 安全且毫不干扰地工作。开发人员努力做到这一点, 他们尝试解决的问题之一就是如何确保数据存储处于适合该产品当前版本的状态。软件在发展, 其数据模型也可能随时间而变化, 例如, 以解决设计错误。为了使问题进一步复杂化, 你可能有许多测试环境或客户以不同的速度迁移到产品的较新版本。你不能仅从一个角度记录商店的结构以及使用闪亮的新版本需要进行哪些操作。
我曾经加入一个项目, 该项目的数据库结构由开发人员直接按需更新。这意味着没有明显的方法来找出需要进行哪些更改以将结构迁移到最新版本, 并且根本没有版本控制的概念!这是在DevOps之前的时代, 如今被认为是一团糟。我们决定开发一种工具, 该工具可用于将所有更改应用于给定的数据库。它具有迁移功能, 并将记录架构更改。这使我们确信不会发生意外更改, 并且架构状态是可预测的。
在本文中, 我们将研究如何应用关系数据库架构迁移以及如何解决伴随的问题。
首先, 什么是数据库迁移?在本文的上下文中, 迁移是应应用于数据库的一组更改。创建或删除表, 列或索引是迁移的常见示例。模式的形状可能会随着时间的推移发生巨大变化, 尤其是在需求仍然模糊的情况下开始开发时。因此, 在发布的几个里程碑过程中, 你的数据模型将得到发展, 并且可能与一开始的数据模型完全不同。迁移只是达到目标状态的步骤。
首先, 让我们探索一下工具箱中的功能, 以避免重蹈覆辙。
工具类
在每种广泛使用的语言中, 都有一些库可帮助简化数据库迁移。例如, 对于Java, 流行的选项是Liquibase和Flyway。我们将在示例中更多地使用Liquibase, 但是这些概念适用于其他解决方案, 并且与Liquibase无关。
如果某些ORM已经提供了自动升级模式并使其与映射类的结构匹配的选项, 为什么还要使用单独的模式迁移库呢?实际上, 这种自动迁移仅进行简单的模式更改, 例如创建表和列, 而不能进行潜在的破坏性操作, 例如删除或重命名数据库对象。因此, 非自动(但仍是自动化)的解决方案通常是一个更好的选择, 因为你不得不自己描述迁移逻辑, 并且知道数据库将要发生什么。
混合自动和手动模式修改也是一个非常糟糕的主意, 因为如果手动更改以错误的顺序应用或根本没有应用(即使需要), 你可能会生成唯一且不可预测的模式。选择该工具后, 使用它来应用所有架构迁移。
典型的数据库迁移
典型的迁移包括创建序列, 表, 列, 主键和外键, 索引以及其他数据库对象。对于大多数常见的更改类型, Liquibase提供了独特的声明性元素来描述应执行的操作。读完Liquibase或其他类似工具支持的每项微不足道的更改将太无聊了。为了了解变更集的外观, 请考虑以下示例, 其中创建表(为简便起见, 省略了XML名称空间声明):
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog>
<changeSet id="R1-20180201-create_product_table" 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>
如果你拥有数不胜数的产品, 性能将会受到影响。为了加快迁移速度, 我们可以将其重写为以下步骤:
- 就像我们之前看到的那样, 使用createTable为产品创建一个新表。在此阶段, 最好创建尽可能少的约束。我们将新表命名为PRODUCT_TMP。
- 使用sql change以INSERT INTO … SELECT …的形式用SQL填充PRODUCT_TMP。
- 创建你需要的所有约束(addNotNullConstraint, addUniqueConstraint, addForeignKeyConstraint)和索引(createIndex)。
- 将PRODUCT表重命名为PRODUCT_BAK。 Liquibase可以使用renameTable实现它。
- 再次将PRODUCT_TMP重命名为PRODUCT(再次使用renameTable)。
- (可选)使用dropTable删除PRODUCT_BAK。
当然, 最好避免这种迁移, 但是最好知道如何实现它们, 以防万一遇到需要的罕见情况之一。
如果你认为XML, JSON或YAML对于描述更改的任务太奇怪了, 那么只需使用简单的SQL并利用所有数据库供应商特定的功能。另外, 你可以在纯Java中实现任何自定义逻辑。
Liquibase免于你编写实际的特定于数据库的SQL的方式可能会导致过分自信, 但是你不应忘记目标数据库的怪癖。例如, 当你创建外键时, 可能会或可能不会创建索引, 具体取决于所使用的特定数据库管理系统。结果, 你可能会发现自己处于尴尬境地。 Liquibase允许你指定仅对特定类型的数据库(例如PostgreSQL, Oracle或MySQL)运行变更集。通过使用特定于供应商的语法和功能, 针对不同的数据库使用相同的与供应商无关的变更集, 以及对其他变更集, 这使得这成为可能。仅当使用Oracle数据库时, 将执行以下变更集:
<changeSet id="..." dbms="oracle" author="...">
...
</changeSet>
除了Oracle, Liquibase还支持其他一些数据库。
命名数据库对象
你创建的每个数据库对象都需要命名。你不需要明确为某些类型的对象(例如, 约束和索引)提供名称。但这并不意味着这些对象没有名称;无论如何, 它们的名称将由数据库生成。当你需要引用该对象以删除或更改它时, 就会出现问题。因此, 最好给他们明确的名字。但是, 关于提供什么名称有任何规定吗?答案很简短:保持一致;例如, 如果你决定这样命名索引:IDX_ <table> _ <columns>, 则上述CODE列的索引应命名为IDX_PRODUCT_CODE。
命名约定极富争议性, 因此在此我们不假定提供全面的说明。保持一致, 尊重你的团队或项目约定, 或者如果没有约定, 就发明它们。
组织变更集
首先要确定的是将变更集存储在何处。基本上有两种方法:
- 保留变更集与应用程序代码。这样做很方便, 因为你可以一起提交和检查变更集和应用程序代码。
- 将变更集和应用程序代码分开, 例如在单独的VCS存储库中。这种方法适用于在多个应用程序之间共享数据模型的情况, 并且更方便地将所有变更集存储在专用存储库中, 而不是将它们分散在应用程序代码所在的多个存储库中。
无论将变更集存储在何处, 将它们分为以下几类通常是合理的:
- 不影响运行系统的独立迁移。如果当前部署的应用程序尚不知道它们, 通常可以安全地创建新表, 序列等。
- 更改存储结构的架构修改, 例如添加或删除列和索引。在仍在使用较旧版本的应用程序时, 不应应用这些更改, 因为这样做可能会由于架构更改而导致锁定或奇怪的行为。
- 快速迁移, 可插入或更新少量数据。如果要部署多个应用程序, 则可以同时执行该类别的变更集, 而不会降低数据库性能。
- 插入或更新大量数据的潜在迁移速度可能很慢。当没有其他类似的迁移正在执行时, 最好应用这些更改。
这些迁移集应在部署较新版本的应用程序之前连续运行。如果系统由多个独立的应用程序组成, 并且其中一些使用同一数据库, 则此方法将变得更加实用。否则, 仅在不影响正在运行的应用程序的情况下分离那些可以应用的变更集是值得的, 其余的变更集可以一起应用。
对于更简单的应用程序, 可以在应用程序启动时应用全套必需的迁移。在这种情况下, 所有变更集都属于一个类别, 并且只要初始化应用程序就可以运行它们。
无论选择在哪个阶段应用迁移, 都值得一提的是, 在应用迁移时, 对多个应用程序使用同一数据库可能会导致锁定。 Liquibase(像许多其他类似的解决方案一样)利用两个特殊的表来记录其元数据:DATABASECHANGELOG和DATABASECHANGELOGLOCK。前者用于存储有关已应用变更集的信息, 而后者则用于防止在同一数据库架构内进行并发迁移。因此, 如果出于某种原因多个应用程序必须使用同一数据库架构, 则最好对元数据表使用非默认名称, 以避免锁定。
既然高层结构已经清晰了, 你就需要决定如何组织每个类别中的变更集。
它在很大程度上取决于特定的应用程序要求, 但是以下几点通常是合理的:
- 将变更日志按产品版本分组。为每个发行版创建一个新目录, 并将相应的变更日志文件放入其中。具有根变更日志, 并包括与发行版相对应的变更日志。在版本变更日志中, 包括构成此版本的其他变更日志。
- 对变更日志文件和变更集标识符有一个命名约定, 当然要遵循它。
- 避免更改太多的变更集。首选多个变更集而不是单个长变更集。
- 如果使用存储过程并需要更新它们, 请考虑使用添加了该存储过程的变更集的runOnChange =” true”属性。否则, 每次更新时, 你都需要使用存储过程的新版本创建一个新的变更集。要求各不相同, 但不跟踪此类历史记录通常可以接受。
- 在合并要素分支之前, 考虑压缩冗余更改。有时, 会发生在功能分支(尤其是寿命长的分支)中, 后来的变更集会细化在先前的变更集中所做的变更。例如, 你可以创建一个表, 然后决定向其添加更多列。如果此功能分支尚未合并到主分支, 则值得将这些列添加到初始createTable更改中。
- 使用相同的变更日志来创建测试数据库。如果尝试这样做, 你很快就会发现并非每个变更集都适用于测试环境, 或者该特定测试环境需要其他变更集。使用Liquibase, 可以使用上下文轻松解决此问题。只需将context =” test”属性添加到只需要使用测试执行的变更集, 然后在启用测试上下文的情况下初始化Liquibase。
滚回来
与其他类似的解决方案一样, Liquibase支持迁移模式”向上”和”向下”。但请注意:撤消迁移可能并不容易, 而且并不总是值得付出努力。如果你决定支持撤消应用程序的迁移, 请保持一致并针对需要撤消的每个变更集执行撤消迁移。使用Liquibase, 通过添加包含执行回滚所需的更改的回滚标签可以撤消更改集。考虑以下示例:
<changeSet id="..." 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能够自动回滚其大多数受支持的更改类型, 例如createTable, addColumn或createIndex。
修复过去
没有人是完美的, 我们都会犯错。当已经应用了损坏的更改时, 其中一些可能发现得太迟了。让我们探讨如何做来节省时间。
手动更新数据库
它涉及通过以下方式弄乱DATABASECHANGELOG和数据库:
- 如果你要纠正错误的变更集并再次执行它们:
- 从DATABASECHANGELOG中删除与变更集相对应的行。
- 删除变更集引入的所有副作用;例如, 如果已删除表, 请还原该表。
- 修复错误的变更集。
- 再次运行迁移。
- 如果你要纠正错误的变更集, 但再次跳过应用它们:
- 通过将与错误的变更集相对应的那些行的MD5SUM字段值设置为NULL, 来更新DATABASECHANGELOG。
- 手动修复错误的数据库。例如, 如果添加了错误类型的列, 则发出查询以修改其类型。
- 修复错误的变更集。
- 再次运行迁移。 Liquibase将计算新的校验和并将其保存到MD5SUM。更正的变更集将不再运行。
显然, 在开发过程中执行这些技巧很容易, 但是如果将更改应用到多个数据库, 则会变得更加困难。
编写更正变更集
实际上, 这种方法通常更合适。你可能想知道, 为什么不只编辑原始变更集?事实是, 这取决于需要更改的内容。 Liquibase为每个变更集计算一个校验和, 如果校验和对于至少一个先前应用的变更集是新的, 则拒绝应用新的更改。通过指定runOnChange =” true”属性, 可以基于每个变更集定制此行为。如果你修改前提条件或可选的变更集属性(上下文, runOnChange等), 则校验和不受影响。
现在, 你可能想知道, 最终如何纠正带有错误的变更集?
- 如果你希望这些更改仍然适用于新架构, 则只需添加纠正性更改集即可。例如, 如果添加了错误类型的列, 则在新的变更集中修改其类型。
- 如果你要假装那些不好的变更集不存在, 请执行以下操作:
- 删除变更集或添加上下文属性, 并使用一个值来保证你再也不会尝试在此类上下文中应用迁移, 例如context =” graveyard-changesets-never-run”。
- 添加新的变更集, 这些变更集将还原错误的内容或对其进行修复。仅当应用了不良更改时, 才应应用这些更改。可以使用诸如changeSetExecuted之类的前提条件来实现。不要忘记添加评论, 说明你这样做的原因。
- 添加新的变更集, 以正确的方式修改架构。
如你所见, 修复过去是可能的, 尽管可能并不总是那么简单。
减轻疼痛
随着你的应用程序变老, 它的更改日志也会增长, 并累积路径上的每个架构更改。这是设计使然, 这本质上没有错。通过定期压缩迁移(例如在发布每个版本的产品后), 可以缩短长的更改日志。在某些情况下, 它将使初始化新架构的速度更快。
压扁并不总是那么琐碎, 并且可能导致回归而没有带来很多好处。另一个不错的选择是使用种子数据库来避免执行所有变更集。如果你需要尽快准备好数据库, 甚至需要一些测试数据, 它也非常适合测试环境。你可能会认为这是挤压变更集的一种形式:在某个时刻(例如, 发布另一个版本之后), 你将对模式进行转储。恢复转储后, 你将照常应用迁移。仅会应用新的更改, 因为在进行转储之前已经应用了较旧的更改;因此, 它们已从转储中恢复。
总结
我们有意避免深入研究Liquibase的功能, 以撰写简短而切题的文章, 重点关注总体上不断发展的模式。希望很清楚, 自动应用数据库模式迁移会带来什么好处和问题, 以及它们如何完全适合DevOps文化。重要的是不要将好的想法变成教条。要求各不相同, 并且作为数据库工程师, 我们的决定应促进产品向前发展, 而不仅仅是遵循互联网上某人的建议。
评论前必须登录!
注册