世界を動かす技術を、日本語で。

PSA: SQLiteのWALチェックサムが静かに失敗し、データが失われる可能性があります

2025年7月24日原文(avi.im)

概要

  • SQLiteのWALモードでは チェックサム が利用されているが、エラー時はエラーを投げずにフレームを破棄
  • チェックサム不一致が発生すると、そのフレーム以降すべて破棄される仕様
  • この挙動は バグではなく仕様 として設計されている
  • チェックサム検証はWAL Index構築時に行われる
  • データ損失やエラー通知方法に課題があり、より柔軟な回復手段が求められる

SQLiteのWALモードにおけるチェックサムの動作

  • WAL(Write-Ahead Logging)モード は、2010年に導入された高書き込み性能向けのオプション
  • 書き込みはまず WALファイル に記録され、チェックポイント時にメインDBファイルへ反映
  • WAL内の各ページは フレーム と呼ばれ、各フレームにはヘッダーとチェックサムが付与
  • チェックサムは ローリング方式 で、n+1フレームのチェックサムはnフレームに依存
  • フレームの整合性確認時、チェックサム不一致があれば、そのフレーム以降の全フレームを破棄
  • この動作は 公式ドキュメント にも明記されている

チェックサム検証のタイミングとWAL Index

  • SQLiteは デフォルトではチェックサム検証を行わない ため、ページ破損に気付かない場合がある
  • WALファイルには同一ページ番号のフレームが複数存在する場合がある
  • 参照高速化のため WAL Index(.db-shmファイル) を構築
  • WAL Index構築時にフレームごとに チェックサム検証 を実施
  • コード上では walIndexRecover 内で walDecodeFrame を呼び出し、チェックサムを照合

チェックサムエラー発生時の挙動

  • .dbと.db-walファイルのみ存在し、.db-shmファイルがない場合に問題発生しやすい
  • WAL書き込み中に 異常終了 し、WAL Indexが未更新の場合、次回起動時に再構築が発生
  • フレームのチェックサム不一致時、そのフレーム以降が 全て破棄 され、たとえ実際には破損していなくてもデータ損失
  • SQLiteは 最後の接続終了時に必ずチェックポイントとWAL切り詰め を行う

デモ例と課題

  • 旧バージョンのページに属するフレームを破損させても、以降の全フレームが破棄されるため 不要なデータ損失
  • インデックスや重要でないテーブルのページ破損でも 全データ損失 のリスク
  • より高度な回復手法があれば 一部データの回復 も可能

振り返りと考察

  • 破損検出時にエラーを投げず、自動的にWALを切り詰める 仕様は好ましくない
  • ユーザー側で破損検出時の挙動を選択できる オプション提供 が望ましい
  • SQLiteは 組み込み環境モバイルデバイス (安価なSDカード利用)での利用が多く、破損検出の重要性が高い
  • 他のデータベースも同様の挙動をとる可能性があるが、未検証

他者の視点

  • Pekka Enbergは「組み込み用途では クラッシュより継続動作 が望ましい」と指摘
  • Alex Millerは「 モバイル端末のSDカード では破損が頻発するため、SQLiteには破損検出機能が重要」とコメント

要望

  • 破損検出時にエラー発生→ユーザー側で対処 できる仕組みの導入
  • デフォルトでの 自動切り詰め動作の見直し や、挙動選択オプションの追加
  • より柔軟な 回復・データ保全機構 の実装

Hackerたちの意見

利点は、実際に存在したデータベースの状態が残ることだね。データベースの観点からは、いくつかのコミットされたトランザクションを削除して、後から来たものを残しても、有効な状態になる保証はないからね。

これがOPが見落としている主なポイントだね。WALファイルの新しい部分が壊れていなくても、その内容は使えない。壊れたブロックから失われたトランザクションが必要だから。チェーンチェックサムは機能であって、無駄な脆さじゃないよ。

そうだね、アプリケーションレベルの整合性だけの問題じゃない。WALはページレベルで動作するから、1つのWALエントリを削除してから後のものを適用すると、Bツリーのレベルで破損が起こる可能性が高い。例えば、ノードAが子ノードBを持っているとする。* トランザクション1がBに値を追加したいけど、Bはすでに満杯だから、Bは新しいノードCとDに分割される。それに伴って、AのBを指すポインタが削除され、CとDを指すポインタに置き換えられる。* トランザクション2がAに無関係な変更を加える。もしトランザクション1の更新をスキップして、トランザクション2の更新を適用したら、突然AのデータがCとDを指す新しいバージョンで上書きされるけど、そのノードはまだ書き込まれてない。ポインタは初期化されていないゴミを指すだけなんだ。

WALのチェックサムは、途中でのランダムなページの破損をチェックするためのものではないと思う。もしかしたら、フレームの最後の書き込みがちゃんとfsyncされたかどうかを確認するためだけかも?これが正しい説明だね。目的は部分的な書き込みを検出することで、任意のデータ破損を検出することではない。もし破損を検出するのが目的なら、WALのチェックサムだけでなく、データベース自体のチェックサムも必要だから、あまり意味がないよね。実際、「SQLiteはデフォルトでチェックサムを行わないが、WALモードではチェックサムがある」というのは正確じゃない。SQLiteは、ロールバックジャーナルでも書き込み先行ログでも、常にジャーナルのためにチェックサムを使ってるんだ。[1] クラッシュや電源障害からの復旧を考えると、データベースファイルへの書き込みは実質的に冪等性がある。クラッシュ前にDBの書き込みの一部だけが保存されていても問題ないし、どれが成功したかを知る必要もない。だって、全部を前に進めたり後ろに戻したりできるから(モードによって)。でも、ジャーナル自体については、部分的なジャーナルエントリと完全なものを区別することが重要なんだ。ディスクが物理的にページを書き出す順番に関係なく、チェックサムがデータと一致する瞬間が、そのトランザクションが明確にコミットされた瞬間なんだ。

その通りだね。別の言い方をすると、sqliteがトランザクションをWALに書き込んでいる途中で電源が切れたと想像してみて(アプリケーションに確認される前に)。電源が戻ってデータベースを再読み込みしたとき、何が起こってほしい?トランザクションが完全に書き込まれていたら、たぶんそれを保持したいよね。でも、もし未完了だったら、ロールバックしたい。sqliteはどうやってトランザクションが完了したかを知るの?2つのことを確認する必要があるんだ:1. トランザクションがコミットフレームで終了していて、アプリケーションが実際にCOMMIT TRANSACTIONを実行したことを示す。2. すべてのチェックサムが正しくて、データがコミットされたときに完全にディスクに同期されていたことを示す。チェックサムが間違っていたら、トランザクションが完全に書き込まれていなかったと仮定される。だから、ロールバックされるべきなんだ。それがまさにsqliteがやっていることだよ。これは「データ損失」ではなくて、トランザクションが完全にコミットされていなかったから。電源障害はアプリケーションにコミットが確認される前に起こったから、誰もそのトランザクションが耐久性があるとは思わなかったはず。チェックサムは、ディスクの損傷やバグのあるアプリがバイトを上書きするなど、他の手段でデータが破損したときに検出するためのものではない。そういうのは他のメカニズムで保護されているはずで、sqliteはそれらのメカニズムが機能していると仮定している。そうでなければ、sqliteができることはほとんどないからね。

これも理由の一つだと思う(https://www.sqlite.org/wal.htmlから): > [...] チェックポイントは通常WALファイルを切り詰めることはありません(ジャーナルサイズ制限のプラグマが設定されていない限り)。代わりに、SQLiteはWALファイルの先頭から上書きを開始します。これは、既存のファイルを上書きする方が追加するよりも通常は速いためです。チェックサムがないと、新しいWALエントリが既存の長いものをきれいに上書きしてしまう可能性があり、それがまだ有効に見えることがあります(例えば、「A|B」→「C|B」ではなく「AB」→「C|データ破損」)。少なくとも、Bを無効なデータで上書きしてfsyncしてから、AをCで上書きして再度fsyncするという(高コストな)スキームを行わない限り。言い換えれば、チェックサムは高コストなfsync/切り詰め操作を減らすための最適化された書き込みパスを可能にします。これは、非WALパスには存在しない下層への不信の突然の表現ではありません。

これがどう違うの?チェックサムの失敗に遭遇したら、その時点以降のことは何も信頼できないよ。もしチェックサムがページごとで、前のページのチェックサムに基づいていなかったら、正しいページだけをWALから適用して、無効なものをスキップすることはできない。そうなると、そのプロセスの最後のデータベースは壊れてしまう。最初の失敗で止まれば、データベースは最後の良好な状態に復元される。それが状況下で達成できる最良の結果だね。いくつかのデータは失われるかもしれないけど、どうせそれを使う方法はなかったし。

これがどう違うの?エラーを出して、続行するか停止するかのオプションを提供してほしいな。続行がデフォルトだから、チェックサムの失敗で停止するオプションが必要だよね。すべてのチェックサムエラーが回復不可能なわけじゃないし。投稿にもあるように、重要でないページが壊れている可能性もある。私の主な不満は、開発者に選択肢を与えないことなんだ。

私が望むのは、破損が検出されたときにエラーを投げて、コードに処理させること。どんなコードになるのか気になるな。私の感覚では、それはトランザクションが最初から発生しなかったかのように実行されるコードと全く同じになると思う。だからSQLiteのデザインが意味を持つんだ。例えば、私はクラウドからローカルに同期するtodoのデータベースを持っている。WALが壊れた。次回DBを開くときにWALが切り詰められる。アプリのロジックはDBの最後の更新タイムスタンプをチェックして、クラウドと同期する。WALの破損について通知されたら、アプリは何を違うことをするのか見当がつかないな。

その通りだね。私はこう読んだよ。> 「SQLiteを実装したDBウィザードが選ばなかったエラーを修正したい」ってこと。そんなに注目されているプロジェクトでデザインの決定に納得できない場合、考えられるのは、1. どうしてそうなったのか理解していない。2. それを解決する変更を提出できる(そして多分する)。もし理解しているのに何もしない状況にいるなら、君はシュレディンガーの開発者だね。お金をかけるまで、君は正しくもあり間違ってもある。簡単に直せるミスなんて滅多にないよ。

いくつかのこと: - 公式のチェックサムVFSシムはあるけど、私は使ったことがないから、どれくらい良いのかはわからない。これとWALチェックサムの違いは、ページ単位で動作することと、手動でチェックサムチェックを実行して、どうするか自分で決める必要があることだね。 - SQLiteのWALで使われるチェックサムは、バックアップや冗長性、データ復旧のためのものではない(限られたビットを復旧するためのエラー回復コードはあるけど、ここで使われるチェックサムよりもオーバーヘッドが大きい)。 - SQLiteはこうしたチェックサムエラーを示すべきだと思う(例えば、外部のデータ復旧を行うために、バックアップをどこかから取得するためにね)。でも、後方互換性のある方法でどう統合するかは、完全にはわからないな。例えば、エラーとして返して、それ以外はSQLITE_BUSYみたいに振る舞うとか?

チェックサムVFSは、チェックポイント中にチェックサムを明示的に無効にするんだ(inCkptを検索してみて):https://sqlite.org/src/doc/tip/ext/misc/cksumvfs.c WAL内のデータは「耐久性が低い」と考えるべきだよ。

Hacker Newsで議論の続きを見る