expand/contractパターンで行うダウンタイムなしのスキーマ変更
expand/contractパターン、安全なバックフィル、互換性のあるリリース、検証、ロールバックで、ダウンタイムなしのスキーマ変更を計画・実施する方法。

スキーマ変更が障害を起こす理由
アプリケーションのバージョン、バックグラウンドワーカー、データベースで、有効な構造や値についての認識がずれると、スキーマ変更は障害を引き起こします。すべてのリクエストがエラーになるように明白な場合もあれば、クエリレイテンシの上昇、書き込み失敗、レプリカ遅延、再実行が必要なジョブの滞留という形で徐々に現れる場合もあります。
本番デプロイで、すべてのプロセスが一斉に切り替わることはほとんどありません。ローリングリリースでは古いアプリと新しいアプリのインスタンスが同時に動きます。長時間動くワーカーは何時間も古いビルドを保持することがあり、モバイルクライアントは数か月使われ続け、レポートや連携ジョブはメインアプリを経由せずテーブルを使うこともあります。どれも同じデータベースを共有しています。
よくある失敗には、次のようなものがあります。
- 新しいコードが、列を作る移行の完了前にその列へ書き込む。
- 古いコードが、後のリリースで名前変更または削除されたテーブルや列を読む。
- テーブルの書き換え、バックフィル、インデックス作成がI/OとCPUを使いすぎ、通常トラフィックが遅くなる。
- スキーマコマンドがロック待ちになり、その後ろでリクエストがたまる。
- 新しい制約が、まだ更新されていないプロセスからの書き込みを拒否する。
危険なのは、実行時間そのものよりロック取得であることが少なくありません。高速なALTER TABLEでも、長いトランザクションの後ろで待つことがあります。待機中に後続クエリが保留中のスキーマロックの後ろへ並び、小さな移行がアプリ全体の停止につながります。
ダウンタイムをなくすには、まだ動き得るすべてのアプリバージョンが、移行途中の各データベース状態を使える必要があります。まず互換性のある構造を追加し、トラフィックとデータを管理された段階で移し、最後の利用者が消えてから古い経路を削除します。
この手間は、ライブトラフィック、ローリングデプロイ、厳しい可用性目標、復旧コストの高い手順があるシステムで正当化されます。静かなデータベースを使う小さな社内ツールなら、十分にテストしたメンテナンス時間の方が適切かもしれません。判断は、障害のコストと移行の運用上の複雑さを踏まえて行います。
expand/contractをわかりやすく説明する
expand/contractパターンでは、互換性のない変更を、互換性のあるリリースの連続に変えます。コードとデータを古い表現から新しい表現へ移す間、データベースは一時的に二つの表現を支えます。
流れは三つです。
- Expand: 現在のコードが必要とするものを削除せず、列、テーブル、インデックス、制約を追加する。
- Transition: 互換性のあるコードをデプロイし、履歴データを移し、読み書きを新しい表現へ向ける。
- Contract: 検証により使われていないと分かってから、古いコードとデータベースオブジェクトを削除する。
たとえばPostgreSQLテーブルが人名をfull_nameに保存しており、アプリにはfirst_nameとlast_nameが必要だとします。Expandではfull_nameを残したままNULL許可列を追加します。互換性のあるリリースでは、移行中に必要な表現へ書き込みます。バックフィルで既存値を分割し、確実に分けられない名前には明示的な扱いを定めます。新しいフィールドが十分に埋まってから、読み取りを切り替えます。Contractで後からfull_nameを削除します。
この順序なら、ローリングデプロイ中も古いビルドはfull_nameを見つけられ、新しいビルドは三つの列をすべて見つけられます。アプリをロールバックする経路も残ります。新しいリリースに問題があれば、スキーマ依存が削除されていないため前のビルドを動かせます。
データベースのロールバックはアプリのロールバックとは別です。データ変換後に移行を戻すと、情報を失ったり古い値に戻したりする恐れがあります。移行中は、追加したデータベースオブジェクトを残したまま、アプリのトラフィックを既知の表現へ戻す方が安全です。障害が安定してから、前進方向の移行を修正します。
このパターンは、すべての変更に二重書き込みが必要という意味ではありません。新しいコードだけが使う任意列の追加なら、追加的な移行と一度のデプロイで済むことがあります。名前変更、表現変更、テーブル分割、必須フィールドの変更では、二つのアプリバージョンが安全に同じスキーマを共有できないため、通常はより多くの段階が必要です。
手順を選ぶ前に変更を分類する
移行計画は、操作の実際のロック、書き換え、互換性、データ変換のリスクに合わせます。すべてのALTER TABLEを同じものとして扱うと、不要に大がかりになるか、安全でないリリースになります。
追加的な変更は通常もっとも簡単です。NULL許可列、別テーブル、オンライン方式で作るインデックスは、多くの場合、アプリコードが使い始める前に導入できます。それでもコマンドにはロックが必要なので、本番に近いテーブルとトランザクション負荷で挙動をテストしてください。
破壊的な変更には、列の削除や名前変更、型の縮小、テーブル置き換え、より厳しい制約の追加があります。これらは既存コードの前提を無効にします。コード参照と外部利用者を取り除いてから、Contractフェーズで実施します。
データを変える操作も、個別に評価が必要です。タイムスタンプ変換、電話番号の正規化、レコードの結合、自由記述テキストの分割では、情報を失うことがあります。バックフィル開始前に、無効値と曖昧な値の扱いを定めます。変換を戻せないなら、結果が業務上の検査を通るまで元データを残します。
事前確認では、次の五点を確認すると有効です。
- 各文はどのロックを要求し、そのロックをどれだけ待機または保持するか。
- テーブルを書き換えるか、大量のWALを生成するか、レプリカ遅延を増やすか。
- 対象オブジェクトを使うアプリ、ジョブ、レポート、変更データキャプチャの利用者は誰か。
- 現行リリースと提案リリースが、移行中のすべての状態で動くか。
- どのシグナルで操作を止め、停止後にどの状態が残るか。
実際の量と分布に近いデータで、正確な移行を実行してください。整った千行のテストテーブルからは、数億行、幅広いタプル、不要行、偏った値、長時間トランザクションを含む本番テーブルについて、ほとんど分かりません。
PostgreSQLで安全にExpandする
安全なPostgreSQLのExpandでは、短いメタデータ変更、上限を設けたロック待機、必要な場合に別実行するオンライン操作を使います。新しいオブジェクトに依存するコードをデプロイする前に、その構造を追加します。
デフォルト値なしのNULL許可列の追加は、通常は短いメタデータ操作です。
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
ALTER TABLE customers
ADD COLUMN phone_e164 text;
COMMIT;
タイムアウトにより、オープン中のトランザクションの後ろでリリースが無期限に待つことを防げます。すぐにロックを取れなければ、移行を失敗させ、ブロッカーを調べてから安全な時間に再試行します。本番トラフィックを何度も妨げる可能性があるため、短い間隔で自動再試行しないでください。
最近のPostgreSQLでは、定数デフォルトを持つ列を追加しても、既存の全行へ直ちに値を書かずに済むことがあります。ただし、この最適化がすべてのデフォルトを無害にするわけではありません。揮発性の式は書き換えを必要とすることがあり、ALTER TABLEには短時間でもACCESS EXCLUSIVEロックが必要です。一般論に頼らず、導入済みのPostgreSQLバージョンと正確な式で挙動を確認してください。
通常のCREATE INDEXは書き込みをブロックすることがあります。テーブルを書き込み可能なままにする必要があれば、同時作成を使います。
CREATE INDEX CONCURRENTLY idx_customers_phone_e164
ON customers (phone_e164);
CREATE INDEX CONCURRENTLYはトランザクションブロック内では実行できません。時間がかかり追加作業も発生し、古いトランザクション待ちになることもありますが、通常のINSERT、UPDATE、DELETEは続けられます。CPU、I/O、WALは消費するため、実行中はデータベースレイテンシとレプリカを監視します。
同時作成に失敗すると、無効なインデックスが残ることがあります。再試行前にインデックス状態を確認し、無効なオブジェクトを意図的に削除または再作成します。各ファイルをトランザクションで包む移行ツールでは、同時インデックス操作向けの、サポートされた非トランザクションモードが必要です。
新しいテーブルは、インプレース変換より導入しやすいことがよくあります。一対多または多対多の関係なら、元の列を残しながら対象テーブルとインデックスを追加します。新しい書き込み、履歴データ、読み取り、下流の利用者が移るまで、元を削除しません。
型変更には特に注意が必要です。メタデータだけで済むものもあれば、全行を書き換えたり、制限の強いロックを長く取ったりするものもあります。リスクの高い変換では、対象型の列を追加し、バッチで埋め、アプリのアクセスを切り替え、元の列は後で削除します。これにより、巨大なALTER COLUMN TYPEを一括で成功か失敗かにする代わりに、変換失敗を記録する場所も得られます。
互換性を保つコードをデプロイする
互換性のあるアプリコードは、移行中の値がなくても扱え、同じロールアウト中に破壊的移行を必要としません。最初のアプリインスタンスが新しいオブジェクトを使う前に、データベースのExpandを完了させます。
二つの表現を最新に保つ必要があるとき、二重書き込みが役立ちます。可能な限り、両方の書き込みを同じデータベーストランザクションで行います。二つ目の非同期書き込みは、一つ目が成功した後に失敗することがあり、後の読み取りで見える不整合を作ります。
二重書き込みには、どちらを正とするかも必要です。phone_e164がphoneから作られるなら、両方が渡された場合にどちらを優先するかを定め、APIハンドラー、ワーカー、インポート、管理ツールで同じ正規化を適用します。そうしないと、正しく見える二つのコード経路が異なる結果を保存します。
読み取りの切り替えは書き込みより後にします。新規書き込みが両形式を埋め、バックフィルが履歴行を処理する間は、確立済みのフィールドから読み取ります。検証後に、新フィールドを優先し、定義済みのフォールバック規則に限って古い値を使う読み取り経路をデプロイします。フォールバック利用を測定してください。黙ったまま残るフォールバックは、不完全なデータを永遠に隠しかねません。
典型的なリリース順序は次のとおりです。
- リリース1で、新しいデータベースオブジェクトを追加するが、アプリの挙動は変えない。
- リリース2で、確立済みの読み取りを続けながら移行用の表現へ書き込む。
- リリース3で、バックフィルと整合性検査の完了後に読み取りを切り替える。
- リリース4で、ロールバック条件の期限後に古い表現の維持を止める。
- リリース5で、古いコード参照を取り除き、その後にデータベースを掃除する。
公開APIの契約は、物理スキーマ変更と分けて扱います。データベース列の名前を変えたからといって、Web、モバイル、連携レスポンスのフィールド名をすぐに変える必要はありません。とくにサーバーと一緒に更新できないクライアントでは、独自の互換性方針で契約を変更します。
書き込み元をすべて洗い出してください。HTTPハンドラーは変更元の一つにすぎません。キュー利用者、定期ジョブ、インポートスクリプト、データ修復ツール、データベーストリガー、直接の管理操作でも、古い形の行が作られます。実務上可能ならデータベース接続にアプリ名を付け、移行経路の利用をログに残して、見落としたプロセスを可視化します。
長時間動くプロセスは、プリペアドステートメント、キャッシュ済みメタデータ、ORMを通じて古い前提を保ち続けることがあります。Contract前に、ローリング再起動とコネクションプールの挙動をテストしてください。最近トラフィックを出していないプロセスでも、まれなジョブが初めて動いたときに失敗することがあります。
データベースを圧迫せずにバックフィルする
安全なバックフィルでは、小さく再開可能なバッチを更新し、本番の健全性が悪化したら速度を落とします。ライブの書き込み元が新しい表現を維持できるようになってから始めます。
バッチは普遍的な行数ではなく、経過時間とデータベースへの影響で選びます。細い千行なら数ミリ秒で終わる一方、大きな値や高価な変換を含む千行では大きなI/Oが出ます。慎重に始め、数秒で終わるトランザクションを目指します。バッチ間でコミットし、ロックと古い行バージョンを一つのトランザクションにためないようにします。
PostgreSQLでは、通常のUPDATEにORDER BYとLIMITを直接付けられません。共通テーブル式でバッチを選んでから更新します。
WITH batch AS (
SELECT id
FROM my_table
WHERE id > $1
AND new_col IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE my_table AS target
SET new_col = transform_expression(target.old_col)
FROM batch
WHERE target.id = batch.id
AND target.new_col IS NULL
RETURNING target.id;
アプリは、完了した最大idをカーソルとして記録します。条件付き更新により再実行は冪等になるため、コミット後のクラッシュで既処理行を壊しません。カーソルが未コミットのバッチを越えないよう、進捗を十分注意して保存します。
増加するidカーソルなら、テーブル先頭を繰り返し走査せずに済みますが、カーソルより小さい位置での後からの修正や挿入は捕捉しません。最後に残るすべてのNULL値を対象に追いつき処理をします。識別子に順序がない場合や、行が対象条件に出入りする場合は、一度の前方走査で完了と決めつけず、作業テーブルまたは明示的なチェックポイントを使います。
複数ワーカーはFOR UPDATE SKIP LOCKEDで行を取得できますが、並列化は書き込み圧力を上げ、進捗管理も複雑にします。スキップした行と、永続的に先へ進むカーソルを組み合わせないでください。並列ワーカーには、取得済み識別子のキューか、対象条件を繰り返し走査する方が安全です。
クエリレイテンシ、アクティブ接続数、ロック待機、WAL生成量、レプリカ再生遅延、不要行の増加といった本番計測値でスロットルします。しきい値を超えたら停止し、チェックポイントから再開します。固定スリープは単純ですが、データベースからのフィードバックの方がトラフィック変化に適応できます。
一部だけを処理すればよいなら、全行を変えないでください。新しいフィールド、元の状態、移行マーカーで絞ります。変換が高価なら、整合性が許す範囲で更新トランザクションの外で計算し、短い条件付き書き込みを行います。データを黙って作り出すのではなく、拒否値の件数とサンプルを残します。
各更新後の負荷を、autovacuumとレプリカが吸収する必要があります。バックフィルはプライマリで成功しても、レプリカが大きく遅れたり、テーブル肥大化で後のクエリが劣化したりします。レート制限では、バッチの即時実行時間だけでなく、この遅れて現れるコストも考慮してください。
データと本番トラフィックを検証する
データ検査、アプリのテレメトリ、依存関係の証拠がそろって、新しい経路が正であると示して初めてContractに進めます。完了ジョブのカウンターだけでは正しさを証明できません。
まず完全性と整合性を確認します。PostgreSQLのIS DISTINCT FROMはNULLを明示的に扱って値を比較します。どちらかがNULLの場合に不明な結果を返す<>とは異なります。
SELECT count(*)
FROM customers
WHERE normalize_phone(phone) IS DISTINCT FROM phone_e164;
非常に大きく忙しいテーブルで、インデックスなしの全表カウントを繰り返し実行しないでください。一度だけ管理下で検証する、識別子範囲を区切る、サンプルを取る、テーブルを進む一時的な検証処理を使います。正しくないことのコストと、利用できるデータベース余力で適切な方法は変わります。
検証では次を確認します。
- 新フィールドが必要な行に、想定外の欠損値が残っていない。
- 不正入力や空入力を含め、新しい値が合意した変換結果と一致する。
- 履歴行の処理終了後も、新しい行と更新が整合している。
- フォールバック読み取りが計画したしきい値、サーバー管理トラフィックでは通常ゼロまで下がっている。
- エラー率、クエリレイテンシ、ロック、レプリカ遅延がリリース上限内にある。
列だけでなく業務上の結果も比較します。移行が価格、権限、アカウント状態、識別子を変えるなら、ユーザーが依存する合計値や不変条件を検証します。二列が機械的に一致しても、両方が誤った業務ルールを表していることがあります。
掃除の前に、一運用サイクルを観察します。適切な期間は固定の一週間ではなく、実際のシステム挙動で決まります。月末処理、まれな請求ジョブ、遅延したキュー再試行、古いモバイルクライアントの最長寿命を含める必要があるかもしれません。各利用者が移行済みである証拠を記録します。
アプリ構成が許せば、読み取り切り替えをカナリア化します。一部トラフィックを新しい読み取り経路へ送り、結果を比較しながら段階的に広げます。ロールバック操作は単純に保ちます。バックフィルを戻さず、読み取りを確立済みの表現へ向け直します。
データの準備後に制約を追加する
制約を厳格にするのは、すべての書き込み元が従い、既存データの検証が済んでからです。Expand中にNOT NULL、チェック、外部キーを強制すると、トラフィックをブロックしたり、古いプロセスの書き込みを拒否したりします。
PostgreSQLではチェック制約をNOT VALIDで追加でき、新規行または変更行には規則を適用しつつ、すべての履歴行をすぐには走査しません。バックフィル後に別途検証します。
ALTER TABLE customers
ADD CONSTRAINT customers_phone_e164_present
CHECK (phone_e164 IS NOT NULL) NOT VALID;
ALTER TABLE customers
VALIDATE CONSTRAINT customers_phone_e164_present;
検証が成功すれば、対応するPostgreSQLバージョンでは、その証拠をNOT NULL設定に利用でき、もう一度全表走査せずに済みます。最後の変更にも強いテーブルロックが必要なので、上限付きロックタイムアウトと再試行計画を使います。
ALTER TABLE customers
ALTER COLUMN phone_e164 SET NOT NULL;
ALTER TABLE customers
DROP CONSTRAINT customers_phone_e164_present;
一時的なチェックに価値があれば残せますが、同等の制約を残しても規則は変わらず、カタログが煩雑になります。
外部キーもNOT VALIDとVALIDATE CONSTRAINTで似た流れを取れます。制約作成後は新規書き込みを検査し、履歴検証は後で行います。参照関係での削除や更新が高コストな走査につながるなら、補助インデックスを意図的に追加します。
アプリのバリデーションはデータベース強制より先に行うべきですが、置き換えにはなりません。コードは分かりやすいユーザー向けエラーを出し、データベースはすべての経路から書かれるデータを守ります。ロールアウト中は制約違反を監視し、依存関係調査で見落とした書き込み元を見つけます。
古い経路を安全にContractする
Contractでは、データベースオブジェクトより先にアプリの依存関係を取り除きます。テレメトリと検証で新経路が正と確認できたら、別リリースで掃除を進めます。
最初に古いフィールドの読み取りとフォールバックロジックを止めます。次に古い書き込みを無効にし、まれな経路を捕まえられる十分な期間、本番を観察します。古い表現に触れる機能フラグ、トリガー、互換ビュー、修復スクリプト、定期ジョブを削除します。エクスポートしたソースや移行コードを検索するだけでなく、メインリポジトリ外のレポート、連携クエリ、変更データキャプチャ設定も調べます。
安全な掃除の順序は次のとおりです。
- フォールバック読み取りを削除し、テレメトリに現れないことを確認する。
- 古い書き込みを止め、同期コードを削除する。
- デプロイ可能なすべてのバージョンからアプリ参照をなくす。
- 適切なオンライン方式で、不要なインデックスと制約を削除する。
- 後のデータベースリリースで、古い列またはテーブルを削除する。
PostgreSQLの列削除は主にカタログ変更ですが、それでもACCESS EXCLUSIVEロックが必要です。そのため短い文でも、長いトランザクションの後ろで待ち、後続作業をブロックすることがあります。ロックタイムアウトを設定し、事前に長時間トランザクションを調べ、リスクの低い時間に試行を予定します。
不要なインデックスで書き込みブロックを許容できない場合は、DROP INDEX CONCURRENTLYを使います。同時作成と同じくトランザクションブロック内では実行できず、移行ツール側で扱うべき制約があります。
コード掃除と物理削除を一つのリリースにまとめないでください。分けることで、掃除済みアプリはまだ未使用オブジェクトを含むデータベースで動けます。アプリの問題が起きても、スキーマ再作成やデータ再構築なしでロールバックできます。
テーブルを削除する前に、シーケンス、ビュー、関数、権限、トリガー、レプリケーション公開、外部クエリの所有関係を確認します。本番移行での近道としてCASCADEを使わないでください。意図した変更に含まれない依存関係まで削除する可能性があります。
ロールバックと失敗した手順への対応
ロールバック計画では、汎用的なdown migration一つに頼らず、フェーズごとの安全な行動を定めます。追加オブジェクト、データ移動、読み取り切り替え、削除では、復旧の性質が異なります。
Expandでロックを取れなければ、アプリを変えず、ブロックしているトランザクションを解消してから再試行します。同時インデックス作成に失敗したら、無効なインデックスが残ったかを調べ、次の試行前にそのオブジェクトだけを掃除します。
バックフィルが負荷を作ったら停止します。コミット済みで冪等なバッチはそのまま残して構いません。バッチサイズやレートを下げ、高価な変換に対処してからチェックポイントから再開します。正しい更新を何百万行も戻しても、通常は本番復旧の助けにならず、リスクを増やします。
新しい読み取り経路が誤った結果を返したら、新データを診断用に残したまま、読み取りを古い表現へ戻します。二重書き込みが正しいと分かっている場合に限り継続します。書き込み側に問題があるなら、影響行を修復する前に無効化するかアプリをロールバックします。
Contract後の復旧には、古いビルドをデプロイするだけでなく、データ復元が必要になることがあります。後戻りできない時点を明示してください。システムの復旧方針で必要なバックアップまたはスナップショットを取り、リリース前に復元をテストし、ストレージコストが許せば合意した保持期間は古いオブジェクトを残します。
スキーマコマンドはトランザクション化できても、外部効果まで一つのトランザクションにはなりません。同時インデックス操作、キューメッセージ、キャッシュ変更、アプリデプロイは同じ原子的トランザクションを共有しません。ランブックには、各部分失敗後に観測できる状態と、安全に続行するコマンドを記載します。
よくある移行の落とし穴を避ける
ダウンタイムなしの移行が失敗する多くの原因は、新しい状態を早く強制しすぎることか、古い状態の利用者を忘れることです。承認前に次の落とし穴を明示的に確認してください。
- 古いアプリインスタンスがまだフィールドを省略できる間に
NOT NULLを追加する。 - 大規模バックフィルを一つのトランザクションで実行し、ロックと行バージョンを長く保持する。
- 古いコードが元の名前を使っているのに、列名変更を追加的な変更として扱う。
- すべての書き込み経路と履歴行が新表現を埋める前に、読み取りを切り替える。
- デプロイ成功だけを、レポート、ワーカー、レプリカ、連携の互換性の証明とみなす。
双方向同期も微妙な失敗を招きます。トリガーがold_colをnew_colへコピーし、アプリコードがnew_colをold_colへ戻す場合、正規化やトリガー順序の違いでループができたり、意図した値を上書きしたり、責任範囲が不明瞭になったりします。一方向を選び、各リリースでどの表現を正とするかを文書化してください。
デフォルト値は、書き込み元の更新漏れを隠すことがあります。新しい必須列に空または汎用的なデフォルトを与えると、古いコードは互換に見えても、意味的に無効なデータを保存します。欠損が診断に役立つなら、まずNULL許可で移行し、すべての書き込み元が意味のある値を送るようになってから本来の規則を強制します。
機能フラグだけでは、互換性のないスキーマコマンドを安全にはできません。無効なコード経路でも、古いプロセスがロード、準備、実行することがあります。デプロイ可能または稼働中のどのバージョンも参照しなくなるまで、データベースオブジェクトを残します。
移行の責任者も重要です。検証と削除予定日を含め、Contractまで一人または一チームに移行を任せます。そうしないと一時列、フラグ、同期ジョブが何か月も残り、後の変更すべてのコストを上げます。
ダウンタイムなしで電話番号列を置き換える
customers.phoneを正規化済みのcustomers.phone_e164に置き換えるには、追加列、定義済みの変換方針、互換コード、上限付きバックフィル、読み取り切り替え、遅らせた掃除が必要です。保存済みのすべての値を自動的に正規化できるわけではないため、変換方針はSQLより先に定めます。
まず既存値を分類します。必要な国情報が分かる有効な番号は変換できます。空の値はNULLにできます。曖昧または不正な番号は推測せず、例外レポートへ入れます。すべての顧客に電話番号が必要かも決めます。これは後でNOT NULLが適切かどうかを決めます。
短いロックタイムアウトで列を追加します。
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE customers
ADD COLUMN phone_e164 text;
COMMIT;
新規入力を正規化し、phoneとphone_e164を一つのトランザクションで書くコードをデプロイします。最初はphoneから読み取ります。アカウントインポート、サポートツール、ワーカージョブ、顧客フィクスチャを作るテストを含め、すべての書き込み元を更新します。
対象行を短いトランザクションでバックフィルします。最後に処理した識別子、変換数、スキップ数、失敗カテゴリごとの理由を記録します。本番レイテンシとレプリカ遅延に対してジョブをレート制限します。前方走査が完了したら、再起動後の見逃しや同時挿入を捕まえるため、対象となるNULL値を再走査します。
アプリと同じ正規化規則で整合性検査を行い、国際プレフィックス、内線、空値、重複連絡先、古いインポートデータを手動でサンプル確認します。行数は範囲を示しますが、電話番号が正しいことまでは示しません。
phone_e164があれば返し、ログを残す例外でだけphoneを使う読み取り経路をデプロイします。フォールバック利用と正規化エラーを監視します。フォールバックを恒久的な挙動にせず、残る例外を解決してください。
新フィールドが正になったら、フォールバックを削除し、phoneへの書き込みを止めます。適切な運用サイクルを通じて、まれなジョブと連携トラフィックを観察します。製品ルールで必要な場合にだけ、検証済みの制約を追加します。
最後にphoneへのコード参照を削除します。インデックスや制約は別途削除し、上限付きロック待機のある後の移行で列を削除します。その削除前に読み取り切り替えが失敗した場合は、両列を使える状態のままアプリ挙動を戻します。
この例は、スキーマの仕組みだけでは解決できない業務上の問題も示します。人が入力したデータの分割や正規化は、常に情報を失わずに行えるわけではありません。移行計画では例外を保存し、担当者が解決できるようにする必要があります。
リリース前に毎回確認する
リリースチェックリストでは、互換性を証明し、本番への影響に上限を設け、現在フェーズの復旧操作を明記します。障害時に運用者が意図を再構成しなくて済むよう、変更と一緒に証拠を残してください。
デプロイ前に次を確認します。
- アプリバージョンが、このリリース前後のデータベース状態で動く。
- トラフィックの後ろで待機し得るスキーマコマンドに、ロックと文のタイムアウトが設定されている。
- バックフィルまたは検証ジョブに、進捗、停止、再開、レート制限の制御がある。
- ダッシュボードがエラー、レイテンシ、ロック、データベース負荷、WAL、レプリカ遅延を扱っている。
- 既に削除したオブジェクトに頼らず、ロールバック操作をテスト済みである。
明示的な完了条件を記録します。たとえば、一つの完全なジョブサイクルで新規整合性失敗がゼロ、サーバー管理トラフィックのフォールバック読み取りがゼロ、既知の利用者がすべて更新済み、管理下の検証クエリが成功、といった条件です。バックフィル中の進捗率は有用ですが、処理済み100パーセントは正しさ100パーセントと同じではありません。
コードレビューとは別に、移行順序を確認します。SQLとアプリ変更の組み合わせが正しくても、デプロイが誤った順序で実行すると失敗します。別の手順が完了してから始められる手順を明記します。
停止条件は可能な限り数値化します。許容するクエリレイテンシ、ロック待機、レプリカ遅延、エラー率、バッチ時間を定めます。しきい値を超えたとき、運用者が障害中に改めて承認を求めず、ジョブ停止、待機文のキャンセル、読み取りの切り替えのどれを行うか分かるようにします。
移行が完了するのは、新しい表現で読み書きができ、履歴データの検証が済み、古いオブジェクトが削除され、一時的な運用の仕組みもなくなった後です。
プロセスを繰り返し可能にする
再利用できる移行ランブックにより、expand/contractは担当者と測定可能なゲートを持つ通常のリリース作業になります。ライブデプロイ中に従える短さを保ちつつ、部分的な失敗状態を説明できる具体性も必要です。
ランブックは五つの節で構成します。
- Expansion: 正確なスキーマ操作、予想ロック、タイムアウト、トランザクション要件。
- Compatibility: 影響を受けるコード、書き込み元、読み取り元、フラグ、クライアント、デプロイ順序。
- Backfill: 変換方針、バッチ、チェックポイント、スロットリング、例外処理。
- Verification: SQL検査、業務上の不変条件、テレメトリ、完了しきい値。
- Contraction: 依存関係の削除、観察期間、物理的な掃除、復旧の限界。
すべての移行用オブジェクトに担当者と完了予定日を割り当てます。列、インデックス、フラグ、トリガー、ジョブを同じ場所で追跡します。掃除は移行の一部であり、任意の保守作業ではありません。
Koder.aiで開発するチームなら、本番変更を始める前にPlanning Modeでこれらのフェーズとチェックポイントを具体化できます。ソースコードのエクスポートにより、移行SQLと互換性ロジックも、ほかのアプリコードと同じようにレビューできます。Koder.aiはデプロイ、ホスティング、スナップショット、ロールバックを支援しますが、アプリのロールバックでコミット済みのデータ変換まで戻るとは考えないでください。データベース復旧計画が古い表現に依存しなくなるまで、スキーマ互換性を保ちます。
可能なら書き込み量の多い作業は低トラフィック時に予定します。ただし、時間帯だけを安全策にしないでください。上限付きトランザクション、フィードバックに基づくスロットリング、観測可能な進捗、テスト済みの停止操作があるからこそ、想定と異なるトラフィックやデータでもオンライン移行を管理できます。
よくある質問
スキーマ変更が障害を起こすのはなぜですか?
スキーマ変更は、古いアプリと新しいアプリが異なるデータベース構造を前提にすると本番障害につながります。ローリングデプロイ中は両方が同時に動くため、列を早く削除したり名前を変えたりすると、読み書きが失敗します。
expand/contract移行パターンとは何ですか?
expand/contractは、互換性のない変更を安全な段階に分ける方法です。まず新しい構造を追加し、コードとデータを移してから、すべての利用者が使わなくなった時点で古い構造を削除します。
ダウンタイムなしでデータベース列の名前変更や置き換えを行うには?
まず新しい列を追加し、古い列は残します。両方のフィールドを扱えるコードをデプロイし、既存行を小さなバッチでバックフィルします。検証後に読み取りを切り替え、古い列は後のリリースで削除します。
PostgreSQLの列はトラフィックを止めずに追加できますか?
多くの場合は可能です。デフォルト値なしのNULL許可列の追加は、PostgreSQLでは短時間のメタデータ変更で済むことが多いですが、テーブルロックは必要です。短いロックタイムアウトを設定し、長いトランザクションの後ろで待ち続けるのではなく移行を失敗させます。
書き込みを止めずにインデックスを作成するには?
テーブルを書き込み可能なままにする必要があれば、CREATE INDEX CONCURRENTLYを使います。時間がかかりデータベース負荷も増え、トランザクションブロック内では実行できません。実行中はレイテンシ、WAL、レプリカ遅延を監視してください。
アプリケーションはいつ古いフィールドと新しいフィールドへ二重書き込みすべきですか?
両方の表現を最新に保つ必要がある場合は、同じデータベーストランザクションで両方の値を書き込みます。値が食い違ったときにどちらを優先するかを決め、API、ワーカー、インポート、サポートツールで同じ正規化ルールを使います。
大きなPostgreSQLテーブルを安全にバックフィルするには?
短く再開可能なバッチで処理し、各バッチ後にコミットします。チェックポイントを保存し、処理が必要な行だけ更新します。クエリレイテンシ、ロック待機、WAL量、レプリカ遅延が増えたら、処理を遅くするか停止します。
バックフィルが完了し、正しいと判断するには?
バックフィル完了だけを理由に読み取りを切り替えてはいけません。必要な値が存在することを確認し、古い表現と新しい表現を比較し、フォールバック読み取りを監視し、履歴データ処理後も新規書き込みの整合性を確認します。
NOT NULL、チェック制約、外部キーはいつ追加すべきですか?
既存データの検証が済み、稼働中のすべての書き込み元が有効な値を送るようになってから、厳格な制約を追加します。PostgreSQLでは一部の制約をNOT VALIDで追加でき、新規行には適用しつつ履歴行を別途検証できます。
古いスキーマ経路を削除しても安全なのはいつですか?
まずフォールバック読み取りをなくし、次に古い書き込みを止め、システムを一運用サイクル観察します。すべてのアプリ、ジョブ、レポート、連携、クライアントが古いオブジェクトを参照しなくなってから、関連コードを削除し、後のリリースでデータベース列またはテーブルを削除します。