數據庫遷移:將毛毛蟲變成蝴蝶
已發表: 2022-03-11用戶不關心他們使用的軟件裡面有什麼。 只是它工作順利,安全,不引人注目。 開發人員努力實現這一目標,他們試圖解決的問題之一是如何確保數據存儲處於適合當前產品版本的狀態。 軟件不斷發展,其數據模型也可能隨著時間而改變,例如,為了修復設計錯誤。 使問題進一步複雜化的是,您可能有許多測試環境或客戶以不同的速度遷移到產品的較新版本。 您不能只從單一角度記錄商店的結構以及使用閃亮的新版本需要哪些操作。
我曾經加入一個項目,其中包含一些數據庫,這些數據庫的結構直接由開發人員按需更新。 這意味著沒有明顯的方法可以找出需要應用哪些更改才能將結構遷移到最新版本,並且根本沒有版本控制的概念! 這是在 DevOps 之前的時代,現在被認為是一團糟。 我們決定開發一種工具,用於將每個更改應用到給定的數據庫。 它進行了遷移,並將記錄架構更改。 這使我們確信不會發生意外更改,並且模式狀態是可預測的。
在本文中,我們將了解如何應用關係數據庫模式遷移以及如何克服隨之而來的問題。
首先,什麼是數據庫遷移? 在本文的上下文中,遷移是應應用於數據庫的一組更改。 創建或刪除表、列或索引是遷移的常見示例。 隨著時間的推移,架構的形狀可能會發生巨大變化,尤其是在需求仍然模糊的情況下開始開發時。 因此,在發布過程中的幾個里程碑過程中,您的數據模型將不斷發展,並且可能與最初的情況完全不同。 遷移只是到達目標狀態的步驟。
首先,讓我們探索一下我們工具箱中的內容,以避免重新發明已經做得好的東西。
工具
在每一種廣泛使用的語言中,都有一些庫可以幫助簡化數據庫遷移。 例如,對於 Java,流行的選項是 Liquibase 和 Flyway。 我們將在示例中更多地使用 Liquibase,但這些概念適用於其他解決方案並且與 Liquibase 無關。
如果某些 ORM 已經提供了自動升級模式並使其與映射類的結構匹配的選項,為什麼還要使用單獨的模式遷移庫呢? 在實踐中,這種自動遷移只做簡單的模式改變,例如,創建表和列,而不能做潛在的破壞性事情,比如刪除或重命名數據庫對象。 因此,非自動(但仍然是自動化)解決方案通常是更好的選擇,因為您被迫自己描述遷移邏輯,並且您知道數據庫究竟會發生什麼。
混合自動和手動模式修改也是一個非常糟糕的主意,因為如果手動更改以錯誤的順序應用或根本不應用,即使它們是必需的,您可能會產生獨特且不可預測的模式。 選擇工具後,使用它來應用所有模式遷移。
典型的數據庫遷移
典型的遷移包括創建序列、表、列、主鍵和外鍵、索引和其他數據庫對象。 對於最常見的更改類型,Liquibase 提供了不同的聲明性元素來描述應該做什麼。 閱讀 Liquibase 或其他類似工具支持的每一個微不足道的更改都太無聊了。 要了解變更集的外觀,請考慮以下示例,其中我們創建了一個表(為簡潔起見,省略了 XML 命名空間聲明):
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog> 如您所見,變更日誌是一組變更集,變更集由變更組成。 可以組合像createTable這樣的簡單更改來實現更複雜的遷移; 例如,假設您需要更新所有產品的產品代碼。 通過以下更改可以輕鬆實現:
<sql>UPDATE product SET code = 'new_' || code</sql>如果您擁有數以萬計的產品,性能將會受到影響。 為了加快遷移速度,我們可以將其重寫為以下步驟:
- 就像我們之前看到的那樣,使用
createTable為產品創建一個新表。 在這個階段,最好創建盡可能少的約束。 讓我們將新表命名為PRODUCT_TMP。 - 使用
sqlchange 以INSERT INTO ... SELECT ...的形式使用 SQL 填充PRODUCT_TMP。 - 創建您需要的所有約束(
addNotNullConstraint、addUniqueConstraint、addForeignKeyConstraint)和索引(createIndex)。 - 將
PRODUCT表重命名為PRODUCT_BAK之類的名稱。 Liquibase 可以使用renameTable來做到這一點。 - 將
PRODUCT_TMP重命名為PRODUCT(再次使用renameTable)。 - 或者,使用
dropTable刪除PRODUCT_BAK。
當然,最好避免此類遷移,但最好知道如何實現它們,以防遇到需要它的罕見情況之一。
如果您認為 XML、JSON 或 YAML 對於描述更改的任務來說太奇怪了,那麼只需使用純 SQL 並利用所有數據庫供應商特定的功能。 此外,您可以在純 Java 中實現任何自定義邏輯。
Liquibase 免除您編寫實際特定於數據庫的 SQL 的方式可能會導致過度自信,但您不應忘記目標數據庫的怪癖; 例如,當您創建外鍵時,可能會或可能不會創建索引,具體取決於所使用的特定數據庫管理系統。 結果,您可能會發現自己處於尷尬的境地。 Liquibase 允許您指定僅應為特定類型的數據庫運行變更集,例如 PostgreSQL、Oracle 或 MySQL。 它使這成為可能,對不同的數據庫使用相同的與供應商無關的變更集,對於其他變更集,使用供應商特定的語法和功能。 僅當使用 Oracle 數據庫時才會執行以下變更集:
<changeSet dbms="oracle" author="..."> ... </changeSet>除了 Oracle,Liquibase 還支持其他一些開箱即用的數據庫。
命名數據庫對象
您創建的每個數據庫對像都需要命名。 您不需要為某些類型的對像明確提供名稱,例如,約束和索引。 但這並不意味著這些對像沒有名字。 無論如何,它們的名稱將由數據庫生成。 當您需要引用該對像以刪除或更改它時,就會出現問題。 所以最好給他們明確的名字。 但是對於起什麼名字有什麼規定嗎? 答案很簡單:保持一致; 例如,如果您決定將索引命名為: IDX_<table>_<columns> ,則上述CODE列的索引應命名為IDX_PRODUCT_CODE 。
命名約定是非常有爭議的,所以我們不會假設在這裡給出全面的說明。 保持一致,尊重您的團隊或項目約定,或者如果沒有,就自行發明它們。
組織變更集
首先要決定的是存儲變更集的位置。 基本上有兩種方法:
- 將變更集與應用程序代碼一起保存。 這樣做很方便,因為您可以一起提交和審查變更集和應用程序代碼。
- 將變更集和應用程序代碼分開,例如,在單獨的 VCS 存儲庫中。 這種方法適用於數據模型在多個應用程序之間共享的情況,並且更方便地將所有變更集存儲在專用存儲庫中,而不是將它們分散到應用程序代碼所在的多個存儲庫中。
無論您將變更集存儲在何處,通常都可以將它們分為以下幾類:
- 不影響正在運行的系統的獨立遷移。 如果當前部署的應用程序還不知道它們,那麼創建新表、序列等通常是安全的。
- 改變存儲結構的模式修改,例如,添加或刪除列和索引。 當應用程序的舊版本仍在使用時,不應應用這些更改,因為這樣做可能會由於架構的更改而導致鎖定或奇怪的行為。
- 插入或更新少量數據的快速遷移。 如果正在部署多個應用程序,則可以同時執行此類別的變更集,而不會降低數據庫性能。
- 插入或更新大量數據的遷移可能會很慢。 當沒有其他類似的遷移正在執行時,最好應用這些更改。

這些遷移集應在部署較新版本的應用程序之前連續運行。 如果一個系統由幾個獨立的應用程序組成並且其中一些使用相同的數據庫,這種方法會變得更加實用。 否則,值得只分離那些可以應用而不影響正在運行的應用程序的變更集,其餘的變更集可以一起應用。
對於更簡單的應用程序,可以在應用程序啟動時應用全套必要的遷移。 在這種情況下,所有變更集都屬於一個類別,並在應用程序初始化時運行。
無論選擇在哪個階段應用遷移,值得一提的是,對多個應用程序使用同一個數據庫可能會在應用遷移時導致鎖定。 Liquibase(像許多其他類似的解決方案一樣)利用兩個特殊的表來記錄其元數據: DATABASECHANGELOG和DATABASECHANGELOGLOCK 。 前者用於存儲有關已應用變更集的信息,後者用於防止在同一數據庫模式中進行並發遷移。 因此,如果多個應用程序出於某種原因必須使用相同的數據庫模式,最好為元數據表使用非默認名稱以避免鎖定。
現在高層結構已經清晰,您需要決定如何組織每個類別中的變更集。
這在很大程度上取決於具體的應用要求,但以下幾點通常是合理的:
- 保留按產品版本分組的變更日誌。 為每個版本創建一個新目錄並將相應的變更日誌文件放入其中。 有一個根變更日誌並包含與版本相對應的變更日誌。 在版本變更日誌中,包括包含此版本的其他變更日誌。
- 對變更日誌文件和變更集標識符有一個命名約定——當然要遵循它。
- 避免有大量更改的變更集。 首選多個變更集而不是單個長變更集。
- 如果您使用存儲過程並需要更新它們,請考慮使用添加該存儲過程的變更集的
runOnChange="true"屬性。 否則,每次更新時,您都需要使用新版本的存儲過程創建新的變更集。 要求各不相同,但不跟踪此類歷史記錄通常是可以接受的。 - 考慮在合併功能分支之前壓縮冗餘更改。 有時,在特性分支(尤其是長期存在的分支)中,後來的變更集會細化在早期變更集中所做的更改。 例如,您可以創建一個表,然後決定向其中添加更多列。 如果此功能分支尚未合併到主分支,則值得將這些列添加到初始
createTable更改中。 - 使用相同的更改日誌來創建測試數據庫。 如果您嘗試這樣做,您可能很快就會發現並非每個變更集都適用於測試環境,或者該特定測試環境需要額外的變更集。 使用 Liquibase,使用contexts可以輕鬆解決這個問題。 只需將
context="test"屬性添加到僅需要通過測試執行的變更集中,然後在啟用test上下文的情況下初始化 Liquibase。
滾回來
與其他類似的解決方案一樣,Liquibase 支持“向上”和“向下”遷移模式。 但請注意:撤消遷移可能並不容易,而且並不總是值得付出努力。 如果您決定為您的應用程序支持撤消遷移,那麼請保持一致並針對需要撤消的每個變更集執行此操作。 使用 Liquibase,撤消變更集是通過添加包含執行回滾所需更改的rollback標籤來完成的。 考慮以下示例:
<changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet> 顯式回滾在這裡是多餘的,因為 Liquibase 會執行相同的回滾操作。 Liquibase 能夠自動回滾大多數受支持的更改類型,例如createTable 、 addColumn或createIndex 。
修復過去
沒有人是完美的,我們都會犯錯誤。 當已經應用了損壞的更改時,可能會發現其中一些為時已晚。 讓我們探索可以做些什麼來挽救這一天。
手動更新數據庫
它涉及通過以下方式弄亂DATABASECHANGELOG和您的數據庫:
- 如果您想更正錯誤的變更集並再次執行它們:
- 從
DATABASECHANGELOG中刪除與變更集相對應的行。 - 刪除變更集引入的所有副作用; 例如,如果表被刪除,則恢復它。
- 修復錯誤的變更集。
- 再次運行遷移。
- 從
- 如果您想更正錯誤的變更集但跳過再次應用它們:
- 通過將與錯誤變更集對應的行的
MD5SUM字段值設置為NULL來更新DATABASECHANGELOG。 - 手動修復數據庫中的錯誤。 例如,如果添加了錯誤類型的列,則發出查詢以修改其類型。
- 修復錯誤的變更集。
- 再次運行遷移。 Liquibase 將計算新的校驗和並將其保存到
MD5SUM。 更正的變更集不會再次運行。
- 通過將與錯誤變更集對應的行的
顯然,在開發過程中很容易做到這些技巧,但如果將更改應用於多個數據庫,則變得更加困難。
編寫糾正性變更集
在實踐中,這種方法通常更合適。 您可能想知道,為什麼不直接編輯原始變更集? 事實是,這取決於需要更改的內容。 Liquibase 為每個變更集計算校驗和,如果校驗和對於至少一個先前應用的變更集是新的,則拒絕應用新的更改。 通過指定runOnChange="true"屬性,可以基於每個變更集自定義此行為。 如果您修改前置條件或可選變更集屬性( context 、 runOnChange等),校驗和不會受到影響。
現在,您可能想知道,您最終如何糾正有錯誤的變更集?
- 如果您希望這些更改仍適用於新模式,則只需添加更正的更改集。 例如,如果添加了錯誤類型的列,則在新變更集中修改其類型。
- 如果您想假裝那些糟糕的變更集從未存在,請執行以下操作:
- 刪除變更集或添加具有值的
context屬性,以保證您永遠不會再次嘗試使用此類上下文應用遷移,例如context="graveyard-changesets-never-run"。 - 添加新的變更集,這些變更集將恢復錯誤或修復錯誤。 只有在應用了錯誤的更改時才應應用這些更改。 它可以通過先決條件來實現,例如使用
changeSetExecuted。 不要忘記添加評論來解釋您這樣做的原因。 - 添加以正確方式修改架構的新變更集。
- 刪除變更集或添加具有值的
如您所見,修復過去是可能的,儘管它可能並不總是那麼簡單。
減輕成長的痛苦
隨著您的應用程序變老,它的更改日誌也會增長,沿路徑累積每個架構更改。 這是設計使然,這本身並沒有錯。 可以通過定期壓縮遷移來縮短長的變更日誌,例如,在發布產品的每個版本之後。 在某些情況下,它會使初始化新模式更快。
擠壓並不總是微不足道的,並且可能會導致回歸而不會帶來很多好處。 另一個不錯的選擇是使用種子數據庫來避免執行所有變更集。 如果您需要盡可能快地準備好數據庫,甚至可能包含一些測試數據,它非常適合測試環境。 您可能會將其視為變更集的一種壓縮形式:在某個時間點(例如,在發布另一個版本之後),您會轉儲模式。 恢復轉儲後,您照常應用遷移。 只會應用新的更改,因為在轉儲之前已經應用了舊的更改; 因此,它們從轉儲中恢復。
結論
我們有意避免深入研究 Liquibase 的功能,以提供一篇簡短而中肯的文章,重點關注總體上不斷發展的模式。 希望數據庫模式遷移的自動化應用帶來了哪些好處和問題,以及它與 DevOps 文化的契合程度。 重要的是不要把好的想法變成教條。 要求各不相同,作為數據庫工程師,我們的決定應該促進產品向前發展,而不僅僅是遵循互聯網上某人的建議。
