本番環境を壊さないデータベースマイグレーションの書き方
何ヶ月も稼働しているデータベースがあるとしよう。ユーザーはそれに依存している。テーブルは大きくなり、クエリは最適化され、スキーマは安定した形に落ち着いている。そこに、新しいカラムやテーブルのリネーム、データマイグレーションを必要とする機能要件が舞い込んでくる。
本番環境で ALTER TABLE を実行する瞬間、あなたは賭けをしている。マイグレーションに時間がかかりすぎると、クエリがキューイングされる。テーブルがロックされると、ユーザーはエラーを目にする。途中で失敗した場合、元に戻す方法が必要になる。そしてロールバック計画がなければ、最後のスナップショット以降に入力されたデータをすべて失うバックアップからのリストアしか選択肢がなくなる。
これが、安全なデータベースマイグレーションが単に正しいSQLを書くことだけではない理由だ。変更をレビュー可能、テスト可能、そしてパニックにならずに元に戻せるように構成することこそが重要なのである。
すべてのマイグレーションには2つのファイルが必要
チームを繰り返し救う最もシンプルなパターンは、アップマイグレーションとダウンマイグレーションのペアだ。
以下がアップマイグレーションとダウンマイグレーションをペアにした具体例である:
-- 20241101_add_last_login_at.up.sql
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;
-- 20241101_add_last_login_at.down.sql
ALTER TABLE users DROP COLUMN last_login_at;
アップマイグレーションには変更を適用するSQLが含まれる。ダウンマイグレーションにはそれを元に戻すSQLが含まれる。すべての変更に対して両方のファイルを作成し、順序が明確になるように一意の識別子とともに保存する。
20241101_add_email_index.sql -- up
20241101_add_email_index_down.sql -- down
識別子にはタイムスタンプ、連番、日付プレフィックスなどが使える。重要なのは、フォルダを見た誰もが変更の正確な順序を把握できることだ。本番環境でマイグレーションを実行して何かがうまくいかなかった場合、ダウンマイグレーションがあれば迅速かつ予測可能な方法で元に戻せる。
ダウンマイグレーションがない場合、唯一のフォールバックはデータベース全体のリストアだ。これには時間がかかり、調整が必要で、最近のデータを失うリスクがある。ダウンマイグレーションなら数秒で実行できる。
ダウンマイグレーションだけでは不十分なケース
ダウンマイグレーションは、カラムの追加、インデックスの作成、参照データの挿入など、可逆的な変更には有効だ。しかし、元に戻すのが難しい変更もある。
カラムの削除はよくある例だ。カラムが削除されデータが消えた後、ダウンマイグレーションでカラムを再追加してもデータは戻せない。同様の問題は、テーブルのリネーム、カラム型の変更、システムの他の部分が依存している制約の削除でも発生する。
このような場合の安全なアプローチは、変更を複数の小さなマイグレーションに分割し、それぞれを個別に元に戻せるようにすることだ:
- 必要な型の新しいカラムを追加する。
- バッチでデータをバックフィルする。
- 新しいカラムを使うようにアプリケーションコードを更新する。
- 古いカラムを削除する。
各ステップには独自のアップマイグレーションとダウンマイグレーションがある。ステップ3で問題が発覚した場合、ステップ2とステップ1をきれいに元に戻せる。リストア以外に道がないという状況には決してならない。
複数回実行可能なマイグレーションを書く
パイプラインは失敗する。ネットワーク障害、タイムアウトが発生し、マイグレーションが途中で実行されてプロセスがクラッシュすることもある。パイプラインがリトライすると、マイグレーションは再実行される。
マイグレーションがまだ変更が適用されていないことを前提としている場合、2回目の実行で失敗する。その失敗はパイプライン全体をブロックし、手動介入が必要になる。
すべてのマイグレーションを冪等にしよう。テーブルやインデックスを作成するときは IF NOT EXISTS を使う。オブジェクトを削除するときは IF EXISTS を使う。カラムを変更する前に、そのカラムがすでに存在するか確認する。目標はシンプルだ:同じマイグレーションを2回実行しても、1回実行したのと同じ結果になること。
大規模テーブルでの長時間ロックを避ける
数百万行のテーブルでカラム型を変更する ALTER TABLE は、テーブル全体を数分間ロックする可能性がある。その間、そのテーブルへのすべての読み取りと書き込みは待機する。ユーザーはタイムアウトを目にし、キューが蓄積される。
解決策は、大規模テーブルでの単一ステップのスキーマ変更を避けることだ。代わりに、マルチステップのアプローチを使う:
- 必要な型の新しいカラムを追加する。
- バッチで行を更新して新しいカラムにデータを投入する。
- 必要に応じてインデックスを追加する。
- 後のマイグレーションで古いカラムを削除する。
各ステップは短時間だけロックする。アプリケーションはステップ間で稼働し続けられる。このパターンは記述に時間がかかるが、実行はずっと安全だ。
環境固有の値をマイグレーションファイルに含めない
マイグレーションファイルは、開発環境、ステージング環境、本番環境で同じように動作する必要がある。データベース名、パスワード、接続文字列をSQLにハードコードすると、そのファイルは1つの環境に固定されてしまう。編集せずに他の環境で実行できず、レビュー済みのマイグレーションファイルを編集することはバージョン管理の目的を損なう。
パラメータ、環境変数、またはマイグレーションツールが提供する設定を使おう。SQL自体にはスキーマとデータのロジックのみを含め、環境の詳細は含めない。
マイグレーションをアプリケーションコードと一緒に保存する
一般的なアプローチは2つある:マイグレーションファイルをアプリケーションコードと同じリポジトリに置くか、別のリポジトリに置くかだ。どちらも機能するが、その選択はチームが変更を調整する方法に影響する。
マイグレーションが同じリポジトリにある場合、スキーマを変更するプルリクエストには必ずマイグレーションも含まれる。コードレビューではアプリケーションの変更とデータベースの変更の両方を一緒に確認できる。これにより、まだ追加されていないカラムを参照するクエリのようなミスマッチを発見しやすくなる。
マイグレーションが別のリポジトリにある場合、アプリケーションとデータベースの変更は異なるタイムラインで進化できる。これは複数のサービスが1つのデータベースを共有する場合に便利だが、スキーマとコードの同期を保つためにより多くの調整が必要になる。
どちらの場合でも、重要なのはマイグレーションファイルがバージョン管理され、レビューされ、追跡可能であることだ。データベースの変更は、コード変更と同じ種類の監査証跡を残すべきである。
安全なマイグレーションを書くための実践的チェックリスト
マイグレーションをパイプラインにマージする前に、以下のチェックを実行しよう:
- すべてのマイグレーションに対応するダウンマイグレーションはあるか?
- ダウンマイグレーションはデータ損失なしに実際に以前の状態を復元できるか?
- マイグレーションは冪等か?エラーなく2回実行できるか?
- マイグレーションは大規模テーブルを数秒以上ロックするか?
- SQLファイルに環境固有の値は含まれていないか?
- マイグレーションファイルはアプリケーションコードまたは専用のデータベースリポジトリに保存されているか?
まとめ
安全なデータベースマイグレーションとは、変更を避けることではない。変更を元に戻し可能にし、テスト可能にし、レビュー可能にすることだ。あなたが書くすべてのマイグレーションファイルは小さな契約である:ここに変更内容があり、ここにそれを元に戻す方法がある。その契約が明確であれば、チームは戻る道があることを知っているため、より速く動けるようになる。