ダウンタイムなしでカラム名変更、テーブル分割、制約変更を実現する方法
usersテーブルにfull_nameというカラムがあるとします。チームはこれをdisplay_nameに変更することに決めました。直接リネームすると、変更が本番環境に反映された瞬間に、まだfull_nameを読み取っているすべてのアプリケーションが壊れます。カラムは消え、クエリは失敗し、ユーザーにはエラーが表示されます。
これは仮定の話ではありません。チームは毎スプリント、カラム名の変更、テーブルの分割、制約の変更を行っています。単純なアプローチ(スキーマを変更して後でコードを修正する)は、回避可能な本番インシデントを引き起こします。解決策はExpand-Contractパターンと呼ばれる手法で、これら3つのシナリオすべてに有効です。
核となる考え方:まず追加、段階的に切り替え、最後に削除
Expand-Contractパターンには3つのフェーズがあります。まず、古い構造と並行して新しい構造を追加してスキーマを拡張(Expand)します。次に、アプリケーションとデータを新しい構造に移行(Migrate)します。最後に、新しい構造に依存するものがなくなったことを確認してから古い構造を削除(Contract)します。
以下の図は、3フェーズからなるExpand-Contractパターンと、それがカラム名変更、テーブル分割、制約変更にどのように適用されるかを示しています。
重要なのは、一度に破壊的な変更を行わないことです。新しいパスが完全に採用されるまで、古いパスは常に動作し続けるようにします。この手順を正しく守れば、スキーマ変更中のダウンタイムはゼロになります。
何も壊さずにカラム名を変更する
full_nameからdisplay_nameへのリネームを具体例として見ていきましょう。Expandフェーズでは、usersテーブルに新しいカラムdisplay_nameを追加します。full_nameは削除しません。新しいバージョンのアプリケーションは、両方のカラムに書き込みを開始します。すべてのINSERTまたはUPDATEで、古いコンシューマー向けにfull_name、新しいコンシューマー向けにdisplay_nameに同じ値を書き込みます。
以下が、リネームの各フェーズに対応するSQLコマンドです。
-- フェーズ1: Expand - 新しいカラムを追加
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);
-- 二重書き込みの例(アプリケーションロジック。SQL単体ではない)
-- ユーザーを挿入または更新する際、両方のカラムに書き込む:
INSERT INTO users (full_name, display_name) VALUES ('Alice', 'Alice');
UPDATE users SET full_name = 'Bob', display_name = 'Bob' WHERE id = 42;
-- フェーズ2: Migrate - 既存データをバックフィル
UPDATE users SET display_name = full_name WHERE display_name IS NULL;
-- フェーズ3: Contract - 古いカラムを削除
ALTER TABLE users DROP COLUMN full_name;
この手順により、データベースがクエリを拒否したりデータを失ったりする瞬間がなくなります。
カラムが追加され、アプリケーションが両方に書き込むようになったら、バックフィルを実行します。これは、すべての行のfull_nameの値をdisplay_nameにコピーするバッチ処理です。件数が一致することを確認し、ランダムなレコードをスポットチェックしてデータが失われていないことを確認します。
次に切り替えです。ユーザー名を読み取るすべてのアプリケーションを、full_nameではなくdisplay_nameから読み取るように更新します。これは段階的に行えます。一部のサービスが先に切り替え、他のサービスが後から続きます。この期間中、両方のカラムにデータが残っているため、まだfull_nameを読み取っているサービスも問題なく動作します。
すべてのアプリケーションとすべてのクエリの移行が完了したら、Contractフェーズに入ります。full_nameカラムを削除します。プロセス全体には、更新が必要なサービスの数に応じて数日から数週間かかる場合がありますが、ユーザーがアプリケーションにアクセスできなくなる瞬間は決してありません。
1つのテーブルを2つに分割する
このシナリオはより複雑です。ordersテーブルに注文詳細と支払い情報が同じ行に格納されているとします。チームは支払いデータを専用のpaymentsテーブルに分離したいと考えています。新しいテーブルを作成して古いテーブルへの書き込みを停止するだけでは不十分です。既存のアプリケーションがまだordersから読み取っているからです。
Expandフェーズでは、paymentsテーブルを作成します。新しいバージョンのアプリケーションは、両方の場所に支払いデータを書き込み始めます。注文が作成または更新されるたびに、アプリケーションは古いコンシューマー向けにordersテーブルに、新しい構造向けにpaymentsテーブルに支払い詳細を書き込みます。これは二重書き込みと呼ばれ、最も正確に行う必要がある部分です。両方の書き込みが成功するか、両方ともロールバックされる必要があります。部分的な書き込みはデータを破損させます。
ここでバックフィルが重要になります。既存のすべての支払いデータをordersからpaymentsにコピーする必要があります。テーブルを長時間ロックしないように、バッチで実行します。各バッチの後、レコード数と支払い総額が2つのテーブル間で一致することを確認します。一致しない場合は、続行する前に停止して調査します。
バックフィルが検証され、すべてのアプリケーションがordersではなくpaymentsから支払いデータを読み取るように更新されたら、Contractフェーズに入ります。ordersから支払い関連のカラムを削除します。このステップでは、クエリ、レポート、レガシーサービスがそれらのカラムにアクセスしていないという確信が必要です。何かを削除する前に、データベースログ、アプリケーションログ、クエリ監視を確認してください。
Nullable制約をNot Nullに変更する
このシナリオは単純に見えますが、しばしばチームを不意を突きます。usersテーブルにnullを許容するemailカラムがあるとします。ビジネス要件により、すべてのユーザーにメールアドレスが必要になりました。カラムを直接NOT NULLに変更しようとすると、nullメールアドレスを持つ既存の行が制約に違反するため、データベースは変更を拒否します。
ここでのExpandフェーズでは、従来の意味での新しいカラムは追加しません。一般的なアプローチは、emailをミラーリングするがNOT NULL制約を持つ新しいカラムemail_not_nullを追加することです。新しいアプリケーションバージョンは両方のカラムに書き込みます。INSERTでは両方のカラムに同じ値が入り、UPDATEでは両方が更新されます。
バックフィルが成否を分けるステップです。nullメールアドレスを持つすべての行を修正する必要があります。デフォルト値を提供するか、ユーザーと調整してメールアドレスを入力してもらうか、他のチームと協力して不足データを供給する必要があります。これは技術的な問題だけではありません。データ品質と組織的な問題です。バックフィルスクリプトは修正できない行をすべてログに記録し、それらのケースを手動で処理するようチームに警告する必要があります。
すべての行に有効なメールアドレスが設定され、すべてのアプリケーションがemail_not_nullから読み取るようになったら、古いemailカラムを削除してContractします。元のカラム名を維持したい場合は、古いカラムが削除された後、email_not_nullをemailにリネームできます。
スキーマ変更のための実践的チェックリスト
本番環境でスキーママイグレーションを実行する前に、次のチェックリストを確認してください。
- 変更後も古いスキーマでリクエストを処理できますか?
- すべての新しいデータに対する二重書き込みパスはありますか?
- バックフィルスクリプトは本番データのコピーでテスト済みですか?
- 何かを削除する前に、すべてのコンシューマーが移行したことを確認しましたか?
- Contractフェーズで依存関係の見落としが発覚した場合のロールバック計画はありますか?
まとめ
本番環境で行うすべてのスキーマ変更は、同じ手順に従うべきです。新しい構造を追加し、データとアプリケーションを段階的に移行し、何も依存しなくなった場合にのみ古い構造を削除します。カラム名の変更、テーブルの分割、制約の強化のいずれであっても、パターンは同じです。必要なのは忍耐と慎重な調整です。得られる報酬は、ダウンタイムゼロ、クエリエラーゼロです。