原文链接
作者|Shant Stepanian ,高盛平台业务部的高级工程师
本文重点
- Obevo 是在高盛开发的企业级数据库部署工具,在 2017 年以 Apache 2.0 许可证发布的开源项目。
- 允许将数据库脚本按对象进行组织,类似于应用代码,对开发人员来说好处多多。
- 可以帮助具有现有数据库的新应用和系统将其数据库更改管理纳入软件开发生命周期 (Software Development Life Cycle / SDLC) 管控。
- 团队可以使用 Obevo 的上手工具和引导示例快速入门。
- 其他功能包括回滚,内存数据库测试和分阶段部署。
近年,高盛采用了标准的 SDLC 来构建和部署应用程序。这包括管理新系统和现有系统的数据库 schema ,这比管理应用程序代码更加困难。在本文中,我们将描述我们最近开源的数据库部署工具 Obevo 是如何帮助高盛的企业级应用程序的数据库纳入 SDLC 管控的。
企业的数据库部署的问题
将数据库定义引入到标准的 SDLC 流程中是具有挑战性的,尤其是考虑到数据库的状态以及执行增量迁移的需求。因此,许多应用程序没有自动化或流畅的数据库部署过程。我们的目标是将数据库 schema 管理纳入与应用程序相同的 SDLC 中:通过将所有定义提交到版本控制系统 (VCS) 并通过标准的构建/发布机制部署。
这项工作由我们实际数据库系统使用案例的多样性而变得复杂:
- 现代系统:全新的 schema 来部署表、进行内存测试,并从一开始就纳入了适当的 SDLC。
- 遗留系统:超过十年的系统,从未有过受控部署过程。
- 复杂系统:数百或数千个对象,包括表、视图、存储过程、函数、静态数据上传和数据迁移等类型。
- 耗时系统:包含数百万行的表,需要花几个小时部署。
无论使用何种案例,由于大量分布各地的开发人员都在进行变更,SDLC 本身都有复杂之处。
虽然现有的开源工具可以处理简单的案例,它们无法处理我们一些现有系统的规模和复杂度。但是我们不能放任这些现有系统没有适当的 SDLC:它们是正在开发和发布的关键系统。
因此,我们开发了 Obevo,一个能处理所有此类使用案例的工具。Obevo 的关键差异化因素在于能够按文件维护数据库对象(类似于更常见的按文件存储类定义),同时仍管理增量部署。
在本文中,我们将讨论数据库部署问题,然后演示基于对象的项目结构如何帮助我们优雅地管理各种对象和环境类型的上百上千个 schema 对象。
数据库对象类型(有状态 vs 无状态)
首先,我们来回顾一下不同数据库对象类型的部署语义,因为这会影响工具的设计。
快速术语说明:
- 将某些代码 / SQL 应用于修改数据库的行为将被称为部署或迁移。
- 被部署的代码单元将被称为脚本片段。
- 一个文件可能包含多个脚本片段,即脚本片段不等同于脚本文件。关于一个文件是否应该包含一个还是多个脚本片段是本文中的主题。
有状态对象(例如:表)
有状态对象需要增量修改其定义,而不是完全的定义替换。以下是添加两列到 MyTable 中的示例:
理想情况下,我们可以通过一个 SQL 语句来把数据库带到终态,该 SQL 定义了一个具有四列的表。不过 SQL 其实无法提供可行的解决方案:
- 删除并重新创建表意味着你会失去数据
- 将数据保存在临时表中可能是一个昂贵且复杂的操作
相反,关系型数据库管理系统 (RDBMS) 允许用户使用 ALTER 语句来修改现有表。
某些列更新可能需要进行数据迁移,例如从一个表中移动列数据到另一个表中。
因此,每个对象都是应用多个脚本片段的结果;初始脚本片段创建对象,后续脚本片段修改对象。
无状态对象(例如:视图,存储过程)
另一方面,无状态对象可以通过指定其完整对象定义来创建和修改。
- 这里,DROP + CREATE 语句或 CREATE OR REPLACE 语句都适用
- DROP + CREATE 在这里是安全的,因为这些对象没有数据/状态
我们也将静态数据文件(代码或参考数据表)视为无状态对象。虽然它涉及到表数据,但是这些数据在你的脚本中已经完全定义,并且可以通过批量 delete + insert 或选择性 insert + update + delete 操作部署到表中。
数据库部署工具原则
Martin Fowler 的 Evolutionary Database Design 对基于源头控制的数据库部署工具遵循的主要原则进行了精彩阐述,下面是其中一些具体相关的要点。
- 所有数据库制品 (Database Artifacts) 都与应用程序代码一起进行版本控制(与从 UI 管理相反)
基于 UI 的管理可能适用于非技术用户,但我们建议开发团队将其数据库脚本片段存储在源代码控制中(和对待其它应用程序一样),并以自动化方式调用部署。
- 对于有状态对象,明确编码的增量迁移比自动计算的迁移更可取。
在我们看来,在企业环境中,例如在当前数据库表状态和代码完整视图之间自动计算迁移是有风险的。
数据库部署工具要求
我们根据以下需求评估数据库部署工具的处理能力:
A) 将增量更改部署到现有数据库中
这是数据库部署工具的主要功能;大多数生产环境都是通过此方式执行的。在一些非生产环境中,尤其是在发布到生产之前的 QA 环境中进行测试时,也会使用此方法。
B) 将完整 schema 部署到空白数据库中
开发人员可能希望将其部署到一个空白沙盒数据库以进行以下操作:
- 验证 SQL 脚本片段是否实际可用;
- 运行涉及你数据库(例如测试新列或存储过程)的系统集成测试;
- 在使用内存数据库时,在单元测试中测试数据访问代码。
这可以通过以下几种方式来完成:
- 通过从头开始重放所有迁移脚本片段,前提是先前的脚本片段已保存在你的包中
- 重新基线化脚本片段,使其可以部署到空白数据库,同时仍然允许后续增量生产部署。
C) 易于维护和阅读
在我们进行数据库部署改进之前,我们看到一些团队为每个数据库对象维护一个文件,其中包含对象的定义,尽管这些文件并未用于部署。
这似乎没有意义,但我们获得了一些见解:
- 开发人员喜欢可视化地看到他们的数据库对象和结构的表示形式
- ORM 工具如 Hibernate and Reladomo 生成显示此类结构的 DDL,并将其与你的数据库实例联系起来是一个加分项。
通用数据库部署工具设计
主要部署算法
根据上述原则,大多数基于源代码控制的数据库部署工具(包括 Obevo)的工作方式如下:
- 开发人员为下一个版本编写脚本片段,添加到已经在源代码控制中部署的脚本集中。
- 测试并将脚本片段,构建成一个软件包。
软件包针对目标数据库进行部署。
- 简单的工具需要部署者指定要部署的脚本集。
- 进阶的工具通过与部署日志表进行对比来确定哪些脚本片段需要进行部署,如下图所示
- 这样相同的软件包和部署命令可针对任何环境进行使用,无论先前在该环境上部署了哪个版本
有状态和无状态对象部署语义
对象类型对变更集计算语义会产生影响。
- 无状态对象允许添加、删除和更新脚本:如果该对象定义是有效的,则可以替换数据库中现有的定义而不会丢失数据。
- 然而,有状态对象通常只允许添加脚本:更新已部署的脚本可能会形成与预期不同的对象。
为了演示有状态用例,我们将 deploy 部署包 v1 以获取右侧表。
假设有人修改了 M3 并重命名了列,然后我们进行重新部署。我们期望会发生什么?
工具检测到不匹配:
- 脚本片段 M3 已更改并想要添加列 C123456。
- 但是数据库已经部署了列 C。
- 源脚本片段不再包括列 C,但也无法从数据库中删除它。
因此,一般规则是:有状态对象脚本一旦部署就不能修改。
某些选择性功能可以让我们在需要时绕过这个问题,例如:
- 回滚:提供一个明确的回滚脚本片段,以在取消部署时使用
- 重新基线化:将已部署的脚本重写为更简洁的脚本而不尝试重新部署
数据库部署工具的实现选择
考虑到它们的底层算法相似,部署工具因几个实现要点而异。
1)如何将脚本片段组织成文件
有如下几种方式将脚本片段进行分组:
- 发布的版本
- 修改的对象
- 不对脚本进行分组,并保留单独的迁移
2)如何排序部署要部署的脚本片段
需要考虑:
- 列出精确迁移顺序的单独文件
- 确定顺序的文件命名约定
- 对暗示顺序的脚本集合进行依赖性分析
接下来,我们将详细介绍 Obevo 是如何解决这两个问题的。
Obevo 设计:基于对象的脚本组织
我们主要的数据库部署问题是如何管理 schema 中大量对象的开发、维护和部署。同时,还有开发人员在编写应用程序时处理他们的数据库对象。
因此,我们希望提供一种对开发人员来说易于理解的体验,这导致了我们根据对象名称组织脚本。在本节中,我们将深入探讨这些详细信息。(该结构增加了一些排序方面的挑战,下一节详细介绍)
项目结构基础
我们根据适用于那些对象的脚本片段来组织,以下是一个示例。
文件结构根据对象是否具有状态而不同。
- 无状态对象可以仅将定义本身存储在文件中,因为它们的完整定义可以针对数据库进行部署。
然而,有状态对象需要使用增量脚本片段进行部署。
- 因此,需要多个脚本片段才能将一个对象带到其当前状态,并且我们将所有这些内容保存在同一文件中。
- 我们通过以 //// CHANGE 开头的行来标记每个部分并将文件拆分成多个脚本片段。
分析:无状态对象处理
基于对象的结构对于无状态对象方便很多,因为完整的无状态对象定义可以在文件中维护,并且可以就地修改。
作为比较,在增量有状态的方式中,技术上可以处理无状态对象部署,例如作为持久化多个版本的增量脚本。然而,这会导致数据库脚本存在冗余,因为对象在多个版本中发生变化。
分析:可读性
从维护的角度来看,这个项目结构有一些优势:
- 数据库结构从项目结构中易于查阅。
- 要更改或审核对象,查找文件很方便。
- 编写或审核更改的同时可以查看对象定义,而不是在其他文件或数据库本身中查看对象定义。
- 尽管脚本片段可能会在有状态的对象文件中累积,但可以通过重新基线化功能将多个脚本合并为一个,而不执行任何部署来缓解。
为了进行比较,可以拿一个项目结构的例子来说明,其中一个文件与迁移或发布相关联,因为许多工具都支持这种方式。这可能会导致一些问题:
- 对象可读性不足:如果一个对象在多个版本中被修改,它的结构将会分散在多个文件中(对于有状态的对象),或者仅仅是在许多文件中冗余存在(如之前所示的无状态对象)。
- 无法读取和写入对象的累积:由于上一条而无法读取,根据不修改有状态对象脚本的规则而无法写入
虽然重新基线化可以减少文件数量,但必须在完整 schema 下执行,而不是按对象进行。但与基于对象的项目结构相比:
- 重新基线化工作量将更大
- 生成的新基线文件将更大且难以阅读
从代码审查/发布审查角度来看:面向对象的结构意味着特定版本中所有更改都会分散在文件中。乍一看,似乎很难审核要部署到下一个版本的脚本片段。但是,我们仍然可以通过比较 VCS 历史记录和标签来审核发布的代码 - 就像处理应用程序代码一样。
分析:对于开发者的好处
使用 Obevo 项目结构,开发人员也好处多多。
由于对象的脚本片段放置在单个文件中,我们可以轻松地在测试中部署单个对象,这对于像在内存数据库中进行数据访问 API 的单元测试等情况非常有用。
开发人员也可以利用 ORM 工具从应用程序生成 DDL,并与迁移脚本进行协调。简洁起见,这里我们不深入讨论,但你可以在文档中了解更多。
Obevo 设计: 通过依赖性分析进行排序
虽然选择基于对象的项目组织方式提供了上一节中提到的许多好处,但它也使排序变得更加复杂。
对象可以互相依赖,随着我们把 schema 扩展到数百或数千个对象,手动指定顺序变得越来越困难。
让我们描述一下我们是如何克服这些挑战的。
排序算法
不是所有的脚本片段都互相依赖,因此我们在对象依赖声明的明显约束下是有一些灵活性的。
因此,我们使用一个简单的图算法来设计解决方案。
比如以下示例语句:
- 3 条用于创建表
- 1 条用于建立一个外键
- 1 条用于创建视图:
注意以下几点:
- 建表的顺序并不重要
- 外键必须在 TABLE_A 和 TABLE_B 之后创建。
- VIEW1 必须在 TABLE_A 之后创建。
这适合使用有向图表示,其中图节点是脚本片段,边是顺序依赖关系。
我们现在可以使用拓扑排序算法得出一个可接受的顺序,以保持这些顺序约束并成功部署我们的数据库。
拓扑排序可以产生许多可接受的顺序,但是我们会调整算法使用方式以给出单一一致的顺序,以便在各个环境中具有一致性。
现在最后一个细节:如何在脚本片段中声明依赖关系?
依赖声明和发现
我们发现最简单的方法是在脚本中声明依赖项。请参见下面 TABLE_B.fkA 脚本片段中的 dependencies 属性。
然而,对于大型数据库来说这并不友好(想象一下注释数百或数千个对象),因此我们需要一种自动检测依赖关系的方式,同时仍允许开发者覆盖。
我们使用两种策略来推断依赖关系:
有状态迁移的内部对象依赖关系:
我们允许有状态的对象定义多个脚本。很自然地,我们认为在同一个文件中编写的迁移按照它们编写的顺序进行部署,因此我们推断出这样的依赖关系(下图)。
通过文本搜索实现跨对象依赖关系
为检测跨对象的依赖关系,我们需要搜索脚本内容以查找相关对象。
从技术上讲,理想的方法是解析 SQL 以查找这些对象。然而,这非常困难,因为我们必须理解所有支持的 DBMS 类型的 SQL 语法。
相反,Obevo 采用简单的方法:在你的项目中选择通过字符串搜索发现的对象名称,并假定它们是依赖项。
实现说明:
- 可以通过列出项目中的文件来查找可用的对象名称
- 有多种方法可以在脚本中搜索对象名称。我们当前的实现通过空格将脚本分解为标记,然后检查该标记是否存在于对象名称集合中
- 算法还有更多细节,但以上内容足以说明
以下是我们先前示例的算法结果:
如果出现误报匹配(例如由于注释)或假阴,开发人员可以根据需要指定排除或包含覆盖。
乍一看,可能很难想象这适用于实际用例,但我们已经成功地使用了这种技术来部署许多复杂的 schema ,其中一些跨越数千个对象,如表、存储过程、视图等。
想看实例的话请查看我们的 kata 课程,该课程是个通过一个大型数据库 schema 反向工程的示例。
跨多个表处理数据迁移
我们快速过一下这种用法(即将数据从旧列移动到新列,然后删除旧列),因为最开始将基于对象的文件结构概念应用于此听起来更复杂。
Obevo 可以处理这个问题 - 简而言之,我们提供了「迁移」对象的概念来帮助解决此问题,它:
- 让我们定义更新脚本片段以促进这些迁移
- 允许每个对象脚本仅保留与其定义相关的脚本片段,从而保留其单独部署进行测试的能力
如需更多信息,请查看文档。
现有数据库 schema 的逆向工程
希望我们成功展示了你可以使用 Obevo 部署复杂的数据库 schema 。但是,为了让现有系统真正加入到 Obevo 中,我们必须使开发人员能够轻松地对现有数据库进行逆向工程。
解决这个问题并不容易,因为不幸的是,在不同的 DBMS 类型之间没有一个完美的统一 API
- Java + JDBC 提供 DatabaseMetaData API,但在不同的 DBMS 上实现方式存在差异
- 一些第三方工具试图弥合差距,但不能涵盖厂商可能公开的所有详细 SQL 语法,并且可能滞后于覆盖 DBMS 发布的新功能
因此,我们选择与供应商提供的反向工程工具集成(见下表)。一些工具只需将完整架构输出到单个文件中即可,但我们提供了一个实用程序,可以将这些文件转换为 Obevo 基于对象结构,并利用简单字符串解析和正则表达式技术。相比 Java API,我们认为这种技术更可靠于现有系统中,特别是核心供应商工具最懂得他们自己架构。
数据库管理系统
结论
虽然有许多开源工具可用于数据库部署,但我们认为更复杂的使用情况需要更强大的工具支持。
通过 Obevo,我们旨在支持所有类型的系统;无论是通过测试功能和易于基于对象维护来增强现代系统的生产力,还是通过促进长期存在但以前没有 SDLC 管控的系统加入管控重获新生。
这里还有更多未涉及到的特性和数据库部署活动(例如回滚、分阶段部署、内存 DB 测试、多 schema 管理)。欢迎访问我们的 Github,文档和 Kata 课程,了解更多关于 Obevo 以及如何将该工具应用到你的系统中。
Bytebase 团队的读后感
通过解读高盛如何变更数据库的案例,我们可以一窥企业级数据库变更的复杂度。这篇文章的精华在于当中对于数据库变更工具设计方案选择的亮点归纳:
- 如何将脚本片段组织成文件
- 如何排序部署要部署的脚本片段
Obevo 相比于业界其他的工具,在这两点上都选择了非主流,但是可以更加自动化的方案:
- 在脚本的组织上,采用了面向数据库对象的组织
- 在部署排序上,采用了隐式的自动化拓扑排序
对于高盛来说这是可行的,因为在一个单一组织内,可以通过各种约束,保证操作的一致性,然后基于这样的统一,提高自动化。但是如果要作为一个对外输出的方案,就未必能应付不同组织不同的操作习惯。
Bytebase 团队接触到 Obevo 也是一个潜在客户在调研各种方案时聊起的。相比于 Obevo 而言,Bytebase 提供的是数据库变更,查询,安全,治理一体化方案,而且还提供了可视化界面。
你可以访问官网:https://www.bytebase.com/,免费注册云账号,立即体验 Bytebase。