問題とスコープ
あるユーザーサービスがPostgreSQL上で稼働しており、そのusersテーブルには2億行が含まれています。username TEXT NOT NULLにはユニークインデックスが設定されています。現在、すべてのオンラインリクエスト、バックグラウンドジョブ、およびイベントコンシューマーがこのカラムを読み書きしています。チームはこのフィールド名をhandleに変更したいと考えており、移行中、両方のカラムはまったく同じビジネス値を表します。
このサービスには80個のアプリケーションインスタンスがあり、ローリングデプロイには30分かかります。バックグラウンドワーカーはWebインスタンスよりも遅れて再起動する可能性があります。通常のトラフィックを停止することはできず、メンテナンスウィンドウもありません。既存のSLOは有効なままです。書き込みのp99、ロック待ち、レプリケーション遅延、またはデータベース負荷が本番の閾値を超えた場合、移行をスロットリングまたは停止する必要があります。目標は、すべてのフェーズに明確な不変条件、開始ゲート、完了ゲート、およびロールバックパスを提供しながら、名前変更を完了することです。
2億行、80インスタンス、30分のロールアウトは面接上の前提条件です。「ダウンタイムゼロ」とは、既存のSLOを保護しながら計画停止を発生させないことを意味し、短いロック、試行の失敗、またはスロットリングを無視することを意味するわけではありません。中核となるスキルは、データベースの変更を前方互換性および後方互換性を持つ複数のリリースに分割することであるため、これはバックエンドの質問です。シャーディング、異種データベース間の移行、およびマルチプライマリの競合は、第1ラウンドのスコープ外です。
面接官が評価しているポイント
第1のシグナルは、候補者が互換性の問題を認識しているかどうかです。チームが直接RENAME COLUMN username TO handleを実行すると、古いインスタンスは引き続きusernameを参照し、新しいインスタンスはhandleしか認識しません。30分間の新旧バージョン混在ウィンドウの間、少なくとも一方のバージョンで障害が発生します。データベースステートメントが高速であっても、アプリケーションのロールアウトがダウンタイムゼロになるわけではありません。
第2のシグナルは、スキーマ移行、データ移行、およびコードの切り替えを分離できているかです。安全な設計は通常、expand-and-contract(拡張と縮小)に従います。古いコードが無視できる構造を追加し、互換性のあるコードをデプロイし、バッチでバックフィルと検証を行い、読み書きを切り替え、その後に初めて古い構造を削除します。各ステップは独立してデプロイ、監視、および再試行可能である必要があります。2億行の更新と破壊的なDDLを1つのデプロイトランザクションにまとめるべきではありません。
第3のシグナルは、PostgreSQLのロックとスキャンの境界を理解しているかどうかです。ALTER TABLEのサブコマンドによって必要なロックが異なり、PostgreSQLはドキュメントに明記されていない限りACCESS EXCLUSIVEを取得します。デフォルト値なしのNULL許容カラムを追加してもテーブルは書き換えられませんが、それでもロックが必要です。そのロックリクエストが長時間トランザクションの後ろに並ぶと、後続のリクエストがさらにその後ろにキューイングされる可能性があります。NOT VALID、VALIDATE CONSTRAINT、およびCREATE INDEX CONCURRENTLYは並行書き込みへの影響を軽減しますが、それぞれロック、負荷、および障害復旧の挙動が異なります。
最後に、面接官は不変条件駆動型のゲートを求めています。優れた回答は、「カラムの追加、二重書き込み、バックフィル、カラムの削除」を単に列挙するだけにとどまりません。すべてのライターがいつ互換性を持つようになるか、書き込み漏れをどのように検出するか、バックフィルがなぜ冪等であるか、新しいカラムのみの読み取りがいつ安全になるか、各フェーズでどこまでロールバックできるか、そして古いカラムがいつ実用的な復旧パスでなくなるかを説明します。
質問による前提条件の確認
- すべてのライターはどこに存在するか? Webサービス、ワーカー、スケジュールされたジョブ、管理スクリプト、データベース関数、トリガー、CDC、ETL、BIクエリ、ビュー、および外部連携を洗い出します。アップグレードされていないライターが1つでもあると、不整合が継続的に再生成される可能性があります。
- 移行中、両カラムは完全に一致している必要があるか? この問題では「はい」です。純粋な名前変更です。
handleで正規化、新しい大文字小文字のルール、またはユーザーが選択した値も導入される場合、競合処理は別のデータ移行の問題になります。 usernameのユニーク性はどのように実装されているか? 新しいカラムには同等のユニークインデックスまたは制約が必要です。大文字小文字の区別、照合順序(collation)、NULLの挙動、および部分述語(partial predicate)が変更されないことを確認します。- アプリケーションとデータベースの変更を個別にリリースできるか? 個別にリリースする必要があります。スキーマとアプリケーションが分割不可能な単一の操作としてしかデプロイできない場合、複数の互換性フェーズを進めることができません。
- 移行にはどのくらいの時間をかけられるか? 2億行のバックフィルには数時間から数日かかる場合があります。設計には、複数回のリリースをまたいで存続できる一時停止、再開、完了、および古いカラムの保持ポリシーが必要です。
- ロールバックとは何を意味するか? アプリケーションコードのロールバック、バックフィルの停止、古いカラムへの書き込みの復元、および削除されたカラムの復旧は、それぞれ異なる4つの操作です。古いカラムが削除された後は、通常のアプリケーションロールバックは安全ではなくなります。
30秒での回答
「インプレースでの名前変更は行いません。expand-and-contractアプローチを採用します。まず、短いlock_timeoutを指定してNULL許容のhandleを追加します。ロックが取得できない場合は失敗させて再試行します。互換リリースAでは引き続きusernameを読み取りますが、両方のカラムにアトミックに書き込みます。80個すべてのインスタンスと各ワーカーがアップグレードされた後、小さな冪等なプライマリキーバッチでhandle IS NULLの行をバックフィルします。全体を通して、NULL、不一致、書き込みエラー、ロック、p99、レプリケーション遅延、WAL、およびautovacuumを監視します。バックフィル後、新しいユニークインデックスをコンカレントに作成し、NOT VALIDチェックを追加して個別に検証し、その後にNOT NULLを設定します。リリースBではusernameへのフォールバックを備えたhandleを優先し、二重書き込みを維持します。監視期間の後、handleのみを読み取るようにし、その後古いカラムへの書き込みを停止します。ロールバック期間全体にわたって古いカラムを保持し、コード、ジョブ、ビュー、およびコンシューマーがそれを参照しなくなった後に初めて削除します。ゲートで失敗した場合は現在の互換フェーズにとどまり、カラムの削除は通常のロールバックポイントとしては扱いません。」
ステップごとの解説
まず移行の不変条件とゲートを定義する
DDLを実行する前に、4つの不変条件を定義します。
- 新旧バージョン混在期間中、
usernameはロールバックの信頼できる唯一の情報源(Source of Truth)であり続けます。handleはNULLである可能性がありますが、非NULL値はusernameと等しくなければなりません。 - 互換リリースがすべてのライターに行き渡った後は、新しい書き込みはすべて1つのトランザクション内で両方のカラムを更新しなければなりません。一方のカラムだけが成功することは許されません。
- NULLおよび非NULLの不一致カウントがゼロになり、すべてのライターに互換性があり、新しいインデックスと制約が有効になるまで、読み取りを古いカラムから切り離すことはできません。
- 本番コード、バックグラウンドジョブ、ビュー、レポート、CDC、およびロールバックビルドが
usernameを必要としなくなるまで、スキーマの縮小(削除)を開始することはできません。
各フェーズのデプロイバージョン、開始時刻、担当者、進捗カーソル、ダッシュボード、および終了条件を記録します。2つの実行プロセスが同じステップを同時に進めることがないよう、移行ランナーはリースまたはPostgreSQLのアドバイザリロックを保持する必要があります。また、各ステップには一意のバージョンと永続的な成功ログが必要であり、再起動時には移行全体をやり直すのではなく再開できるようにします。
フェーズ1:動作を変更せずにスキーマを拡張する
本番規模のコピー環境でDDLを検証し、長時間トランザクション、ロックキュー、およびディスクの空き容量を確認します。デフォルト値なしのNULL許容カラムを追加します。
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users ADD COLUMN handle text;
COMMIT;デフォルト値のないNULL許容カラムは2億行のテーブル書き換えを発生させませんが、ALTER TABLEには強力なロックが必要です。lock_timeoutを指定することで、ロックを迅速に取得できない試行を失敗させ、デプロイシステムがジッター(揺らぎ)を持たせて後で再試行できるようにします。実行前に異常な長時間トランザクションを特定し、実行中には誰が誰をブロックしているかを監視します。キューに入った強力なロック要求の後ろに後続のテーブルリクエストが滞留する可能性があるため、無制限に待機するのは安全ではありません。
このステップの後も古いアプリケーションはhandleを無視するため、アプリケーションのロールバックは独立したままです。この追加処理を、NOT NULL、揮発性(volatile)のデフォルト値、ユニーク制約、および全行更新と同時に実行しないでください。それらをまとめると、メタデータの変更、テーブルスキャン、インデックス構築、およびデータ書き込みの増幅が1つの高リスクな操作に結合されてしまいます。
フェーズ2:古いカラムを読み取りソースのまま維持しつつ、互換性のあるライターをデプロイする
リリースAは次のように動作します。
- 読み取りは引き続き
usernameのみを使用するため、ユーザーから見える挙動は変更されません。 - ユーザーの作成および更新では、1つのSQLトランザクション内で
usernameとhandleに同じ値を書き込みます。 - いずれかのカラムで障害が発生した場合、トランザクション全体がロールバックされます。二重書き込みは2つの非同期リクエストではありません。
- メトリクスは、実際のユーザー名をログに出力することなく、二重書き込みの試行、失敗、および不一致を記録します。
リリースAのロールアウト中、未アップグレードのインスタンスは依然としてusernameのみに書き込むため、新しく作成された行でhandle IS NULLとなる状態は一時的に許容されます。その例外は、80個すべてのWebインスタンス、ワーカー、スケジュールされたジョブ、および独立したコンシューマーが互換バージョンを報告した後にのみ解消されます。サードパーティ製プログラムがデータベースに直接書き込むなど、チームがすべてのライターを把握・管理できない場合は、一時的なデータベーストリガーでカラムを同期させることができます。ただし、これには隠れた書き込み動作、余分なコスト、およびレプリケーション、CDC、インシデント診断の複雑化というトレードオフがあります。このようなトリガーは、削除日が設定された移行用インフラストラクチャとして扱ってください。
フェーズ3:プライマリキーの範囲ごとに冪等にバックフィルを実行する
バックグラウンド移行は、すべてのライターに互換性が確保された後にのみ開始します。OFFSETではなくプライマリキーのキーセット範囲を使用し、各バッチを短いトランザクション内に収めます。
UPDATE users
SET handle = username
WHERE id > $1
AND id <= $2
AND handle IS NULL;handle IS NULLという述語により、バッチを安全に再試行可能にし、オンラインリクエストによってすでに書き込まれた値の上書きを防ぎます。最後に完了したプライマリキーの範囲を永続化します。ランナーは任意のバッチの後に停止でき、再起動後は最後に確認された範囲から継続できます。まずは1つのワーカーから開始し、事前に固定のスループットを保証することなく、実測値に基づいてバッチサイズと遅延を調整します。
各バッチの後、書き込みのp99、データベースCPU、ロック待ち、レプリケーション遅延、WAL、ディスク、デッドタプル、およびautovacuumを監視します。いずれかの測定値が本番の閾値に近づいたら、直ちにバッチサイズを縮小するか一時停止します。目的はSLOの範囲内で安定して完了させることです。スクリプトが短くなるからといって、2億行を単一のトランザクションで処理することは正当化されません。
同時に2つの独立した検証を実行します。
SELECT count(*) FROM users WHERE handle IS NULL;
SELECT count(*)
FROM users
WHERE handle IS DISTINCT FROM username;1つ目のカウントはゼロに向かって着実に減少するはずです。2つ目はIS DISTINCT FROMを使用して、NULLの差異と不等な非NULL値の両方をキャプチャします。結果がゼロ以外の場合は切り替えをブロックし、プライマリキーの範囲ごとに調査します。バックフィルで非NULLの競合を暗黙的に上書きしてはなりません。古いライターや誤った新しいロジックがまだアクティブであることを覆い隠してしまう可能性があるためです。
フェーズ4:インデックスと制約を追加する
usernameはユニークであるため、新しいカラムには同等のユニークインデックスが必要です。バックフィルと重複の検証が完了した後、トランザクションブロックの外部で以下を実行します。
CREATE UNIQUE INDEX CONCURRENTLY users_handle_key
ON users (handle);コンカレント(並行)構築により通常の書き込みを継続できますが、より多くの処理が発生し、テーブルを2回スキャンし、関連するトランザクションを待機します。CPUとI/Oも消費します。構築が失敗すると、INVALIDインデックスが残る可能性があります。復旧にあたっては、カタログを検査し、失敗したアーティファクトを削除し、原因を修正した後に再試行する必要があります。コマンドの失敗単体では、データベースが元の状態に戻ったことを証明できません。
非NULL制約の設定もフェーズに分けて行います。
ALTER TABLE users
ADD CONSTRAINT users_handle_not_null
CHECK (handle IS NOT NULL) NOT VALID;
ALTER TABLE users
VALIDATE CONSTRAINT users_handle_not_null;
ALTER TABLE users
ALTER COLUMN handle SET NOT NULL;
ALTER TABLE users
DROP CONSTRAINT users_handle_not_null;NOT VALIDは、古い行を即座にスキャンすることなく、以降の書き込みに対してチェックを適用し始めます。その後、VALIDATE CONSTRAINTがより低いロックレベルで既存の行をチェックします。有効なCHECKが存在することはNULLが存在しないことの証明となり、その後のSET NOT NULLでは通常のフルテーブルスキャンをスキップできます。各DDLステートメントには、短いロック待機、個別の実行ステップ、および本番監視が依然として必要です。「コンカレント」や「テーブル書き換えなし」は「負荷ゼロ」を意味するわけではありません。
フェーズ5:読み取りを切り替え、古いカラムへの書き込みを停止する
リリースBではhandleを優先し、NULLの場合はusernameにフォールバックし、アトミックな二重書き込みを継続します。ゲート条件としてはNULLがすでにゼロになっているはずですが、フォールバック機構によりロールバックの互換性が維持され、予期しないデータが分離されます。まずはカナリアリリースから開始し、徐々に拡大して、新旧カラムの結果、エラー、およびビジネスメトリクスを比較します。
観察期間の後、リリースCでは両方のカラムへの書き込みを継続しながら、handleのみを読み取るようにします。usernameは最新の状態に保たれているため、読み取りパスに問題が発生してもBまたはAにロールバックできます。バックグラウンドジョブ、低頻度のエンドポイント、および完全なデプロイサイクルをカバーする観察期間を経た後にのみ、リリースDでusernameへの書き込みを停止する必要があります。
古いカラムへの書き込みを停止すると、ロールバックのセマンティクスが変わります。その後、usernameしか認識しないビルドへロールバックするには、まず二重書き込みを復元し、停止期間中に作成された値を逆バックフィルする必要があります。アプリケーションを直接ロールバックすると、古いデータが公開されてしまいます。インシデント対応がエンジニアの記憶に依存しないよう、この要件を手順書(ランブック)とデプロイゲートに明記してください。
フェーズ6:縮小(削除)を遅らせる
削除を行う前に、コード検索、クエリログ、依存関係カタログ、およびコンシューマーのインベントリを使用して、usernameにリーダー(読み取り処理)が存在しないことを証明します。まず古いインデックス、制約、トリガー、およびビューの依存関係を削除し、その後の個別リリースでカラムをドロップします。
ALTER TABLE users DROP COLUMN username;カラムのドロップは破壊的な境界を越える操作です。PostgreSQLが即座にテーブルを書き換えないとしても、古いアプリケーション、スキーマキャッシュ、ビュー、および外部クエリは即座に失敗する可能性があります。また、削除されたデータは通常のアプリケーションロールバックでは利用できなくなります。ドロップは、読み書きの切り替えから少なくともロールバック保持期間を丸々1回分空けて、カラムの使用を停止するコードリリースとは完全に切り離して実行する必要があります。バックアップは災害復旧のためのものであり、低レイテンシのデプロイロールバック用ではありません。
優れた回答例
「主なリスクは、30分間のローリングデプロイ中における互換性です。変更を拡張、互換性のある書き込み、バックフィルと検証、読み取りの切り替え、および遅延縮小に分割します。
まず、短いlock_timeoutを指定してNULL許容のhandleを追加します。ロックが取得できない場合、試行は失敗して再試行されます。デフォルト値のないNULL許容カラムはテーブルを書き換えませんが、DDLは依然として強力なロックを取得するため、長時間トランザクションとロックキューを検査します。リリースAでも引き続きusernameから読み取りを行い、すべてのライターは単一のデータベーストランザクション内で両方のカラムを更新します。バックフィルは、80個すべてのインスタンス、ワーカー、およびコンシューマーがアップグレードされた後にのみ開始します。
バックフィルはUPDATE ... WHERE handle IS NULLを用いた短いプライマリキー範囲のトランザクションを使用し、進捗を永続化し、安全に再試行できるようにします。その処理速度は本番のp99、レプリケーション遅延、WAL、デッドタプル、およびautovacuumに応じて調整します。NULLカウントはゼロに達する必要があり、handle IS DISTINCT FROM usernameはゼロのままでなければなりません。非NULLの競合は上書きするのではなく調査します。
データゲートを通過した後、CREATE UNIQUE INDEX CONCURRENTLYを使用して同等のユニークインデックスを構築し、失敗時には残存した無効なインデックスを適切に処理します。CHECK ... NOT VALIDを追加して個別に検証し、その後NOT NULLを設定します。リリースBでは古いカラムへのフォールバックを備えた新しいカラムを優先し、二重書き込みを維持します。リリースCでは新しいカラムのみを読み取りますが、二重書き込みは継続します。リリースDでは、完全な観察サイクルを経た後にのみ古いカラムへの書き込みを停止します。
各フェーズにはロールバックパスが存在します。読み取り切り替えの前であれば、アプリケーションを直接ロールバックできます。新しいカラムを読み取っている最中でも二重書き込みが継続していれば、古い読み取りに戻すことができます。古い書き込みを停止した後は、古いアプリケーションを安全に動かす前に二重書き込みを復元し、逆バックフィルを行う必要があります。最後に、コード、ジョブ、ビュー、CDC、およびレポートがusernameを参照しなくなり、ロールバック期間が経過した後に、個別のリリースでカラムをドロップします。ゲートに失敗した場合は、破壊的な縮小に進むのではなく、システムを互換性のある状態に保ちます。」
よくある間違い
- カラムをインプレースでリネームする → ロールアウト中、古いインスタンスと新しいインスタンスで異なるカラム名が必要になります → 新しいカラムを使用し、複数の互換リリースに分割します。
- 1つのトランザクションで2億行を更新する → トランザクションによってWAL、ロック、レプリケーション遅延、テーブル肥大化(bloat)、およびリカバリ時間が増幅されます → 短く冪等なプライマリキー範囲のバッチを使用します。
- 二重書き込みが始まった瞬間にバックフィルを開始する → アップグレードされていないインスタンスによって新しいNULLが生成される可能性があります → ゼロNULLをゲート条件にする前に、すべてのライターが互換性を持つまで待機します。
- 二重書き込みを2つのリクエストとして実装する → タイムアウトにより一方のカラムしか更新されない可能性があります → 単一のデータベーストランザクション内で両方をアトミックに更新します。
handle IS NULLのみをチェックする → 一致しない非NULL値の検出漏れが発生します →IS DISTINCT FROMもチェックし、競合を調査します。ADD COLUMNをロックフリーとして扱う → メタデータDDLであってもテーブルロックが必要であり、キューに入ったDDLリクエストはブロッキングを拡大させる可能性があります → 短いロック待機、再試行、およびロック/トランザクション監視を使用します。- ユニークインデックスを通常通り作成する → 大規模テーブルでのインデックス作成は、許容できない時間ライターをブロックする可能性があります →
CONCURRENTLYを使用し、追加負荷と無効インデックスの復旧に対応します。 - バックフィル直後に古いカラムをドロップする → 低頻度のワーカー、ビュー、スキーマキャッシュ、またはロールバックビルドがまだそれを必要としている可能性があります → 完全な観察およびロールバック期間を設けて縮小を遅らせます。
- バックアップをロールバックボタンとして扱う → 2億行のデータベースの復元は、アプリケーションのロールバックよりもはるかに遅く、リスクが高くなります → 破壊的な境界を越えるまで、オンライン互換性のある構造を維持します。
- 「影響ゼロの移行」を約束する → DDL、インデックス、およびバックフィルはすべてロックまたはリソースを消費します → 計画停止なしを約束し、SLOを保護し、閾値を超える前に一時停止します。
関連する質問
なぜデフォルト値を指定してhandleを追加しないのか?
PostgreSQLは、非揮発性(non-volatile)の定数デフォルト値であればテーブルの書き換えを回避できますが、「この行の既存のusernameをコピーする」と表現できる定数は存在しません。物理的に高速なデフォルト値であってもDDLロックは必要であり、古いインスタンスが古いカラムにしか書き込まない問題は解決しません。この移行には依然として互換性のあるライターとデータのバックフィルが必要です。将来のINSERTにデフォルト値が必要かどうかはビジネスセマンティクス上の決定であり、ロールアウト計画の代わりになるものではありません。
handleを小文字に変換して再度ユニークにする必要がある場合はどうするか?
それはもはや純粋な名前変更ではありません。1つの正規化関数と競合ポリシーを定義し、レプリカまたはオフラインジョブでlower(username)の衝突をカウントします。値を維持するか、サフィックスを付与するか、ユーザーのアクションを要求するかを決定します。バックフィル中は元の値と変換ステータスを保存し、競合がゼロになった後にのみユニークインデックスを作成します。オンライン書き込みとバックフィルでは、まったく同じ正規化実装を使用する必要があります。
すべてのライターを一度にアップグレードできない場合はどうするか?
迅速に移行できない外部システムに対しては、古いカラムへの書き込みを新しいカラムにコピーし、残りの呼び出し元を特定できるように使用状況を記録する一時的なデータベーストリガーを追加します。トリガーは競合する値を提供するリクエストを拒否する必要があります。再帰、レプリケーション、およびCDCの動作を評価します。すべての呼び出し元が移行を完了したら、非表示のビジネスロジックが恒久化しないように、トリガーを無効化して観察した後に削除します。
バックフィル中にレプリケーション遅延が増加し続けた場合はどうするか?
進捗を急ぐためにワーカーを追加するのではなく、新しいバッチを一時停止してレプリカを追いつかせます。バッチサイズ、コミット頻度、WAL生成速度、長時間クエリ、およびautovacuumを調査します。バッチサイズを小さくし、並行度を下げて再開します。読み取りがレプリカに依存している場合、レプリケーション遅延はすでにユーザーから見えるリスクとなっています。バックフィルの完了期日は本番のSLOに従属します。
失敗後にCREATE UNIQUE INDEX CONCURRENTLYを単に再実行してもよいか?
盲目的に再実行してはなりません。同名の無効なインデックスがカタログに残っている可能性があり、また並行ユニーク構築が失敗したフェーズ中に他のトランザクションに対してすでにユニーク性を強制していた可能性があります。インデックスの有効性を検査し、データまたはリソースの障害を特定し、手順書に従って失敗したインデックスを削除してから再構築します。また、このコマンドは通常のトランザクションブロック内では実行できないため、移行ツールがその実行モードをサポートしている必要があります。
どのフェーズが最もロールバックしにくいか?
古いカラムへの書き込みを停止した後、古い値の遅れ(乖離)が始まります。古いカラムをドロップした後は、データとスキーマの両方が破壊的な境界を越えます。前者の場合、古いビルドを安全に動かす前に二重書き込みを復元して逆バックフィルを行う必要があります。後者の場合、通常は前方修復(修正パッチの適用)またはバックアップからの復元が必要になります。これらのアクションは個別のリリースに分け、十分に長い観察期間にわたって古いカラムを最新の状態に維持してください。