這麼強?!Erda MySQL Migrator:持續集成的資料庫版本控制
- 2022 年 7 月 8 日
- 筆記
為什麼要進行資料庫版本控制?
現代軟體工程逐漸向持續集成、持續交付演進,軟體一次性交付了事的場景逐漸無法滿足複雜多變的業務需求,「如何高效地進行軟體版本控制」成為我們面臨的挑戰。同時,軟體也不是僅僅部署到某一套環境中,而是需要部署到開發、測試、生產以及更多的客戶環境中,「如何一套程式碼適應不同的環境」也成為我們要思考的問題。

一套軟體的副本要部署在不同的環境(圖源:Flyway)
程式碼版本管理工具(Git、SVN 等)和託管平台(Github、Erda DevOps Platform 等)讓我們能有效地進行程式碼版本管理。越來越豐富的 CI/CD 工具讓我們能定義可重複的構建和持續集成流程,發布和部署變得簡單清晰。
「基礎設施即程式碼」的思想,讓我們可以用程式碼定義基礎設施,從而抹平了各個環境的差異。
可以說,在軟體側我們應對這些挑戰已經得心應手。
但是絕大多數項目都至少包含兩個重要部分:業務軟體,以及業務軟體所使用的資料庫——許多項目資料庫側的版本控制仍面臨亂局:
- 很多項目的資料庫版本控制仍依賴於「人肉維護」,需要開發者手動執行 SQL;
- 環境一多,幾乎沒人搞得清某個環境上資料庫是什麼狀態了;
- database migrations 腳本沒有統一管理,遺失錯漏時有發生;
- 不確定腳本的狀態是否應用,也許在這個環境應用了但在另個環境卻沒有應用;
- 腳本里有一行破壞性程式碼,執行了後將一個表欄位刪除了,數據無法恢復,只能「從刪庫到跑路」;
- ……
為了應對這樣的亂局,我們需要資料庫版本控制工具。
資料庫版本控制,即 Database Migration,它能幫你:
- 管理資料庫的定義和遷移歷程
- 在任意時刻和環境從頭創建資料庫至指定的版本
- 以確定性的、安全的方式執行遷移
- 清楚任意環境資料庫處於什麼狀態
從而讓資料庫與軟體的版本管理同步起來,軟體版本始終能對應正確的資料庫版本,同時提高安全性、降低維護成本。
Erda 如何實踐資料庫版本控制
Erda 是基於多雲架構的一站式企業數字化平台,為企業提供 DevOps、微服務治理、多雲管理以及快數據管理等雲廠商無綁定的 IT 服務。Erda 既可以私有化交付,也提供了 SaaS 化雲平台 Erda Cloud,以及開源的社區版。
當你正在閱讀這篇文章時,有無數來自不同組織的應用程式正在 Erda Cloud 或 Erda 私有化平台的流水線上完成以構建和部署為核心的 CI/CD 流程,無數的程式碼,以這種持續而自動化的方式轉化成服務實例。
Erda 平台不但接管了這些組織的應用程式的集成、交付,Erda 項目自身的集成也是託管在 Erda DevOps 平台的。Erda 自身的持續集成和豐富的交付場景要求它能進行安全、高效、可持續的資料庫版本控制,託管在 Erda 上的應用程式也要求 Erda 提供一套完整的資料庫版本控制方案。
Erda 項目使用 Erda MySQL Migrator 作為資料庫版本控制工具,它被廣泛應用於 CI/CD 流程和命令行工具中。
基本原理
第一次使用 Erda MySQL Migrator 進行資料庫版本控制時會在資料庫中新建一個名為 schema_migration_history 的表,如下如所示:

schema_migration_history 表的基本結構(部分主要欄位)
Erda MySQL Migrator 每次執行 database migration 時,會對比文件系統中的 migrations 腳本和 schema_migration_history 表中的執行記錄,標記出增量的部分。在一系列審查後,Erda MySQL Migrator 將增量的部分應用到目標 database 中。成功應用的腳本被記錄在案。
Erda MySQL Migrator 命令行工具
erda-cli 工具的構建與安裝
erda-cli 是 erda 項目命令行工具,它集成了 Erda 平台安裝、Erda 拓展管理以及開發腳手架。其中 erda-cli migrate 命令集成了資料庫版本控制全部功能。
從 erda 倉庫 拉取程式碼到本地,切換到 master 分支,執行以下命令可以編譯erda-cli :
% make prepare-cli
% make cli
注意編譯前應確保當前環境已安裝 docker。編譯成功後項目目錄下生成了一個 bin/erda-cli 可執行文件。
使用 erda-cli migrate 進行資料庫版本遷移
Erda MySQL Migrator 要求按 modules/scripts 兩級目錄組織資料庫版本遷移腳本,以 erda 倉庫為例:
.erda/migrations
├── apim
│ ├── 20210528-apim-base.sql
│ ├── 20210709-01-add-api-example.py
│ └── requirements.txt
... ...
├── cmdb
│ ├── 20210528-cmdb-base.sql
│ ├── 20210610-addIssueSubscriber.sql
│ ├── 20210702-updateMbox.sql
│ └── 20210708-add-manageconfig.sql
└── config.yml
└── 20200528-tmc-base.sql
erda 項目將資料庫遷移腳本放在 .erda/migrations 目錄下,目錄下一層級是按模組名(微服務名)命名的腳本目錄,其各自下轄本模組所有腳本。與腳本目錄同級的,還有一個 config.yml 的文件,它是 Erda MySQL Migration 規約配置文件,它描述了 migrations 腳本所需遵循的規約。
腳本目錄下按文件名字元序排列著 migrations 腳本,目前支援 SQL 腳本和 Python 腳本。如果目錄下存在 Python 腳本,則需要用 requirements.txt 來描述 Python 腳本的依賴。
進入 migrations 腳本所在目錄 .erda/migrations,執行 erda-cli migrate :
% erda-cli migrate --mysql-host localhost \
--mysql-username root \
--mysql-password 123456789 \
--sandbox-port 3307 \
--database erda
INFO[0000] Erda Migrator is working
INFO[0000] DO ERDA MYSQL LINT....
INFO[0000] OK
INFO[0000] DO FILE NAMING LINT....
INFO[0000] OK
INFO[0000] DO ALTER PERMISSION LINT....
INFO[0000] OK
INFO[0000] DO INSTALLED CHANGES LINT....
INFO[0000] OK
INFO[0000] COPY CURRENT DATABASE STRUCTURE TO SANDBOX....
INFO[0014] OK
INFO[0014] DO MIGRATION IN SANDBOX....
INFO[0014] OK
INFO[0014] DO MIGRATION....
INFO[0014] module=apim
... ...
INFO[0014] module=cmdb
INFO[0014] OK
INFO[0014] Erda MySQL Migrate Success !
執行 erda-cli migrate 命令
從執行日誌可以看到,命令行執行一系列檢查以及沙盒預演後,成功應用了本次 database migration。我們可以登錄資料庫查看到腳本的應用情況。
mysql> SELECT service_name, filename FROM schema_migration_history;
+---------------+-------------------------------------------+
| service_name | filename |
+---------------+-------------------------------------------+
| apim | 20210528-apim-base.sql |
| apim | 20210709-01-add-api-example.py |
... ... ... ... ... ...
| cmdb | 20210528-cmdb-base.sql |
| cmdb | 20210610-addIssueSubscriber.sql |
| cmdb | 20210702-updateMbox.sql |
| cmdb | 20210708-add-manageconfig.sql |
+---------------+-------------------------------------------+
登錄 MySQl Server 查看腳本應用情況
基於 Python 腳本的 data migration
從上一節我們看到,腳本目錄中混合著 SQL 腳本和 Python 腳本,migrator 對它們一致地執行。Erda MySQL Migrator 在設計之初就決定了單腳本化的 migration,即一個腳本表示一次 migration 過程。大部分 database migration 都可以很好地用 SQL 腳本表達,但仍有些包含複雜邏輯的 data migration 用 SQL 表達則會比較困難。對這類包含複雜業務邏輯的 data migration,Erda MySQL Migrator 支援開發者使用 Python 腳本。
erda-cli 提供了一個命令行 erda-cli migrate mkpy 來幫助開發者創建一個基礎的 Python 腳本。執行:
% erda-cli migrate mkpy --module my_module --name myfeature.py --tables blog,author,info
命令生成如下腳本:
"""
Generated by Erda Migrator.
Please implement the function entry, and add it to the list entries.
"""
import django.db.models
class Blog(django.db.models.Model):
name = models.CharField(max_length=100)
tagline = models.TextField()
class Meta:
db_table = "blog"
class Author(django.db.models.Model):
name = models.CharField(max_length=200)
email = models.EmailField()
class Meta:
db_table = "author"
class Info(django.db.models.Model):
blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
headline = models.CharField(max_length=255)
body_text = models.TextField()
pub_date = models.DateField()
mod_date = models.DateField()
authors = models.ManyToManyField(Author)
number_of_comments = models.IntegerField()
number_of_pingbacks = models.IntegerField()
rating = models.IntegerField()
class Meta:
db_table = "info"
def entry():
"""
please implement this and add it to the list entries
"""
pass
entries: [callable] = [
entry,
]
該腳本可以分為四個部分:
- import 導入必要的包。腳本中採用繼承了 django.db.models.Model 的類來定義庫表,因此需要導入 django.db.model 庫。開發者可以根據實際情況導入自己所需的包,但由於單腳本提交的原則,腳本中不應當導入本地其他文件。
- 模型定義。腳本中
class Blog、class Author和class Entry是命令行工具為開發者生成的模型類。開發者可以使用命令行參數--tables指定要生成哪些模型定義,以便在開發中引用它們。注意,生成這些模型定義類時並沒有連接資料庫,而是根據文件系統下過往的 migration 所表達的 Schema 生成。生成的模型定義只表示了表結構而不包含表關係,如「一對一」、「一對多」、「多對多」等。如果開發者要使用關聯查詢,應當編輯模型,自行完成模型關係的描述。Django ORM 的模型關係僅表示邏輯層面的關係,與資料庫物理層的關係無關。 - entry 函數。命令行為開發者生成了一個名為
entry的函數,但是沒有任何函數體,開發者需要自行實現該函數體以進行 data migration。 - entries,一個以函數為元素的列表,是程式執行的入口。開發者要將實現 data migration 的業務函數放到這裡,只有 entries 中列舉的函數才會被執行。
從以上腳本結構可以看到,我們選用的 Django ORM 來描述模型和進行 CRUD 操作。
為什麼採用 Django ORM 呢?
因為 Django 是 Python 語言里最流行的 web 框架之一,Django ORM 也是 Python 中最流行的 ORM 之一,其設計完善、易用、便於二次開發,且有詳盡的文檔、豐富的學習材料以及活躍的社區。無論是 Go 開發者還是 Java 開發者,都能在掌握一定的 Python 基礎後快速上手該 ORM。我們通過兩個簡單的例子來了解下如何利用 Django ORM 來進行 CRUD 操作。
示例 1 創建一條新記錄。
# 示例 1
# 創建一條記錄
def create_a_blog():
blog = Blog()
blog.name = "this is my first blog"
blog.tagline = "this is looong text"
blog.save()
Django ORM 創建一條記錄十分簡單,引用模型類的實例,填寫欄位的值,調用 save()方法即可。
示例 2 刪除所有標題中包含 "Lennon" 的 Blog 條目。
Django 提供了一種強大而直觀的方式來「追蹤」查詢中的關係,在幕後自動處理 SQL JOIN 關係。它允許你跨模型使用關聯欄位名,欄位名由雙下劃線分割,直到拿到想要的欄位。
# 示例 2
# 刪除所有標題中含有 'Lennon' 的 Blog 條目:
def delete_blogs_with_headline_lennon():
Blog.objects.filter(info__headline__contains='Lennon').delete()
最後,別忘了將這兩個函數放到 entries 列表中,不然它們不會被執行。
entries: [callable] = [
create_a_blog,
delete_blogs_with_headline_lennon,
]
可以看到,編寫基於 Python 的 data migration 是十分方便的。erda-cli migrate mkpy 命令行為開發者生成了模型定義,引用模型類及其實例可以便捷地操作數據變更,開發只須關心編寫函數中的業務邏輯。
想要進一步了解 Django ORM 的使用請查看文檔:
在 CI/CD 時進行資料庫版本控制
每日凌晨,Erda 上的一條流水線靜靜啟動,erda 倉庫的主幹分支程式碼都會被集成、構建、部署到集成測試環境。開發者一早打開電腦,登錄集成測試環境的 Erda 平台驗證昨日集成的新 feature 是否正確,發現昨天新合併的 migrations 也一併應用到了集成測試環境。這是怎麼做到的呢 ?

Erda 每日自動化集成流水線(部分步驟)
原來這條流水線每日凌晨拉取 erda 倉庫主幹分支程式碼 -> 構建應用 -> 將構建產物製成部署製品 -> 在集成測試環境執行 Erda MySQL 數據遷移 -> 將製品部署到集成測試環境。流水線中的 Erda MySQL 數據遷移 節點是集成了 Erda MySQL Migrator 全部功能的 Action,是 Erda MySQL Migrator 在 Erda CI/CD 流水線中的應用。
Erda MySQL Migrator 除了可以作為 Action 編排在流水線中,還可以脫離 Erda 平台作為命令行工具單獨使用。
Erda MySQL Migrator 其他特性
規約檢查
成熟的團隊一般都會制定程式碼開發規約。Erda MySQL Migrator 支援開發者團隊通過配置規約文件,來約定 SQL 腳本規範,如啟用和禁用特定的 SQL 語句、約束表名與欄位名格式、約束欄位類型等。
比如要求 id 欄位必須是 varchar(36) 或 char(36),可以添加如下配置:
- name: ColumnTypeLinter
meta:
columnName: id
types:
- type: varchar
flen: 36
- type: char
flen: 36
比如要求表名必須以 "erda_" 開頭,可以添加如下配置:
- name: TableNameLinter
alias: "TableNameLinter: 以 erda_ 開頭僅包含小寫英文字母數字下劃線"
white:
committedAt:
- "<20220215" ## 此處表示對提交時間早於2022年2月5日的文件不作此條規約要求
meta:
patterns:
- "^erda_[a-z0-9_]{1,59}"
關於如何編寫規約配置文件的更新資訊見鏈接:
//github.com/erda-project/erda-actions/tree/master/actions/erda-mysql-migration/1.0-57#規約配置
使用命令行工具進行規約檢查
erda-cli migrate lint 命令可以檢查指定目錄下所有腳本的 SQL 語句是否符合規約。開發者在編寫 migration 時用該命令來預先檢查,避免提交不合規了不合規的腳本。
例如開發者在 SQL 腳本中編寫了如下語句:
alter table dice_api_assets add column col_name varchar(255);
執行規約檢查:
% erda-cli migrate lint
2021/07/19 17:39:43 Erda MySQL Lint the input file or directory: .
apim/20210715-01-feature.sql:
dice_api_assets:
- 'missing necessary column definition option: COMMENT'
- 'missing necessary column definition option: NOT NULL'
apim/20210715-01-feature.sql [lints]
apim/20210715-01-feature.sql:1: missing necessary column definition option: COMMENT:
~~~> alter table dice_api_assets add column col_name varchar(255);
apim/20210715-01-feature.sql:1: missing necessary column definition option: NOT NULL:
~~~> alter table dice_api_assets add column col_name varchar(255);
使用命令行工具進行本地規約檢查
可以看到命令行返回了檢查報告,指出了某個文件中存在不合規的語句,並指出了具體的文件、行號、錯誤原因等資訊。上面示例中指出了這條語句有兩條不合規處:一是新增列時,應當有列注釋,此處缺失;二是新增的列應當是 NOT NULL 的,此處沒有指定。
使用 CI 工具進行規約檢查
開發者自行使用命令行工具自檢是合規檢查的第一道關卡。在提交的程式碼合併到 erda 倉庫主幹分支前,PR 觸發的 CI 流程會利用命令行工具檢查 migrations 合規性則是第二道關卡。當提交包含不合規的 SQL 的 PR 時,CI 就會失敗:

Github CI:Erda MySQL Lint 失敗提示
使用 Erda MySQL Migration Lint Action 進行規約檢查
對於託管在 Erda DevOps 平台的項目,可以使用 Erda MySQL Migration Lint Action 進行規約檢查。前文中的 Erda MySQL 數據遷移 Action 已經包含了規約檢查功能,所以從功能上來說,Erda MySQL Migration Lint Action 可以看做 Erda MySQL 數據遷移 Action 的一部分。下圖是使用 Erda MySQL Migration Lint Action 編排的流水線檢查腳本合規性的示例。

使用 Erda MySQL Migration Lint Action 編排流水線檢查腳本合規性
示例中該 Action 失敗,打開 Action 日誌可以查看具體失敗原因。
沙盒與 Dryrun
引入沙盒是為了在將 migrations 應用到目標資料庫前進行一次模擬預演,期望將問題的發現提前,防止將問題 migration 應用到了目標資料庫中。對 Erda 這樣的有豐富的交付場景的項目而言,在 migrate 前先進行一道預演是十分有意義的。這是 Erda MySQL Migrator 根據自身實際設計的,是 Flyway 等工具所不具備的。
Erda MySQL Migrator 可以配置僅在沙盒中而不在真實的 MySQL Server 中執行執行 migration,從而達到 Dryrun 的目的。
文件篡改檢查與修訂機制
Erda MySQL Migrator 不允許篡改已應用過的文件。之所以這樣設計是因為一旦修改了已應用過的腳本,那麼程式碼與真實資料庫狀態就不一致了。如果要修改表結構,應當增量地提交新的 migrations。這是一種常見的做法,Flyway 等工具也會對已執行的文件進行檢查。
但實際生產中,「絕不修改過往文件」這種理想狀態很難達到,Erda MySQL Migrator 提供了一種修訂機制。當用戶想修改一個文件名為「some-feature.sql」過往文件時,他應該修改該文件,並提交一個名為「patch-some-feature.sql」的包含了修改內容的文件到 .patch 目錄中。
日誌收集
Erda MySQL Migrator 在 debug 模式下,會列印所有執行執行過程和 SQL 的標準輸出。除此之外,它還可以將純 SQL 輸出到指定目錄的日誌文件中。
獲取工具
erda-cli 下載地址
Mac
//erda-release.oss-cn-hangzhou.aliyuncs.com/cli/mac/erda-cli
Linux
//erda-release.oss-cn-hangzhou.aliyuncs.com/cli/linux/erda-cli
注意:以上 erda-cli 僅用於 amd64 平台,其他平台請按文中介紹的安裝方式自行構建。
源碼地址
Erda MySQL MIgrator Action 源碼地址
//github.com/erda-project/erda-actions/tree/master/actions/erda-mysql-migration/1.0-57

