データベーススキーマは正しいのに、データが間違っているとき

新しいカラムを追加するデータベースマイグレーションを実行したとしよう。すべて順調に見えた。スキーマ変更は成功し、カラムも存在し、アプリケーションも稼働している。しかし、誰かが気づく。「3年前に登録した全ユーザーが、検証済みとしてマークされるべきなのに、されていない」。あるいは、移行した電話番号のフォーマットが統一されていない。古いデータが新しいルールに従っていなかったからだ。

スキーマは正しい。カラムの型も合っている。制約も有効だ。問題はデータそのものにある。

この状況は、マイグレーションが失敗するよりも厄介だ。失敗したマイグレーションは明らかだ。エラーが見えれば、何かが壊れたとわかり、行動を起こせる。しかし、不正なデータを伴って成功したマイグレーションは目立たない。誰かが気づくまで、何時間も何日も本番環境に潜んでいる可能性がある。そして気づいたとき、本能的にパニックになり、すべてを元に戻す方法を探したくなる。

しかし、データを修正するためにスキーマをロールバックすると、新たな問題が発生する。アプリケーションが古いスキーマで動作しなくなるかもしれない。すでに正しかったデータを失うかもしれない。そして、実際には正しかった構造変更を、間違っていたコンテンツを修正するためだけに元に戻すことになる。

本当の問題はスキーマではない

マイグレーションで is_verified のようなカラムをデフォルト値 false で追加した場合、スキーマ変更は単純だ。カラムは存在し、デフォルト値は機能し、新しいレコードは正しく動作する。問題は、検証済みであるべき既存ユーザーが未検証とマークされることだ。スキーマが原因ではない。マイグレーションロジックが原因ではない。既存データがどうあるべきかという理解にギャップがあったのだ。

もう一つのよくある例:電話番号の保存に国番号を必須とするマイグレーション。新しいフォーマットは正しく、新しいエントリはルールに従う。しかし、国番号なしで保存されていた古い電話番号は一貫性を失う。スキーマは問題ない。データが問題なのだ。

どちらの場合も、解決策はスキーマを元に戻すことではない。解決策は、スキーマをそのままにしてデータを修正することだ。

補償スクリプト:構造に触れずにデータを修正する

補償スクリプト(Compensating Script)とは、スキーマではなくデータのみを変更するマイグレーションのことだ。通常のマイグレーションとして実行され、同じパイプラインを通り、同じデプロイプロセスに従う。ただし、ALTER TABLECREATE INDEXADD COLUMN の代わりに、データを修正する UPDATEINSERTDELETE 文のみを含む。

目標はシンプルだ:テーブル構造を変更せずに、データを正しい状態にすること。

実用的な例を示す。デフォルト値 'IDR'currency カラムを追加した後、国際パートナーからのすべてのトランザクションは 'USD' を使うべきだったとチームが気づいたとする。補償スクリプトは次のようになる:

UPDATE transactions SET currency = 'USD' WHERE partner_type = 'international';

スキーマ変更はない。新しいカラムもない。型変換もない。単なる対象を絞ったデータ修正だ。

補償スクリプトは、部分的なマイグレーション失敗も処理する。新しいテーブルを作成し、古いテーブルからデータを移動するマイグレーションを想像してほしい。制約違反のため、一部の行が転送に失敗する。マイグレーション全体をロールバックしてやり直す代わりに、補償スクリプトが残りの行を処理できる。見逃されたレコードのみを挿入または更新し、完全なマイグレーションを再実行しない。

補償スクリプトを冪等にする

何よりも重要なルールが一つある:補償スクリプトは冪等(べきとう)でなければならない。スクリプトを2回実行しても、1回実行したのと同じ結果になること。

これは理論上の話ではない。実際には、マイグレーションは再実行される。パイプラインが再起動する。環境がリフレッシュされる。デバッグ中に誰かが手動でマイグレーションを実行する。スクリプトが冪等でなければ、2回実行するとデータが壊れる可能性がある。

修正方法は単純だ。変更を加える前に常に現在の状態を確認する。修正が必要な行のみに影響するよう、十分に具体的な WHERE 句を使用する。データベースがサポートしていれば、挿入には ON CONFLICT 句を使う。

次のような書き方ではなく:

UPDATE transactions SET currency = 'USD' WHERE partner_type = 'international';

こう書く:

UPDATE transactions SET currency = 'USD' WHERE partner_type = 'international' AND currency IS DISTINCT FROM 'USD';

違いは小さいが重要だ。2番目のバージョンは、通貨がまだ 'USD' でない行のみを更新する。100回実行しても、変更が必要な行にのみ影響し、最初の実行時のみだ。

補償スクリプトで不十分な場合

補償スクリプトは万能ではない。スキーマが正しく、データのみ修正が必要な場合に機能する。スキーマ自体が間違っている場合は、適切なスキーママイグレーションが必要だ。

例えば、間違ったデータ型でカラムを追加した場合、またはカラム制約が保存すべきデータに対して厳しすぎる場合、補償スクリプトは役に立たない。スキーマを変更する必要がある。同様に、マイグレーションが単純な UPDATE 文では修正できない方法でデータを壊すバグを導入した場合、より複雑なアプローチが必要かもしれない。

しかし、スキーマは正しくデータが間違っているという一般的なケースでは、補償スクリプトは他のどの方法よりも安全だ。ダウンマイグレーションのリスクを回避し、バックアップからの復元を必要とせず、アプリケーションがトラフィックを処理している最中でも実行できる。

補償スクリプト作成のクイックチェックリスト

  • スクリプトを書く前に、スキーマが正しいことを確認する。構造を変更する必要があるなら、それを先に処理する。
  • データベースに直接適用するホットフィックスとしてではなく、新しいマイグレーションとしてスクリプトを書く。
  • すべての文を冪等にする。更新や挿入の前に条件を確認する。
  • 空のデータベースだけでなく、本番データのコピーに対してスクリプトをテストする。
  • なぜデータ修正が必要なのかを説明するログやコメントを含め、将来のチームメンバーがコンテキストを理解できるようにする。

まとめ

マイグレーションがうまくいかなかったとき、本能的にすべてを元に戻したくなる。しかし、スキーマを元に戻すのは重い操作であり、アプリケーションを壊したり、正しいデータを失ったりする可能性がある。補償スクリプトは、より軽量で正確なツールを提供する。動作するスキーマを維持しながら、データを修正できる。次に、成功したものの不正なデータを残したマイグレーションを見つけたら、自問してほしい:スキーマが間違っているのか、それともデータが間違っているのか?答えがデータなら、補償スクリプトを書こう。ロールバックよりも速く、安全で、混乱も少ない。