本番データベースを壊さずにレガシーデータをバックフィルする方法
新しいマイグレーションをデプロイして、users テーブルに last_login_at カラムを追加したとしよう。スキーマ変更自体はスムーズにいった。しかし、データを見てみると、既存の全ユーザーのそのカラムが NULL になっている。先週、先月、去年のログイン履歴はすべて、新しいフィールドからは見えない状態だ。
ここで必要になるのがバックフィルである。
バックフィルとは何か
バックフィルとは、マイグレーションを適用するより前に存在していたデータを埋める処理のことだ。データを新しい構造に移すことではない。それはマイグレーションスクリプトの役割である。バックフィルは、システムが新たに従うルールに合わせて、古いデータを最新の状態に更新する作業だ。
バックフィルが必要になる状況はよくある。
- 新しいカラムを追加したが、既存の行には値が入っていない。
- 住所の保存方法を、単一のテキストフィールドから「番地」「市区町村」「郵便番号」の個別カラムに変更した。
- トランザクションのリスクスコアのような新しい計算ロジックを導入したが、過去のトランザクションにはスコアが付与されていない。
どのケースでも、データ自体は存在しているが、不完全な状態にある。システムは新しいデータをどう扱うかは分かっているが、古いデータは以前のフォーマットのまま取り残されている。
なぜ一度にすべてを処理してはいけないのか
初心者がやりがちなのは、すべての行を一度に更新する単一のクエリを実行することだ。数百行しかない小さなテーブルならそれでも問題ない。しかし、数百万行あるテーブルでは、それは災害の引き金になる。
一度に大量の更新を行うと、行ロックが発生し、トランザクションログを消費し、同じテーブルに対する他のすべてのクエリを遅くする。バックフィル中もアプリケーションがユーザーにサービスを提供している場合、ユーザーはタイムアウト、応答遅延、リクエスト失敗を経験することになる。データベースがメモリやディスク容量を使い果たしてしまう可能性すらある。
解決策は、データを小さく制御されたチャンク単位で処理することだ。
バッチ処理: 核となるテクニック
100万行を一度に更新する代わりに、1万行ずつ更新し、一時停止してから次のバッチを処理する。これがバッチ処理であり、安全なバックフィルの基礎となる。
実際の動作は以下の通りだ。
次のフローチャートは、冪等性チェックとスロットリングを含むバックフィルループ全体を示している。
-- バックフィルが必要な行を1バッチ分処理する
UPDATE users
SET last_login_at = (
SELECT MAX(login_time)
FROM login_history
WHERE login_history.user_id = users.id
)
WHERE last_login_at IS NULL
LIMIT 10000;
このクエリを実行した後、影響を受けた行数を確認する。バッチサイズと一致していれば、数秒待ってから再度実行する。返ってきた行数が少なければ、バックフィルはほぼ完了している。
適切なバッチサイズの選び方
すべてのデータベースに通用する普遍的なバッチサイズは存在しない。適切なサイズは以下に依存する。
- データベースサーバーの性能。
- アプリケーションがデータベースにかけている負荷。
- 更新ロジックの複雑さ。
- 利用可能なトランザクションログの容量。
まずは控えめなサイズ、例えば5,000行から始める。数バッチ実行してデータベースのメトリクス(CPU使用率、ディスクI/O、アプリケーション側からのクエリレイテンシ)を監視する。データベースが余裕で処理できていれば、バッチサイズを2倍にする。レイテンシのスパイクやロック競合が見られたら、サイズを半分に減らす。
目標は、他のクエリに顕著な影響を与えず、数秒で完了するバッチサイズを見つけることだ。30秒もかかるバッチは、通常負荷の本番システムには大きすぎる可能性が高い。
スロットリング: データベースに休憩を与える
バッチサイズは1回の作業量を制御する。スロットリングは作業単位間の時間間隔を制御する。
各バッチの完了後、次のバッチを開始する前に意図的に一時停止を入れる。この一時停止により、データベースは保留中の書き込みをフラッシュし、ロックを解放し、バックフィルとの競合なしに他のクエリにサービスを提供できる。
典型的なスロットルは、バッチ間に2〜5秒の間隔を設ける。ピーク時には10〜15秒に増やすこともある。メンテナンスウィンドウ中は1秒に減らすか、完全に削除することもできる。
スロットルは安全弁だ。アプリケーショントラフィックの急増、他チームによる遅いクエリ、レプリケーションラグの警告など、何か問題が発生した場合、一時停止を長くしてシステムを安定させてから続行できる。
バックフィルを冪等にする
バックフィルスクリプトは、何度実行しても安全でなければならない。バッチが途中で失敗した場合や、プロセス全体を再起動する必要がある場合でも、同じスクリプトを再度実行しても重複データやエラーが発生してはならない。
バックフィルにおける冪等性は、通常以下のいずれかを意味する。
- 書き込み前に確認する:
NULLまたは古い値のままの行のみを更新する。 - アップサートロジックを使用する: 行に新しいデータが既にあるかどうかに基づいて、挿入または更新を行う。
last_login_at の例では、上記のクエリはすでに冪等である。なぜなら、カラムがまだ NULL である行のみを対象としているからだ。バッチが5,000行を更新した後に失敗した場合、次回の実行ではそれらの行をスキップし、残りの行から続行する。
派生値を再計算するような、より複雑なバックフィルの場合は、processed_at タイムスタンプカラムを追加するとよい。バックフィルスクリプトは各行を処理する前に processed_at が NULL かどうかを確認する。処理が完了するとタイムスタンプが設定され、以降の実行ではその行はスキップされる。
ログ記録: 壊れるまで誰も考えない細部
バックフィルが何時間も実行される場合、現在どこまで進んでいるか、正しく動作しているかを把握する必要がある。すべてのバッチをログに記録すること。
- バッチ番号と時間範囲。
- 処理された行数。
- バッチの所要時間。
- 発生したエラー。
- 進捗状況(パーセンテージまたは行数)。
このログには2つの目的がある。第一に、バックフィルが予期せず停止した場合、最初からやり直す代わりに、最後に完了したバッチから再開できる。第二に、バックフィルが完了したときに、何が起こったかの正確な記録が残り、デバッグや監査に役立つ。
シンプルなログエントリは次のようになる。
2025-03-15 14:32:01 | Batch 47 | Processed 10,000 rows | Duration 3.2s | No errors
2025-03-15 14:32:06 | Batch 48 | Processed 10,000 rows | Duration 3.1s | No errors
2025-03-15 14:32:11 | Batch 49 | Processed 10,000 rows | Duration 3.5s | No errors
実践的なバックフィルチェックリスト
本番環境でバックフィルを実行する前に、以下のリストを確認すること。
- バッチサイズは、同様のデータ量を持つステージング環境でテスト済みであること。
- スロットル間隔が設定可能であり、コード変更なしで調整できること。
- スクリプトが冪等であること。2回実行しても同じ結果になること。
- ログ記録により、バッチの進捗、エラー、タイミングが取得できること。
- ロールバック計画が存在すること。問題が発生した場合にバックフィルを元に戻せること。
- データベースのパフォーマンス低下を検出するためのモニタリングが設定されていること。
- 本番データのコピーを使用してドライランが実行済みであること。
まとめ
バックフィルは、一度書いて忘れるようなスクリプトではない。データベースがユーザーにサービスを提供している最中にデータを変更するという事実を尊重した、制御された操作である。バッチ処理とスロットリングは、単なる最適化ではない。この作業を安全に行うための最低限の要件だ。これらなしでは、たった一つの大きなクエリが本番インシデントを引き起こすことになる。