2回実行しても壊れないデータベースマイグレーションの書き方

新しい機能をデプロイするために、usersテーブルにカラムを追加する必要が出てきたとします。マイグレーションスクリプトを書き、ステージング環境で実行し、すべて問題なく動作しました。ところが、パイプラインの再実行中にチームの誰かが誤って同じスクリプトをもう一度実行してしまいました。すると「column already exists」というエラーが発生します。デプロイは失敗し、誰かが手動でデータベースを修正しなければならず、リリースは遅延します。

このシナリオは、ほとんどのチームが認めるよりも頻繁に発生しています。修正自体は複雑ではありませんが、マイグレーションスクリプトに対する考え方を変える必要があります。複数回実行しても問題が起きないようにスクリプトを書く必要があるのです。

安全なマイグレーションの条件

核となる原則は**べき等性(idempotency)**です。ある操作がべき等であるとは、1回実行しても100回実行しても最終的な状態が同じになることを指します。これはスクリプトが2回実行されるのを防ぐことではなく、2回実行しても害がないことを保証することです。

なぜスクリプトが複数回実行される可能性があるのか考えてみましょう。パイプラインが途中で失敗して再実行される。誰かがステージングでマイグレーションを実行した後、忘れて後でもう一度実行する。2人の開発者が異なる環境で同じ変更を異なるタイミングで適用する。べき等性がなければ、これらのシナリオのいずれもがデータを破壊したり、デプロイを失敗させたりする可能性があります。

シンプルなパターン:実行前に確認する

マイグレーションをべき等にする最も簡単な方法は、変更を適用する前にその変更が既に存在するかどうかを確認することです。SQLデータベースでは条件文を使うことで簡単に実現できます。

具体的な例を示します。ユーザーアクティビティを追跡するためにlast_login_atカラムを追加する必要があるとします。

-- 非べき等:カラムが既に存在すると失敗する
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;

-- べき等:カラムの有無にかかわらず成功する
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;

最初のバージョンはカラムが既に存在する場合にエラーをスローします。2番目のバージョンは毎回安全に実行されます。

以下のように書く代わりに:

ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);

次のように書きます:

ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(20);

IF NOT EXISTS句により、カラムが既に存在するかどうかに関わらずスクリプトは成功します。同じアプローチはインデックス、制約、新しいテーブルにも有効です。PostgreSQL、MySQL、および最新のデータベースのほとんどはこれらの条件構文をサポートしています。

削除の場合も同じロジックが適用されます。存在しないカラムを削除しようとすると失敗します。IF EXISTSを使って操作を安全にします:

ALTER TABLE users DROP COLUMN IF EXISTS old_phone_number;

データマイグレーションの扱い方

カラムの追加や削除は簡単なケースです。本当の複雑さは、既存のデータを移動または変換する必要がある場合に発生します。full_nameカラムをfirst_namelast_nameに分割する場合を考えてみましょう。マイグレーションでは以下の処理が必要です:

  1. 2つの新しいカラムを追加する
  2. 既存のデータから新しいカラムに値を投入する
  3. スクリプトが再度実行された場合のケースを処理する

単純なアプローチでは、データが既にコピーされているかどうかを確認せずにコピーします。これを2回実行すると、重複データが作成されたり、有効な値が上書きされたりします。より安全なパターンは次のようになります:

-- カラムが存在しなければ追加
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);

-- ターゲットカラムが空の場合のみ投入
UPDATE users 
SET first_name = SPLIT_PART(full_name, ' ', 1),
    last_name = SPLIT_PART(full_name, ' ', 2)
WHERE first_name IS NULL AND last_name IS NULL;

WHERE句により、まだ処理されていない行に対してのみ更新が実行されることが保証されます。スクリプトが再度実行されても、それらの行は既に値が投入されているため、更新は何も行いません。

より複雑なシナリオでは、ソースデータとターゲットデータを比較したり、チェックサムを使用して変換が正しい結果を生成したことを検証する必要があるかもしれません。原則は変わりません:データが元の状態のままであると決して仮定しないことです。

段階的な削除でリスクを低減する

カラムやテーブルの削除は、簡単に元に戻せないためリスクが高いです。より安全なアプローチは、段階的に行うことです:

  1. 最初のマイグレーション:カラム名をcolumn_name_deprecatedに変更する
  2. 数回のリリースサイクルを待ち、何も壊れていないことを確認する
  3. 2番目のマイグレーション:非推奨カラムを削除する

このパターンにより、チームは古いカラムをまだ参照しているコードを発見する時間を得られます。問題が発生した場合、名前の変更は元に戻せますが、削除は元に戻せません。

実行記録を残す

べき等性はスクリプトが複数回実行されるケースを処理します。しかし、どのスクリプトがいつ実行され、成功したかどうかを把握する必要もあります。これが監査証跡です。

FlywayやLiquibaseのようなほとんどのマイグレーションフレームワークはこれを自動的に処理します。スクリプト名、チェックサム、実行タイムスタンプごとにすべてのマイグレーションスクリプトを追跡するテーブルを作成します。フレームワークを使わずに生のSQLスクリプトを書いている場合は、独自の追跡テーブルを作成します:

CREATE TABLE IF NOT EXISTS migration_log (
    script_name VARCHAR(255) PRIMARY KEY,
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    status VARCHAR(20),
    script_hash VARCHAR(64)
);

マイグレーションを実行する前に、スクリプト名と「実行中」ステータスの行を挿入します。完了後、ステータスを「成功」または「失敗」に更新します。スクリプトが失敗した場合、パイプラインは再試行でき、マイグレーションランナーはスクリプトが既に正常に完了したかどうかを確認できます。

このログはデバッグのためだけではありません。コンプライアンスとガバナンスのための証拠です。「誰がいつusersテーブルを変更したのか」と聞かれた場合、答えはログから得られるべきであり、誰かの記憶からではありません。

マイグレーションスクリプト作成の実践的チェックリスト

本番環境でマイグレーションを実行する前に、以下のポイントを確認してください:

  • スクリプトを2回実行してもエラーにならないか?データベースのコピーで2回続けて実行してテストする。
  • スクリプトはカラム、インデックス、制約を作成する前にそれらが存在するかどうかを確認しているか?
  • データ変換の場合、スクリプトは既に変換された行を安全に処理できるか?
  • 削除は段階的に行われ、削除前に非推奨期間を設けているか?
  • すべてのスクリプト実行について、タイムスタンプとステータスを含むログエントリがあるか?
  • 問題が発生した場合に変更をロールバックできるか?できない場合、計画はあるか?

まとめ

2回実行すると失敗するマイグレーションスクリプトは、マイグレーションスクリプトではありません。それは誰かがパイプラインを再実行するのを待っている時限爆弾です。すべてのマイグレーションは複数回実行されることを前提に書きましょう。実際には、おそらくそうなるからです。作成する前に確認し、変換する前に検証し、すべてをログに記録しましょう。午前2時にデプロイのデバッグをしている未来の自分が感謝するでしょう。