SQLite の -wal ファイルが大きくなり続けるとき:チェックポイントと終わらない読み取りトランザクション

SQLite の WAL モードで -wal ファイルが小さくならない理由を公式ドキュメントで整理しました。1000ページの自動チェックポイント、読み取りトランザクションがチェックポイントを止める仕組み、PASSIVE・FULL・RESTART・TRUNCATE の違いと journal_size_limit を扱います。

開発データベースSQLite

SQLite のデータベースを WAL モードにすると、app.db の隣に app.db-wal と app.db-shm が作られます。ふつう -wal ファイルはある程度の大きさで頭打ちになりますが、条件によっては際限なく大きくなり続けることがあります。この記事は2026年10月9日に確認した SQLite の公式ドキュメント(Write-Ahead Logging、PRAGMA、sqlite3_wal_checkpoint_v2()、ファイル形式)をもとに、チェックポイントが何をして、いつ止まるのかを整理します。

WAL モードでの書き込みと読み取り

データベース接続のデフォルトのジャーナルモードは DELETE(ロールバックジャーナル)で、PRAGMA journal_mode=WAL; で切り替えます。ほかのジャーナルモードと違って WAL モードは永続的で、接続を閉じて開き直しても維持されます。

ロールバックジャーナルは元の内容をジャーナルにコピーしておき、変更をデータベースファイルに直接書きます。WAL はその逆です。データベースファイルはそのままにして、変更したページを -wal ファイルの末尾に追記し、コミットを示すレコードが WAL に追加された時点でコミットされたとみなします。ファイル形式のドキュメントでは WAL に書かれる単位をフレームと呼び、1つのフレームが1ページ分の新しい内容です。

読み取りトランザクションは開始時に、WAL 内で最後に有効なコミットレコードの位置を覚えます。ドキュメントはこれを end mark と呼び、トランザクションが終わるまで変えません。そのため、あとから別の接続がコミットしても、この読み取りトランザクションは開始時点のデータを見続けます。ページが必要になると、end mark より前の WAL からそのページの最後のコピーを探し、なければデータベースファイルから読みます。

この仕組みのおかげで、読み取りと書き込みは互いをブロックしません。ただし WAL ファイルは1つなので、書き込めるのは一度に1つの接続だけです。ドキュメントには WAL モードでもまれに SQLITE_BUSY が返るケースが別にまとめられているので、書き込みの競合がなくなるとは考えないでください。

チェックポイントと WAL のリセット

WAL にたまった内容をデータベースファイルへ移す処理がチェックポイントです。チェックポイントはまず WAL をディスクに同期し、有効な内容をデータベースファイルへ移してから、データベースファイルを同期します。

デフォルト設定では、コミットによって WAL が1000ページ以上になると SQLite が自動でチェックポイントを実行します。最後の接続が閉じるときにもチェックポイントを1回行い、WAL と -shm ファイルを削除します。1000というデフォルト値はコンパイル時オプション SQLITE_DEFAULT_WAL_AUTOCHECKPOINT で変えられ、実行時には PRAGMA wal_autocheckpoint=N で変えます。N が0以下なら自動チェックポイントは無効になります。自動チェックポイントはすべて PASSIVE で、デフォルトでは WAL を上限より大きくしたコミットを実行したスレッドで動きます。そのため、ほとんどのコミットは速く、ときどき1つだけ目に見えて遅くなります。

チェックポイントが終わっても -wal ファイルは小さくなりません。すべての内容がデータベースへ移り、WAL を使っている読み取りトランザクションがなければ、次の書き込みトランザクションが WAL を先頭から上書きします。これを WAL のリセットと呼びます。リセット時には WAL ヘッダーの salt 値が変わり、移し終えたもののまだ上書きされていない古いフレームは無効になります。ファイルを縮めないのは、既存のファイルを上書きするほうが、ファイルを伸ばすより一般に速いからです。ドキュメントは1000ページを約 4MB と説明しています。3.12.0 以降、新しいデータベースのデフォルトのページサイズが4096バイトであることと合う数字で、実際のページサイズはデータベースごとに異なることがあります。

チェックポイントが最後まで進めない理由

チェックポイントは読み取りトランザクションと同時に動けますが、実行中のいずれかの読み取りトランザクションの end mark より後ろにあるページに達すると止まります。そのページをデータベースファイルに書くと、その読み取りトランザクションが見ている内容を上書きしかねないからです。チェックポイントはどこまで進んだかを覚えておき、次回はそこから再開します。

公式ドキュメントが、WAL が際限なく大きくなるケースとして挙げているのは次の3つです。

  1. 自動チェックポイントを無効にした場合:コンパイル時や実行時の設定で無効にしたり先送りしたりしていれば、WAL の肥大を止めるものがありません。
  2. チェックポイントの飢餓:読み取りトランザクションが重なり合って続き、アクティブな読み取りトランザクションが1つもない瞬間が来なければ、どのチェックポイントも完了できず、WAL をリセットできません。
  3. 非常に大きな書き込みトランザクション:書き込みトランザクションの途中では WAL をリセットできないので、大きな変更は大きな WAL を生みます。3.11.0 以降、1つのトランザクションの WAL の大きさはそのトランザクションが変更した量に比例します。それより前のバージョンでは、トランザクションがページキャッシュより大きくなると同じページが WAL に何度も書かれることがありました。

WAL が大きいとディスクを使うだけでは済みません。読み取り側はページごとにまず WAL を確認する必要があり、ドキュメントによればその時間は WAL の大きさに比例します。共有メモリ上の wal-index が検索を速くしますが、WAL が大きくなれば読み取り性能はやはり落ちます。

4つのチェックポイントモードと NOOP

sqlite3_wal_checkpoint() と自動チェックポイントは常に PASSIVE です。ほかのモードは sqlite3_wal_checkpoint_v2() か PRAGMA wal_checkpoint(モード) で実行します。

モード 待つもの 書き込み接続 完了時
PASSIVE 待たない。busy handler を呼ばない ブロックしない 移せる分だけ移す。すべて移したときだけデータベースファイルを同期
FULL 書き込み接続がなく、すべての読み取り接続が最新のスナップショットを見るまで 待機中は新しい書き込みをブロック すべてのフレームを移し、データベースファイルを同期
RESTART FULL に加え、移したあと WAL を使う読み取り接続がなくなるまで 待機中は新しい書き込みをブロック 次の書き込み接続が WAL を先頭から書く
TRUNCATE RESTART と同じ 待機中は新しい書き込みをブロック 成功して戻る直前に WAL ファイルを0バイトに切り詰める

現在のドキュメントは、この4つとは別に NOOP モードも定義しています。NOOP はフレームを1つも移さないので、チェックポイントの処理は何もしません。後で説明する WAL のフレーム数と移されたフレーム数を取得するためだけのモードです。sqlite3_wal_checkpoint_v2() に渡すか、PRAGMA wal_checkpoint(NOOP); で実行します。確認したドキュメントには NOOP がどのバージョンで追加されたかが書かれていないので、使う前に手元の SQLite がこのモードを受け付けるか確かめてください。

FULL、RESTART、TRUNCATE は、書き込みロックの取得と読み取り接続を待つ間に busy handler を呼びます。busy handler が0を返すと、その時点から PASSIVE と同じように移せる分だけ移し、SQLITE_BUSY を返します。別のプロセスがすでにチェックポイントを実行していればチェックポイントロックを取得できず、busy handler を呼ばずにすぐ SQLITE_BUSY を返します。この待機がブロックするのは新しい書き込み接続で、新しい読み取り接続はブロックしません。ただし Write-Ahead Logging のドキュメントは、RESTART と TRUNCATE の欠点として、チェックポイントの実行中に読み取り接続がブロックされることがあると書いています。

PRAGMA wal_checkpoint の結果の読み方

PRAGMA wal_checkpoint(TRUNCATE); は整数3つからなる1行を返します。

  • 1列目:ふつうは0です。FULL、RESTART、TRUNCATE がほかの接続のせいで最後まで進めなかった場合は1です。C API でいえば SQLITE_OK なら0、SQLITE_BUSY なら1です。
  • 2列目:WAL にあるフレーム数です。
  • 3列目:そのうちデータベースファイルに移されたフレーム数です。この呼び出しより前に移されたものも含みます。

WAL モードでなければ2列目と3列目は -1 です。TRUNCATE が成功すると WAL は0バイトになるので、どちらも0です。PASSIVE は最後まで進めなくても1列目が1にならないので、3列目が2列目より小さいかどうかを見る必要があります。

大きくなった WAL に対処する順番

  1. まず、終わっていない読み取りトランザクションを探します。チェックポイントの飢餓が原因なら、PASSIVE チェックポイントをいくら頻繁に実行しても完了しません。ドキュメントは、誰も読み取りをしていない隙間を作り、そのときにチェックポイントを試すよう勧めています。
  2. PRAGMA wal_autocheckpoint; で自動チェックポイントが無効になっていないか確かめます。0以下なら、アプリが自分でチェックポイントを実行しているかを確認します。
  3. 読み取り接続が多くて隙間がなかなかできないなら、RESTART か TRUNCATE で手動チェックポイントを実行します。これらのモードは busy handler で待つので、その接続には PRAGMA busy_timeout(または sqlite3_busy_timeout())で busy handler を設定しておきます。成功したかどうかは結果の1列目で確かめます。
  4. コミットやチェックポイントのあとにディスクに残る WAL の大きさを抑えたいなら PRAGMA journal_size_limit=N(バイト単位)を使います。コミットのたび、また WAL がリセットされるたびに、残っている WAL が N より大きければ N に切り詰めます。制限するのは後に残る大きさで、WAL が大きくなれる上限ではありません。大きな書き込みトランザクションの実行中は、WAL が N を超えて大きくなることがあります。デフォルトは -1(制限なし)で、0にすると毎回最小サイズまで切り詰めます。この設定は指定した1つのデータベースにだけ適用されます。
PRAGMA wal_autocheckpoint;         -- 自動チェックポイントの間隔(ページ数)
PRAGMA journal_size_limit;         -- コミット・リセット後に残す WAL の大きさ(バイト)、-1 なら制限なし
PRAGMA busy_timeout = 5000;        -- 例として示した値(ミリ秒)
PRAGMA wal_checkpoint(TRUNCATE);   -- 結果:ブロックの有無、WAL のフレーム数、移したフレーム数

-wal ファイルを手で削除してはいけません。WAL ファイルはデータベースの永続的な状態の一部で、データベースファイルと切り離すと、コミット済みのトランザクションが失われたり、データベースが壊れたりすることがあります。コピーや移動のときも一緒に扱ってください。公式ドキュメントによれば、安全に取り除く唯一の方法は、sqlite3_open() 系の関数でデータベースを開き、すぐに sqlite3_close() で閉じることです。最後の接続が正常に閉じれば、チェックポイントのあと WAL を削除するからです。プロセスが正常に終了しなかった場合や SQLITE_FCNTL_PERSIST_WAL を使った場合は、すべての接続が閉じたあとも WAL が残ることがあります。

チェックポイントに関わる WAL-reset バグ

公式ドキュメントには、2026年3月に修正された WAL-reset バグも載っています。同じファイルを別々のスレッドやプロセスの2つ以上の接続が開いていて、2つの接続が同じ瞬間に書き込みかチェックポイントをしようとすると、まれにデータベースが壊れることがあるデータ競合です。3.7.0 から 3.51.2 まで存在する可能性が高く、3.51.3 以降で修正され、3.44.6 と 3.50.7 にも修正がバックポートされています。開発者は、一般的な使い方で起きる可能性は低く緊急ではないとしつつ、影響が深刻なのでアップグレードを勧めています。

WAL の読み取りトランザクションが開始時点のデータを見続ける方式は、PostgreSQL のスナップショットに似ています。PostgreSQL がいつスナップショットを取るかは READ COMMITTED とスナップショット で扱いました。

参考にしたドキュメント

リンクと内容は2026年10月9日に確認しました。

ブログへ