Skip to content
Published on

SQLiteのWAL-resetバグ — 15年間隠れていたデータ破損の競合

シェア
Authors

はじめに — "Choose any three" に付いた注釈

SQLiteのドキュメントページの上部には、いつも同じ一文があります。「Small. Fast. Reliable. Choose any three.」そしてSQLiteは、おおむねこの約束を守ってきました。SQLiteはどこにでもあるで整理したとおり、このエンジンはあなたのスマートフォン、ブラウザ、航空電子機器の中で静かに動いており、その信頼性はほぼインフラの定数のように扱われています。

だからこそ2026年3月の出来事は興味深いのです。公式文書の言葉を借りれば「SQLite開発者の一人(Dan)」が2026年3月3日に、WALモードで稀にデータベースを破損させうるバグを見つけて直しました。SQLiteチーム自身が付けた名前は「WAL-reset bug」です。そしてこのバグは、WALが初めて登場した3.7.0(2010-07-21)から3.51.2(2026-01-09)まで生き続けていました。リリースノートの表現をそのまま移せば、「15年物のデータベース破損バグが数日前に発見され、いま修正された」となります。

まず温度を合わせておきます。これは今夜あなたがポケベルで呼び出されるべき類の出来事ではありません。SQLiteのドキュメント自身が「これは緊急事態ではない(this is not an emergency)」と明言しており、その根拠も併せて示しています。ただ、このバグがどのように生まれ、どのように捕まったのかは、WALを使う人なら読んでおく価値があります。ロック一つではなくロック二つが一つのフィールドを分担して守るとどうなるか、その非常にきれいな教科書的事例だからです。

WAL巻き戻し — このバグが住む界隈

バグを見る前に、舞台を押さえておく必要があります。WALの基本構造はSQLite内部構造で扱ったので、ここではこのバグに直接関わる部分だけを取り上げます。

WALモードでは、書き込みはデータベースファイルではなくWALファイルの末尾にフレーム(frame)として追加されます。読み取りはデータベースファイルとWALの両方を見ます。ところがWALが際限なく大きくなると読み取りが遅くなります — ドキュメントの表現では、読み取り性能はWALファイルが大きくなるほど悪化し、WALを確認する時間はWALのサイズに比例するからです。

そこでチェックポイント(checkpoint)が働きます。チェックポイントはWALの内容をデータベースファイルへ書き戻します(backfill)。既定の戦略は、WALが約1000ページになるまで育てたあと、WALが再び1000ページを下回るまで、以降のCOMMITごとにチェックポイントを回すというものです。

ここで本稿の中心概念が出てきます。WAL全体がデータベースへ移され、同期まで終わり、どのリーダーもWALを読んでいない状態なら、次のwriterはWALを先頭へ巻き戻し(rewind)、新しいトランザクションをWALの先頭から書き始めます。これが「WAL reset」です。ファイルを削除して作り直すのではなく、カーソルをゼロへ戻して先頭から上書きするのです。この巻き戻しのおかげで、WALファイルは無限には大きくなりません。

巻き戻しは純粋な最適化であり、ほとんどの時間は完璧に動作します。問題は「巻き戻してよいか」を判断する帳簿のほうにありました。

帳簿の二行 — mxFrameとnBackfill

その帳簿はwal-index(共有メモリ、たいてい-shmファイル)のヘッダーにあります。本稿に必要なフィールドは二つです。

mxFrame     WAL内の有効なフレーム数。
            フレームは1から番号が振られるため、最後の有効なコミットフレームの
            インデックスでもある。
            0ならWALは空 -> すべてDBファイルから直接読めばよい。

nBackfill   WALのフレームのうち、すでにデータベースへコピーされた数。
            常に nBackfill <= mxFrame。

            mxFrame == nBackfill  ->  WALの内容が全てDBに反映済み
                                  ->  WAL_READ_LOCK(N>0) を保持する接続が
                                      なければ、次のwriterはWALを
                                      巻き戻してよい。

つまり mxFrame == nBackfill が巻き戻しの許可証です。そして nBackfill は、チェックポインタが「ここまではすでに移したので、次のチェックポイントはここから始めればよい」と書き残す進捗表示でもあります。

ここで本稿全体でもっとも重要な二文が出てきます。SQLiteのドキュメント(walformat.html)をそのまま引用します。

  • nBackfillWAL_CKPT_LOCKを保持しているときにしか増やせない。
  • しかし nBackfill はWALリセットの間にゼロへ変わり、それはWAL_WRITE_LOCKを保持している状態で起きる。

読み流してしまいそうな文ですが、ここにバグが丸ごと入っています。フィールドは一つなのに、それを触る二つの経路が別々のロックを取ります。 チェックポインタはCKPTロックを持って nBackfill を上げ、巻き戻すwriterはWRITEロックを持って nBackfill をゼロへ落とします。両者は互いを排除しません — 違う鍵を持ったまま同じ部屋に入るようなものです。

この設計が15年間問題なく回っていたのには理由があります。通常の経路では、チェックポインタが nBackfill を上げようとする瞬間と、writerがそれをゼロにする瞬間は別々の状態(state)に属し、他の検査がその間を塞いでくれます。問題は「チェックポイントがちょうど始まる瞬間」という、非常に狭い窓でした。

破損は6段階で起きる

SQLiteのドキュメントがまとめたシナリオは、ちょうど6段階です。順番に見ていきます。

1. 接続Aがチェックポイントを完走する。
   「完全な」チェックポイントでなければならない — WALの内容を
   すべてDBへ移し、WALが巻き戻せる状態のまま残す。
   (すなわち mxFrame == nBackfill)

2. その直後、二つ目のチェックポイントが始まる。

3. チェックポイント#2が「開始中」のとき、別の接続がトランザクションを
   コミットする。このコミットがWALを巻き戻し(reset)、新しい内容を
   WALの先頭に書く。

4. データ競合が発生する。チェックポイント#2は、WALが3で巻き戻された
   事実を知らない。そのためwal-indexヘッダーのフィールドに誤った値を
   残す -- 「WALのこの部分はすでにチェックポイント済み」と記録するが、
   実際にはそうではない。

5. さらにトランザクションがコミットされ、WALのフレーム数が
   チェックポイント#1の時点より多くなる。

6. のちに三つ目のチェックポイントが回るとき、3で書かれたトランザクションの
   全部または一部を読み飛ばす。
   -> そのトランザクションは永遠にDBファイルへ到達しない。
   -> データベース破損。

4段階目が事故の瞬間です。巻き戻しが起きれば nBackfill はゼロになるのが正しい — WALが先頭から新たに書かれているのだから、「すでに移したフレーム」は0個のはずです。ところが、始まったばかりのチェックポイント#2は巻き戻し以前の世界観を抱えたままです。そのチェックポイントが残す nBackfill は「先頭のN個のフレームはすでに処理済み」と主張します。しかし巻き戻し後、その先頭N個のフレームはまったく別のデータです。3段階目で新しく書かれたトランザクションです。

そのため6段階目のチェックポインタは、それらのフレームを「もう済んでいる」と信じて読み飛ばします。読み飛ばされたデータはDBファイルへ届かず、WALはやがて再び巻き戻され、その上へ上書きされます。コミット済みと応答されたはずのトランザクションが、静かに消え去るのです。これがdurability違反であり、破損です。

注目すべきは、破損が即座には表面化しないという点です。4段階目で誤った値が記録され、実際の被害は6段階目で発生します。その間に5段階目(さらなるコミット群)が挟まる必要があります。事故と症状のあいだに時間とトランザクションが横たわっている — 事後分析をひどく難しくする種類のバグです。

発動条件 — なぜほとんどの人は無事なのか

ドキュメントが明示する発動条件はこうです。

  • データベースがWALモードであること。
  • 同じファイルに二つ以上の接続が開かれていて、それらの接続が別々のスレッドまたはプロセスにあること。
  • その二つの接続がまったく同じ瞬間に書き込みまたはチェックポイントを試みること。

ここで最初の正直な観察を。この条件は、単一プロセス・単一スレッドのアプリ — デスクトップアプリ、モバイルアプリ、CLIツール、テストフィクスチャ — を丸ごと除外します。SQLiteの配布量の圧倒的多数がここに属します。

同時に、公平を期すなら二つ目の正直な観察もしておくべきです。残る条件 — 「WALモード + 複数プロセスが一つのファイルへ書き込む」 — は、たまたま近年流行している配備形態とぴったり重なります。ワーカープロセスを複数立ち上げたWebアプリが、ローカルのSQLiteファイル一つを共有する構成です。つまりこのバグの必要条件は、SQLiteの伝統的な使い方ではなく、最近増えてきた使い方を狙い撃ちしています。

だからといって脅かすつもりはありません。条件を満たすことと実際に発生することはまったく別の話であり、その隔たりこそがこの出来事のもっとも興味深い部分です。

再現できないバグをどう「直した」と言うか

ここがこの出来事の本当の見どころです。SQLiteドキュメントの記述をそのまま移せばこうなります。

開発者たちは、このバグを実験室で自然に(organically)再現することに一度も成功しなかった。

バグが発火するには、いくつもの細部がぴたりと合った瞬間に整列する必要があります。とりわけ3段階目のコミットが、2段階目のチェックポイントの「開始中」という狭い区間に正確に入り込まなければなりません。これはナノ秒単位の窓です。そこでSQLiteチームが選んだ方法はこうです。

SQLiteのソースコード自体を修正し、sqlite3_test_control() で制御されるコールバックを埋め込みました。 そのコールバックは、テストプログラムがチェックポイント#2が回っている最中のまさにその瞬間に、3段階目の書き込みトランザクションを発動できるようにします。つまり競合を待つ代わりに、競合を命令として作り出すのです。ドキュメントの表現では、このコードハックなしには、開発・テストの過程でこの問題が観測されたことは一度もありませんでした。

このアプローチがなぜ正しいのか、立ち止まる価値があります。確率的にしか発生しない競合について「直した」と主張する方法は二つしかありません。

  1. 証明する(モデル検査、形式検証)。
  2. 競合を決定論的にしたうえでテストする。

SQLiteは2番を選び、そのためにプロダクションコードへテスト専用フックを入れるコストを支払いました。これは無料ではありません — 検証可能性のためにコード表面積を広げる、明示的なトレードオフです。しかし代案は「直った気がする」であり、データ破損バグにおいて「気がする」は答えになりません。

ここに実務的な教訓が一つあります。あなたのシステムにも確率的な競合があるなら、「負荷をかけ続ければいつか再現するはず」は戦略ではありません。SQLiteでさえそれはできませんでした。決定論的にスケジュールを操れるフックを埋め込むほうが — 見た目は汚くても — 実際に答えを与えてくれる唯一の道であることが多いのです。(同じ哲学をシステム全体の規模に押し広げると、決定論的シミュレーションテストになります。)

どれくらい危険か — ドキュメント自身が明かした確率

この節は、SQLiteドキュメントの表現をできるだけそのまま伝えます。私が数字を作って付け足す場所ではありません。

ドキュメントはまず深刻さを認めます。このバグは稀だが深刻な結果を招くため、アプリケーション開発者は修正済みのバージョンへアップグレードすべきだと述べます。

そしてすぐに温度を下げます。「しかし、これは緊急事態ではない。」 根拠はこうです — 入手可能なテレメトリに基づけば、この問題が実環境で発生する割合は、SSDの誤動作や宇宙線ヒットの予想発生率以下に見えるというものです。だから、パッチの当たっていないバージョンを動かしていたとしても、よほど特異なことをしていない限り、この問題に出会うことはないだろうと書いています。

この比較を正しく読む方法が重要です。「SSD故障率以下」は「ゼロ」ではありません。それはすでにあなたが受け入れているハードウェア故障確率のノイズに埋もれるという意味です。SSD故障やビット反転に備えてバックアップと整合性検査をすでに備えているなら、このバグはその備えの内側に収まります。逆にそうした備えがないなら、心配すべきはこのバグではなくSSDのほうです。

そして、留保を一つ残しておきます。ドキュメントは「入手可能なテレメトリに基づけば(based on available telemetry)」と条件を付けています。どのテレメトリか、標本がどれほどかは公開されていません。また先ほど見たとおり、この破損は原因と症状が時間的に切り離されているため、たとえ発生していたとしても「SQLiteのバグ」に帰属されず、「原因不明の破損」や「ディスクの問題」として分類された可能性が構造的に存在します。つまりこの推定値は、楽観方向に偏りうる種類の推定値です。SQLiteチームが不誠実だという意味ではなく、この種のバグの観測そのものが本来的に難しいという意味です。

リリースが混乱した2週間 — 3.52.0の撤回

ここで話が少しおかしな方向へ進みます。そしてこの部分は誤解しやすいので、正確に押さえておきます。

タイムラインはこうです。

2026-01-09   3.51.2 リリース        (バグが生きていた最後のバージョン)
2026-03-03   DanがWAL-resetバグを発見し修正
2026-03-06   3.52.0 リリース        (WAL-reset修正 + CLI大改編を含む)
2026-03-13   3.52.0 撤回(Withdrawn)
             同日、3.51.3 リリース  (WAL-reset修正のみバックポート)
2026-04-09   3.53.0 リリース        (3.52.0の再出荷 + 追加修正)
2026-05-05   3.53.1  /  2026-06-03  3.53.2  /  2026-06-26  3.53.3

発見から三日でメジャーリリースが出て、一週間後にそのリリースが撤回されました。

重要: 3.52.0が撤回された理由はWAL-resetバグではありません。 これは時系列上、勘違いしやすい箇所なので明確にしておきます。撤回の理由はまったく別の問題でした — 3.52.0の新機能の一部が旧リリースと100%互換ではなく、その機能と関連APIを作り直す必要があったためです。

具体的には、リリースノートは範囲をこう絞り込んでいます。3.52.0は、データベースに式インデックス(expression index)VIRTUAL計算列に対するインデックスがない限り問題なく動作し、完全に下位互換です。問題になるのは、そのインデックスの式がテキストやJSONB入力から導出される浮動小数点値として評価される場合です。そうしたインデックスがあると、稀なケースで3.52.0が旧リリースと正しく相互運用できないことがあります。

参考までに、同じリリースの変更ログには、浮動小数点とテキストの変換を再実装し、丸めの既定値をそれまでの全バージョンの有効桁数15桁から17桁へ変えたという項目が別にあります。両者が同じ領域に触れている点は目を引きますが、公開文書はこの変更が上記の互換性問題の原因だとは明示していません — したがってここで因果関係を断定はしません。

ではチームはどう対応したのか。ここが学ぶべき点です。彼らは「3.53.0が出るまで待ってください」とは言いませんでした。代わりに3.51ブランチへパッチリリース3.51.3を出しました。 3.51.3はWAL-resetバグと、3.51.2以降に見つかった細かな問題だけを直します。新機能はありません。

この選択の意味を解きほぐすとこうなります。破損バグの修正は、CLI大改編、新しいSQL構文、QRFライブラリ、浮動小数点変換の作り直しとひとかたまりに束ねられていました。 そしてそのかたまりの中で互換性問題が起きました。もし3.52.0が唯一の経路だったなら、ユーザーは「破損バグを直すには、互換性リスクのある機能リリースを丸ごと飲み込め」という選択を強いられていたはずです。古い安定ブランチへバックポートを出すことは、その強制をなくすことです — リスクを切り分け、破損修正だけが欲しい人が破損修正だけを持って帰れるようにするのです。

そしてこれは理論上の話ではありませんでした。node-sqlite3 リポジトリには、撤回当日である2026-03-13に「撤回された3.52.0を安定版の3.51.3に置き換えよう」というissueが立ち、報告者は自分が被害を受けたわけではなく、問題を予防するための整理作業だと明記していました。バックポートがなければ、このリポジトリの選択肢はもっと悪かったはずです。

3.53.0(2026-04-09)は、リリースノートの表現で「3.52.0の再出荷(re-release)であり、古びた式インデックス問題に対処する改善が追加された」バージョンです。そしてWAL-resetバグについては、「3.52.0を経由していないなら、この修正を含む最初のメジャーリリース」と書き、アップグレードを勧めています。

付け加える注釈が一つあります。3.52.0のリリースノートには、こんな一文もありました — SQLiteのリリース周期をおよそ6か月に一度まで遅くしようと努めているが、これは約束ではなく目標だ、と。その一文を含むリリースが一週間で撤回されたというのは、共感を誘う類の皮肉です。

あなたが実際にすべきこと

まとめると、行動リストは短くなります。

1. バージョンを確認する。 アプリケーションが実際にリンクしているSQLiteのバージョンを確認してください。パッケージマネージャが報告するバージョンではなく、ランタイムが報告するバージョンです。

SELECT sqlite_version();

2. 基準線は3.51.3(2026-03-13)以上です。 それより低い3.7.0以上のすべてのバージョンにこのバグがあります。メジャーリリースへ進みたいなら3.53.0以上で、本稿執筆時点(2026-07-16)の最新は3.53.3(2026-06-26)です。

3. アップグレードできないならバックポートがあります。 SQLiteのドキュメントは一部の旧リリースに対するバックポートを提供しています — 3.44.6と3.50.7です。ただしこれは正式なリリースのtarballではなく、Fossilのチェックインという形で提供される点に注意してください。

4. そしてこれが本当の落とし穴です — 一度上げたら終わりではありません。

この最後の項目のために、実例を一つ見てみましょう。OpenAIのcodexリポジトリには2026年6月に「バンドルされたSQLiteをWAL-reset修正済みのバージョンへピン留めする」というPRが上がり、2026-06-14にマージされました。PRの説明に書かれた事故の経緯はこうです。

SQLx 0.9libsqlite3-sysに対して広いバージョン範囲を許容していました。そのためこの問題とはまったく無関係なロックファイルの更新一つがlibsqlite3-sysを0.37.0から0.35.0へ巻き戻し、バンドルされたSQLiteランタイムが3.51.3から3.50.2へ静かにダウングレードされました。 つまり、すでに修正済みのバージョンへ到達していたプロジェクトが、無関係な依存関係の整理作業一つによって脆弱なバージョンへ引き戻されたのです。

これがSQLiteがライブラリであることの代償です。SQLiteはどこにでもあるではサーバーレス設計の利点を語りましたが、そのコインの裏側がここにあります。サーバーがないということは、パッチを当てるべき中央地点がないということでもあります。PostgreSQLなら、サーバーを一つ上げればすべてのクライアントが直ります。SQLiteはそのコードをリンクしたすべてのバイナリが個別に直る必要があり、そのバージョンはたいてい自分で直接選んだものではなく、依存関係ツリーのどこかで推移的に決まります。そして推移的な依存関係は、あなたが見ていない間に動きます。

実務的には、バンドルされたSQLiteを使うプロジェクトなら、バージョンに上限と下限の両方でピンを打ち、CIでSELECT sqlite_version()の結果を実際にアサートするほうがよいでしょう。ロックファイルの差分を人間の目で見つけてもらえると期待してはいけません — codexの事例は、まさにそれが失敗した実例です。

この出来事から残るもの

いくつか整理しておきたいことがあります。

第一に、15年はコードが正しいことの証拠ではありません。 このバグはSQLiteの歴史の中でもっとも多くテストされたコード経路の一つに、業界でもっとも偏執的なテストスイートを備えたプロジェクトの中に、15年間生き続けていました。理由は単純です — テストはスケジュールを制御できず、このバグはスケジュールの中にしか存在しなかったからです。カバレッジ100%は、インターリーブ100%を意味しません。

第二に、不変条件はフィールドではなくロックに紐づきます。 この出来事を一文に縮めれば、「nBackfill を増やす経路とゼロにする経路が、それぞれ別のロックを取っていた」となります。それぞれの経路は、自分自身のロックを正確に守っていました。それぞれは正しかったのです。間違っていたのは「このフィールドを守るロックは何か」という問いに答えが二つあったという事実です。あなたのコードで共有状態をレビューするときに問うべきなのは「ロックを取っているか」ではなく、「この状態を触るすべての経路が同じロックを取っているか」です。

第三に、良い開示(disclosure)の模範です。 SQLiteチームがしたことを見てください。専用のドキュメントセクションを作り、影響を受けるバージョン範囲を正確に明示し、6段階のメカニズムを公開し、再現できなかったという事実を認め、再現のためにソースをハックしなければならなかったことまで書き、発生率の推定値とその根拠を明かし、「緊急事態ではない」と温度を調整し、How To Corrupt An SQLite Database Fileの破損原因リストに自分たちのバグを項目として追加しました。最後の項目が特に良い点です — その文書はもともと「破損はたいていあなたのせいだ」を説明するページなのに、そこへ「あるいは私たちのせいかもしれない」を自分で書き加えたのです。

第四に、リスクを束ねてはいけません。 3.52.0の教訓は互換性問題そのものではなく、破損修正が機能リリースとひとかたまりに束ねられていたという構造です。古いブランチへバックポートを出せる能力は、リリースエンジニアリングの贅沢品ではなく安全装備です。

おわりに

正直な結論はこうです。WAL-resetバグをあなたが経験している可能性はきわめて低く、SQLiteドキュメント自身がその発生率をSSD故障率のノイズに埋もれるものだと述べています。本稿はあなたを驚かせるために書いたものではありません。

しかし対処は簡単です — 3.51.3以上へ上げ、CIでそのバージョンが維持されているかをアサートしてください。コストはほとんどかからず、ドキュメントが推奨していることでもあります。

そして、この出来事を読む、もっと良い方法があると思います。これはSQLiteの信頼性が過大評価だったという話ではありません。むしろ逆です — 15年間、実務の現場で誰も自然発生させられなかった競合を、開発者がコードを読んで見つけ、再現のためにテスト用フックを埋め込み、三日で直し、リリースが混乱すると古いブランチへバックポートを出し、その全過程を文書として公開しました。「Reliable」とはバグがないという意味ではありません。バグをこう扱う、という意味です。

参考資料