一度も実行されていないロールバック — SQLite WAL のバックアップと復元リハーサル
一言でいうと
ロールバック計画は、実行してみたことがなければ、計画ではなく希望です。SQLiteをWALモードで使うサービスなら、バックアップはファイルのコピーではなく、.backupかVACUUM INTOで取り、マイグレーションは1つのトランザクションにまとめ、復元は、残った-walまで処理したあとで、内容ハッシュで、バックアップ時点と同じであることを証明してはじめて終わります。
なぜ必要なのか
顧客先のデプロイの前日の確認会議で、ロールバック計画書が回覧されました。「問題が起きたら、cp /backup/orders.db /srv/app/orders.dbで戻す」。全員がうなずき、翌日、マイグレーションが途中で失敗しました。計画書どおりにコピーしたところ、サービスが上がり、1時間後に、顧客サポートチームが尋ねました。「昨日の夕方の注文が見えません」。バックアップは、サービスが動いたままcpで取ったもので、復元のあとには、元に戻すべきだった緊急の修正が、そのまま生きていました。
コマンド自体が間違っていたのではありません。そのコマンドが、このサービスのファイル配置で何をするのかを、誰も実行してみなかったのです。FDEが顧客の現場でするべきことは、計画書を書くことではなく、リハーサルを行って、証拠を残すことです。バックアップは完全か、マイグレーションが失敗したら痕跡が残るか、復元すれば本当にその時点に戻るか。それぞれを、数字で示す必要があります。
どう動くのか
1. WALファイルは、データベースの一部です。WALのドキュメントによると、WALモードでは、変更が本体のファイルではなく、-walファイルに追記され、コミットもそこに記録されます。チェックポイントが、その内容を本体のファイルに移し、デフォルトでは、WALが1000ページに達したときに自動で動きます。WALファイルは、接続が開いている間は残り、通常、最後の接続が閉じられたときに消えます。そしてドキュメントは、WALがデータベースの永続的な状態なので、コピー・移動するときは一緒に動かす必要があり、離れると、コミットされたトランザクションを失ったり、ファイルが壊れたりすることがあると、はっきり書いています。
実測で確認すると、恐ろしいことがわかります。注文500件を本体のファイルに入れ、接続を開いたまま(自動チェックポイントはオフ)、さらに137件をコミットしてから、本体のファイルだけをcpしました。コピーには500件があり、PRAGMA integrity_checkはokでした。ファイルは無傷で、コミットだけが静かに抜けます。検査に通ったという事実は、バックアップが完全だという証拠ではありません。
2. オンラインバックアップは2つ。バックアップAPIのドキュメントは、バックアップを少しずつコピーしながら、読んでいる間だけロックし、結果が、コピーを始めた時点の元データと同じコピーだと説明しています。別の接続が途中で書き込むと、通常は最初からコピーし直します。CLIの.backupが、このAPIを使います。VACUUMのドキュメントのVACUUM INTOは、元データの一貫したスナップショットを、最小サイズの新しいファイルに書く代替手段です。対象のファイルがすでにあれば(空のファイルでなければ)失敗し、途中で電源が落ちると、結果が不完全になることがあります。
2つは、結果のファイルが違います(実測: sqlite3 3.45.1)。637件が入ったWALのDBで、.backupのコピーは、ヘッダーがWALモードのままで、VACUUM INTOのコピーは、ロールバックジャーナル(delete)モードでした。ファイルのsha256も、互いに違います。ところが、CLIのドキュメントの.sha3sumは、ディスク上の表現ではなく内容のハッシュなので、VACUUMのような変換で変わりません。--schemaを渡すと、スキーマまで入ります。2つのコピーの.sha3sum --schemaは、同じでした。「同じ時点の同じデータか」は、ファイルのハッシュではなく、この値で問います。
3. マイグレーションは1つのトランザクション、バージョンはuser_version。PRAGMAのドキュメントによると、user_versionは、ファイルのヘッダーのオフセット60にある整数で、SQLite自身は使わない、アプリケーション用の値です。スキーマのバージョン番号として使うのに向いています。マイグレーションの内容と、user_versionを上げることを、1つのトランザクションに入れれば、2つは一緒に行われるか、一緒に行われないかのどちらかです。
ここに、落とし穴が2つあります。CLIのドキュメントは、CLIが、デフォルトでは、エラーのあとも次のコマンドを実行し続け、-bailを渡してはじめて止まると書いています。ON CONFLICTのドキュメントは、デフォルトの解決方式であるABORTが、その文だけを元に戻し、同じトランザクションの前の文は残し、トランザクションも生かしておくと説明しています。2つの事実が重なると、実測で、こうなります。
| 適用方法 | 3つ目の文がUNIQUE違反のとき |
|---|---|
sqlite3 db < migration.sql |
前のALTER・UPDATEが残る |
BEGIN; …; PRAGMA user_version=3; COMMIT;を、-bailなしで |
前の文が残ったままCOMMITされ、user_versionも3になる |
同じ内容をsqlite3 -bailで |
終了コード1で、列・user_versionともにそのまま |
トランザクションのドキュメントのBEGIN IMMEDIATEは、書き込みロックを開始時に取るので、別の書き込みが進行中なら、途中ではなく、最初に失敗します。
4. 復元は、残った-walから。サービスが強制終了されると、接続が閉じられないので、-walが残ります。その状態で、バックアップを本体のファイルの上にcpすると、次に開くときに、残ったWALが、復元したファイルの上に適用されます。実測では、user_versionはバックアップの値に戻ったのに、元に戻すべきだった緊急の修正の行が、そのまま見えました。復元の前に、-wal・-shmを片付けるか、CLIの.restoreで、SQLiteを経由して上書きし、終わったら、.sha3sum --schemaを、バックアップの値と比べます。
리허설 한 바퀴
.backup → integrity_check · 행 수 · .sha3sum --schema · user_version 기록
migrate(N) → user_version N
migrate(깨진 N+1) → 실패해야 하고, 내용 해시가 그대로여야 한다
restore(백업) → 내용 해시 = 백업, user_version = 백업
現場での姿
「バックアップは毎日動いています」という言葉は、たいてい、cronがcpを動かしているという意味です。サービスが静かな明け方なので、ほとんどは問題がなく、問題があった日は、誰も気づきませんでした。リハーサルで、バックアップの行数を、サービスがコミットした数と比べて見せた瞬間に、会話が変わります。
マイグレーションも似ています。開発DBで通ったファイルが、運用データで途中で失敗することは、よくあります(顧客ごとの注文が1件だけの開発データでは、UNIQUEインデックスが作れてしまいます)。そのとき、痕跡が残らないことを、あらかじめ見せておけば、顧客は、「失敗したら元に戻せばよい」ではなく、「失敗しても、何も変わらない」と信じて、デプロイの時間枠を開けます。
実務で本当に大切なこと
- サービスが動いているWALのDBは、ファイルのコピーでバックアップしません。integrity_checkがokでも、コミットが抜けることがあります。
- バックアップの証拠は、行数、integrity_check、内容ハッシュ(.sha3sum --schema)、user_versionの4つをあわせて残します。ファイルのsha256は、「そのファイルが変わっていない」の証拠であって、「同じデータだ」の証拠ではありません。
- マイグレーションは、内容とuser_versionを1つのトランザクションに、CLIは
-bailで。すでに適用されたバージョンは何もせず、前のバージョンでなければ拒否します。 - 復元は、残った-walを処理して、内容ハッシュで証明してはじめて終わります。
- 失敗の注入が失敗しなければ、そのリハーサルは何も証明しません。
次のラボですること
WALモードで動く注文サービスを立ち上げて、cpのバックアップが何を失うかを測り、.backup・VACUUM INTOでバックアップしてから、基準値を残します。バージョン番号を守るmigrate.shと、壊れたバックアップを拒否するrestore.shを作り、デプロイ中に強制終了が起きた運用DBを、実際に復元します。最後に、バックアップから復元の証明までを一度に行うrehearse.shを作ると、採点ツールが、-walが残った新しいDBで、そのリハーサルを動かしてみます。