SQLite 内部架构:1つのファイルがトランザクションを守る仕組み

単一ファイルで ACID をどう実現するか。ページと B+tree のレイアウト、ロールバックジャーナルから WAL への進化、並行アクセスを制御する 5 つのロック状態、性能を引き出す 4 つの PRAGMA 設定(WAL を有効化、synchronous を下げる、cache_size を増やす、mmap を有効化)を扱う。

日本語
コピー

はじめに

B ツリー、WAL、ロックが単一ファイルの中でどう組み合わさり、安全なトランザクションを実現するかを解説する実践ガイド

現代のソフトウェア開発にデータベースは欠かせない。中でも SQLite は世界で最も広く使われているデータベースエンジンの一つと言っていい。スマートフォンのアプリ、組み込みシステム、Web ブラウザ、さらには小規模な Web サーバーまで、あらゆるところに姿がある。SQLite の最大の特徴は、その名の通り「Lite」であること、そして何よりすべてのデータを 1 つのファイルに格納するアーキテクチャにある。MySQL や PostgreSQL のようなクライアント・サーバー型データベースとは違い、SQLite はライブラリとしてアプリケーションのプロセス内で直接動く。しかし、単一ファイルという単純な構造でありながら、SQLite は ACID(原子性・一貫性・分離性・永続性)を完全に備えたトランザクションをサポートする。複数のプロセスから同時にアクセスされてもデータは壊れない。本記事では SQLite の内部アーキテクチャ(B ツリー、WAL、ロック機構)を掘り下げ、この魔法のような仕組みがどう実現されているかを実践的な視点で説明する。

1. 単一ファイルの魔法:ページと B ツリーのアーキテクチャ

OS から見れば、SQLite のデータファイルはただのバイナリファイルにすぎない。しかし内部的には、SQLite はファイルを固定サイズのブロック(通常 4KB)に分割して管理している。このブロックを「ページ」と呼ぶ。

ページの構造

ファイル全体は 1 から始まるページ番号でインデックスされる。第 1 ページは特別なページで、データベースヘッダー情報(バージョン、ページサイズ、エンコーディングなど)と、データベースのスキーマ情報を格納する特別なテーブル(sqlite_schema)のルートノードを含む。各ページは次のいずれかの役割を持つ:

  • B ツリーページ:テーブルデータとインデックスデータを格納する
  • フリーリストページ:削除済みで、現在は空き領域となっているページ
  • ポインタマップページ:ページの移動を追跡するページ(特定の機能を有効にした場合)

B ツリーによるデータ管理

データの検索・挿入・削除を効率的に行うため、SQLite は B ツリー というデータ構造を採用している。具体的には、テーブルデータには「B+ ツリー」(データを葉ノードにのみ格納する)、インデックスデータには「B ツリー」(内部ノードにもキーを格納する)を使う。

 graph TD
 Root["Root Page (Page N)"] --> Internal1["Internal Page (Page X)"]
 Root --> Internal2["Internal Page (Page Y)"]
 Internal1 --> Leaf1["Leaf Page (Data 1-10)"]
 Internal1 --> Leaf2["Leaf Page (Data 11-20)"]
 Internal2 --> Leaf3["Leaf Page (Data 21-30)"]
 Internal2 --> Leaf4["Leaf Page (Data 31-40)"]

この階層構造のおかげで、数百万件のレコードがあっても、数回のディスク I/O(ページ読み込み)で目的のデータにたどり着ける。この複雑な木構造が 1 つのファイル内にマッピングされている。

2. トランザクションを守る仕組み:ロールバックジャーナルから WAL へ

データベースの最も重要な役目の一つは「クラッシュからの復旧」だ。書き込みの途中で電源が落ちたり OS がフリーズしたりしても、データが不整合な状態に陥らないことを保証しなければならない。歴史的に SQLite が採用してきたのは「ロールバックジャーナル(Rollback Journal)」と呼ばれる方式で、現在では性能と並行性に優れた「WAL(Write-Ahead Logging、先行書き込みログ)」モードが主流になっている。

旧方式:ロールバックジャーナル

ロールバックジャーナルは、データを書き換える前に、変更対象ページの「変更前の状態」を別のファイル(ジャーナルファイル)にコピーしておく方式だ。 トランザクションが失敗したりクラッシュが起きたりした場合、次回の起動時にこのジャーナルファイルを使って変更を「ロールバック」(巻き戻し)、一貫性を回復する。この方式の最大の欠点は、「書き込み操作の進行中は、他のプロセスが読み取りすらできない(データベース全体がロックされる)」ことにある。

新方式:WAL(先行書き込みログ)

WAL モードは SQLite 3.7.0 以降で導入され、上記の並行性の問題を大幅に改善した。WAL モードでは、変更されたページは元のデータベースファイルに直接書き戻されず、別のファイル(.wal ファイル)の末尾に追記される。

 sequenceDiagram
 participant App as Application
 participant DB as db file
 participant WAL as .wal file
 
 App->>WAL: Start Transaction
 App->>WAL: Append changed pages (Write)
 Note over WAL: Data exists
only in WAL
 App->>WAL: Commit
 App->>DB: Read
 Note over App, DB: Latest data is fetched
by referencing both WAL and DB

WAL の利点:

  1. 並行性の向上:書き込みは .wal ファイルへの追記として行われるため、元のデータベースファイルを読む「読み取り操作」をブロックしない。つまり1 つの書き込みと複数の読み取りを同時に実行できる。
  2. 性能の向上:ディスク上の位置をランダムに書き換えるのではなく、順番に追記するため、ディスク I/O の性能が高い。

WAL ファイルに蓄積された変更は、一定のサイズに達するか、コマンドを明示的に実行したタイミングで元のデータベースファイルに書き戻される。この処理を「チェックポイント(Checkpoint)」と呼ぶ。


3. 同時アクセスを制御する:ロック機構

複数のプロセス(またはスレッド)が SQLite の単一ファイルに同時にアクセスするとき、データの衝突を防ぐためにロック機構が欠かせない。

SQLite のロック状態

1 つの SQLite データベース接続は、次の 5 つのロック状態のいずれかを取る:

  1. UNLOCKED:接続がデータベースにアクセスしていない。
  2. SHARED:データを読むためのロック。複数の接続が同時に SHARED ロックを取得できる(つまり並行して読める)。
  3. RESERVED:将来データを書き込むことを宣言するロック。データベース全体で 1 つの接続しか取得できない。この状態でも、他の接続は引き続き SHARED ロックを取得できる。
  4. PENDING:書き込みの準備が完了し、現在アクティブな SHARED ロックが解放されるのを待っている状態。この間、新たな SHARED ロックの取得はブロックされる。
  5. EXCLUSIVE:実際に書き込みを行うロック。この状態では、他の接続は読み取りも書き込みもできない。

ロックの昇格

トランザクションを開始してデータを読み書きするとき、SQLite はこれらのロック状態を段階的に自動で引き上げていく(ロックの昇格)。

  • SELECT を実行すると SHARED ロックを取得する。
  • INSERT や UPDATE を実行しようとすると、まず RESERVED ロックを取得する。
  • 実際にトランザクションをコミットして変更をファイルに書き込む段階になると、PENDING を経て EXCLUSIVE ロックの取得を試みる。

別のプロセスが SHARED ロックを長時間保持していると、書き込み側のプロセスは EXCLUSIVE ロックを取得できず、SQLITE_BUSY(database is locked)エラーが発生する。

Busy Timeout の設定

アプリ開発でこの SQLITE_BUSY エラーに対処する最も簡単で効果的な方法は、タイムアウト(busy_timeout) を設定することだ。

1PRAGMA busy_timeout = 5000; -- Wait for 5000 milliseconds (5 seconds)

設定しておけば、ロックを取得できないときに即座にエラーを返すのではなく、指定した時間内で繰り返しリトライする。タイムアウトを適切に設定すれば、中小規模の並行アクセスならこの種のエラーはほぼ避けられる。


4. 性能を最大化するベストプラクティス

SQLite の内部構造が分かったところで、アプリの性能と安全性を最大化するための実用的な設定(PRAGMA)をいくつか挙げる。

1. WAL モードを有効にする

前述のとおり、並行アクセスがあるなら必須だ。

1PRAGMA journal_mode = WAL;

2. 同期モードを最適化する

WAL モードと組み合わせて同期モードを NORMAL まで下げると、書き込み性能が大きく向上する。データ破損のリスクは依然としてごく低い。

1PRAGMA synchronous = NORMAL;

3. メモリキャッシュを増やす

SQLite がより多くのページをメモリにキャッシュすれば、ディスク I/O を減らせる。(デフォルトは 2000 ページ)

| 1 2 | -- cache size を負の値にすると KB 単位。以下は 64MB。 PRAGMA cache_size = -64000; | |---|---|

4. mmap で読み取りを高速化する

メモリマップド I/O(mmap)を有効にすると、OS の仮想メモリ機構を通じてファイルに直接アクセスできるようになり、読み取りが速くなる。

1PRAGMA mmap_size = 30000000000;

おわりに

SQLite は「1 つのファイル」という極めてシンプルな外見の裏に、B 木による精巧なデータ構造、WAL ベースの高度なトランザクション管理、そして緻密なロック機構を備えている。「軽量だから本格的な用途には向かない」というのは大きな誤解だ。内部アーキテクチャを正しく理解し、適切な設定(WAL モードの有効化やタイムアウトの設定など)を行えば、SQLite は驚くべき性能と安定性を発揮する。次にプロジェクトのデータベースを選ぶとき、「世界で最も広く使われているデータベース」が最も合理的な選択かもしれない。

出典: kenji.blog← ホームへ戻る