过去十多年里,我们逐步发展并完善了一套技术,使数据库设计能够随着应用程序开发持续演进。对于敏捷开发而言,这一点至关重要。演进式数据库设计的核心,是通过数据库迁移、数据库重构、版本控制和持续集成,让数据库架构能够安全、可控地随业务需求变化而变化。这些技术依赖于在数据库开发中引入持续集成和自动化重构,并强调数据库管理员与应用程序开发人员之间的紧密协作。它们既适用于预生产系统,也适用于已经上线的系统;既适用于全新项目,也适用于遗留系统。
过去十多年间,敏捷方法快速发展。与传统软件开发方法相比,敏捷方法对数据库设计提出了新的要求,其中最核心的一点就是演进式架构。在敏捷项目中,系统需求往往无法在项目一开始就被完整、准确地确定。因此,在项目初期进行一次性、详尽的总体设计并不现实。系统架构必须随着软件迭代不断演进。敏捷方法,尤其是极限编程(XP),提供了许多实践,使这种演进式架构成为可能。

当我们与海外某咨询公司的同事开始实施敏捷项目时,很快意识到:如果应用程序架构要持续演进,数据库也必须能够同步演进。大约在 2000 年,我们启动了一个项目,该项目最终拥有约 600 张数据库表。在开发过程中,我们逐步形成了一套技术,使团队能够方便地修改数据库模式并迁移已有数据,从而让数据库具备充分的灵活性和可演进性。我们曾在本文早期版本中介绍过这些技术,这些内容后来也启发了其他团队和相关工具的发展。此后,我们在全球数百个项目中持续应用并改进这些技术,项目规模从小型团队到大型跨国项目不等。我们一直计划更新这篇文章,如今终于有机会对其进行全面修订。
小林实现了一个新用户故事
为了更好地理解演进式数据库设计如何运作,我们先看一个具体场景:开发人员小林正在编写代码,以实现一个新的用户故事。
这个用户故事要求用户能够查看、搜索并更新库存中产品的位置、批次号和序列号。查看数据库架构后,小林发现现有的 inventory 表中并没有这些独立字段,只有一个 inventory_code 字段,而该字段实际上是位置、批次号和序列号三者的组合。因此,她需要把这个单一字段拆分为三个独立字段:location_code、batch_number 和 serial_number。
她需要完成以下工作:
- 在现有
inventory表中添加新列。 - 编写数据迁移脚本,将现有
inventory_code中的数据拆分并写入location_code、batch_number和serial_number。 - 修改应用程序代码,使其使用新列。
- 修改所有相关数据库代码,例如视图、存储过程和触发器,使其使用新列。
- 根据新的列结构调整原先基于
inventory_code的索引。 - 将数据库迁移脚本和应用程序代码变更一并提交到版本控制系统。
为了添加新列并迁移数据,小林编写了一个 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(inventory_code, 1, 6); UPDATE inventory SET batch_number = SUBSTR(inventory_code, 7, 6); UPDATE inventory SET serial_number = SUBSTR(inventory_code, 13, 10); DROP INDEX uidx_inventory_code; CREATE UNIQUE INDEX uidx_inventory_identifier ON inventory (location_code, batch_number, serial_number); ALTER TABLE inventory DROP COLUMN inventory_code;
小林先在自己本地机器上的数据库副本中运行这段迁移脚本。随后,她开始更新应用程序代码,使其使用这些新列。与此同时,她运行现有测试套件,以检测应用程序行为是否发生了非预期变化。一些依赖旧合并字段的测试需要更新,也可能需要补充新的测试。
当小林完成所有修改,并确认应用程序在本地所有测试都通过后,她将变更推送到共享的项目版本控制仓库,也就是我们所说的主线。这些变更既包括数据库迁移脚本,也包括应用程序代码变更。
如果小林对这类修改并不熟悉,幸运的是,这属于数据库开发中较常见的变更类型。她可以查阅数据库重构相关资料,参考既有的重构模式和实践说明。
变更合并到主线后,持续集成服务器会获取这些变更。它会在主线数据库副本上运行迁移脚本,然后运行全部应用程序测试。如果一切正常,同样的过程会沿着部署流水线继续推进,包括测试环境、预发布环境,最后进入生产环境。最终,同一套迁移脚本和应用程序代码会在生产环境中运行,从而更新生产数据库的模式和数据。
像这样规模较小的用户故事通常只需要一次数据库迁移。较大的用户故事则往往需要把数据库更改拆分成多次独立迁移。我们的原则是:每一次数据库变更都应尽可能小。变更越小,出错的概率越低;即便出错,也更容易发现和调试。迁移脚本本身很容易组合,因此,多做几次小迁移通常优于一次大型迁移。
为什么敏捷开发需要演进式数据库设计
敏捷方法在 21 世纪初迅速普及,其中一个最显著的特点,就是它能够灵活应对变化。在敏捷方法出现之前,软件开发过程通常围绕这样的理念展开:尽早理解需求,确认需求,基于需求完成设计,再确认设计,然后开始构建。这是一种计划驱动的开发流程,通常被称为“瀑布式”开发方法,而且这个称呼常常带有批评意味。
这类方法试图通过大量前期工作来最大限度减少后续变更。然而,一旦前期工作完成,任何变更都会带来重大问题。因此,当需求发生变化时,这类流程就会陷入困境;而需求频繁变化,恰恰是许多项目的常态。
敏捷流程采取了不同的方式。它们并不试图消除变化,而是拥抱变化,甚至允许在项目后期继续进行变更。虽然变更依然需要受到控制,但整个流程的态度是尽可能促成合理变化。这一方面是为了应对许多项目固有的需求不确定性,另一方面也是为了更好地支持动态商业环境,使企业能够应对竞争压力。
要做到这一点,我们必须改变对设计的理解。设计不应被看作一个在构建开始前基本完成的阶段,而应被看作一个持续进行的过程,与构建、测试乃至交付交织在一起。这就是计划式设计与演进式设计之间的区别。
敏捷方法的重要贡献之一,是提供了一系列实践,使演进式设计能够以可控方式进行。换言之,它并不是让设计陷入缺乏前期规划的混乱状态,而是提供了一套技术,让团队能够控制设计演进,并使其真正可行。
这种方法的一个重要组成部分是迭代开发,也就是在项目生命周期内多次运行完整的软件生命周期。敏捷流程会在每次迭代中完成一个完整周期,最终交付一小部分针对最终产品需求的、可运行、经过测试且已经集成的代码。这些迭代周期通常较短,从几个小时到几周不等。经验丰富的团队往往会采用更短的迭代周期。
尽管这些技术的应用范围和关注度不断提高,一个重要问题仍然存在:如何将演进式设计应用于数据库?长期以来,数据库领域普遍认为数据库设计必须前期规划。在开发后期修改数据库模式,往往会导致应用程序大范围故障。此外,在系统部署后修改模式,还会带来棘手的数据迁移问题。
过去十多年里,我们参与了许多大型项目,这些项目都成功采用了演进式数据库设计。有些项目包含分布在全球多个地点的一百多名工作人员;有些项目拥有超过五十万行代码和五百多张表;有些项目需要在生产环境中同时运行多个版本的应用程序,并保持全天候不间断运行。我们曾经历过一个月和一周长度的迭代周期,而实践表明,更短的迭代周期效果更好。下文将介绍我们实现这一目标所使用的技术。
从一开始,我们就尝试将这些技术推广到更多项目中,并从更多实践案例中积累经验。如今,我们的项目普遍采用这种方法。与此同时,我们也从其他敏捷实践者那里吸收了许多灵感、想法和经验。
演进式数据库设计的局限性
在深入讨论这些技术之前,需要说明的是:我们并没有解决演进式数据库设计中的所有问题。
我们曾参与过一些项目,其中数百家零售门店各自拥有数据库,而这些数据库都需要统一升级。不过,我们还没有深入探索过在如此庞大的站点群中存在大量定制化的情况。例如,一款允许客户自定义数据库架构的小型企业应用,可能会部署到数千家不同的小型企业中。
我们也越来越多地看到,人们在同一个数据库环境中使用多个模式。我们参与过一些使用少量模式的项目,但还没有遇到过模式数量达到几十个甚至几百个的情况。我们预计未来几年内可能会面对这类场景。
我们并不认为这些问题本质上无法解决。毕竟,在撰写本文初稿时,我们也尚未解决全天候运行或数据库集成等问题。后来,我们找到了应对这些问题的方法。因此,我们也期待继续拓展演进式数据库设计的边界。不过,在真正做到之前,我们不会声称自己已经解决了这些问题。
演进式数据库设计的关键实践
我们的演进式数据库设计方法依赖几项关键实践。
数据库管理员与开发人员密切合作
敏捷方法的核心原则之一,是不同技能和背景的人必须紧密协作。他们不能主要依靠正式会议和文档进行沟通,而应当随时交流、即时协作。这一原则适用于所有角色:分析师、项目经理、领域专家、开发人员,也包括数据库管理员。
开发人员执行的任何任务,都可能需要数据库管理员(DBA)的帮助。开发人员和 DBA 都需要判断某项开发任务是否会对数据库模式产生重大影响。如果需要更改,开发人员就应当与 DBA 协商,确定合理的变更方式。开发人员了解新功能需要什么,DBA 则通常拥有应用程序及其周边系统的数据全局视图。很多时候,开发人员只能看到自己正在开发的应用程序,而不一定了解该数据库模式的所有上游或下游依赖。即使数据库只服务于单个应用程序,也可能存在开发人员并不了解的数据库依赖。
开发人员应当能够随时联系 DBA,并邀请其结对处理数据库变更。通过结对,开发人员可以更好地理解数据库工作方式,DBA 也可以理解需求背景。对于大多数变更,如果开发人员担心其对数据库的影响,就应主动联系 DBA。与此同时,DBA 也应主动介入。当他们发现某个用户故事可能对数据产生重大影响时,可以主动联系相关开发人员,讨论数据库影响。DBA 还可以审查提交到版本控制系统中的迁移脚本。虽然回滚数据库迁移通常较麻烦,但如果每次迁移都足够小,回滚和修复也会容易得多。
要实现这种协作,DBA 必须容易接触、能够及时响应。她需要让开发人员能够方便地抽几分钟时间来提问,无论是通过团队即时沟通工具、项目协作频道,还是团队使用的其他沟通方式。在布置项目空间时,应确保 DBA 和开发人员坐得足够近,以便轻松交流。同时,也应让 DBA 了解所有应用程序设计会议,以便在需要时参与。我们见过许多组织在 DBA 与应用程序开发之间设置了壁垒。要让演进式数据库设计有效运转,这些壁垒必须被拆除。在跨角色协作要求较高的团队中,也可以借助 Worktile 这类通用项目协作系统,将任务、项目、文档、即时沟通、日历和审批等协作信息集中管理,减少数据库变更过程中的沟通遗漏。
数据库版本控制:所有数据库组件都应与应用程序代码一起管理
开发人员从版本控制中获得了巨大收益。应用程序代码、单元测试、功能测试以及构建脚本、环境创建脚本等,都应纳入版本控制。

图 1:所有数据库工件与其他项目工件一起进行版本控制。
同样,所有数据库组件也应纳入版本控制,并与其他项目工件存放在同一个代码仓库中。这样做有几个好处:
- 所有内容都集中在一个地方,项目中的任何人都更容易找到所需内容。
- 数据库的每一次更改都会被记录下来,出现问题时可以方便地审计。我们能够追踪数据库的每次部署,精确到数据库模式及其支撑数据的具体状态。
- 可以避免数据库与应用程序不同步的部署,因为这种不同步会导致数据检索和更新错误。
- 可以轻松创建新的环境,包括开发、测试甚至生产环境。创建可运行软件版本所需的一切都应保存在同一个代码仓库中,从而能够快速检出并构建。
数据库迁移:用迁移管理所有数据库变更
在许多组织中,我们经常看到这样的流程:开发人员使用图形化数据库管理工具和针对现有数据的临时 SQL 修改开发数据库。开发任务完成后,DBA 会将开发数据库与生产数据库进行比较,并在软件上线时对生产数据库做相应修改。
这种做法在生产环境中非常棘手,因为开发阶段的变更上下文已经丢失。另一组人员不得不重新理解这些更改的目的和影响。
为了避免这种情况,我们倾向于在开发过程中捕获变更,并将其作为一等工件维护。这样,数据库变更就能像应用程序代码变更一样,经过相同的流程和控制,被测试并部署到生产环境。我们通过将每一次数据库变更表示为数据库迁移脚本来做到这一点。这些脚本与应用程序代码变更一起纳入版本控制。迁移脚本包括:模式变更、数据库代码变更、参考数据更新、事务数据更新,以及对生产数据问题的修复。
下面的示例向 equipment_type 表添加 min_insurance_value 和 max_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;
下面的示例则向 location 和 equipment_type 表添加一些历史数据。– 创建新的仓库地点 #请求 497 INSERT INTO location ( location_code, name, location_address_id, created_by, created_dt ) VALUES ( ‘PA-PIT-01’, ‘匹兹堡仓库’, 4567, ‘APP_ADMIN’, SYSDATE ); INSERT INTO location ( location_code, name, location_address_id, created_by, created_dt ) VALUES ( ‘LA-MSY-01’, ‘新奥尔良仓库’, 7134, ‘APP_ADMIN’, SYSDATE ); — 创建新的设备类型 #请求 562 INSERT INTO equipment_type ( equipment_type_id, name, min_insurance_value, max_insurance_value, created_by, created_dt ) VALUES ( seq_equipment_type.NEXTVAL, ‘叉车’, 40000, 4000000, ‘APP_ADMIN’, SYSDATE );
在这种工作方式下,我们不会使用图形化数据库管理工具直接修改数据库模式,也不会执行临时 DDL 或 DML 语句来添加现有数据或修复问题。除了应用程序正常运行产生的数据更新之外,所有数据库变更都通过迁移完成。
将迁移定义为一组 SQL 命令只是第一步。为了正确应用迁移,我们还需要额外机制来管理它们:
- 每次迁移都需要一个唯一标识符。
- 我们需要跟踪哪些迁移已经应用到某个数据库。
- 我们需要管理迁移之间的顺序约束。例如,在上面的示例中,必须先执行
ALTER TABLE迁移,否则后续插入设备类型数据的迁移就无法成功。
我们通常通过为每个迁移分配一个序列号来满足这些需求。该序列号既是唯一标识符,也确保我们能够维护迁移应用到数据库的顺序。开发人员创建迁移时,会将 SQL 代码放入项目版本控制仓库中 migrations 文件夹下的一个文本文件中。她会查找该文件夹中当前最大的序列号,并使用新的序列号和简短描述来命名文件。例如,早期迁移文件可能命名为:0007_add_insurance_value_to_equipment_type.sql 0008_data_location_equipment_type.sql
为了跟踪数据库已应用哪些迁移,我们使用变更日志表。数据库迁移框架通常会创建该表,并在每次应用迁移时自动更新。这样,数据库就能始终报告自己同步到了哪一次迁移。如果不使用这类框架——毕竟在我们刚开始这项工作时,它们还不存在——我们也会编写脚本来自动化这一过程。

图 2:数据库迁移框架维护的变更日志表
有了这种编号方案,我们就能跟踪大量数据库的变化。

图 3:迁移脚本从创建到生产环境部署的生命周期
有些数据迁移可能需要比功能相关迁移更频繁地发布。在这种情况下,我们发现,为数据相关错误修复设置独立的迁移仓库或文件夹会很有用。每个文件夹都可以由数据库迁移工具单独跟踪,并使用单独的表来存储迁移编号。许多迁移工具也支持修改用于存储迁移元数据的表名。

图 4:使用单独的文件夹来管理新功能数据库变更和生产数据修复
为每位开发者提供独立数据库实例
大多数开发组织会共享一个开发数据库,供团队所有成员使用。可能还会有单独的数据库用于质量保证或预发布测试,但总体目标通常是限制数据库实例数量。这种共享数据库方式源自一个历史现实:数据库实例曾经设置和管理困难,因此组织倾向于尽可能减少数据库数量。不同组织对谁可以修改数据库模式也采取不同控制方式:有些组织要求所有更改都必须通过 DBA 团队;有些组织允许任何开发人员修改开发数据库模式,而 DBA 在变更向下游环境传递时才介入。
当我们开始参与敏捷数据库项目时,注意到应用程序开发人员通常会在自己的私有代码副本中工作。人们通过尝试来学习,因此,在编程中,开发人员会尝试不同实现方式,并在确定最终方案前进行多次试验。能够在私有工作区中实验,并在代码稳定后再推送到共享区域,这一点非常重要。如果所有人都在共享区域工作,就会不断用未完成的变更相互干扰。
虽然我们倡导持续集成,也就是在几个小时内完成集成,但私有工作副本仍然非常重要。版本控制系统正是支持这种工作方式的工具:它允许开发人员独立工作,同时又支持将个人工作集成回主线。
这种独立工作模式不仅适用于文件,也适用于数据库。每位开发人员都应拥有自己的数据库实例,可以自由修改,而不会影响其他人的工作成果。准备好之后,他们再推送并共享更改。下一节将对此进行详细说明。
这些独立数据库可以是共享服务器上的独立模式,也可以是如今更常见的形式:运行在开发人员笔记本电脑或工作站上的独立数据库。十多年前,数据库许可费用可能使单个数据库实例昂贵得令人望而却步,但今天这种情况已经少见得多,尤其是在开源数据库日益普及之后。我们发现,在开发人员机器上运行虚拟机来管理数据库非常方便。团队可以使用虚拟化工具和基础设施即代码来定义数据库虚拟机的构建方式,因此开发人员不需要了解数据库虚拟机的设置细节,也不需要手动配置。

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

图 6:团队中的每个成员都有自己的数据库模式,用于开发和测试。
许多 DBA 仍然非常反感多数据库管理,认为它在实践中难以操作。但我们的经验是,管理一百个左右的应用程序数据库实例并不困难。关键在于拥有能够像操作文件一样操作数据库的工具。
例如,可以通过构建脚本自动创建开发人员数据库模式:<target name=”create_schema” description=”创建用户属性中定义的数据库模式”> <echo message=”管理员用户名:${admin.username}”/> <echo message=”正在创建模式:${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>
利用构建脚本自动创建开发人员模式,可以大幅减轻 DBA 的工作量。而且,这类自动化可以仅限于开发环境使用。
同样,也可以编写删除模式的脚本:<target name=”drop_schema”> <echo message=”管理员用户名:${admin.username}”/> <echo message=”工作用户名:${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 为自己的用户名,同时配置其他必要参数。完成这些设置后,她只需运行:ant create_schema
即可在团队开发数据库服务器或自己笔记本电脑上的数据库服务器中创建专属数据库模式。
创建好模式后,她就可以运行数据库迁移脚本来构建完整数据库内容,包括表、索引、视图、序列、存储过程、触发器、同义词以及其他特定于数据库的对象。
类似地,也应提供脚本来删除模式。删除的原因可能是不再需要该模式,也可能只是开发人员希望清理环境并重新创建一个新的模式。数据库环境应该像凤凰一样:能够根据需要定期彻底重置和重建。这样可以降低环境逐渐积累无法重现、无法审计特征的风险。
开发人员需要私有工作空间,团队中的其他成员也一样。QA 人员应当能够创建自己的数据库,这样他们就不会被自己不了解的变更所干扰。DBA 在探索建模方案或进行性能调优时,也应能够使用自己的数据库副本进行实验。
持续集成:让数据库变更持续进入主线
尽管开发人员可以在自己的沙箱环境中频繁实验,但仍然必须通过持续集成(CI)将不同变更频繁集成到主线软件中。CI 需要设置一台集成服务器,自动构建并测试主线软件。我们的经验法则是:每位开发人员每天至少应向主线集成一次。业界有许多常见持续集成工具可以支持这一实践。

图 7:数据库变更、迁移的开发和集成与应用程序代码的开发和集成方式相同。
数据库迁移的持续集成过程通常包括:开发迁移脚本、本地测试、提交到源代码控制系统、由 CI 服务器获取并应用到集成数据库、再次运行测试,然后打包供下游环境使用。对于希望把目标、需求、评审排期、开发、测试、发布和知识沉淀统一串联起来的研发团队,也可以结合 PingCode 这类智能化研发管理工具,将数据库变更纳入完整研发流程,使变更记录、测试结果、发布状态和团队经验能够更顺畅地流转和沉淀。
下面通过一个示例说明。
1. 小林开始进行数据库变更
小林开始处理一个涉及数据库架构变更的开发任务。如果变更很简单,例如添加一列,小林可以直接决定如何修改。如果变更较复杂,她会找 DBA 一起讨论。
理清变更方案后,她编写迁移脚本。ALTER TABLE project ADD projecttypeid NUMBER(10) NULL; ALTER TABLE project ADD ( CONSTRAINT fk_project_projecttype FOREIGN KEY (projecttypeid) REFERENCES projecttype (projecttypeid) DEFERRABLE INITIALLY DEFERRED ); UPDATE project SET projecttypeid = ( SELECT projecttypeid FROM projecttype WHERE name = ‘Integration’ );
添加一个可为空的列通常是向后兼容的更改,因此小林不必立即修改应用程序代码,也可以先集成这个数据库变更。但如果变更不是向后兼容的,例如拆分表,那么小林也需要同步修改应用程序代码。
2. 小林在本地完成集成准备
小林完成修改后,就可以开始集成。集成的第一步,是更新本地副本,使其与主线代码保持一致。主线中可能包含团队其他成员在她开发期间提交的变更。随后,她通过重建数据库并运行所有测试,检查自己的变更是否与这些更新兼容。
如果遇到问题,例如其他开发人员的变更与她的代码产生冲突,她就需要修复这些问题。通常这类冲突很容易解决,但有时也会比较复杂。复杂冲突通常会引发小林与团队成员之间的讨论,以便找到处理重叠变更的合理方案。
一旦她的本地副本恢复正常运行,她还需要检查在自己处理集成期间是否又有新的变更推送到主线。如果有,她就需要重复拉取和集成新变更的过程。不过通常只需要一两次循环,她的代码就能完全与主线集成。
3. 小林将变更推送到主线
由于这个数据库变更向后兼容现有应用程序代码,小林可以先集成数据库变更,再更新应用程序代码以使用它。这是并行变更的一种常见形式。
4. CI 服务器检测到变更
CI 服务器检测到主线代码发生变化后,会启动新的构建,其中包括数据库迁移。
5. CI 服务器应用迁移并运行测试
CI 服务器使用自己的数据库副本进行构建,因此会将数据库迁移脚本应用到该数据库上。此外,它还会执行其他构建步骤:编译、单元测试、功能测试等。
6. CI 服务器打包并发布构建产物
构建成功完成后,CI 服务器会打包并发布构建产物。这些构建产物包含数据库迁移脚本,以便在下游环境中应用,例如部署流水线中的测试环境和生产环境。构建产物还包含打包成 JAR、WAR、DLL 等格式的应用程序代码。
这正是持续集成的实践方式。它通常用于管理应用程序源代码,而上述步骤则是把数据库代码视为另一类源代码。因此,数据库代码,包括 DDL、DML、数据、视图、触发器和存储过程,都采用与源代码相同的配置管理方式进行维护。每次构建成功后,通过将数据库工件与应用程序工件一起打包,我们就能获得应用程序与数据库完整且同步的版本历史。
对于应用程序源代码,集成变更的大部分难题可以通过源代码控制系统和本地测试解决。对数据库来说,则需要额外努力,因为数据库中存储着必须保持业务意义的数据,也就是状态。我们稍后会更详细地讨论自动化数据库重构。此外,DBA 需要审查所有数据库变更,并确保它们符合数据库模式和数据架构的整体规划。要让这一切顺利进行,重大变更不应在集成阶段才让人感到意外。因此,DBA 必须与开发人员密切合作。
我们强调频繁集成,是因为我们发现:频繁的小规模集成,比低频的大规模集成容易得多。这是“频率降低难度”的典型例子。集成难度会随着集成规模扩大而呈指数级上升。因此,尽管许多人一开始会觉得有些反直觉,但实践证明,多次小调整通常比一次大调整更容易。
数据库由模式和数据共同组成
这里所说的数据库,不仅包括数据库模式和数据库代码,也包括相当数量的数据。这些数据包括应用程序常用的基础数据,例如州或省、国家或地区、货币、地址类型等,也包括各种应用程序特定数据。我们还可能包含一些测试样本数据,例如客户样本、订单样本等。除非是为了健康检查或语义监控,否则这些测试样本数据不会部署到生产环境。
这些数据存在有许多原因。最重要的原因是便于测试。我们坚信,大量自动化测试有助于稳定应用程序开发。这类测试体系是敏捷开发方法中的常见实践。为了让测试高效运行,使用预先填充了一些示例测试数据的数据库是明智做法。所有测试都可以假定这些数据在运行前已经存在。
这些示例数据也需要纳入版本控制。这样,当我们需要填充一个新数据库时,就知道该从哪里获取数据;同时也可以记录这些数据如何随着测试和应用程序代码同步变化。
除了帮助测试代码之外,示例测试数据还允许我们在修改数据库架构时测试迁移。有了示例数据,我们就必须确保任何架构变更都能正确处理这些数据。
在大多数项目中,我们看到的示例数据都是虚构的。但在少数项目中,也有人使用真实数据作为示例。在这些情况下,数据通常通过自动化转换脚本从旧系统中提取。显然,我们不可能一开始就转换所有数据,因为在早期迭代中,新数据库实际上只构建了很小一部分。不过,我们可以采用增量迁移的方式开发转换脚本,及时提供所需数据。
这不仅有助于及早发现数据转换问题,也让领域专家更容易使用不断扩展的新系统。因为他们熟悉这些数据,通常能够帮助识别可能导致数据库和应用程序设计出现问题的情况。因此,我们现在建议,在项目第一个迭代阶段就尽量引入真实数据。我们发现,一些数据抽取和数据子集生成工具可以帮助团队完成这一过程。
数据库重构:将数据库变更视为重构
我们对数据库所做的修改,通常会改变数据库存储信息的方式,引入新的信息存储方式,或移除不再需要的存储结构。但这些数据库变更本身并不改变软件的整体行为。因此,我们可以将它们视为重构。
重构是指对软件内部结构进行修改,使其更易理解、修改成本更低,同时不改变其可观察行为。
认识到这一点后,我们收集并记录了许多数据库重构模式。通过编写这样的目录,我们可以遵循过去已经成功使用过的步骤,从而更容易正确完成这些变更。
数据库重构的一大特点是,它通常涉及三项不同变更,而且三者必须协调完成:
- 修改数据库模式。
- 迁移数据库中的数据。
- 修改数据库访问代码。
因此,每当我们描述一次数据库重构时,都必须描述这三个方面的变更,并确保它们在进行其他重构之前得到完整处理。
与代码重构类似,数据库重构也应保持小步进行。将一系列微小变更串联起来的思路,既适用于代码重构,也适用于数据库重构。由于数据库重构具有模式、数据和访问代码三个维度,小步变更在数据库中显得更加重要。
许多数据库重构,例如引入新列,并不要求立即更新所有访问系统的代码。如果代码并不知道新模式的存在,那么该列就不会被使用。然而,也有许多变更不具备这种特性,我们称之为破坏性变更。破坏性变更需要更加谨慎,谨慎程度取决于其破坏性大小。
一个轻微破坏性变更的例子是“将列设为非空”,即把一个可为空的列改为不可为空。它之所以具有破坏性,是因为如果任何现有代码没有为该列设置值,就会出错;如果现有数据中存在空值,也会出问题。
我们可以通过为所有空值行分配默认数据来避免现有空值问题,代价是这些默认值可能会与原始业务语义略有差异。对于应用程序代码未赋值或赋空值的问题,有两种处理方式。一种是为该列设置默认值。ALTER TABLE customer MODIFY last_usage_date DEFAULT SYSDATE; UPDATE customer SET last_usage_date = ( SELECT MAX(order_date) FROM orders WHERE orders.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;
另一种方式是在重构过程中修改应用程序代码。如果我们能够确保可以访问所有更新数据库的代码,就会优先采用这种方法。如果数据库只服务于单个应用程序,这通常很容易做到;但如果是共享数据库,就会困难得多。
拆分表则更为复杂,尤其当应用程序代码中大量访问该表时。在这种情况下,务必让所有相关人员提前知晓即将发生的变更,以便做好准备。此外,选择相对空闲的时间段进行变更也可能更合适,例如在迭代开始时。
如果系统中所有数据库访问都集中在少数几个模块中,任何破坏性变更都会容易得多。这样更容易找到并更新数据库访问代码。
总之,最重要的是选择适合当前变更类型的流程。如果拿不准,就尽量选择更简单、更安全的变更方式。我们的经验是,实际遇到问题的频率远低于许多人的想象。而且,只要对整个系统进行强有力的配置控制,即便出现最糟情况,也更容易恢复。
过渡阶段:同时支持新旧数据库访问方式
前文已经提到,当进行破坏性数据库重构且无法轻松修改访问代码时,会遇到不少困难。如果数据库是共享的,并且可能被多个应用程序和报表使用,这些问题会更加棘手。在这种情况下,像重命名表这样的操作就必须格外谨慎。为了避免这些问题,我们需要引入过渡阶段。
过渡阶段是指数据库在一段时间内同时支持旧访问模式和新访问模式。这使旧系统可以按照自己的节奏迁移到新架构。

图 8:数据库重构,应用于遗留数据库以及实施前需要经历的各个阶段
以重命名表为例,开发人员可以创建一个脚本,将 customer 表重命名为 client,同时创建一个同名视图 customer,供现有应用程序继续使用。ALTER TABLE customer RENAME TO client; CREATE VIEW customer AS SELECT id, first_name, last_name FROM client;
这种并行变更同时支持新旧访问方式。它确实会增加复杂性,因此,一旦下游系统完成迁移,就必须将兼容层移除。在某些组织中,这可能只需要几个月;而在另一些组织中,可能需要几年。
视图是实现过渡阶段的一种方式。我们也经常使用数据库触发器,尤其是在重命名列之类的操作中,触发器非常有用。
自动化数据库重构
自从重构在应用程序代码领域广为人知以来,许多编程语言都对自动化重构提供了良好支持。自动化重构能够快速执行各种步骤,无需人工干预,从而简化并加速重构过程,同时避免人为错误。数据库领域同样可以从自动化中受益。一些数据库迁移框架提供了用于数据库重构的领域特定语言(DSL),从而形成了标准化的数据库迁移方式。
不过,这类标准化重构方法并不总是完全适合数据库。因为处理数据迁移和遗留数据的规则,很大程度上取决于具体团队和业务语境。因此,我们更倾向于通过编写迁移脚本来处理数据库重构,并把重点放在开发能够自动执行这些脚本的工具上。
如前所述,每个脚本通常结合 SQL DDL 和 DML:DDL 用于模式变更,DML 用于数据迁移。脚本被放入版本控制仓库的指定文件夹中。我们的自动化流程确保这些变更永远不会由人手动执行,而是由自动化工具完成。这样,我们就能保持重构顺序,并更新数据库元数据。
我们可以将这些重构应用到任何数据库实例,使其与最新主线保持同步,或者更新到某个历史版本。工具会利用数据库中的元数据信息判断当前版本,然后应用当前版本与目标版本之间的每一项重构。我们可以用这种方法更新开发实例、测试实例和生产数据库。
更新生产数据库与更新测试数据库并没有本质区别。我们使用相同脚本处理不同数据。我们倾向于频繁发布更新,因为这样可以让每次更新保持较小规模,从而加快更新速度,也更容易处理可能出现的问题。最简单的更新方式,是在应用迁移时关闭生产数据库,这在许多情况下都适用。如果需要在保持应用程序运行的同时更新数据库,也可以做到,只是相关技术需要另写一篇文章来展开说明。
到目前为止,我们发现这种方法效果显著。通过将所有数据库变更分解为一系列小而简单的变更,我们能够在不出问题的情况下,对生产数据进行相当大的修改。
除了自动化正向变更之外,也可以考虑为每次重构自动化反向变更。这样,就可以用同样的自动化方式撤销数据库更改。我们发现,这种做法的成本收益并不理想,不足以让我们长期采用,而且真正需要它的人也并不多。不过,其基本原理是一样的。总体而言,我们更倾向于编写迁移脚本,使数据库访问代码能够同时兼容新旧数据库版本。这样,我们可以先更新数据库以满足未来需求,并让它在线运行一段时间;等确认新数据结构稳定可靠后,再推送使用新数据结构的应用程序更新。
如今,有许多工具可以自动执行数据库迁移,团队可以根据技术栈、部署方式和治理要求选择合适的工具。

开发人员可以按需更新数据库
如前所述,将变更集成到主线的第一步,是拉取我们工作期间主线发生的所有变更。这不仅在集成阶段至关重要,在开发完成之前也很有用,因为我们可以借此评估同事提到的任何变更会产生什么影响。无论哪种情况,能够轻松从主线拉取变更并应用到本地数据库都非常重要。
首先,我们需要将主线分支的变更拉取到本地工作区。通常这很简单,但有时会发现,在我们工作期间,同事已经将新的迁移推送到了主线。如果我们编写了一个序列号为 8 的迁移,那么迁移文件夹中可能已经出现了另一个同样序列号为 8 的迁移。运行迁移工具时,应该能够检测到这种冲突。

一旦发现冲突,第一步很简单:重新编号自己的迁移,例如将其改为 9,使其排在主线新增迁移之后。重新编号后,需要测试迁移之间是否存在冲突。为此,我们会清空数据库,然后将所有迁移,包括主线新增的 8 和重新编号后的 9,应用到一个空白数据库副本上。

通常这种方法运行良好,但偶尔也会出现冲突。例如,其他开发人员可能重命名了我们正在修改的表。在这种情况下,我们需要找到解决冲突的方法。由于每次迁移规模较小,冲突通常更容易发现和处理。
最后,一旦数据库变更完成集成,我们需要重新运行应用程序测试套件,以确保从主线拉取的迁移没有导致任何测试失败。
这一流程允许我们在短时间内独立工作,即使没有网络连接也可以继续开发,然后在合适的时间进行集成。何时集成、集成频率多高,完全由开发人员决定;但在推送到主线之前,必须完成同步。
清晰隔离数据库访问代码
要理解数据库重构的影响,必须了解应用程序如何使用数据库。如果 SQL 代码散落在代码库各处,就很难做到这一点。因此,清晰的数据库访问层非常重要,它能够表明数据库在何处、以何种方式被使用。为此,我们建议遵循经典企业应用架构模式中的数据源架构模式之一。
清晰的数据库访问层还有许多额外价值。它最大限度地减少了需要掌握 SQL 知识才能操作数据库的系统区域,从而简化了那些通常并不精通 SQL 的开发人员的工作。对于 DBA 而言,它也提供了一块清晰的代码区域,方便他们查看数据库如何被使用。这有助于创建索引、优化数据库,并审查 SQL 代码以寻找性能改进机会,也让 DBA 能够更好地理解数据库的运行方式。
通过数据库迁移支持频繁发布
十多年前,当我们撰写本文初稿时,“软件应当频繁发布到生产环境”这一观点还远未普及。但自那以后,许多互联网企业的实践已经表明,快速发布是成功数字化战略的关键组成部分。
通过用迁移捕获每一次数据库变更,我们可以更轻松地将新变更部署到测试环境和生产环境。本文讨论的演进式数据库设计,既是实现频繁发布的关键,也能从真实软件使用反馈中获益。
常见应用场景与变体
任何一组实践都应根据具体情境进行调整。以下是我们遇到过的一些常见情况。
多个版本
简单项目可能只需要一条代码主线,因此也只需要一个数据库版本。更复杂的项目则可能需要支持多个版本,例如用于 A/B 测试,或在金丝雀发布中进行滚动部署,因此也会需要多种形式的项目数据库。每个版本可能都需要自己的测试数据,或者需要针对特定功能或错误修复做出调整。这与在生产环境中管理多个代码版本没有本质区别,只不过数据库也必须能够支持应用程序的多个版本。
我们发现另一种有效做法,是使用单一的数据库代码库,让其他应用程序版本都依赖这个数据库代码库。采用这种方法时,必须确保所有应用版本都与同一个数据库版本兼容。因此,数据库必须向后兼容所有仍在生产环境中运行的旧应用版本。
随应用一并交付数据库变更
在某些项目中,我们需要将产品变更推送给成千上万的终端客户。对于这类项目,更好的做法是将所有数据库变更打包到应用程序本身中进行升级。因为我们无法预先知道客户会从哪个版本升级,所以应用程序应在启动时使用内置的数据库迁移机制升级数据库。
多个应用程序使用同一个数据库
在许多企业中,多个应用程序最终会使用同一个数据库。这就是共享数据库集成模式。在这种情况下,当一个应用程序修改数据库时,很可能会导致其他应用程序故障。
为了解决这个问题,最好将数据库提取出来,作为一个独立代码库供所有依赖应用程序使用。这个公共数据库库应包含自动化行为测试,以确保跨应用程序依赖关系得到测试。如果依赖应用程序受到影响,构建就应失败。
这与拥有一个独立代码库的共享软件组件并没有本质区别。该软件组件不仅需要测试自身行为,也需要测试它与下游应用程序之间通过消费者驱动契约建立的契约。
NoSQL 数据库的演进式设计
本文主要讨论关系型数据库,一方面是因为最初的文章就是基于关系型数据库写成的,另一方面也是因为我们发现关系型数据库仍然是最常见的数据库类型。不过,我们也关注近年来日益普及的 NoSQL 数据库。要完整讨论如何以演进式方式处理 NoSQL 数据库,需要另写一篇文章。这里先做一个简要概述。
NoSQL 数据库常常声称更容易演进,因为它们大多是“无模式”的。但无模式并不意味着可以完全摆脱模式。事实上,仍然存在一个隐式模式:任何访问数据库的代码都会隐含这种模式。这个模式仍然需要管理,本质上仍要使用与源代码库中数据迁移类似的技术。
缺少显式存储模式确实提供了另一种技术可能:支持针对不同版本数据的多种读取策略。这可以简化数据库演进管理,但并不意味着我们可以忽视数据库演进问题。
演进式数据库设计不需要大量 DBA
采用本文描述的技术,听起来似乎工作量很大,但实际上并不需要太多人手。在许多项目中,我们有三十多名开发人员,整个团队规模接近百人,包括测试人员、分析师和管理人员。每天,我们会在不同工作站上部署大约一百份数据库模式副本。然而,所有这些工作只需要一名全职 DBA,以及几名了解流程和工作方式的开发人员提供兼职协助和支持。
对于更小的项目,甚至连这也不一定需要。我们一直在规模较小的项目中使用这些技术,例如十几个人的团队。我们发现,这类项目通常不需要全职 DBA。相反,可以由几位对数据库问题感兴趣的开发人员兼职承担数据库管理任务,并在必要时邀请 DBA 参与设计或架构决策。
实现这一切的关键在于自动化。只要你下定决心将所有任务自动化,就能用更少的人完成大量工作。尤其是在 DevOps 以及基础设施自动化、容器化和虚拟化工具普及之后,这一点更加明显。
自多年前开始采用这种工作方式以来,我们已经习惯于让数据库像应用程序代码一样持续演进。这使我们能够缩短发布周期,更快地将软件投入生产。本文介绍的技术如今已经成为我们日常工作的一部分。
不过,我们的目标并不仅仅是改进自己的方法,也希望与整个软件行业分享经验。我们相信,越多团队采用类似技术,软件就越能够帮助人们实现目标,并带来丰富生活的进步。
文章包含AI辅助创作:演进式数据库设计:敏捷开发中的数据库迁移、重构与持续集成,发布者:su,转载请注明出处:https://worktile.com/kb/p/3977879
微信扫一扫
支付宝扫一扫