16年間潜んだSQLiteのWALリセットバグ — Tailscaleが19回のDB破損から突き止めたデータ競合の全貌
はじめに
SQLite は「世界で最も多くデプロイされたデータベースエンジン」を名乗り、本体の590倍——2023年時点で約9,200万行におよぶテストコードとテストスクリプトで知られている。そのSQLiteに、2010年7月リリースの 3.7.0 から2026年1月の 3.51.2 まで、16年間にわたってデータベース破損を引き起こしうるバグが潜んでいた(SQLite公式は「この範囲の全バージョンに存在するとみられる」と表現している)。
見つけたのは SQLite の開発チームではない。Tailscale が自社のコントロールプレーンで半年間に19回のDB破損に遭い、アプリ層・OS層・ストレージ層を一つずつ潰していった末に、SQLite本体のチェックポイント処理に到達した。その調査記録が公開され、Hacker News で500ポイント超を集めている。
この記事では、WALリセットバグとは技術的に何だったのか、なぜ16年も発見されなかったのか、そして自分のアプリケーションが影響を受けるのかをどう判断するかを掘り下げる。SQLiteをWALモードで複数プロセスから触っているなら、他人事ではない。
19回の破損 — 何が起きていたのか
Tailscale が観測した症状は、デバッグする側にとって最も厄介な部類だった。
data written and committed by one transaction was inexplicably invisible to later transactions (あるトランザクションが書き込みコミットしたデータが、後続のトランザクションからは説明不能なことに見えなくなっていた) — Tailscale Blog
エラーは出ない。書き込みは成功したと返る。ただ、後から読むとデータが消えている。PRAGMA integrity_check を回して初めて破損が検出される、という「沈黙する不具合」だ。
調査の過程で彼らが潰していった候補は、障害調査の定石そのものだった。直近のコード変更 → 無関係。インシデント間の共通因子(時刻・シャード・特定顧客・負荷)→ 見つからず。POSIXロックの不備、メモリ管理のミス、スレッド安全性の違反 → いずれも仮説として立てたが確証に至らず。10月から12月にかけて6週間ほど破損が止まり、原因が消えたかに見えたところで再発した、という経緯も記録されている。
決定打になったのは、チェックポイント処理が報告するページ数の異常だった。
If there are 10 pages in the WAL file and 20 pages get copied to the database, something is clearly wrong. (WALファイルに10ページしかないのに20ページがデータベースへコピーされたなら、明らかに何かがおかしい)
WALファイルに存在する以上のページを「コピーした」と報告している。この矛盾が、破損の原因がストレージやアプリケーションではなくSQLiteのチェックポイント処理そのものにあることを指し示した。
最終的に彼らは SQLite 開発陣とプロフェッショナルサポート契約を結び、開発陣が用意した tmstmpvfs シム(SQLiteの仮想ファイルシステム層をラップしてディスク操作のトレースを吐くデバッグ用ラッパー)を本番環境に投入する。次の破損インシデントで取れたログから、バグは特定された。
WALリセットとは何か — バグの技術的な仕組み
前提:WALモードとチェックポイント
WAL(Write-Ahead Logging)モードでは、更新されたページは本体のDBファイルではなく -wal ファイルへフレームとして追記される。読み手はDB本体とWALを重ね合わせて最新状態を見る。WALが膨らみすぎないよう、チェックポイントがWAL内のフレームをDB本体へ書き戻す(バックフィル)。
ここで重要なのがWALリセットだ。WAL内の全フレームがDB本体へ書き戻され、かつ古いフレームを参照している読み手がいなくなると、次の書き手はWALファイルを延々と伸ばす代わりに先頭に巻き戻して上書きを始められる。このときWALヘッダの salt 値が更新される。
At the start of the first new write transaction, the WAL header salt-1 value is incremented and the salt-2 value is randomized. These changes to the salts invalidate old frames in the WAL that have already been checkpointed but not yet overwritten, and prevent them from being checkpointed again. — SQLite File Format
各フレームのヘッダには salt-1 / salt-2 のコピーが埋め込まれており、フレームが有効と見なされるのはヘッダのsalt値と一致する場合だけ。salt を変えることで、まだ物理的に上書きされていない古いフレームを一括で無効化する仕組みだ。世代番号として機能している。
競合の発生手順
SQLite 公式ドキュメントは、破損に至る条件を6ステップで説明している(wal.html §11.1)。重要なのは、単に「チェックポイントと書き込みが衝突した」だけでは足りない点だ。
- あるコネクションがチェックポイントを実行する。この1回目は完走している必要がある — WALの全内容をDB本体へコピーし終え、WALがリセット可能な状態になっていること
- その直後に、2回目のチェックポイントが開始される
- 2回目が立ち上がっている最中に、別のコネクションがWALをリセットして先頭から新しい内容を書くトランザクションをコミットする
- データ競合により、2回目のチェックポイントはWALがリセットされたことに気づかない。結果、wal-index ヘッダの「WALのこの範囲はチェックポイント済み」を示すフィールドが、実際にはコピーしていないのに誤って設定される
- さらにトランザクションがコミットされ、WALのページ数がステップ1の時点より多くなる
- その後3回目のチェックポイントが走ると、ステップ3で書かれたトランザクションの全部または一部がスキップされる。その内容はDB本体に到達しないまま、DBファイルは破損する
ステップ1の「完走したチェックポイントが先行していること」とステップ5の「WALがその後さらに伸びること」はどちらも必須条件で、これが欠けると破損には至らない。発火条件が厳しい理由でもある。
Canonical が TLA+ でこの競合をモデル化しており、変数レベルの挙動はそちらが詳しい。チェックポイント処理は wal-index ヘッダをローカルにコピーしてから排他ロックを取りに行くため、その隙にWALリセットが入ると古い mxFrame を前提に処理を進めてしまう。そして「バックフィル済みページ数」を示す nBackfill を、実際にはコピーしていない位置まで進めてしまう。
誤解しやすいが、この瞬間にフレームがWALから消えるわけではない。フレームは物理的にはWAL上に残ったまま「バックフィル済み」と誤ってマークされる。DB本体へ届く機会を失った状態で放置され、やがて後続の書き込みに上書きされて失われる。Tailscaleが見た「コミットしたはずのデータが後続トランザクションから見えない」症状と、「WALにある以上のページをコピーしたと報告する」矛盾は、どちらもこの mxFrame の陳腐化で説明がつく。
修正は拍子抜けするほど小さい。Canonical の記述を借りれば「チェックポイント用にヘッダを読んで以降、WALが巻き戻されていないことを確認する」だけ——バックフィル前の salt値の比較チェックを一つ足したにすぎない。
なお Canonical は同じ TLA+ 仕様で dqlite(SQLiteベースの分散DB)を検証し、dqlite は影響を受けないと結論づけている。dqlite はユーザー起動のチェックポイントをブロックし自動チェックポイントを無効化する「stop-the-world」方式を採っており、そもそもチェックポイントと書き込みが並行しないためだ。
なぜ16年間、誰も踏まなかったのか
発火条件は SQLite 公式ドキュメントに明記されている。
The bug only affects databases in WAL mode when there are two or more database connections open on the same file, in separate threads or processes, and when those two connections attempt to write or checkpoint at the same instant.
つまり「WALモード」かつ「同一ファイルに複数スレッド/プロセスからの接続」かつ「書き込みとチェックポイントが同じ瞬間に衝突」の三条件が揃う必要がある。タイミング要件が極端にシビアなデータ競合であり、SQLite開発陣はバグを自然な形で再現できず、意図的に競合状態を作り出すテストコードを追加してようやく検証できたという。
SQLite側のリスク評価はかなり踏み込んでいる。
Based on available telemetry, the occurrence rate of this problem in the wild appears to be less than or equal to the expected occurrence rate of SSD malfunctions and/or cosmic-ray hits. (観測されているテレメトリに基づけば、この問題の発生率は SSD の故障や宇宙線ヒットの期待発生率以下に見える)
では、なぜ Tailscale だけが半年で19回も踏んだのか。答えは彼らの使い方にある。Tailscale はバックアップを高速かつ一貫性のある形で取るためにチェックポイントを手動制御し、極めて積極的に回していた。チェックポイントの実行頻度が上がれば、書き込みと衝突する窓に入る確率も上がる。競合の当たり判定を自ら広げていたわけだ。
HN で支持を集めていたのは、Tailscale が OSS 開発者とプロフェッショナルサポート契約を結び、専用のデバッグシムの開発まで費用を負担した点だった。修正を無償で期待するのではなく金を払って解決したことを「great decision」と評価するコメントが上位に並んでいる。本体の590倍のテストコードを持つプロジェクトですら、実運用の極端なワークロードからしか出てこないバグがある、という事例でもある。
エンジニアへの影響 — 何を確認すべきか
1. バージョンを確認する
影響範囲は 3.7.0(2010-07-21)から 3.51.2(2026-01-09)まで。修正は 3.51.3(2026-03-13)以降に入っている。3.44.6 と 3.50.7 にバックポート版もある。
なお 3.52.0 は 2026-03-06 にリリースされたが取り下げられている。理由はバグではなく後方互換性で、式インデックスや VIRTUAL 計算列へのインデックスで、テキストや JSONB 由来の浮動小数点値を扱う場合に旧バージョンと相互運用できないケースがあった。新機能は再設計のうえ 3.53.0(2026-04-09)へ移され、3.52.0 の代わりにパッチリリース 3.51.3 が出された、という経緯だ。「3.52を入れれば安心」ではない点に注意したい。手元の確認は次で済む。
SELECT sqlite_version();
言語ランタイムに同梱されたSQLiteを使っている場合(Python の sqlite3、Node の better-sqlite3、各種ORMのバンドル)、アプリのバージョンではなく実際にリンクされているSQLiteのバージョンを見る必要がある。同梱バージョンは本体の更新から数年遅れることが珍しくない。実例として Plex Media Server の 1.43.3 系が 3.39.4(2022年リリース)を同梱していることがフォーラムで指摘されている。ただしこれは「影響を受けるバージョン範囲に入っている」という話であり、そこで報告された破損がこのバグ由来だと確認されたわけではない(Plex 側はSSD故障や宇宙線より稀という公式評価を引いて懐疑的に応じている)。
2. 自分のワークロードが条件に該当するか判断する
- 単一プロセス・単一接続でしか触っていない → 該当しない
- WALモードで複数プロセス/スレッドから書き込む → 条件に該当する
- さらに
wal_checkpointを明示的に叩いている、チェックポイント間隔を詰めている → 発火確率が上がる側にいる
3番目に当てはまるなら、バージョン更新の優先度を上げるべきだ。逆に、デスクトップアプリやモバイルアプリの組み込み用途のように接続数が限られる構成なら、SQLite自身の評価どおり実務上のリスクは低い。
3. 破損を検出できる状態にしておく
このバグに限らず、DB破損は「エラーを出さずに進行する」のが厄介な点だ。定期的な PRAGMA integrity_check と、バックアップからの復元テストが最終防衛線になる。Tailscaleがバグに到達できたのも、トランザクションログを再生して差分を突き合わせるパイプラインを持っていたからだった。
4. 「観測 → 証拠 → 確定 → 対策」の順を崩さない
この調査記録が読み物として優れているのは、推測に基づく修正へ逃げていない点にある。19回の破損の間、彼らは「たぶんロックまわりだろう」で当て推量のパッチを当てるのではなく、まず本番環境に観測手段(tmstmpvfsシム)を仕込み、次の再現を証拠付きで捕らえた。再現率の低い不具合ほど、修正を投入しても「直ったのか、たまたま出ていないだけか」を区別できなくなる。実際 Tailscale は修正投入後、競合を検知して正しく防いだことを示す警告ログが2か月後に発火するまで、確証を保留していた。その「奇妙に喜ばしいアラート」以降、記事執筆時点でさらに4か月間、DB破損は起きていない。
まとめ
- SQLite 3.7.0 から 3.51.2 までの16年間、WALモードでチェックポイントと書き込みが衝突した際にコミット済みデータを失うデータ競合が存在した
- 原因はチェックポイント処理が wal-index ヘッダのローカルコピーを保持している間にWALリセットが起き、陳腐化した
mxFrameを元にnBackfillを進めてしまうこと。フレームはWAL上に残ったまま「コピー済み」と誤ってマークされ、DB本体に届かず失われる。修正は salt値の比較チェック一つ - 発火には「WALモード + 複数接続 + 同時刻の書き込み/チェックポイント」に加え、先行する完走済みチェックポイントと、その後WALが伸びることまで必要。SQLite公式は発生率をSSD故障や宇宙線ヒット以下と評価している
- Tailscale が半年で19回踏んだのは、バックアップ一貫性のためチェックポイントを手動かつ高頻度で回していたため。標準的でない使い方が競合の窓を広げた
- 対応は 3.51.3 以降(または 3.44.6 / 3.50.7)への更新。3.52.0 は取り下げ済みなので選ばないこと
「枯れている」はバグがないことを意味しない。エッジケースが踏まれていないだけだ。ワークロードが標準から外れるほど、自分が世界で最初の踏み手になる確率は上がっていく。
今日のその他のニュース
(HNのポイント数は 2026-08-13 時点)
Qwen3.8-2.4T-A95B が公開 — Alibaba が総パラメータ2.4T・アクティブ95BのMoEモデルを Hugging Face で公開(HN 266pt)。同じ日に DeepSeek V4 Pro の 0813 スナップショットも OpenRouter に登場しており(437pt)、オープンウェイト側の更新ペースが落ちていないことを示している。
AIクローラのUser-Agentを偽装した大規模脆弱性スキャン — ClaudeBot などのUAを騙る大量スキャンが観測されているとの報告(HN 156pt)。AIクローラを善意のトラフィックとして素通しする設定が広まったことを逆手に取った手口で、UA文字列だけを信頼した許可リストの危うさを示す。逆引き検証や公式IPレンジでの検証を入れているか確認したい。
V8 の Array.prototype.copyWithin が最大約450倍高速化 — 要素を1つずつプロパティ操作で処理する代わりに MoveElements(内部で memmove)でメモリブロックごと移動させる最適化。PACKED配列は無条件に、HOLEY配列はプロトタイプ検証を通してから高速パスに乗る。日本語での個人によるV8本体へのコントリビュート記録として読み応えがある。
ソース
- How Tailscale helped find the SQLite WAL-Reset bug — Tailscale Blog
- Write-Ahead Logging(WAL-Reset Bug の項)— SQLite 公式
- SQLite Database File Format(WAL Header / salt値)— SQLite 公式
- SQLite Release History — SQLite 公式
- SQLite News(3.52.0 の取り下げと 3.51.3 リリース)— SQLite 公式
- How SQLite Is Tested(テストコード規模)— SQLite 公式
- Hunting a 16-year-old SQLite bug with TLA+: is dqlite affected? — Canonical
- Tailscale Traces Database Corruption to 16y/o SQLite WAL-Reset Bug — Hacker News
- Chromium(V8)のArray.prototype.copyWithinを最大約450倍高速化した — Zenn