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

本番環境におけるSQLite: WALモード、同時実行性、VFSレイヤーの最適化

2026年7月29日原文(micrologics.org)

概要

  • SQLite は従来「ローカル専用」と見なされがちだったが、その神話は現代のインフラで再考が必要
  • WALモード やメモリ最適化により、サーバー用途でも高い性能と同時接続性を実現可能
  • 適切な チューニング とVFS活用で耐障害性・スケーラビリティも担保
  • クラウド環境 やエッジでの単一テナント運用に特に有効
  • 大規模分散書き込みや巨大データセットにはPostgreSQL等が依然有利

SQLite「ローカル専用」神話の再検証

  • SQLite は従来、モバイルやIoT、開発用途の組み込みDBとして利用されてきた歴史
  • 本番運用には PostgreSQLMySQL などクライアントサーバー型DBが「必須」という常識
  • しかし、 NVMe SSD や超高速ストレージの普及、エッジ単一テナント化により、DBのネットワーク遅延が主なボトルネックに
  • アプリケーションプロセス内で SQLite を直接動かせば、ネットワークオーバーヘッドがゼロ
    • 読み込みは単なるメモリマップドファイル操作となり、サブミリ秒の高速応答を実現
  • 本番運用には WALモード、ロック制御、キャッシュ管理、カスタムVFSなどの深い理解とチューニングが不可欠

Write-Ahead Logging(WAL)モードの詳細

  • デフォルトの ロールバックジャーナル 方式では、書き込み時に元のページを別ファイルに保存
    • トランザクション成功時に削除、失敗時に復元
    • この方式は 同時実行性が低く、書き込み中は読み取りもブロックされる
  • WALモード では、書き込みは.sqlite-walファイルに追記され、読み取りと書き込みが非ブロッキングに
    • 読み取りはメインDBファイル+WALファイルから最新ページを参照
    • 書き込みはWAL末尾への追記のみ

チェックポイント処理

  • WALファイルは肥大化するため、定期的にメインDBへ統合する「 チェックポイント」が必要
  • チェックポイントモードは4種類
    • PASSIVE :読者・書き手をブロックせず、可能な範囲で統合
    • FULL :新規書き込みをブロックし、全読者終了後に全統合
    • RESTART :FULLに加え、WALサイズをゼロにリセット
    • TRUNCATE :RESTARTに加え、WALファイル自体をゼロバイトに
  • 高頻度書き込みサーバーでは、 バックグラウンドで明示的にチェックポイント を実施推奨
    • 例:PRAGMA wal_checkpoint(PASSIVE);
  • ディスク同期のボトルネック回避にはPRAGMA synchronous = NORMAL;を併用
    • NORMALモードはチェックポイント等の重要時のみディスク同期
    • WALモードならこの設定でもDB破損リスクなし

同時実行アーキテクチャとSQLITE_BUSY対策

  • WALモード でも書き込みは「単一トランザクションのみ許可」
    • 複数接続が同時書き込み要求すると SQLITE_BUSY エラー発生
  • 接続プール やトランザクション設計でこの制約への対策が必須

1. ビジータイムアウト設定

  • PRAGMA busy_timeout = 5000;などでロック取得リトライ時間を指定
    • SQLiteが内部で指数バックオフしつつ5秒間リトライ
    • 高負荷時のアプリ側エラー大幅減

2. トランザクションモードとロック制御

  • DEFERRED (デフォルト):最初はロック取得せず、書き込み時に昇格。デッドロックリスクあり
  • IMMEDIATE :開始時に予約ロック取得。他接続はIMMEDIATE/EXCLUSIVE不可(読み取りは可)。デッドロック回避
  • EXCLUSIVE :完全排他ロック取得。全読み書きをブロック
  • 書き込みトランザクションは常にBEGIN IMMEDIATE推奨
    • 例:BEGIN IMMEDIATE; ... COMMIT;

メモリ・キャッシュ最適化

  • デフォルトのキャッシュサイズは 2MB程度 と小さい
  • ワークセットに合わせて キャッシュサイズを拡張 することでディスクI/O削減
    • 例:PRAGMA cache_size = -64000;(約64MB割当て)

メモリマップドI/O(mmap)

  • SQLiteはmmapでDBファイルを仮想アドレス空間に直接マッピング可能
    • OSカーネルがページキャッシュ管理を担当し、高速読み取りを実現
    • 例:PRAGMA mmap_size = 2147483648;(2GBまでマッピング)

クラウド時代のCustom VFS活用

  • VFS(Virtual File System) はSQLiteの全ファイル操作を抽象化
    • OSファイルシステム以外への書き込みも可能
  • 代表的なVFSベースレプリケーション
    • Litestream :WALフレームをオブジェクトストレージ(例:AWS S3)へストリーミングし、ポイントインタイムリカバリを実現
    • LiteFS :FUSEベースVFSでSQLiteをクラスタ複製し、リアルタイム同期・分散リードレプリカを実現
  • クラウド環境や一時的なローカルディスク では、VFSベースのレプリケーションツール利用が高可用性・耐障害性の鍵

本番運用向けSQLite設定ブループリント

  • アプリ起動時、各DB接続直後に下記PRAGMAを実行推奨
    • PRAGMA journal_mode = WAL; -- WALモード有効化
    • PRAGMA synchronous = NORMAL; -- 同期オーバーヘッド削減
    • PRAGMA busy_timeout = 5000; -- ビジータイムアウト設定
    • PRAGMA cache_size = -64000; -- キャッシュ64MB割当て
    • PRAGMA mmap_size = 1073741824; -- 1GBメモリマップ有効化
    • PRAGMA foreign_keys = ON; -- 外部キー制約有効化
    • PRAGMA journal_size_limit = 67108864; -- WALファイル64MB制限
    • PRAGMA auto_vacuum = INCREMENTAL; -- インデックス・クエリプラン最適化

結論:SQLite本番運用の判断基準

  • WALモードメモリマップ適切なトランザクション設計 により、1台のSQLiteで数百同時接続・数百万クエリ/日も可能
  • 複雑な分散書き込み数TB超の巨大データ にはPostgreSQL等が適切
  • リード中心・数百GB規模・超低遅延重視 なら、アプリサーバー直上SQLite運用は高性能・運用シンプル・低コストな選択肢

#SQLite #Database Engineering #Performance Tuning #Backend Architecture #Systems Programming

Hackerたちの意見

これらの最適化をデフォルトのSQLite設定と比較する生産性のベンチマークを含めるといいですね。

これがAI生成だと思うけど、やっぱり考えさせられるね。こういう記事を見ると、実際にSQLiteを本番環境で使ったことがあるのか気になる。パフォーマンスを最適化する方法、例えばWALを使うことについてはよく見るけど、実際に困ることや問題についてはあまり触れられてないから。今の流行りで本番環境で使うのが普通になってるけど、確かに優れたデータベースだと思う。でも、自分で試してみた結果、本番環境では使わないだろうなって思った。Postgresみたいなデータベースが持ってる力が欠けてるから。実際の本番環境ではその力が重要なんだよね。 - カラム定義は作成後にalter columnみたいなもので変更できない。カラム定義を変更するには、writable_schemaプラグマを使って手動でスキーマを更新しなきゃいけない。これを間違えると、壊れたデータベースになっちゃう。 - カラムタイプはかなり制限されてる。実際にはアプリケーションコードである程度対応できるから大きな問題ではないけど、時々ちょっと面倒だなって思うこともある。 - スキーママイグレーションの選択肢が限られてる。基本的にはマイグレーションをサーバーにコピーしてそこで実行するか(手動かAnsibleみたいなもので)、アプリケーションの起動時にマイグレーションを実行するかのどちらか。理想的にはアプリケーションとは別にスキーママイグレーションを行いたいけど、マイグレーションをサーバーにコピーして実行するのはちょっと面倒だよね。この3つはもっと強力な(ローカル専用じゃない)データベースではうまく処理されてるから、プロトタイピング(またはブラウザやスマホアプリのようにパフォーマンスがあまり関係ない場所)以外でSQLiteを選ぶ理由がわからないな。

自分が何をしているか分かっているなら(または徹底的に学ぶ準備ができているなら)SQLiteを勧めるよ。これは自分でデータベースを作るようなもので(たいていは異なる用途のために複数のSQLiteデータベースを持つことになる)、すごくやりがいがあって、素晴らしいパフォーマンスを得られることもある。マイグレーションの話がよく出るけど、実際にはSQLiteを使うと、真実のソースデータベース(イベントログ)から投影して使い捨てのSQLiteデータベースにプロジェクトすることが多い。だから、スキーマ変更はたいてい削除して投影を再構築するだけなんだ。

カラム定義を変更したことなんて一度もないよ。理論上はその選択肢があるけど、より良い方法は新しいカラムを正しい定義で追加して、古いカラムのデータをコピーすることだと思う。それが本当にポジティブかネガティブかはわからないし、マイグレーションを別にするのが理想っていうのもあなたの意見だよね。自分はアプリケーションと同じソースツリーにデータベース定義を持っていたいから、理想的にはプロジェクト内に.sqlファイルを置いておくのがいいな。

数字が裏付けを示さない生産性最適化に関する記事にはちょっと注意してる。もし「これをやれば、あれが得られる」と言うなら、その結果を測定して再現する方法を説明できるべきだよね。SQLiteを本番環境で選ぶ理由はレイテンシかもしれないけど、それならそのトレードオフの価値を証明しないと。

「理想的にはアプリケーションとは別にスキーママイグレーションを行うべき」 それが理想なのはなぜ?SQLiteではデータベースがアプリケーションと1:1で接続されてるから(つまり、そのデータベースを使っている他のアプリケーションはない)、アプリを新しいバージョンに移行するのにデータベースはそのままってのは意味がないし、その逆も同様。アプリの起動時にマイグレーションを実行するのが理想だよ。SQLiteのマイグレーションは、必要以上に書くのが難しいことがある(DROPカラムが最近追加されたばかりだし…)。だから、後で変更しなくて済むようにカラム定義を何度か調整することが多い。あなたが言うようにカラムタイプは限られてるし(強制も緩いけど)、実際にはデータをアプリケーション特有のタイプに変換してDBから読み込むから、あまり問題にはならないよね(正しいデータタイプだけを書き込むことで強制するし)。

カラムの定義を変更するには、writable_schema プラグマを使って手動で基盤のスキーマを更新しなきゃいけないよ。これを間違えると、データベースが壊れちゃうから注意。直接カラムを変更できる方が便利だけど、書き込み可能なスキーマをいじったり、壊れるリスクを冒す必要はないよ。

トレードオフの話だね。時にはその制限があまり気にならないこともあるし、逆に気になることもある。重要なのは、優れた選択肢に固執して二度と考えなくなることじゃなくて、違いを理解してそれに応じて選ぶことだと思ってる。少なくとも、俺はそう考えてる。SQLiteを使うことを考えるたびに、これらのドキュメントを読んで制限に納得できるか確認するようにしてるよ。

SQLiteはALTER TABLEをサポートしてるよ: https://www.sqlite.org/lang_altertable.html Goではマイグレーションをバイナリに埋め込むことができるよ: https://oscarforner.com/blog/2023-10-10-go-embed-for-migrati...

自分のsqlite-utils CLIツールとPythonライブラリは、alter tableの制限やスキーママイグレーションの必要性に対する解決策を提供してる。alter tableの場合は、「transform」コマンドがあって、これを使うと新しいテーブルを作って、古いテーブルからデータをコピーして、テーブルの名前を変更する(すべてトランザクション内で): https://sqlite-utils.datasette.io/en/stable/cli.html#transfo... sqlite-utils transform fixtures.db roadside_attractions \ --rename pk id \ --default name Untitled \ --column-order id \ --column-order longitude \ --column-order latitude \ --drop address それからマイグレーション用には、新しいv4の「migrate」コマンドがあって、順序付きのマイグレーションを作成して実行できる: https://sqlite-utils.datasette.io/en/stable/cli.html#running... sqlite-utils migrate creatures.db path/to/migrations.py マイグレーションファイルはこんな感じ: https://sqlite-utils.datasette.io/en/stable/migrations.html#... from sqlite_utils import Migrations migrations = Migrations("creatures") @migrations() def create_table(db): db["creatures"].create( {"id": int, "name": str, "species": str}, pk="id", ) @migrations() def add_weight(db): db["creatures"].add_column("weight", float)

カラムタイプに関する便利なトリックはチェック制約だね。例えば、カラムに対して有効なJSONのテキストであることを保証する制約を定義できる: CREATE TABLE documents ( id INTEGER PRIMARY KEY, data TEXT NOT NULL CHECK ( json_valid(data) json_type(data) = 'object' ) ); 特定のキーを保証するためには: CREATE TABLE documents ( id INTEGER PRIMARY KEY, data TEXT NOT NULL CHECK ( json_valid(data) AND json_type(data) = 'object' AND json_type(data, '$.name') = 'text' AND json_type(data, '$.age') = 'integer' ) ); YYYY-MM-DDの日付を強制するためにも使えるけど、ちょっと複雑になる: CREATE TABLE events ( id INTEGER PRIMARY KEY, occurred_on TEXT NOT NULL CHECK ( length(occurred_on) = 10 AND occurred_on GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' AND date(occurred_on, '+0 days') = occurred_on ) );

参考までに、ほとんどの場合、数千の同時ユーザーを期待しているわけじゃないから、SQLiteは全然有効な選択肢だよ。Cloudflare D2やTursoみたいな、SQLiteをコアにしてスケールや同時処理のための異なる機能を持つサービスもあるしね。多くの人が、個人用や小グループ用に、リバースプロキシの背後で単一のVPS上にいくつかのコンテナ化されたアプリケーションを運営してるよ。フルRDBMSを管理するのは、複数のアプリケーションをサポートするために手間がかかるし、言ったようなコンテナ化されたフローでアプリごとに複数のインスタンスを立ち上げるのはリソースを余分に使うから、SQLiteで十分なんだ。すべてが5以上の稼働率で分散ワークロードを処理するわけじゃないよ。ちゃんとしたサーバーで、十分なバックアップシステムがあれば、実際に多くのことが動いてるんだから。

Hacker Newsで議論の続きを見る