新旧スキーマの共存と安全な縮小
一言でいうと
スキーマを変えるときは、新しいコードが成功するかどうかだけを見ずに、まだ動いている古いコードがどの列を読み書きするかまで、互換性の契約に入れる必要があります。
なぜ必要なのか
エイリアン・フェスティバルの名札システムを直します。顧客は、nameという名前があいまいなので、display_nameに変えてほしいと言います。列名を一度に変えれば終わりそうですが、会場のタブレットには、古いプログラムが残っています。新しいプログラムがデプロイされている間も、バッチの出力機能や、再試行中のジョブは、古い列を読みます。データが1件も消えていないのに、列名が見つからないというエラーで、名札の印刷が止まるおそれがあります。
この問題は、Kubernetesのローリングアップデートでも起きます。新しいPodがReadyになったことは、古いコンシューマーがすべて終了したという意味ではありません。画面に接続できても、待機中のジョブ、ほかのサービス、分析クエリ、運用者のツールは別です。DBの変更の成功を、新しいPod1つの正常な応答だけで判断すると、検証の範囲が狭すぎます。
このレッスンは、文字どおり同じ名前の値を移す変更です。名前を分割したり翻訳したりする、損失のある変換は扱いません。両方の値が食い違った場合に、どちらを捨てるかを自動で決めることもしません。また、使い捨てのラボのDBだけで実行し、LabHubの運用DBのスキーマは変更しません。
どう動くのか
1. 広げるとは、古い契約を残すこと
既存のname NOT NULLはそのままにして、nullableなdisplay_nameを追加します。既存の行の新しい列にはNULLが入ります。これを空文字列や「不明」で埋めてしまうと、実際の名前と、未移行の状態を区別しにくくなります。PostgreSQLのALTER TABLEは、作業の種類ごとに必要なロックが違い、列の追加も、実行中の読み取りトランザクションを待つことがあります。ALTER TABLEの公式ドキュメントで、各形式の説明を確認してください。
ロックの待機を無限にせず、短いロックの上限を適用します。失敗したら既存のスキーマを保全し、開いているトランザクションを片付けます。「単純なDDLだからすぐ終わる」という推測は、長いSELECTが開いている接続の前で崩れます。ラボでは、実際の古い読み取り接続を生かしておいて、この待機を再現します。上限値の500msは、小さな教育用DBの契約であり、すべての運用環境への推奨値ではありません。
2. クライアントは2種類ではなく3種類
| クライアント | 読む式 | 古い列を削除したあと |
|---|---|---|
| 旧バージョン | name | 列がないエラー |
| 移行用 | COALESCE(display_name, name) | やはり列がないエラー |
| 最終バージョン | display_name | 新しい列だけで動作する |
移行用のコードは、まだ移していない行も読むために必要です。しかし、COALESCEが実行時に古い値を選ばなくても、SQLはnameという列を参照します。新しい列がすべて埋まったからといって、移行用のコードをそのままにして、古い列を削除することはできません。バックフィルと制約の検証のあとに、最終バージョンへ移すリリースが、もう1つ必要です。
最終バージョンは、古い列を削除する前にも試験できます。このときは、旧バージョン・移行用・最終バージョンが、しばらく同時に存在します。この共存の区間を実際のSQLで確認してはじめて、そのあとの縮小が、データの保全とクライアントの互換性の両方を満たすかどうかを判断できます。
3. 両方の書き込みをつなぐが、矛盾は隠さない
新しい列だけを追加したあとで、古いアプリがnameを変更すると、display_nameは取り残されます。このラボは、BEFORE INSERT OR UPDATEトリガーを、互換性の橋として使います。旧バージョンがnameだけを変更すれば新しい列にコピーし、最終バージョンがdisplay_nameだけを変更すれば古い列にコピーします。両方を同じ新しい値に変えるのは許可しますが、互いに違う値に変えるものは拒否します。
どのフィールドが変わったかは、NEWとOLDを、NULLまで含めて比較する必要があります。通常の等価比較だけを使うと、NULLで判定が抜け落ちるおそれがあります。PL/pgSQLトリガーの公式ドキュメントのNEW・OLD・TG_OPと、行を返す規則を読み、入力のないフィールドを自動で埋める場合と、明示的にNULLで消そうとする場合を区別してください。
INSERTで片方のフィールドだけがあれば、もう一方を埋めます。UPDATEで値は変わっていないが、新しい列がまだNULLの既存の行は、古い値を新しい列に埋められます。一方、すでにある名前をNULLで消す書き込みは、拒否します。この規則は、一般的なすべてのデータ変換の規則ではなく、同じ名前の2つの表現の同等性を保つための、今回の顧客との契約です。
トリガーは、一時的な複雑さです。長く残すと、どのコードが実際の元データなのか、わかりにくくなり、アプリケーションのログだけでは、追加の書き込みが見えないことがあります。削除する条件と検証を、設置するときから一緒に設計します。ユーザー認証や顧客ごとの権限を、トリガーが代わりに担ってくれると考えないでください。
4. バックフィルは、過去に読んだ値ではなく、現在の行を基準にする
バックフィルの対象は、明示したIDのdisplay_name IS NULLの行です。UPDATEの右辺のnameも、DBの中で現在の行を読みます。アプリが先にSELECTで古い名前を取得し、あとでUPDATEすると、その間に別の担当者が変えた名前を、過去の値で上書きするおそれがあります。
ラボでは、古いアプリの名前変更のトランザクションを開いてロックを取り、別のプロセスでバックフィルを始めます。バックフィルが実際に待っているのを確認してから、古いアプリをコミットします。正しいバックフィルは、ロックの解除後に、現在のNULLの条件を再評価し、すでに移された行には触れません。Read Committedの公式の説明を、この行の時間の順序とあわせて読んでください。先に取得した古い値を、あとから無条件に書く誤答も、同じ状況で検査します。
5. 新しい書き込みの保護と、過去全体の検証を分ける
CHECK(display_name IS NOT NULL) NOT VALIDは、既存の未移行の行を即座にすべて検査することはせずに、新しい書き込みには条件を適用します。互換トリガーを先に用意しておかないと、古いアプリの新しい挿入も、この規則を満たせません。バックフィルが終わったあとで、VALIDATE CONSTRAINTで既存の行まで確認し、最後に列自体をNOT NULLに強化します。
NOT VALIDを「まだ何も検査していない」と読んではいけません。逆に、制約の名前があるというだけで、既存のデータの検証が終わったと信じてもいけません。ラボでは、バックフィルの前に全体の検証が失敗するか、バックフィルのあとでconvalidatedとattnotnullが実際に変わるかを確認します。コマンドにVALIDATEという単語があるかどうかだけを見ることはしません。
現場での姿
互換機能を先に消したのに、あとのDDLが失敗したら
トリガーの削除、関数の削除、古い列の削除が別々のコミットだと、途中の失敗のあとに、名前は2つ残っているのに、両方の書き込みをつなぐ機能が消えていることがあります。今回の縮小は、1つのトランザクションです。途中の例外やクライアントの終了が、コミットの前に起きたなら、互換の橋まで復元されている必要があります。コミットのあとに応答だけを失った場合は、新しい接続で、実際のスキーマを観測する必要があります。
psycopgのtransactionコンテキストが、どの区間をコミットするのかを確認してください。外側のトランザクションがすでにあると、内側のコンテキストの終了が、実際のコミットではないことがあります。このレッスンは、autocommit=TrueのIDLEの接続で、各マイグレーション関数を開始する契約です。トランザクション管理のドキュメントと、フックの位置をあわせて確認してください。
「退役の承認True」が、実際にコンシューマーがいないことを証明するわけではない
ラボの2つの承認の値は、外部で、旧バージョンと移行用のコンシューマーの退役を確認したという入力です。運用でその事実を立証するには、動作中のバージョン、スケジュールされたジョブ、再試行のキュー、DBを直接読むツールまで調べる必要があります。単純なフラグや、数日間エラーがなかったという観測だけでは、すべてのコンシューマーがいないとは証明できません。
FDEに必要なのは、顧客と一緒にこのコンシューマーの地図を確認し、いつ止めるか、いつ元に戻すかを合意することです。確認したPalantir FDEの求人の、顧客の課題の理解・解決策の実装という能力に結びつけた、著者の設計による事例であり、その会社がこのトリガーのパターンを要求したり、採用を保証したりするという意味ではありません。
次のラボですること
名札の名前とIDを保全しながら、nullableな列の追加、移行用の読み取り、双方向の書き込み、ID限定のバックフィル、制約の検証、承認された縮小、最終クライアントへの移行を行います。誤って移された2つの列の値が違えば、件数が同じでも縮小を拒否します。実際のDDLのロック待機・同時のバックフィル・3地点でのプロセス終了を確認し、縮小のあとに、古いSQLと移行用のSQLが、なぜ失敗するのかを自分の目で見ます。
このラボは、サーバーの電源障害への耐久性や、大容量のオンラインマイグレーションの性能を証明するものではありません。用意された小規模なDBで、クライアント・スキーマ・トランザクションの互換関係を検証することが目標です。