19億件のテーブルをメンテナンス1時間以内にマイグレーション
背景
Timespread には、ランダムボックスという機能があります。広告を見るとアプリ内通貨の「キャッシュ」がランダムに入ったボックスがもらえ、そのボックスを開けるとキャッシュが入るというシンプルな機能でした。ボックス1つは、luckybox テーブルの1行でした。ユーザーが増えるにつれてこのテーブルに19億件が積み上がりましたが、主キーが INT で宣言されていたため、上限の21億まで2か月ほどしか残っていない状態でした。このテーブルを、BIGINT で宣言した新しいテーブルに移す必要がありました。
1回目の試み
まず、サービスの特徴を調べました。ボックスは24時間が過ぎると期限切れになり、ランキングを処理するバッチプログラムは直近1か月のデータしか読んでいませんでした。メンテナンス時間内にこのデータだけを移せば、サービスを再開できるとわかりました。
チームリーダーと相談し、平日の明け方にサービスを1時間止めて、あらかじめ用意したマイグレーションスクリプトを実行することにしました。スクリプトは、次の作業を順番に行います。
- 既存テーブルの名前を RENAME して、接続を切ります。
- 新しいテーブルを BIGINT で作り直します。
- 万一の重複を防ぐため、PK が22億から始まるようにします。
- 1か月前のデータが何番の ID なのかを調べます。
- MySQL のプロシージャで、100件ずつバッチでデータをコピーします。
スクリプトを書くうえで最も重視したのは、「ロールバックできるか」でした。作業の途中で問題が起きたらすぐに止められなければならず、進捗も目で確認できる必要がありました。データの挿入はクエリ1行でも書けましたが、進捗がわからず途中で止めることもできないため、作業を細かく分けることにしました。
マイグレーションは無事に進みました。1か月分のデータが入り、新しいボックスは22億から作られ始めました。ところが、なぜか Android アプリでクラッシュが起き始めました。残りのメンテナンス時間が少なかったため、用意しておいた手順どおりにロールバックし、サービスは元の状態に戻りました。
原因
理由は単純でした。アプリでも ID を INT で扱っていたため、21億を超える値を処理できなかったのです。サーバーだけ直せば済む問題ではありませんでした。別の方法を探す必要がありました。
2回目の試み
チームリーダーと解決策を話し合っているうちに、ふと INT には負の領域もあることを思い出しました。幸い unsigned で宣言されていなかったので、PK を負数で保存する方法について話し合いました。チームリーダーも良い方法だと言ってくれたので、すぐに作業に取りかかりました。
BEFORE INSERT トリガーを設定し、値が入ってくるときにシーケンステーブルから次の値を読んで、PK を負数に替えて入れるようにしました。テストサーバーで、サーバーとアプリの両方が負の PK を問題なく処理できることを確認し、明け方にトリガーを設置しました。
その日以降に挿入されるすべてのデータの PK は負数で入り、アプリでも問題は起きませんでした。この対応で目の前の障害を防ぎ、約2年分のリファクタリングの時間を稼ぎました。