演进式数据库设计

在过去十年中,我们开发并完善了一系列技术,允许数据库设计随着应用程序的开发而演进。对于敏捷方法论来说,这是一个非常重要的功能。这些技术依赖于将持续集成和自动化重构应用于数据库开发,以及 DBA 和应用程序开发人员之间的紧密合作。这些技术在生产前和发布的系统中都有效,在绿地项目和遗留系统中都有效。

2016 年 5 月


Photo of Pramod Sadalage

Pramod 开发了 Thoughtworks 在 2000 年使用的演进式数据库设计和数据库重构的原始技术。从那时起,他与世界各地的许多客户合作,使用和开发这些技术,培训了许多 ThoughtWorkers,并撰写了两本书。

Photo of Martin Fowler

Martin 很高兴找到 Pramod 来解决一些关于演进数据库的粗略想法,并解决使它们在实践中发挥作用的所有棘手问题。Martin 如今的主要作用是帮助他的同事向更广泛的软件开发世界解释他们所开发的内容。


在过去十年中,我们见证了 敏捷方法论 的兴起。与它们的前身相比,它们改变了对数据库设计的需求。其中最核心的需求之一是演进式架构的概念。在敏捷项目中,您假设无法在项目开始时就确定系统的需求。因此,在项目开始时进行详细的设计阶段变得不切实际。系统的架构必须随着软件的各个迭代而演进。敏捷方法,特别是 极限编程 (XP),具有一些使这种演进式架构切实可行的实践。

当我们和我们的 Thoughtworks 同事开始做敏捷项目时,我们意识到我们需要解决如何演进数据库以支持这种架构演进的问题。我们大约在 2000 年开始了一个项目,该项目的数据库最终拥有大约 600 个表。在我们进行这个项目时,我们开发了一些技术,允许我们轻松地更改模式并迁移现有数据。这使得我们的数据库能够完全灵活和可演进。我们在本文的早期版本中描述了这些技术,这些描述启发了其他团队和工具集。从那时起,我们在世界各地数百个项目中使用并进一步开发了这些技术,从小型团队到大型跨国工作项目。我们一直打算更新这篇文章,现在我们有机会对其进行彻底的更新。

Jen 实现了一个新的故事

为了了解这一切是如何运作的,让我们概述一下当开发人员(Jen)编写一些代码来实现一个新的用户故事时会发生什么。该故事指出,用户应该能够查看、搜索和更新库存中产品的地理位置、批次和序列号。查看数据库模式,Jen 发现目前库存表中没有这样的字段,只有一个 inventory_code 字段,它是这三个字段的串联。她必须将此单个代码拆分为三个独立的字段:location_codebatch_numberserial_number

以下是她需要执行的步骤

  • 在现有模式的 inventory 表中添加新列
  • 编写一个数据迁移脚本,将数据从现有的 inventory_code 列拆分,更新 location_codebatch_numberserial_number 列。
  • 更改应用程序代码以使用新列
  • 更改任何数据库代码,例如视图、存储过程和触发器,以使用新列
  • 更改基于 inventory_code 列的任何索引
  • 将此数据库迁移脚本和所有应用程序代码更改提交到版本控制系统

为了添加新列并迁移数据,Jen 编写了一个 SQL 迁移脚本,她可以在当前模式上运行它。这将同时更改模式,并将所有现有数据迁移到库存中。

ALTER TABLE inventory ADD location_code VARCHAR2(6) NULL;
ALTER TABLE inventory ADD batch_number VARCHAR2(6) NULL;
ALTER TABLE inventory ADD serial_number VARCHAR2(10) NULL;

UPDATE inventory SET location_code = SUBSTR(product_inventory_code,1,6);
UPDATE inventory SET batch_number = SUBSTR(product_inventory_code,7,6);
UPDATE inventory SET serial_number = SUBSTR(product_inventory_code,11,10);

DROP INDEX uidx_inventory_code;

CREATE UNIQUE INDEX uidx_inventory_identifier
  ON inventory (location_code,batch_number,serial_number);

ALTER TABLE product_inventory DROP COLUMN inventory_code;

Jen 在她机器上的数据库本地副本上运行此迁移脚本。然后,她继续更新应用程序代码以使用这些新列。当她这样做时,她会针对此代码运行现有的测试套件,以检测应用程序行为的任何变化。一些测试(那些依赖于组合列的测试)需要更新。可能需要添加一些测试。一旦 Jen 完成了所有这些操作,并且应用程序在她机器上所有测试都变为绿色后,Jen 将所有更改推送到共享的项目版本控制存储库 - 我们称之为 主线。这些更改包括迁移脚本和应用程序代码更改。

如果 Jen 对进行此更改不太熟悉,她很幸运,因为这是对数据库进行的一种常见更改。因此,她可以查阅 数据库重构书籍,还有一个 在线摘要

一旦更改进入主线,它们将被 持续集成 服务器获取。它将在主线数据库副本上运行迁移脚本,然后运行所有应用程序测试。如果一切正常,此过程将在整个 部署管道 中重复,包括 QA 和暂存环境。相同的代码最终在生产环境中运行,现在更新了实时数据库的模式和数据。

像这样的小用户故事只有一个数据库迁移,较大的故事通常被分解为几个单独的迁移,用于对数据库的每个更改。我们的通常规则是使每个数据库更改尽可能小。它越小,就越容易正确,并且任何错误都很快就能发现和调试。像这样的迁移很容易组合,因此最好进行许多小的迁移。

应对变化

随着敏捷方法在 21 世纪初的普及,它们最明显的特征之一就是它们对变化的态度。在它们出现之前,关于软件过程的大部分思考都是关于尽早理解需求、签署这些需求、使用这些需求作为设计的基础、签署设计,然后继续进行构建。这是一个计划驱动的循环,通常被称为(通常带有嘲讽)瀑布方法

这种方法试图通过在前期进行大量工作来最大限度地减少更改。一旦前期工作完成,更改就会造成重大问题。因此,如果需求发生变化,这种方法就会遇到麻烦,而需求波动对于这种过程来说是一个大问题。

敏捷过程对变化采取不同的方法。它们寻求拥抱变化,允许即使在开发项目的后期也发生变化。更改是受控的,但该过程的态度是尽可能地启用更改。部分原因是应对许多项目中需求固有的不稳定性,部分原因是为了更好地支持动态的商业环境,帮助它们随着竞争压力的变化而变化。

为了使这能够奏效,您需要对设计有不同的态度。与其将设计视为一个阶段,该阶段在您开始构建之前基本上已经完成,不如将设计视为一个持续的过程,它与构建、测试甚至交付交织在一起。这就是计划设计和演进式设计的对比。

敏捷方法的重要贡献之一是它们提出了一些实践,使演进式设计能够以受控的方式运作。因此,与其像通常在没有事先计划设计时发生的那样混乱,这些方法提供了控制演进式设计并使其切实可行的技术。

这种方法的一个重要部分是迭代开发,您在项目的整个生命周期中多次运行整个软件生命周期。敏捷过程在每个迭代中运行完整的生命周期,在每个迭代结束时完成最终产品需求子集的工作、测试、集成代码。这些迭代很短,从几个小时到几周不等,更有经验的团队使用更短的迭代。

虽然这些技术的使用和兴趣不断增长,但其中最大的问题之一是如何使演进式设计适用于数据库。长期以来,数据库界的人们认为数据库设计是绝对需要事先计划的东西。在开发后期更改数据库模式往往会导致应用程序软件出现广泛的故障。此外,在部署后更改模式会导致痛苦的数据迁移问题。

在过去十多年中,我们参与了许多使用演进式数据库设计并使其发挥作用的大型项目。一些项目涉及 100 多人在世界各地多个地点工作。另一些项目涉及超过 50 万行代码,超过 500 个表。有些项目在生产环境中有多个版本的应用程序,以及需要 24*7 正常运行时间的应用程序。在这些项目中,我们已经看到了一个月和一周的迭代,更短的迭代效果更好。我们下面描述的技术是我们用来使这一切能够运作的技术。

从早期开始,我们就试图将这些技术推广到更多项目中,从更多案例中获得更多经验,现在我们所有的项目都使用这种方法。我们也从其他敏捷实践者那里获得了灵感、想法和经验。

限制

在我们深入研究这些技术之前,重要的是要说明我们还没有解决演进式数据库设计的所有问题。

我们有一些项目有数百家零售店拥有自己的数据库,所有这些数据库都需要一起升级。但是,我们还没有探索过在如此庞大的站点组中进行大量定制的情况。例如,一个小型商业应用程序允许对模式进行定制,并部署到数千家不同的小型公司。

我们越来越多地看到人们使用多个模式作为单个数据库环境的一部分。我们已经与使用少量模式的项目合作,但还没有将其扩展到数十个或数百个模式。这是我们预计在未来几年内必须处理的情况。

我们不认为这些问题本质上是不可解决的。毕竟,当我们编写这篇文章的原始版本时,我们还没有解决 24*7 正常运行时间或集成数据库的问题。我们找到了解决这些问题的方法,并预计我们也会进一步推动演进式数据库设计的极限。但在此之前,我们不会声称我们可以解决这些问题。

实践

我们对演进式数据库设计的方法依赖于几个重要的实践。

DBA 与开发人员紧密合作

敏捷方法的信条之一是,具有不同技能和背景的人需要非常紧密地合作。他们不能主要通过正式会议和文档进行沟通。相反,他们需要一直互相交谈,互相合作。每个人都受到影响:分析师、项目经理、领域专家、开发人员……以及 DBA。

开发人员进行的每个任务都有可能需要数据库管理员的帮助。开发人员和数据库管理员都需要考虑开发任务是否会对数据库模式进行重大更改。如果是,开发人员需要咨询数据库管理员以决定如何进行更改。开发人员知道需要哪些新功能,而数据库管理员则对应用程序中的数据以及其他周边应用程序具有全局视图。很多时候,开发人员只了解他们正在开发的应用程序,而不一定了解模式的所有其他上游或下游依赖关系。即使是单个数据库应用程序,也可能存在开发人员不知道的数据库依赖关系。

开发人员随时可以联系数据库管理员并要求配对以解决数据库更改问题。通过配对,开发人员可以了解数据库的工作原理,而数据库管理员可以了解对数据库的需求背景。对于大多数更改,如果开发人员担心更改对数据库的影响,则需要他们联系数据库管理员。但数据库管理员也会主动出击。当他们看到可能对数据产生重大影响的故事时,他们可以主动联系相关的开发人员,讨论数据库的影响。数据库管理员还可以审查提交到版本控制中的迁移。虽然撤销迁移很烦人,但我们仍然可以从每次迁移都很小中获益,这使得撤销变得更容易。

为了实现这一点,数据库管理员需要让自己易于接近和可用。她需要让开发人员能够轻松地过来花几分钟时间问一些问题,也许是在 Slack 频道或 HipChat 房间,或者开发人员正在使用的任何其他通信媒介。在设置项目空间时,确保数据库管理员和开发人员彼此靠近,以便他们可以轻松地聚在一起。确保告知数据库管理员任何应用程序设计会议,以便他们可以轻松地参加。在许多环境中,我们看到人们在数据库管理员和应用程序开发功能之间设置了障碍。为了使演进式数据库设计流程能够正常工作,这些障碍必须消除。

所有数据库工件都与应用程序代码一起进行版本控制

开发人员从对所有工件使用版本控制中获益良多:应用程序代码、单元测试和功能测试,以及其他代码,例如构建脚本,以及用于创建环境的 Puppet 或 Chef 脚本。

图 1:所有数据库工件都与其他项目工件一起在版本控制中

同样,所有数据库工件都应该在版本控制中,与其他所有人使用的同一个存储库中。这样做的好处是:

  • 只有一个地方可以查找,使项目中的任何人都可以更容易地找到东西。
  • 对数据库的每次更改都会被存储,如果出现任何问题,可以轻松地进行审计。我们可以将数据库的每次部署追溯到模式和支持数据的精确状态。
  • 我们防止数据库与应用程序不同步的部署,这会导致检索和更新数据的错误。
  • 我们可以轻松地创建新环境:用于开发、测试,以及生产。创建软件运行版本所需的一切都应该在一个存储库中,因此可以快速检出并构建。

所有数据库更改都是迁移

在许多组织中,我们看到一个流程,开发人员使用模式编辑工具和用于常量数据的临时 SQL 对开发数据库进行更改。完成开发任务后,数据库管理员将开发数据库与生产数据库进行比较,并在将软件推广到生产环境时对生产数据库进行相应的更改。但在生产时这样做很棘手,因为开发中的更改上下文会丢失。更改的目的需要由不同的团队重新理解。

为了避免这种情况,我们更倾向于在开发过程中捕获更改,并将更改作为一等工件,可以与应用程序代码更改使用相同的流程和控制进行测试和部署到生产环境。我们通过将对数据库的每次更改表示为数据库迁移脚本来实现这一点,该脚本与应用程序代码更改一起进行版本控制。这些迁移脚本包括:模式更改、数据库代码更改、参考数据更新、事务数据更新以及修复由错误引起的生产数据问题。

以下是对 equipment_type 表添加 min_insurance_valuemax_insurance_value 的更改,并设置一些默认值。

ALTER TABLE equipment_type ADD(
  min_insurance_value NUMBER(10,2),
  max_insurance_value NUMBER(10,2)
);

UPDATE equipment_type SET
          min_insurance_value  =  3000,
          max_insurance_value = 10000000;

此更改向 locationequipment_type 表添加了一些常量数据。

-- Create new warehouse locations #Request 497
INSERT INTO location (location_code, name , location_address_id,
  created_by, created_dt)
VALUES ('PA-PIT-01', 'Pittsburgh Warehouse', 4567,
  'APP_ADMIN' , SYSDATE);
INSERT INTO location (location_code, name , location_address_id,
  created_by, created_dt)
VALUES ('LA-MSY-01', 'New Orleans Warehouse', 7134,
  'APP_ADMIN' , SYSDATE);

-- Create new equipment_type #Request 562
INSERT INTO equipment_type (equipment_type_id, name,
  min_insurance_value, max_insurance_value, created_by, created_dt)
VALUES (seq_equipment_type.nextval, 'Lift Truck',
  40000, 4000000, 'APP_ADMIN', SYSDATE);

通过这种工作方式,我们永远不会使用模式编辑工具(例如 NavicatDBArtisanSQL Developer)来更改模式,我们也永远不会运行临时 DDL 或 DML 来添加常量数据或修复问题。除了由于应用程序软件而发生的数据库更新外,所有更改都是通过迁移进行的。

将迁移定义为一组 SQL 命令是故事的一部分,但为了正确应用它们,我们需要一些额外的工具来管理它们。

  • 每个迁移都需要一个唯一的标识。
  • 我们需要跟踪哪些迁移已应用于数据库
  • 我们需要管理迁移之间的排序约束。在上面的示例中,我们必须首先应用 ALTER TABLE 迁移,否则第二个迁移将无法插入设备类型。

我们通过为每个迁移分配一个序列号来处理这些需求。这充当唯一标识符,并确保我们可以维护它们应用于数据库的顺序。当开发人员创建迁移时,他们将 SQL 放入项目版本控制存储库中的迁移文件夹内的文本文件中。他们查找迁移文件夹中当前使用的最高编号,并使用该编号以及描述来命名文件。因此,前面的迁移对可能被称为 0007_add_insurance_value_to_equipment_type.sql0008_data_location_equipment_type[1]

为了跟踪迁移应用于数据库的情况,我们使用更改日志表。数据库迁移框架通常会创建此表,并在每次应用迁移时自动更新它。这样,数据库始终可以报告它与哪个迁移同步。如果我们不使用这样的框架(毕竟,当我们开始这样做时,它们并不存在),我们会使用脚本自动执行此操作。

图 2:由数据库迁移框架维护的更改日志表

有了这种编号方案,我们就可以跟踪更改应用于我们管理的许多数据库的情况。

图 3:迁移脚本从创建到在生产环境中部署的生命周期

其中一些数据迁移可能需要比与新功能相关的迁移更频繁地发布,在这种情况下,我们发现拥有单独的迁移存储库或文件夹来存放与数据相关的错误修复非常有用。

图 4:单独的文件夹用于管理新功能数据库更改和生产数据修复

每个文件夹都可以由数据库迁移工具(如 FlywaydbdeployMyBatis 或类似工具)单独跟踪,并使用单独的表来存储迁移编号。 Flyway 中的属性 flyway.table 用于更改存储迁移元数据的表的名称

每个人都拥有自己的数据库实例

大多数开发组织共享一个单一的开发数据库,该数据库由组织的所有成员使用。也许会为 QA 或暂存使用单独的数据库,但理念是限制运行的数据库数量。这样共享数据库是由于数据库实例难以设置和管理,导致组织将数据库实例的数量降至最低。在这种情况下,对谁可以更改模式的控制有所不同,有些地方要求所有更改都必须通过数据库管理员团队进行,而其他地方则允许任何开发人员更改开发数据库的模式,数据库管理员在更改被推广到下游时才会介入。

当我们开始使用敏捷数据库项目时,我们注意到应用程序开发人员通常遵循一个模式,他们在一个私有的代码工作副本中工作。人们通过尝试来学习,因此在编程方面,开发人员会尝试如何实现某个功能,并且可能在选择一个之前尝试几次。能够在私有工作区中进行实验并在事情更加稳定时推送到共享区域非常重要。如果每个人都在共享区域工作,那么他们会不断地用半完成的更改互相干扰。虽然我们更倾向于持续集成(集成在不超过几个小时后发生),但私有工作副本仍然很重要。版本控制系统支持这项工作,允许开发人员独立工作,同时支持将他们的工作集成到主干副本中。

这种单独的工作适用于文件,但也适用于数据库。每个开发人员都会获得他们自己的数据库实例,他们可以自由地修改它,而不会影响其他人的工作。当他们准备好了,他们可以推送并共享他们的更改,正如我们将在下一节中看到的那样。

这些单独的数据库可以是共享服务器上的单独模式,或者更常见的是,是运行在开发人员笔记本电脑或工作站上的单独数据库。十年前,数据库许可成本可能使单个数据库实例变得过高,但如今这种情况很少发生,尤其是随着开源数据库越来越受欢迎。我们发现,在开发人员机器上运行的虚拟机中运行数据库非常方便。我们使用 Vagrant基础设施即代码 定义数据库 VM 的构建,因此开发人员不需要了解设置数据库 VM 的细节,也不需要手动进行操作。

图 5:在开发中对团队中所有成员使用单个数据库模式的问题

图 6:团队中的每个成员都获得他们自己的数据库模式,用于开发和测试

许多数据库管理员仍然认为多个数据库是禁忌,在实践中太难操作,但我们发现,您可以轻松地管理数百个应用程序数据库实例。关键是要有工具,让您像操作文件一样操作数据库。

<target name="create_schema"
        description="create a schema as defined in the user properties">
    <echo message="Admin UserName: ${admin.username}"/>
    <echo message="Creating Schema: ${db.username}"/>
    <sql password="${admin.password}" userid="${admin.username}"
         url="${db.url}" driver="${db.driver}" classpath="${jdbc.classpath}"
         >
        CREATE USER ${db.username} IDENTIFIED BY ${db.password} DEFAULT TABLESPACE ${db.tablespace};
        GRANT CONNECT,RESOURCE, UNLIMITED TABLESPACE TO ${db.username};
        GRANT CREATE VIEW TO ${db.username};
        ALTER USER ${db.username} DEFAULT ROLE ALL;
    </sql>
</target>

可以使用构建脚本自动创建开发人员模式,以减少数据库管理员的工作量。这种自动化也可以仅限于开发环境。

<target name="drop_schema">
    <echo message="Admin UserName: ${admin.username}"/>
    <echo message="Working UserName: ${db.username}"/>
    <sql password="${admin.password}" userid="${admin.username}"
         url="${db.url}" driver="${db.driver}" classpath="${jdbc.classpath}"
         >
        DROP USER ${db.username} CASCADE;
    </sql>
</target>

例如,一个开发人员加入了一个项目,检出代码库并开始设置她的本地开发环境。她使用模板 build.properties 文件并进行更改,例如将 db.username 设置为 Jen,以及其他设置。完成这些设置后,她只需运行 ant create_schema 就可以在团队开发数据库服务器或她笔记本电脑上的数据库服务器上获得她自己的模式。

创建模式后,她就可以运行数据库迁移脚本,构建所有数据库内容以填充她的数据库实例:表、索引、视图、序列、存储过程、触发器、同义词和其他数据库特定对象。

同样,也有一些脚本用于删除模式——要么是因为它们不再需要,要么仅仅是因为开发人员希望清理并从一个新的模式开始。数据库环境应该是 凤凰——定期销毁并重建,随心所欲。这样,环境中积累的不可复制或不可审计的特性会更少。

这种对私有工作区的需求对开发人员来说是正确的,对团队中的其他人来说也是正确的。QA 人员应该创建他们自己的数据库,这样他们也可以工作,而不会因为他们不知道的更改而感到困惑。数据库管理员应该能够使用他们自己的数据库副本进行实验,因为他们探索建模选项或性能调优。

开发人员持续集成数据库更改

虽然开发人员可以在自己的沙盒中频繁地进行实验,但使用 持续集成 (CI) 定期将他们的不同更改整合在一起至关重要。CI 涉及设置一个集成服务器,该服务器会自动构建和测试主线软件。我们的经验法则是,每个开发人员每天至少应该集成到主线一次。许多工具可以帮助进行 CI,包括:GoCDSnap CIJenkinsBambooTravis CI

图 7:数据库更改,迁移与应用程序代码一样被开发和集成

图 7 展示了数据库迁移的开发、本地测试、签入源代码控制、CI 服务器获取、应用到集成数据库、再次测试以及打包以供下游使用的流程。

让我们举个例子

1

Jen 开始一项开发工作,其中包括数据库模式更改。如果更改很简单,例如添加一列,Jen 会决定如何直接进行更改。如果很复杂,她会找 DBA 并与她讨论。

一旦她确定了更改,她就会编写迁移。

ALTER TABLE project ADD projecttypeid NUMBER(10) NULL;

ALTER TABLE project ADD (CONSTRAINT fk_project_projecttype
  FOREIGN KEY (projecttypeid)
  REFERENCES projecttype DEFERRABLE INITIALLY DEFERRED);

UPDATE project
      SET projecttypeid = (SELECT projecttypeid
                  FROM projecttype
                  WHERE name='Integration');

添加一个可为空的列是向后兼容的更改,因此她可以在不需要更改任何应用程序代码的情况下集成更改。但是,如果它不是向后兼容的更改,例如拆分表,那么 Jen 就需要修改应用程序代码。

2

Jen 完成更改后,就可以进行集成。集成的第一步是 从主线更新她的本地副本。这些是她在处理任务时团队其他成员所做的更改。然后,她通过重建数据库并运行所有测试来检查她的更改是否与这些更新一起工作。

如果她遇到问题,由于其他开发人员的更改干扰了她的更改,她需要在她的副本上修复这些问题。通常,这种冲突很容易解决,但有时会更复杂。通常,这些更复杂的冲突会引发 Jen 和她的队友之间的对话,以便他们可以解决如何解决重叠的更改。

一旦她的本地副本再次正常工作,她会检查在她工作期间是否有更多更改被推送到 master,如果有,她需要重复使用新更改进行集成。但是,通常情况下,在她将代码完全集成到主线之前,只需要进行一到两次这样的循环。

3

Jen 将更改推送到主线。由于更改与现有的应用程序代码向后兼容,因此她可以在更新应用程序代码以使用它之前集成数据库更改 - 这是 并行更改 的一个常见示例。

4

CI 服务器检测到主线中的更改并启动一个包含数据库迁移的新构建。

5

CI 服务器使用自己的数据库副本进行构建,因此将数据库迁移脚本应用于此数据库以应用迁移中的更改。此外,它还运行其余的构建步骤:编译、单元测试、功能测试等。

6

构建成功完成后,CI 服务器会打包构建工件并发布它们。这些构建工件包含数据库迁移脚本,以便可以将它们应用于下游环境中的数据库,例如 部署管道。构建工件还包含打包到 jar、war、dll 等中的应用程序代码。

这正是 持续集成 的实践,它通常与应用程序源代码管理一起使用。上面的步骤只是将数据库代码视为另一段源代码。因此,数据库代码 - DDL、DML、数据、视图、触发器、存储过程 - 以与源代码相同的方式保存在配置管理下。每当我们成功构建时,通过将数据库工件与应用程序工件一起打包,我们就拥有了应用程序和数据库的完整且同步的版本历史记录。

对于应用程序源代码,集成更改的大部分痛苦可以通过源代码控制系统和在本地环境中使用各种测试来处理。对于数据库,需要付出更多努力,因为数据库中存在需要保留其业务含义的数据(状态)。此外,DBA 需要查看任何数据库更改,并确保它们符合数据库模式和数据架构的总体方案。为了使所有这些都能顺利进行,重大更改不应在集成时出现意外 - 因此 DBA 需要与开发人员密切合作。

我们强调频繁集成,因为我们发现,频繁进行小的集成比不频繁进行大的集成要容易得多 - 这是 频率降低难度 的一个例子。集成的痛苦随着集成规模的增加呈指数级增长,因此在实践中进行许多小的更改要容易得多,即使这在许多人看来似乎违反直觉。

数据库由模式和数据组成

当我们在这里谈论数据库时,我们不仅指数据库的模式和数据库代码,还指相当数量的数据。这些数据包括应用程序的常见常驻数据,例如不可避免的所有州、国家、货币、地址类型以及各种特定于应用程序的数据。我们还可以包含一些示例测试数据,例如几个示例客户、订单等。除非为了健全性测试或语义监控的特定需要,否则这些示例数据不会进入生产环境。

这些数据存在的原因有很多。主要原因是启用测试。我们非常相信使用大量的自动化测试来帮助稳定应用程序的开发。这样的测试集是敏捷方法中的一种常见方法。为了使这些测试有效地工作,在包含一些示例测试数据的数据库上进行工作是有意义的,所有测试都可以在运行之前假设这些数据已就位。

这些示例数据需要进行版本控制,这样我们就知道在需要填充新数据库时在哪里查找它们,并且我们拥有与测试和应用程序代码同步的更改记录。

除了帮助测试代码之外,这些示例测试数据还允许我们测试迁移,因为我们改变了数据库的模式。通过拥有示例数据,我们被迫确保任何模式更改也处理示例数据。

在我们看到的大多数项目中,这些示例数据都是虚构的。但是,在一些项目中,我们看到人们使用真实数据作为示例。在这些情况下,这些数据是从以前的遗留系统中提取的,并使用自动数据转换脚本。显然,你不能立即转换所有数据,因为在早期迭代中,实际上只构建了新数据库的一小部分。但是,我们可以使用 增量迁移 开发转换脚本,以便及时提供必要的数据。

这不仅有助于尽早发现数据转换问题,而且还使领域专家更容易使用不断增长的系统,因为他们熟悉他们正在查看的数据,并且通常可以帮助识别可能导致数据库和应用程序设计出现问题的案例。因此,我们现在认为,你应该尝试从项目的第一个迭代开始引入真实数据。我们发现 Jailer 是一个有用的工具,可以帮助完成此过程。

所有数据库更改都是数据库重构

我们对数据库所做的更改会改变数据库存储信息的方式,引入新的存储信息方式,或删除不再需要的存储。但是,所有数据库更改本身都不会改变软件的整体行为。因此,我们可以将它们视为符合重构的定义。

对软件内部结构进行的更改,以使其更容易理解和更便宜地修改,而不会改变其可观察的行为

-- 重构(第 2 章)

认识到这一点,我们 收集并记录了许多这样的重构。通过编写这样的目录,我们可以更容易地正确进行这些更改,因为我们可以遵循我们之前成功使用过的步骤。

Scott 和 Pramod 的书详细介绍了您需要的大多数数据库重构所需的步骤。

数据库重构的一个主要区别在于它们涉及必须一起完成的三种不同更改

  • 更改数据库模式
  • 迁移数据库中的数据
  • 更改数据库访问代码

因此,每当我们描述数据库重构时,我们都必须描述更改的所有三个方面,并确保在应用任何其他重构之前应用所有三个方面。

与代码重构一样,数据库重构也非常小。将一系列非常小的更改链接在一起的概念对于数据库来说与代码相同。更改的三维性质使得坚持小的更改变得更加重要。

许多数据库重构,例如 引入新列,可以在不更新访问系统的所有代码的情况下完成。如果代码在不知道的情况下使用新模式,则该列将不会被使用。但是,许多更改没有这种属性,我们称之为 **破坏性更改**。破坏性更改需要更多小心,程度取决于所涉及的破坏程度。

一个次要破坏性更改的示例是 使列不可为空,它将一个可为空的列更改为不可为空的列。这是破坏性的,因为如果任何现有代码没有将其设置为值,那么我们将收到错误。如果现有数据中存在任何空值,我们也会遇到问题。

我们可以通过将默认数据分配给此处具有空值的任何行来避免现有空值的问题(以略微不同的问题为代价)。对于应用程序代码没有分配(或分配空值)的问题,我们有两个选择。一个是为该列设置一个默认值。

ALTER TABLE customer
  MODIFY last_usage_date DEFAULT sysdate;

UPDATE customer
  SET last_usage_date =
    (SELECT MAX(order_date) FROM order
      WHERE order.customer_id = customer.customer_id)
  WHERE last_usage_date IS NULL;

UPDATE customer
  SET last_usage_date = last_updated_date
  WHERE last_usage_date IS NULL;

ALTER TABLE customer
  MODIFY last_usage_date NOT NULL;

处理缺乏分配的另一种方法是在重构过程中更改应用程序代码。如果我们能够自信地访问所有更新数据库的代码,这是我们更喜欢的选择,如果数据库只被单个应用程序使用,这通常很容易,但如果它是共享数据库,则很难。

一个更复杂的情况是 拆分表,特别是如果对表的访问广泛分布在应用程序代码中。如果是这种情况,重要的是让每个人都知道更改即将到来,以便他们可以为此做好准备。等待一个相对安静的时刻,例如迭代开始,也可能明智。

如果数据库访问全部通过系统的几个模块进行,任何破坏性更改都会容易得多。这使得查找和更新数据库访问代码变得更容易。

总的来说,重要的是选择适合您正在进行的更改类型的过程。如果有疑问,请尽量选择使更改更容易的方法。我们的经验是,我们遇到的麻烦比许多人想象的要少得多,并且通过对整个系统的强大配置控制,如果发生最坏的情况,恢复并不困难。

在开发过程中处理包括 DDL、DML 和数据迁移在内的数据库更改,为数据团队提供了最多的上下文,避免了数据团队在部署期间在没有上下文的情况下对所有更改进行批量迁移。

过渡阶段

我们已经提到了当我们遇到破坏性数据库重构并且无法轻松更改访问代码时遇到的困难。当您拥有一个共享数据库时,这些问题会变得更加严重,该数据库可能有多个应用程序和报告使用它。在这种情况下,您必须对诸如 重命名表 之类的操作更加小心。为了保护我们免受这些问题的影响,我们转向过渡阶段。

过渡阶段是指数据库同时支持旧的访问模式和新的访问模式的一段时间。这使得旧系统可以按照自己的节奏迁移到新的结构。

图 8:应用于遗留数据库的数据库重构及其实施前需要经历的阶段

ALTER TABLE customer RENAME to client;

CREATE VIEW customer AS
SELECT id, first_name, last_name FROM client;

对于重命名表示例,开发人员将创建一个脚本,将表customer重命名为client,并创建一个名为customer视图,供现有应用程序使用。这种并行更改支持新的和旧的访问方式。它确实增加了复杂性,因此在迁移到下游系统后,务必将其删除。在一些组织中,这可以在几个月内完成,而在另一些组织中可能需要数年。

视图是实现过渡阶段的一种技术。我们还使用数据库触发器,这对重命名列等操作非常有用。

自动化重构

由于重构在应用程序代码中变得广为人知,许多语言都对自动化重构提供了良好的支持。这些重构通过快速执行各种步骤,无需人工干预,从而简化并加快了重构过程。数据库也提供这种自动化功能。像LiquibaseActive Record Migrations这样的框架提供了一个 DSL 来应用数据库重构,允许以标准方式应用数据库迁移。

但是,这些类型的标准化重构对于数据库来说并不适用,因为处理数据迁移和遗留数据的规则高度依赖于团队的特定环境。因此,我们更倾向于通过编写迁移脚本来处理数据库重构,并专注于自动化应用脚本的工具。

正如我们之前所展示的那样,我们通过将 SQL DDL(用于模式更改)和 DML(用于数据迁移)组合起来,并将结果放在版本控制存储库中的一个文件夹中来编写每个脚本。我们的自动化确保我们永远不会手动应用这些更改,它们只会被自动化工具应用。这样,我们就可以维护重构的顺序并更新数据库元数据。

我们可以将重构应用于任何数据库实例,以使其与最新的主分支保持一致,或应用于任何以前的版本。该工具使用数据库中的元数据信息来找出其当前版本,然后应用它与目标版本之间的每个重构。我们可以使用这种方法来更新开发实例、测试实例和生产数据库。

更新生产数据库与更新测试数据库没有什么不同,我们对不同的数据运行相同的脚本集。我们更倾向于频繁发布,因为这样可以使更新保持较小,这意味着更新会更快完成,并且更容易处理出现的任何问题。执行这些更新的最简单方法是在应用更新时将生产数据库关闭,这在大多数情况下都适用。如果我们必须在应用程序保持活动状态的情况下执行更新,这是可能的,但我们使用的技术需要另一篇文章来解释。

到目前为止,我们发现这种技术非常有效。通过将所有数据库更改分解为一系列小的、简单的更改,我们能够对生产数据进行相当大的更改,而不会遇到麻烦。

除了自动化正向更改之外,您还可以考虑为每个重构自动化反向更改。如果您这样做,您将能够以相同的方式自动撤销对数据库的更改。我们发现这样做并不具有成本效益和足够的好处,因此我们没有一直尝试,而且我们对它的需求也不高,但这遵循相同的基本原则。总的来说,我们更倾向于编写迁移,以便数据库访问部分可以同时使用数据库的旧版本和新版本。这使我们能够更新数据库以支持未来的需求并使其上线,让它在生产环境中运行一段时间,然后在我们发现它们在没有问题的情况下稳定下来后,才推送使用新数据结构的更新。

如今,有很多工具可以自动应用数据库迁移,包括:FlywayLiquibaseMyBatis migrationsDBDeploy。以下是使用 Flyway 应用迁移的示例。

psadalag:flyway-4 $ ./flyway migrate
Flyway 4.0.3 by Boxfuse

Database: jdbc:oracle:thin:@localhost:1521:xe (Oracle 11.2)
Successfully validated 9 migrations (execution time 00:00.021s)
Creating Metadata table: "JEN_DEV"."schema_version"
Current version of schema "JEN_DEV": << Empty Schema >>
Migrating schema "JEN_DEV" to version 0 - base version
Migrating schema "JEN_DEV" to version 1 - asset
Migrating schema "JEN_DEV" to version 2 - asset type
Migrating schema "JEN_DEV" to version 3 - asset parameters
Migrating schema "JEN_DEV" to version 4 - inventory
Migrating schema "JEN_DEV" to version 5 - split inventory
Migrating schema "JEN_DEV" to version 6 - equipment type
Migrating schema "JEN_DEV" to version 7 - add insurance value to equipment type
Migrating schema "JEN_DEV" to version 8 - data location equipment type
Successfully applied 9 migrations to schema "JEN_DEV" (execution time 00:00.394s).
psadalag:flyway-4 $ 

开发人员可以按需更新其数据库

正如我们上面所解释的,将我们的更改集成到主分支的第一步是拉取在我们进行自己的工作时发生的任何更改。这不仅在集成步骤中至关重要,而且在我们完成工作之前通常也很有用,这样我们就可以评估我们听到同事谈论的任何更改的影响。在这两种情况下,能够轻松地从主分支拉取更改并将其应用于我们的本地数据库非常重要。

我们首先将更改从主分支拉取到我们的本地工作区。通常这很简单,但有时我们会发现我们的同事在我们在工作时将迁移推送到主分支。如果我们编写了一个序列号为8的迁移,我们将在迁移文件夹中看到另一个序列号为8的迁移。运行我们的迁移工具应该检测到这一点。

psadalag:flyway-4 $ ./flyway migrate
Flyway 4.0.3 by Boxfuse

Database: jdbc:oracle:thin:@localhost:1521:xe (Oracle 11.2)
ERROR: Found more than one migration with version 8
Offenders:
-> /Users/psadalag/flyway-4/sql/V8__data_location_equipment_type.sql (SQL)
-> /Users/psadalag/flyway-4/sql/V8__introduce_fuel_type.sql (SQL)
psadalag:flyway-4 $

一旦我们发现冲突,我们的第一步很简单,我们需要将我们的迁移重新编号为9,这样它就可以应用到主分支上的新迁移之上。重新编号后,我们需要测试迁移之间是否存在任何冲突。为此,我们清理数据库,然后将所有迁移(包括新的8和我们重新编号的9)应用到一个空白的数据库副本。

psadalag:flyway-4 $ mv sql/V8__introduce_fuel_type.sql sql/V9__introduce_fuel_type.sql
psadalag:flyway-4 $ ./flyway clean
Flyway 4.0.3 by Boxfuse

Database: jdbc:oracle:thin:@localhost:1521:xe (Oracle 11.2)
Successfully cleaned schema "JEN_DEV" (execution time 00:00.031s)
psadalag:flyway-4 $ ./flyway migrate
Flyway 4.0.3 by Boxfuse

Database: jdbc:oracle:thin:@localhost:1521:xe (Oracle 11.2)
Successfully validated 10 migrations (execution time 00:00.013s)
Creating Metadata table: "JEN_DEV"."schema_version"
Current version of schema "JEN_DEV": << Empty Schema >>
Migrating schema "JEN_DEV" to version 0 - base version
Migrating schema "JEN_DEV" to version 1 - asset
Migrating schema "JEN_DEV" to version 2 - asset type
Migrating schema "JEN_DEV" to version 3 - asset parameters
Migrating schema "JEN_DEV" to version 4 - inventory
Migrating schema "JEN_DEV" to version 5 - split inventory
Migrating schema "JEN_DEV" to version 6 - equipment type
Migrating schema "JEN_DEV" to version 7 - add insurance value to equipment type
Migrating schema "JEN_DEV" to version 8 - data location equipment type
Migrating schema "JEN_DEV" to version 9 - introduce fuel type
Successfully applied 10 migrations to schema "JEN_DEV" (execution time 00:00.435s).
psadalag:flyway-4 $

通常这可以正常工作,但偶尔我们会看到冲突——也许其他开发人员重命名了我们要更改的表。在这种情况下,我们需要弄清楚如何解决冲突。迁移的规模很小,这使得更容易发现和处理冲突。

最后,一旦数据库更改被集成,我们需要重新运行应用程序测试套件,以防从主分支获取的迁移导致任何测试失败。

此过程允许我们在短时间内独立工作,即使没有网络连接,然后在方便的时候进行集成。我们何时以及如何频繁地进行这种集成完全由我们决定——只要我们确保在推送到主分支之前同步即可。

明确分离所有数据库访问代码

要了解数据库重构的后果,必须能够看到应用程序如何使用数据库。如果 SQL 代码散落在代码库中,这将非常难做到。因此,必须有一个清晰的数据库访问层来显示数据库的使用位置和方式。为此,我们建议遵循P ofEAA中的一种数据源架构模式。

拥有一个清晰的数据库层有很多有价值的附带好处。它将开发人员需要 SQL 知识来操作数据库的系统区域降到最低,这使得开发人员的生活更轻松,因为他们通常对 SQL 不太精通。对于 DBA 来说,它提供了一个清晰的代码部分,他可以查看该部分以了解数据库的使用方式。这有助于准备索引、数据库优化,以及查看 SQL 以了解如何重新制定 SQL 以提高性能。这使 DBA 能够更好地了解数据库的使用方式。

频繁发布

十多年前,当我们编写本文的原始版本时,很少有人支持将软件频繁发布到生产环境的想法。从那时起,互联网巨头的兴起表明,快速发布序列是成功数字战略的关键部分。

通过将每个更改都捕获到迁移中,我们可以轻松地将新更改部署到测试和生产环境中。我们在这里讨论的这种演化式数据库设计既是实现频繁发布的关键部分,也从我们从看到软件实际使用的反馈中学到的知识中受益。

变体

与任何一组实践一样,这些实践应该根据您的具体情况进行调整,以下是一些我们遇到的情况。

多个版本

一个简单的项目可以使用一行代码,因此只有一个数据库版本。对于更复杂的项目,需要支持多个版本以进行 AB 测试,或者在进行金丝雀发布时进行滚动部署,因此需要支持多个版本的项目数据库。每个版本可能需要自己的测试数据,或者需要更改以测试特定功能或修复特定错误。这与在生产环境中管理多个版本的代码没有什么不同,但它有一个额外的变化,即数据库必须支持应用程序的多个版本。

我们发现另一种有用的方法是为数据库创建一个单独的存储库,所有其他应用程序版本都依赖于该数据库存储库。使用这种方法,您必须确保所有版本的代码都与同一个数据库版本一起工作,从而迫使您的数据库向后兼容所有在生产环境中运行的先前应用程序版本。

与应用程序一起发布更改

在一些项目中,我们发现对产品的更改必须交付给数千个最终用户。在这些类型的项目中,最好让应用程序自行升级,方法是将所有数据库更改与应用程序打包在一起(因为我们不知道用户从哪个版本升级),并让应用程序在启动时使用像Flyway或其众多同类产品这样的框架来升级数据库。

多个应用程序使用同一个数据库

在许多企业中,许多应用程序最终使用同一个数据库——共享数据库集成模式。在这种情况下,当一个应用程序对数据库进行更改时,很可能该更改会破坏其他应用程序。为了解决这个问题,最好将数据库提取为一个单独的代码存储库,所有依赖应用程序都使用该存储库。这个公共数据库存储库应该有自动行为测试,以确保跨应用程序依赖关系得到测试,如果依赖应用程序受到影响,则构建失败。这与拥有一个带有自己的代码存储库的共享软件组件没有什么不同。软件组件对其自身行为的视图进行测试,但也对其与使用消费者驱动的契约的下游应用程序之间的契约进行测试。

NoSQL 数据库

我们编写这篇文章的重点是关系型数据库,部分原因是我们最初就是这样编写的,部分原因是我们仍然发现它们是最常见的。但我们也对 NoSQL 数据库有所了解,这些数据库最近变得越来越普遍。关于如何以演化方式处理这些数据库的完整讨论将是另一篇文章,但我们将尝试进行一个肤浅的概述。

NoSQL 数据库声称在演化方式中更容易处理,因为它们大多数是“无模式的”。但无模式并不意味着我们不再需要担心模式,仍然存在一个隐式模式——它是由访问数据库的任何代码隐含的。该模式仍然需要管理,本质上是使用源代码存储库中管理的数据迁移的相同技术。缺乏存储模式确实为我们提供了另一种技术,即支持不同版本的多个读取策略。这可以使管理数据库的演化变得更容易,但它仍然是我们需要担心的事情。

您不需要一支 DBA 军队

使用我们在这里描述的技术可能听起来工作量很大,但实际上它并不需要大量的人力。在许多项目中,我们有三十多名开发人员,团队规模(包括 QA、分析师和管理人员)接近一百人。在任何一天,我们都会在人们的工作站上有一百多个不同模式的副本。然而,所有这些活动只需要一名全职 DBA,以及几名了解流程和工作流程的开发人员进行一些兼职协助和覆盖。

在较小的项目中,甚至不需要这样做。我们一直在较小的项目(大约十几人)中使用这些技术,我们发现这些项目不需要全职 DBA。相反,我们依靠几个对数据库问题感兴趣的开发人员来兼职处理 DBA 任务,并在需要时,请 DBA 参与设计/架构决策。

实现这一点的关键是自动化。如果你决心自动化所有任务,你就可以用更少的人处理大量工作。尤其是在 DevOps 及其相关工具(如 PuppetChefDockerRocketVagrant)流行的情况下。

从多年前开始采用这种工作方式以来,我们开始依赖那些可以像应用程序代码一样不断演化的数据库,这使我们能够加快发布周期,并更快地将软件投入生产。我们在这里描述的技术现在已经成为我们工作习惯的一部分。然而,我们的目标不仅是改进我们自己的方法,而是与软件行业分享我们的经验。我们越看到这些技术的采用,我们就越看到软件能够帮助人们实现目标,从而产生丰富我们所有人生活的进步。


脚注

1: 领先的“000”在文件系统上正确排序文件名。该数字在数据库中以整数类型存在

帮助工具

做这种事情需要大量的自动化 - 以下是一些我们发现有用的工具。

  • Liquibase,一个用于管理数据库迁移的框架
  • MyBatis migrations,一个用于管理数据库迁移的框架
  • Flyway,一个用于管理数据库迁移的框架
  • DBDeploy,一个用于管理数据库迁移的框架
  • DBmaestro,一个商业工具,用于支持演化式数据库开发
  • RedGate,一个商业工具,用于支持演化式数据库开发
  • Datical,一个商业工具,通过自动化数据库发布管理来支持应用程序发布流程
  • Jailer,一个从数据库中提取数据子集的工具
  • DiffKit,一个用于比较两组数据并报告任何差异的框架
  • DbUnit,一个 JUnit 扩展,用于测试数据库
  • DbFit,一个用于编写可读、易于维护的数据库代码单元和集成测试的工具。
  • Data Anonymization,一个用于匿名化生产数据以供开发使用的工具。

分析师和 QA 人员通常需要查看数据库中的测试数据,并能够轻松地更改它。为此,我们创建了一个带有 VBA 脚本的 Excel 应用程序,用于将数据从数据库拉到 Excel 文件中,允许人们编辑文件,并将数据发送回数据库。虽然存在其他用于查看和编辑数据库内容的工具,但 Excel 运作良好,因为许多人熟悉它。

项目中的每个人都需要能够轻松地探索数据库设计,这样他们就可以找到可用的表以及如何使用它们。构建一个查询数据库元数据的 Web 应用程序,为开发人员、QA、分析师以及任何想要使用它的人提供一个简单的界面。到目前为止,我们已经将一个简单的应用程序作为我们项目工具的一部分构建。

重大修订

2016 年 5 月: 完全重写

2003 年 1 月: 首次发布