前言
在数据库代码化(Database-as-Code)实战一文中介绍了如何借助 Flyway 实现 migration based 的数据库迁移。但在实践过程中,发现了如下问题:
- 随着项目的发展,迁移脚本数量会越来越多,而全新部署时由于要执行所有的历史变更,部署时间会越来越长。
- 由于数据库的最终状态是由变更脚本依次执行形成的,这就导致了开发人员无法通过源码直观看到数据库的当前状态。
- 因为很多数据迁移场景涉及到字段的解析以及和第三方系统或工具的交互,使用 Python 脚本实现迁移过程会更加方便。但目前 Flyway 只支持执行 SQL 类型的迁移脚本。
为了解决上述问题,我们基于 migration based 方法,并借鉴了 Flyway 的设计思想,改进了原有的数据库代码化方案。
数据库代码化改进方案
迁移脚本命名规范
迁移脚本命名规范参考了 Flyway 的标准,但也增加了一些限制,下面对其进行说明:
- Prefix - 固定为
V
。 - Version - 由日期和索引组成,格式固定为
yyyy.mm.dd.index
。其中 index 长度固定为 3,范围是 000 ~ 999,用于区分当天新增的不同迁移脚本。 - Separator - 固定为两个下划线
__
。 - Description - 描述信息,文字之间可以用下划线或空格分隔。
- Suffix - 后缀标识,支持
.sql
和.py
。
元数据表结构
和所有 migration based 方案类似,该方案会在目标数据库中创建一个名为schema_version_history
的元数据表用于记录变更信息,具体表结构如下。
mysql> describe schema_version_history;
+--------------+--------------+------+-----+-------------------+-------+
| Field | Type | Null | Key | Default | Extra |
+--------------+--------------+------+-----+-------------------+-------+
| version | bigint(20) | NO | PRI | NULL | |
| description | varchar(200) | YES | | NULL | |
| installed_on | timestamp | NO | | CURRENT_TIMESTAMP | |
| type | varchar(20) | YES | | NULL | |
+--------------+--------------+------+-----+-------------------+-------+
下面对各字段的含义作简要说明。
- version - 已成功应用到数据库上的迁移脚本的版本信息,字段值会从迁移脚本名中提取。例如,
2019.11.11.003
会被转换成整数20191111003
进行存储。 - description - 已成功应用到数据库上的迁移脚本的描述信息,字段值会从迁移脚本名中提取。
- installed_on - 迁移脚本成功应用到数据库上的时间。
- type - 迁移脚本的类型标识(NORMAL、LEGACY、PRE INSTALL、POST INSTALL、UPGRADE EMPTY、OUT OF ORDER)。
迁移脚本组织结构
改进方案的迁移脚本组织结构如下:
|--{db1}
|--db.conf
|--init_scripts
|--a.sql
|--b.sql
|...
`--z.sql
|--upgrade_legacy_private_cloud_scripts
|--V0666.00.00.000__alter_TB_a_add_column.sql
|--V0666.00.00.001__change_to_new_index.py
`--V0666.00.00.002__alter_TB_c_add_column.sql
|--upgrade_scripts
|--V2019.11.11.000__alter_TB_b_add_column.sql
|--
|--V2019.11.11.001__TB_c_insert.sql
`--V2019.11.13.000__migrate_legacy_alert_rule.py
|--{db2}
|--db.conf
...
|--common
|--procedure.sql
`--schema_version_history.sql
下面对其进行说明:
- 每个数据库对应一个独立的目录,包含了该数据库的迁移脚本和配置信息。
- 数据库目录下的文件
{db}/db.conf
包含了该数据库的连接、认证等信息。 - 子目录 init_scripts 用于存放数据库的最新 schema。
- 子目录 upgrade_legacy_private_cloud_scripts 用于存放专有云老版本到新版本的迁移脚本。版本号需要小于全新部署时的前置版本号
10000000000
。 - 子目录 upgrade_scripts 统一存放公有云和专有云的后续迁移脚本。版本号由当前日期和索引组成,大于全新部署时的后置版本号
20000000000
。
可以看到,和原方案相比,新方案有如下改变:
- 增加了 SQL 文件
common/schema_version_history.sql
用于初始化元数据表。 - 去掉了用于存放存量 schema 的目录
{db}/base_scripts
。 - 新建目录
{db}/init_scripts
用于存放数据库的最新 schema,全新部署时将直接执行该目录下的 SQL 脚本,免去了执行所有历史变更的过程。 - 支持执行 SQL 和 Python 类型的迁移脚本。
执行流程
基于上述迁移脚本的管理模式,公有云和专有云不同场景的执行流程如下:
版本号编制
为了方便处理公有云、专有云各类新老版本的部署和升级场景,通过如下方法对版本号进行编制。
- 数据库全新安装前置版本号设为
10000000000
。 - 数据库全新安装后置版本号设为
20000000000
。 - 专有云老版本到新版本的迁移脚本版本号小于
10000000000
,例如06660000007
。 - 公有云和专有云新增的迁移脚本以当前期间和索引作为版本号,大于
20000000000
,例如20191111003
。
全新安装 or 升级
不能单纯根据数据库是否为空判断当前应该执行全新安装步骤还是升级步骤,因为程序有可能在执行全新安装步骤时创建了若干张表后异常退出。这里采用的方案如下:
- 在执行 init_scripts 中的脚本之前,向元数据表
schema_version_history
中插入一条 version 为10000000000
的记录。 - 如果 init_scripts 中的脚本全部执行成功,则将 upgrade_scripts 目录中脚本的最新 version 插入
schema_version_history
中。 - 如果 upgrade_scripts 目录为空,则向
schema_version_history
中插入一条 version 为20000000000
的记录。
这样即使程序中途退出,再次启动后只要发现数据库的版本为10000000000
,就继续执行全新安装的步骤。
脚本的可重入性
每一个迁移脚本的成功执行都对应着schema_version_history
中的一条记录。如果迁移脚本是 SQL 文件,并且是单纯的 DML,则可以将迁移脚本和迁移记录的插入封装在一个事务中执行,从而避免出现状态不一致。但对于包含 DDL 的 SQL 或是 Python 类型的迁移脚本,显然无法通过事务保证迁移脚本和迁移记录的插入同时成功或失败。因此,这里采用了先执行迁移脚本,再进行迁移记录插入的策略。这就对迁移脚本的可重入性提出了要求。让脚本具备可重入性的通用方法可参考幂等性实践。
迁移脚本执行时机
应用升级过程中的数据迁移可能发生在多个阶段,下图展示了某个常见的升级场景。
- 应用开始升级前需要进行一些表结构的变更(数据迁移),支持应用升级后数据以新的格式写入。
- 应用的升级过程是分批次灰度进行的,此时数据有可能以旧的格式写入。
- 应用的全部实例完成升级后,需要对升级过程中产生的旧数据进行订正(数据迁移)。
如果严格按 version 大小判断脚本是否需要执行,则有可能出现数据修正脚本无法执行的情况。为此,我们将迁移脚本分成了 pre_upgrade 和 post_upgrade,对于 post_upgrade 中的脚本,只要在schema_version_history
中不存在对应的执行记录,就允许它执行。
总结
和原方案相比,改进后的方案让全新部署场景下数据库的初始化时间不会随着迁移脚本的增加而延长,同时也可以通过源码直观看到数据库的当前状态,另外也支持了 Python 类型迁移脚本的执行。但这些改进也是有一定代价的,它要求开发人员在进行数据库变更时,既要增加迁移脚本,也要修改数据库初始化脚本。为了防止开发人员的遗漏,建议对数据库代码化部分执行更加严格的代码合入和代码 review 策略。