データベースの移行:毛虫を蝶に変える

公開: 2022-03-11

ユーザーは、使用するソフトウェアの内容を気にしません。 スムーズに、安全に、そして目立たないように機能するというだけです。 開発者はそれを実現するために努力しており、解決しようとしている問題の1つは、データストアが製品の現在のバージョンに適した状態にあることを確認する方法です。 ソフトウェアは進化し、そのデータモデルも時間の経過とともに変化する可能性があります。たとえば、設計ミスを修正するためです。 問題をさらに複雑にするために、さまざまなペースで製品の新しいバージョンに移行するテスト環境または顧客が多数存在する場合があります。 ストアの構造と、光沢のある新しいバージョンを単一の観点から使用するために必要な操作を文書化するだけでは不十分です。

データベースの移行:毛虫を蝶に変える

私はかつて、開発者によって直接オンデマンドで更新された構造を持ついくつかのデータベースを使用してプロジェクトに参加しました。 つまり、構造を最新バージョンに移行するために適用する必要のある変更を見つける明確な方法がなく、バージョン管理の概念もまったくありませんでした。 これは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>

数え切れないほどの製品があると、パフォーマンスが低下します。 移行をスピードアップするために、次の手順に書き直すことができます。

  1. 前に見たように、 createTableを使用して製品の新しいテーブルを作成します。 この段階では、できるだけ少ない制約を作成することをお勧めします。 新しいテーブルにPRODUCT_TMPという名前を付けましょう。
  2. SQLchangeを使用してINSERT INTO ... SELECT ...の形式でPRODUCT_TMPsqlを入力します。
  3. 必要なすべての制約( addNotNullConstraintaddUniqueConstraintaddForeignKeyConstraint )とインデックス( createIndex )を作成します。
  4. PRODUCTテーブルの名前をPRODUCT_BAKのような名前に変更します。 LiquibaseはrenameTableでそれを行うことができます。
  5. PRODUCT_TMPの名前をPRODUCTに変更します(ここでも、 renameTableを使用します)。
  6. 必要に応じて、 dropTableを使用してPRODUCT_BAKを削除します。

もちろん、そのような移行は避ける方が良いですが、必要なまれなケースの1つに遭遇した場合に備えて、それらを実装する方法を知っておくとよいでしょう。

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という名前を付ける必要があります。

命名規則は非常に物議を醸しているので、ここで包括的な指示を与えるとは思いません。 一貫性を保ち、チームやプロジェクトの慣習を尊重するか、慣習がない場合はそれらを発明します。

チェンジセットの整理

最初に決定するのは、チェンジセットをどこに保存するかです。 基本的に2つのアプローチがあります。

  1. チェンジセットはアプリケーションコードと一緒に保管してください。 チェンジセットとアプリケーションコードを一緒にコミットしてレビューできるので、そうすると便利です。
  2. チェンジセットとアプリケーションコードを別々に、たとえば別々のVCSリポジトリに保管します。 このアプローチは、データモデルが複数のアプリケーション間で共有される場合に適しており、すべてのチェンジセットを専用のリポジトリに保存し、アプリケーションコードが存在する複数のリポジトリに分散させない方が便利です。

チェンジセットを保存する場所はどこでも、一般的に次のカテゴリに分類するのが妥当です。

  1. 実行中のシステムに影響を与えない独立した移行。 現在デプロイされているアプリケーションがまだそれらを認識していない場合は、通常、新しいテーブルやシーケンスなどを作成しても安全です。
  2. 列やインデックスの追加や削除など、ストアの構造を変更するスキーマの変更。 これらの変更は、古いバージョンのアプリケーションがまだ使用されている間は適用しないでください。適用すると、スキーマの変更によりロックや奇妙な動作が発生する可能性があります。
  3. 少量のデータを挿入または更新するクイック移行。 複数のアプリケーションがデプロイされている場合、データベースのパフォーマンスを低下させることなく、このカテゴリーのチェンジセットを同時に実行できます。
  4. 大量のデータを挿入または更新する移行が遅くなる可能性があります。 これらの変更は、他の同様の移行が実行されていないときに適用することをお勧めします。

4つのカテゴリのグラフィック表現

これらの一連の移行は、新しいバージョンのアプリケーションをデプロイする前に連続して実行する必要があります。 システムが複数の個別のアプリケーションで構成されており、それらの一部が同じデータベースを使用している場合、このアプローチはさらに実用的になります。 それ以外の場合は、実行中のアプリケーションに影響を与えずに適用できるチェンジセットのみを分離する価値があり、残りのチェンジセットは一緒に適用できます。

より単純なアプリケーションの場合、必要な移行のフルセットをアプリケーションの起動時に適用できます。 この場合、すべてのチェンジセットは単一のカテゴリに分類され、アプリケーションが初期化されるたびに実行されます。

移行を適用するために選択されたステージが何であれ、複数のアプリケーションに同じデータベースを使用すると、移行が適用されているときにロックが発生する可能性があることに注意してください。 Liquibase(他の多くの同様のソリューションと同様)は、メタデータを記録するために2つの特別なテーブルDATABASECHANGELOGDATABASECHANGELOGLOCKを利用します。 前者は、適用されたチェンジセットに関する情報を格納するために使用され、後者は、同じデータベーススキーマ内での同時移行を防ぐために使用されます。 したがって、何らかの理由で複数のアプリケーションが同じデータベーススキーマを使用する必要がある場合は、ロックを回避するために、メタデータテーブルにデフォルト以外の名前を使用することをお勧めします。

高レベルの構造が明確になったので、各カテゴリ内でチェンジセットを編成する方法を決定する必要があります。

サンプルチェンジセット構成

特定のアプリケーション要件に大きく依存しますが、通常、次の点が妥当です。

  1. 製品のリリースごとにグループ化された変更ログを保持します。 リリースごとに新しいディレクトリを作成し、対応する変更ログファイルをそのディレクトリに配置します。 ルート変更ログを作成し、リリースに対応する変更ログを含めます。 リリース変更ログには、このリリースを構成する他の変更ログを含めます。
  2. チェンジログファイルとチェンジセット識別子の命名規則を定めてください。もちろん、それに従ってください。
  3. 変更の多いチェンジセットは避けてください。 単一の長いチェンジセットよりも複数のチェンジセットを優先します。
  4. ストアドプロシージャを使用していて、それらを更新する必要がある場合は、そのストアドプロシージャが追加されているチェンジセットのrunOnChange="true"属性の使用を検討してください。 それ以外の場合は、更新するたびに、新しいバージョンのストアドプロシージャを使用して新しいチェンジセットを作成する必要があります。 要件はさまざまですが、そのような履歴を追跡しないことは許容されることがよくあります。
  5. 機能ブランチをマージする前に、冗長な変更を潰すことを検討してください。 機能ブランチ(特に長寿命のブランチ)では、後のチェンジセットが前のチェンジセットで行われた変更を改良することがあります。 たとえば、テーブルを作成してから、そのテーブルに列を追加することを決定できます。 この機能ブランチがまだメインブランチにマージされていない場合は、これらの列を最初のcreateTableの変更に追加する価値があります。
  6. 同じ変更ログを使用して、テストデータベースを作成します。 そうしようとすると、すべてのチェンジセットがテスト環境に適用できるわけではないこと、またはその特定のテスト環境に追加のチェンジセットが必要であることがすぐにわかる場合があります。 Liquibaseでは、この問題はコンテキストを使用して簡単に解決できます。 テストでのみ実行する必要があるチェンジセットに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は、サポートされているほとんどの種類の変更( createTableaddColumncreateIndexなど)を自動的にロールバックできます。

過去を直す

完璧な人は誰もいませんし、私たちは皆間違いを犯します。 それらのいくつかは、甘やかされて育った変更がすでに適用されている場合、発見が遅すぎる可能性があります。 その日を救うために何ができるかを調べてみましょう。

データベースを手動で更新する

これには、次の方法でDATABASECHANGELOGとデータベースをいじることが含まれます。

  1. 不良チェンジセットを修正して再実行する場合:
    • チェンジセットに対応する行をDATABASECHANGELOGから削除します。
    • チェンジセットによって導入されたすべての副作用を取り除きます。 たとえば、テーブルが削除された場合はテーブルを復元します。
    • 悪いチェンジセットを修正します。
    • 移行を再度実行します。
  2. 悪いチェンジセットを修正したいが、それらを再度適用することをスキップしたい場合:
    • 不良チェンジセットに対応する行のMD5SUMフィールド値をNULLに設定して、 DATABASECHANGELOGを更新します。
    • データベースの誤りを手動で修正します。 たとえば、間違ったタイプで追加された列がある場合は、クエリを発行してそのタイプを変更します。
    • 悪いチェンジセットを修正します。
    • 移行を再度実行します。 Liquibaseは新しいチェックサムを計算し、 MD5SUMに保存します。 修正されたチェンジセットは再度実行されません。

明らかに、開発中にこれらのトリックを実行するのは簡単ですが、変更が複数のデータベースに適用されると、はるかに困難になります。

修正チェンジセットを書く

実際には、通常、このアプローチの方が適切です。 元のチェンジセットを編集してみませんか? 真実は、それは何を変更する必要があるかに依存するということです。 Liquibaseは、各チェンジセットのチェックサムを計算し、以前に適用されたチェンジセットの少なくとも1つでチェックサムが新しい場合、新しい変更の適用を拒否します。 この動作は、 runOnChange="true"属性を指定することにより、変更セットごとにカスタマイズできます。 前提条件またはオプションのチェンジセット属性( contextrunOnChangeなど)を変更しても、チェックサムは影響を受けません。

さて、あなたは疑問に思うかもしれませんが、最終的にどのようにして変更セットを間違いで修正しますか?

  1. これらの変更を新しいスキーマに引き続き適用する場合は、修正チェンジセットを追加するだけです。 たとえば、間違ったタイプで追加された列がある場合は、新しいチェンジセットでそのタイプを変更します。
  2. これらの悪いチェンジセットが存在しなかったふりをしたい場合は、次のようにします。
    • チェンジセットを削除するか、 context属性に値を追加して、そのようなコンテキストで移行を再度適用しようとしないことを保証します(例: context="graveyard-changesets-never-run"
    • 間違ったことを元に戻すか、修正する新しいチェンジセットを追加します。 これらの変更は、悪い変更が適用された場合にのみ適用する必要があります。 これは、 changeSetExecutedなどの前提条件で実現できます。 なぜそうしているのかを説明するコメントを追加することを忘れないでください。
    • スキーマを正しい方法で変更する新しいチェンジセットを追加します。

ご覧のとおり、過去を修正することは可能ですが、必ずしも簡単ではない場合があります。

成長痛の緩和

アプリケーションが古くなると、その変更ログも大きくなり、パスに沿ってすべてのスキーマ変更が蓄積されます。 これは仕様によるものであり、本質的に問題はありません。 製品の各バージョンをリリースした後など、定期的に移行を破棄することで、長い変更ログを短くすることができます。 場合によっては、新しいスキーマの初期化が速くなります。

押しつぶされている変更ログの図

押しつぶしは必ずしも些細なことではなく、多くの利点をもたらさずに退行を引き起こす可能性があります。 もう1つの優れたオプションは、シードデータベースを使用して、すべてのチェンジセットの実行を回避することです。 おそらくいくつかのテストデータを使用して、データベースをできるだけ速く準備する必要がある場合は、テスト環境に最適です。 これは、チェンジセットの押しつぶしの一形態と考えることができます。ある時点で(たとえば、別のバージョンをリリースした後)、スキーマのダンプを作成します。 ダンプを復元した後、通常どおり移行を適用します。 ダンプを作成する前に古い変更がすでに適用されているため、新しい変更のみが適用されます。 したがって、それらはダンプから復元されました。

シードデータベースのイラスト

結論

Liquibaseの機能を深く掘り下げることを意図的に避けて、一般的に進化するスキーマに焦点を当てた、短くて要点のある記事を提供しました。 うまくいけば、データベーススキーマ移行の自動化されたアプリケーションによってどのようなメリットと問題がもたらされ、それがすべてDevOpsカルチャーにどれだけうまく適合するかが明確になります。 良いアイデアでさえ教義に変えないことが重要です。 要件はさまざまであり、データベースエンジニアとして、私たちの決定は、インターネット上の誰かからの推奨事項に従うだけでなく、製品を前進させることを促進する必要があります。