新旧スキーマの狭間で:レガシーレコードのバックフィルと検証
本番環境で3年間稼働しているデータベーステーブルに、新しいカラムを追加したとしよう。アプリケーションは新旧両方の構造に書き込むようになった。しかし、この変更前に作成された数百万行のレコードはどうなるのか?新しいカラムにはNULLが入ったまま、技術的には不完全なデータが残っている。
ここで多くのチームが気付くのは、スキーママイグレーションとは単にカラムを追加することだけではないということだ。既存のすべてのレコードを新しいルールに追いつかせることこそが本質である。古いデータを新しい構造に埋める処理をバックフィルと呼ぶ。そして、これを不注意に行うと、本番テーブルを何時間もロックし、アプリケーションを遅くし、クエリの結果を待つ全員を苛立たせることになる。
なぜバックフィルは単純なUPDATEでは済まないのか
初心者がやりがちなのは、新しいカラムがNULLのすべての行に対して、1つの大きなUPDATEクエリを実行することだ。テーブルが数千行程度なら、これでも数秒で終わるかもしれない。しかし、数千万行を扱う場合、その1つのクエリが長時間テーブルロックを保持する。他のクエリはキューイングされ、アプリケーションは遅延し、ユーザーはタイムアウトを目にするようになる。
バックフィルは、本番運用と同様の注意を払って計画する必要がある。最初のステップは、どのデータを埋める必要があるかを正確に定義することだ。例えば、is_active と deleted_at という2つの既存カラムから値を導出する status カラムを追加したとする。バックフィルのロジックは、新しいレコードを書き込む際に新しいアプリケーションコードが使用するのと同じルールに一致しなければならない。アプリケーションが is_active がtrueで deleted_at がnullの場合に status を 'active' に設定するなら、バックフィルも同じようにしなければならない。
バッチ処理:安全な方法
1つの巨大なクエリではなく、データを小さなバッチで処理する。新しいカラムがまだNULLの行を1000行または10000行取得し、それらを更新し、次のバッチに移る前に少し休止する。これによりデータベースに呼吸する時間を与え、他のクエリが待たずに実行できるようにする。
一部のデータベースにはバッチ更新の組み込みサポートがある。例えばPostgreSQLでは、サブクエリと共に UPDATE ... WHERE ... LIMIT を使用できる。MySQLはUPDATEで直接LIMITをサポートしていないが、ストアドプロシージャでループを使用するか、マイグレーションスクリプトからバッチ処理を扱うことができる。重要なのは、各バッチをワークロードにとって許容可能なロック期間に収まる程度に小さく保つことだ。
以下は、多くのデータベースで機能する大まかなパターンである:
while there are rows with NULL in new_column:
select a batch of primary keys where new_column IS NULL
update those rows with the computed value
sleep for a short interval
以下のフローチャートは、この安全な反復的アプローチを示している:
具体的な例として、is_active と deleted_at から導出される status カラムを追加したとしよう。PostgreSQLでのバッチUPDATEは次のようになる:
UPDATE your_table
SET status = CASE
WHEN is_active = true AND deleted_at IS NULL THEN 'active'
WHEN is_active = false AND deleted_at IS NULL THEN 'inactive'
ELSE 'deleted'
END
WHERE id IN (
SELECT id
FROM your_table
WHERE status IS NULL
LIMIT 1000
);
内部のサブクエリは、新しいカラムがまだNULLである主キーの小さなバッチを選択する。外部のUPDATEはそれらの行のみに影響を与え、ロックを短く保つ。NULLがなくなるまでこれをループで繰り返す。
スリープ間隔は、処理を遅くするためのものではない。他の操作に実行機会を与えるためのものだ。バッチ間のわずか100ミリ秒でも、負荷時のデータベースの動作に大きな違いをもたらす。
バックフィル中の2つの真実の源
バックフィルの実行中も、アプリケーションは新旧両方の構造に新しいデータを書き込み続ける。つまり、同じ論理データに対して2つの真実の源が存在することになる:
- アプリケーションによって書き込まれたデータ(アプリケーションが両方の場所に同時に書き込むため、常に一貫している)
- バックフィルプロセスによって埋められたデータ(アプリケーションのロジックと完全に一致する必要がある)
両者は同じ入力に対して同じ値を生成しなければならない。バックフィルロジックにバグがある場合、またはバックフィルの開始から終了までの間にアプリケーションロジックが変更された場合、不整合なデータが発生する。これが、検証がオプションではない理由である。
検証:NULLチェック以上のもの
バックフィル完了後、直感的には「NULL値は残っていないか?」という簡単なチェックを実行したくなる。それでカラムが埋められたことはわかるが、値が正しいかどうかはわからない。
適切な検証とは、すべての行について計算された値を期待値と比較することを意味する。古いカラムを読み取り、新しい値がどうあるべきかを計算し、それを新しいカラムに実際に格納されている値と比較する。不一致があれば調査が必要だ。
不一致はいくつかの原因から発生する可能性がある:
- アプリケーションとは異なる方法で値を計算するバックフィルロジックのバグ
- バックフィルが処理した時点と検証が実行された時点の間でデータが変更された
- マイグレーション途中でロジックが更新されたため、アプリケーションが異なる値を書き込んだ
- バックフィル後、検証前にアプリケーションによって行が更新された競合状態
それぞれの不一致は、根本原因まで追跡する必要がある。単純な修正で済む場合もあれば、マイグレーションプランのより深い問題を明らかにする場合もある。
段階的検証
テーブル全体を1回で検証する必要はない。ランダムサンプルから始めよう。1000行を選び、値を比較し、すべてが一致するか確認する。サンプルが合格したら、より大きなサンプルに移る。それも合格したら、完全な検証を実行する。
大規模テーブルの場合、完全な検証とはすべての行を1つずつ読み取ることを意味しない。すべてのデータを取得せずに不一致をカウントするクエリを書くことができる。例えば:
SELECT COUNT(*) FROM table
WHERE computed_value != actual_new_value
カウントがゼロなら、問題はない。ゼロでない場合は、何行注意が必要かがわかり、それらをバッチで調査できる。
検証が合格したら
検証により、新しい構造のすべての行が期待値と一致することが確認されたら、データが一貫しているという確信が持てる。しかし、マイグレーションはまだ終わっていない。アプリケーションは依然として両方の構造に書き込んでいる。次のステップは、古いスキーマからの読み取りを停止し、新しいスキーマのみに依存することだ。この移行は、独自のリスクと検証手順を伴う別のフェーズである。
バックフィルと検証は、その移行を安全にする基盤である。これらがなければ、不完全または不正確なデータで新しいスキーマに切り替えることになる。これらがあれば、自分が何を持っているかを正確に把握できる。
実践的なチェックリスト
- 新しい値を計算するための正確なロジックを定義し、アプリケーションコードと一致させる
- データベースにとって許容可能なロック期間を維持するバッチサイズを選択する
- バッチ間に一時停止を入れてバッチ処理を実装する
- メンテナンスウィンドウまたは低トラフィック期間にバックフィルを実行する
- 完全な検証の前にランダムサンプルで検証する
- 先に進む前にすべての不一致を調査する
- バックフィルロジックと検証結果を将来の参照のために文書化する
具体的な教訓
バックフィルは、急いで済ませる技術的な雑用ではない。これは、マイグレーションロジックが実際のデータで大規模に機能することを証明するステップである。検証をスキップすれば、バックフィルコードが完璧で、処理中にデータが変更されなかったと信頼していることになる。その信頼が正当化されることはほとんどない。バックフィルはバッチで実行し、結果を体系的に検証し、すべてのレコードが正しいという証拠を得てから次のフェーズに進むこと。