「ファイルベースだから遅い」は本当か
SQLite をサーバー用途に使う話をすると、たいてい「やめておいたほうがいい」と言われます。これ自体はでたらめな脅しではなく、SQLite の公式ドキュメント(Appropriate Uses For SQLite )自身が、同時書き込みの多いサービスにはクライアント/サーバー型のデータベースを勧めています。では、なぜ駄目なのか。理由を考え始めたとき、まず思い浮かぶのはこんな筋書きではないでしょうか。SQLite の実体はただのファイルで、ファイルの読み書きはディスク I/O。Redis のようなキャッシュ(メモリ上でキーと値を預かる、キャッシュ用途の定番サーバーです)が「メモリで持つから速い」と説明されるのなら、その裏返しで、ディスクに読み書きする SQLite は遅いはずだ——一見、筋が通って聞こえます。
本当にそうか、まず手元で測ってみます。10 万行のテーブルに主キーで 1 件ずつ問い合わせる、キャッシュ用途を想定したいちばん素朴な読み取りです。コードは Python の標準ライブラリだけで動きます。実験はすべて使い捨ての Docker コンテナ(Linux)上で実行していて、再現手順は記事末尾にまとめました。
import sqlite3, time, random
con = sqlite3.connect("cache.db")
con.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)")
with con:
con.executemany("INSERT INTO kv VALUES (?, ?)",
((f"k{i}", "x" * 100) for i in range(100_000)))
keys = [f"k{random.randrange(100_000)}" for _ in range(200_000)]
cur = con.cursor()
t0 = time.perf_counter()
for k in keys:
cur.execute("SELECT v FROM kv WHERE k = ?", (k,))
cur.fetchone()
print(200_000 / (time.perf_counter() - t0))
手元(Apple シリコンの Mac 上の Linux コンテナ、SSD)ではこうなりました。
440560.3 # 毎秒 44 万クエリ
1 回あたり約 2.3 マイクロ秒です。もし本当に毎回ディスクを読んでいたら、この数字は出ません。SSD のランダムリードは速いといっても 1 回あたり数十マイクロ秒、HDD なら数ミリ秒かかります。Python のループのオーバーヘッドまで含めてこの速さということは、この読み取りはほぼすべてメモリの速度で返っています。ファイルベースなのに、です。
では SQLite のボトルネック、「大規模なサーバーでは使うな」と言われる本当の理由はどこにあるのか。先に全体像を図にしておきます。
読みは速い。詰まるのは書き込みです。それもディスクの転送速度が足りないのではなく、書き手がデータベース全体で同時に 1 人しか通れないことと、コミットのたびに「確実に書けたか」の確認を待つこと、この 2 つが壁になります。この記事では、読みが速い理由(ページキャッシュ)から始めて、ロックと fsync の 2 つの壁を順に実測で確かめ、最後に「キャッシュサーバーとして SQLite はアリなのか」まで考えます。
前提:データの置き場所は2階建て
本題に入る前に、土台をひとつだけ固めておきます。コンピュータの記憶装置は、役割の違う 2 階建てになっています。上の階が RAM(メモリ)で、速い代わりに容量が小さく、電源を切ると中身が消えます。下の階がディスクで、遅い代わりに容量が大きく、電源を切っても中身が残ります。プログラムが扱うデータの居場所は、突き詰めればこのどちらか、または両方です。
プログラムが実行中に作るデータ——変数やオブジェクトの中身、Redis に入れたキーと値——は、RAM の中のヒープと呼ばれる作業用の区画に置かれます。ここにあるのは「RAM にしかない本物」です。捨てたら消えますし、電源が落ちても消えます。一方、SQLite のデータベースはディスク上のファイルです。こちらは「ディスクにある本物」なので電源断でも残りますが、そのまま毎回読み書きしていたら、RAM より桁違いに遅いはずです。
ところが冒頭の実測では、ファイルの読み取りがメモリ並みの速さで返っていました。この 2 つの階のあいだに、OS がこっそり挟んでいる仕組みがあるからです。
ファイルを読んでも、ディスクは読んでいない
さきほどの 44 万クエリ/秒の種明かしが、OS のページキャッシュです。
プログラムがファイルを読むとき、read() という OS の窓口を呼びます。このとき OS は、ディスクから読んだ内容をプログラムへ渡して終わりにはしません。読んだページ(4KB 程度の塊)の写しを、RAM の空いている領域に残しておきます。次に同じ場所への読み取りが来たら、ディスクへは行かず RAM の写しから返す。これがページキャッシュで、Linux でも macOS でも、すべてのプロセスのファイル読み書きに対して OS が勝手にやってくれます。
ポイントは、これが「空きメモリの有効活用」だということです。ページキャッシュ専用にメモリを確保するわけではなく、余っている RAM を写し置き場に使い、アプリがメモリを必要としたら明け渡します。
ここで自然な疑問が湧きます。メモリに乗るから速いのなら、結局 RAM を大量に積んでいないと速くないのではないか。データベースが RAM より大きくなったら、あふれた分がスワップに追い出されて本末転倒ではないか——。
前半はそのとおりで、速さの条件は「よく読む部分(ホットな部分)が RAM に収まること」です。ただし後半のスワップの心配は、実は逆向きです。ページキャッシュはディスクにある本物の写しなので、メモリが足りなくなったら OS は写しを捨てるだけで済みます。また必要になったらディスクから読み直せばいいからです。スワップが起きるのは、2 階建ての図で見たヒープのほうです。あちらは RAM にしかない本物で、捨てたら消えてしまうため、メモリが足りなくなったら OS はディスクへ退避させるしかありません。ページキャッシュの写しがスワップされることはありません。つまりデータベースファイルが RAM より大きくても、起きるのは「キャッシュに乗り切らない分だけディスク読みに戻って遅くなる」という緩やかな劣化であって、スワップで OS 全体が沈むような壊れ方はしません。
この「緩やかな劣化」の中身を具体的にしておきます。キャッシュはページ(4KB 程度の塊)単位で管理されているので、たとえば DB が 10GB で RAM の空きが 1GB しかなくても、よく読まれるページから順に 1GB 分が RAM に残ります。そこに当たる読み取りはメモリ速度のまま。乗っていないページに当たったときだけ、その数 KB をディスクから読み(SSD なら数十マイクロ秒)、読んだページはまたキャッシュに入って、しばらく使われていないページと入れ替わります。全部乗るか全滅かの二択ではなく、よく使う部分だけが自然に RAM に居座るのです。
この点は、データを全部プロセスのヒープに抱える Redis や memcached と比べたときの、SQLite の見落とされがちな長所でもあります。Redis はデータセットが RAM を超えるとスワップで激しく劣化するか、設定した上限で古いデータを捨てるかの二択になりますが、SQLite は何も設定しなくても「よく読む部分だけ RAM に乗っている」状態に自然に落ち着きます。
ファイルの読み取りが本当にメモリ速度なのか、インメモリデータベースと比べて確かめておきます。SQLite には、ファイルを一切作らず RAM 上だけで動くモードがあります。使い方は拍子抜けするほど簡単で、ファイル名の代わりに :memory: という特別な名前を渡すだけです。
con = sqlite3.connect(":memory:") # ファイルの代わりにRAM上にDBを作る
こうするとエンジンはページをディスクではなくプロセスのメモリに置くようになり、SQL もテーブルも普段とまったく同じに使えます。ただし実体はどこにも保存されないので、接続を閉じた瞬間に消えます。接続ごとに別々の DB になる(同じ :memory: へ 2 回つなぐと、中身の違う 2 つの DB ができる)ため共有にも向かず、テストや一時計算の作業台用の使い捨てモードと思ってください。この :memory: 版とファイル版で、同じ 10 万行・20 万クエリを比べると、手元ではこうなりました。
ファイル DB : 440560 クエリ/秒
:memory: : 766528 クエリ/秒
差は 2 倍弱。ディスクとメモリの速度差(数百〜数万倍)とは桁がまったく違います。ファイル版が少し遅いのは、SQLite が自前で持つプロセス内のページキャッシュ(既定で 2MB ほど)に乗り切らなかった分を、OS への read() 呼び出し越しにページキャッシュから取り寄せる手間の差で、ディスクを読んでいる差ではありません。読み取りに関する限り、「ファイルベースだから遅い」は成り立たない、と言ってよさそうです。
壁その1:書き手はデータベース全体で1人
読みが速いなら、何が詰まるのか。1 つめの壁は書き込みの直列化です。
SQLite のロックはデータベース 1 個につき 1 本しかありません。PostgreSQL が行単位のロックを持ち、別々の行への UPDATE なら何人でも同時に書けるのに対して、SQLite は「このファイルに今書いていいのは 1 人」という取り決めで動きます。2 人めの書き手がどうなるか、実際にぶつけてみます。
import sqlite3
w1 = sqlite3.connect("cache.db", timeout=0)
w2 = sqlite3.connect("cache.db", timeout=0)
w1.execute("BEGIN IMMEDIATE") # 書き手1が書き込みトランザクションを開始
w1.execute("UPDATE kv SET v = 'w1' WHERE k = 'k1'")
w2.execute("BEGIN IMMEDIATE") # 書き手2も書こうとすると……
sqlite3.OperationalError: database is locked
この database is locked は、SQLite への書き込みが重なったときに出る代表的なエラーです。エラーコードでいうと SQLITE_BUSY で、「先客の書き手がいるから今は入れない」という意味です。物々しい見た目からデッドロック——2 人が互いに相手の持っているロックを待って、どちらも永遠に動けなくなる膠着——を連想するかもしれませんが、あれとは別物です。こちらは先客のコミットが終われば解ける、ただの順番待ちです(ちなみに SQLite はロックがデータベース全体で 1 本しかないので、別々のロックを逆順に掴み合って起きる古典的なデッドロックは、そもそも起こりようがありません)。実務では接続時の timeout(PRAGMA busy_timeout)を数秒に設定して、先客が終わるまで待たせるのが普通ですが、待てば解決するのは行列が短いあいだだけで、書き手が増えるほど行列は伸びます。何人来ても同時に通れるのは 1 人、という構造そのものは変わりません。
壁その2:コミットのたびの「確認待ち」
2 つめの壁は、1 人の書き手がゲートを通り抜けるのにかかる時間、つまりコミット 1 回のコストです。
前回の記事で、PostgreSQL が COMMIT を返す前に WAL への書き込みを fsync で確実にディスクへ届ける様子を見ました。fsync は OS に対して「ページキャッシュに置いただけでは駄目で、本当にディスクまで書き切ったことを確認してくれ」と頼む呼び出しです。書き込みは読み取りと違って、ページキャッシュに書いて「終わったこと」にすると電源断でデータが消えるので、耐久性を約束するデータベースはコミットのたびにこの確認を待ちます。SQLite も同じで、既定の設定ではコミット 1 回ごとに fsync が入ります。
これがどれくらい効くか、500 件の INSERT を「1 件ずつコミット」と「500 件まとめて 1 トランザクション」で比べます。あわせて、次の節で説明するジャーナルモードと同期設定も振ってみます。
| 書き方 | 速度(Linux コンテナでの実測) |
|---|---|
| 1件ずつコミット(既定: ロールバックジャーナル) | 約 250 insert/秒 |
| 1件ずつコミット(WAL モード) | 約 1,500 insert/秒 |
| 1件ずつコミット(WAL + synchronous=NORMAL) | 約 63,000 insert/秒 |
| 500件を1トランザクション(既定のまま) | 約 170,000 insert/秒 |
| 500件を1トランザクション(WAL + NORMAL) | 約 1,000,000 insert/秒 |
1 行めと 4 行めを見比べてください。設定は既定のまま何も変えず、500 件を 1 つのトランザクションにまとめただけで、毎秒 250 件が 17 万件になりました。およそ 700 倍です。同じディスク、同じデータ量で変わったのは、トランザクションごとに繰り返される仕事——ジャーナルの用意と後片付け、ロックの取得、そして「確実に書けたか」の確認——が 500 回から 1 回になったことだけです。この繰り返しの中でいちばん高くつくのが fsync の待ち時間です。まとめれば毎秒百万件近く入るのだから、ディスクの書き込み能力はまったく余っています。fsync もれっきとしたディスク I/O には違いないのですが、そのコストは運ぶデータの量ではなく、確認の往復 1 回ぶんの待ち時間が回数分だけ積み上がることから来ます。ディスクが遅いのではなく、確認を待つ回数が多いのです。SQLite が遅く感じられる場面の多くは、ループの中で 1 件ずつ INSERT してコミットを繰り返している場合で、遅いのはディスクの帯域ではなくコミットの往復です。
ひとつ注意があります。表の 3 行めと 5 行めの synchronous=NORMAL は、コミットごとの fsync を省略する設定で、この速さはただではありません。電源断や OS ごと落ちる障害の瞬間には、直近にコミットしたはずの数件が消えることを許容します(データベース自体は壊れません)。キャッシュのような消えても作り直せるデータなら良い取引ですが、消えては困るデータなら既定の FULL のままにしてください。
なお絶対値は OS とディスクでまるで変わります(記事末尾の検証環境の節に実例を書きました)。見てほしいのは行と行の比率のほうです。
SQLiteにもWALがある
上の表で、ジャーナルモードを WAL にするだけでコミットが倍以上速くなっていました。PostgreSQL の心臓部だった WAL(先行書き込みログ)は、実は SQLite にも PRAGMA journal_mode=WAL として入っています。ただし既定では有効になっていません。
既定のモードはロールバックジャーナルといって、WAL とは逆向きの日誌を使います。これから書き換えるページの「変更前の姿」を別ファイルに退避してから本体を直接書き換え、途中で落ちたら退避しておいた古い姿を書き戻す、という方式です。本体をその場で書き換えるので、書いている途中の半端な状態を読み手に見せないよう、コミットの瞬間は読み手全員に出ていってもらう必要があります。つまり読みと書きが互いにブロックし合います。
WAL モードにすると、変更は本体ではなく WAL ファイルの末尾へ追記され、本体への反映はあとでまとめて行われます。前回の記事で見た PostgreSQL と同じ形です。読み手はコミット済みの古い状態を読み続ければよく、書き手の追記と衝突しません。これも実験で確かめられます。読み手がトランザクションを開いて読んでいる横で、書き手がコミットを試みます。
# 1回目は既定(ロールバックジャーナル)のまま実行する。
# 2回目は次の行を実行してWALモードへ切り替えてから、同じことを繰り返す。
# (切り替えはファイルに記録され、以後はずっとWALのまま)
# sqlite3.connect("cache.db").execute("PRAGMA journal_mode=WAL")
r = sqlite3.connect("cache.db", timeout=0)
w = sqlite3.connect("cache.db", timeout=0)
r.execute("BEGIN")
r.execute("SELECT * FROM kv").fetchall() # 読み手が読んでいる最中に
w.execute("BEGIN IMMEDIATE")
w.execute("UPDATE kv SET v = 'w' WHERE k = 'k1'")
w.commit() # 書き手がコミットすると?
結果はモードで分かれます。ロールバックジャーナル(既定)では書き手のコミットが database is locked で弾かれ、WAL モードへ切り替えたあとは何事もなく通ります。
サーバー用途で SQLite を使うなら WAL モードはほぼ必須の設定と言えます。ただし冒頭の図に戻ると、WAL が外してくれるのは「読みと書きの衝突」だけです。書き手がデータベース全体で 1 人、という 1 つめの壁はそのまま残ります。
それでも「大規模サーバーでは使えない」理由
ここまでを組み立て直すと、「大規模なサーバーで SQLite を避けろ」と言われる理由が、性能の話と構造の話の 2 段に分かれていることが見えてきます。
構造の話が先です。その前提として、PostgreSQL や Redis と SQLite は「そもそも形が違う」ことを図にしておきます。
PostgreSQL のようなクライアント/サーバー型では、アプリに組み込む JDBC などのドライバーは電話機にすぎません。SQL 文をネットワークの向こうへ送るだけで、それを解析して実行する主体(エンジン)は、別プロセスとして常駐しているサーバーの側にいます。Redis も形は同じです。テーブルも SQL もない「キーと値の預かり屋」という違いはありますが、独立したサーバープロセスを立てておき、アプリがネットワーク越しに問い合わせる点は変わりません。この形の利点は、入口がネットワークであることそのものです。アプリが 2 台でも 10 台でも、別のマシンからでも、同じサーバーに電話をかければ同じデータへ安全にたどり着けます。
SQLite には、このサーバーが存在しません。SQLite の実体は「ファイル形式」と「それを読み書きするライブラリ(エンジン)」の 2 つで、たとえば Java 用の SQLite ドライバーは、中にこのエンジンを丸ごと抱えています。同じ「ドライバー」という名前でも、電話機ではなくエンジンそのものです。SQL の解析も実行もファイルの読み書きも、すべてアプリ自身のプロセスの中で行われます。「SQLite はアプリと一体で動く」とは、この意味です。
エンジンがアプリのプロセスに同居しているとなると、PHP のような動かし方との相性が気になってきます。PHP の Web アプリは、Java のような常駐型と違って、リクエストのたびに状態を作り直し、DB への接続もリクエスト終了時に閉じるのが基本だからです。毎回エンジンごと読み込み直すのだとしたら相当なオーバーヘッドでは——と心配になりますが、実際には話が 2 段に分かれています。まず、エンジン(共有ライブラリ)の読み込みはワーカープロセスの起動時の 1 回だけです。現代の PHP は、リクエストごとにプロセスを立ち上げていた昔の CGI とは違い、php-fpm の常駐ワーカーが次々にリクエストを捌く形なので、ライブラリのロードは毎回は起きません。毎回起きるのは接続を開き直すことのほうで、こちらを実測すると、10 万行の DB を開いて 1 件読んで閉じるまでで約 30 マイクロ秒(0.03 ミリ秒)でした(コストの中身はファイルを開いてスキーマを読み込む分です)。数ミリ秒から数十ミリ秒かかる PHP のリクエスト処理全体の中では誤差のような大きさで、TCP の接続と認証を伴う MySQL への毎回接続と比べても軽いくらいです。接続を閉じると SQLite 自前の小さなキャッシュは消えますが、先に見た OS のページキャッシュはマシン全体で共有されて残り続けるので、開き直した直後の接続でも読みは RAM の速さのままです。PHP が SQLite を標準で同梱しているのは、この相性の良さの裏返しでもあります。
では、同じマシンで動くもう 1 つのアプリが同じ DB ファイルを開いたら危ないのか。実はこれは想定内で、SQLite は OS のファイルロックで交通整理をする前提で設計されています。読みは同時に何人でも、書きは 1 人ずつ。先ほど実験で見た database is locked は、まさにこの交通整理が働いた瞬間です。危ないのは、マシンをまたいだときです。サービスが育ってアプリケーションサーバーを 2 台、3 台と横に並べる段になると、全員が同じファイルに触るには NFS のようなネットワーク越しの共有ファイルシステムへ DB ファイルを置くしかありませんが、そこではこのファイルロックが当てにならず、データベースを壊す定番の原因になっています。複数台構成にした瞬間、SQLite は性能以前に選択肢から外れます。PostgreSQL や Redis が「サーバー」なのは偉いからではなく、複数のマシンから同時に安全にアクセスするための形だからです。
性能の話はその手前、1 台のマシンの中で決着します。読み取りは今日見たとおりメモリ速度で、並列にいくら走っても構いません。書き込みは 1 人ずつで、1 回のコミットに fsync の往復がかかる。つまり SQLite が苦しくなるのは「書き込みがたくさんの接続から絶え間なく来る」ワークロードで、逆に読みが大半なら、Web サーバーの裏に置いても驚くほど普通に動きます。実際、1 件ずつコミットしても毎秒数千件は書けていたわけで、この量で足りるサービスは世の中にたくさんあります。
キャッシュサーバーとして見たSQLite
最後に、読み取り中心の代表格であるキャッシュ用途を考えます。キャッシュは「書くのは元データが変わったときだけ、読むのは毎リクエスト」という、まさに SQLite の得意な偏りを持っています。
Redis と比べたときの SQLite の強みは、意外にもレイテンシです。Redis は速いデータベースですが、さきほどの比較図のとおり別プロセスであり、多くの構成では別マシンです。1 回の問い合わせにはネットワークかソケットの往復が挟まり、同一ホストでも数十マイクロ秒、別ホストなら 1 ミリ秒近くかかることがあります。SQLite はエンジンがアプリと同じプロセスに同居しているので、この往復がそもそも存在せず、今日の実測では 1 読み取り約 2 マイクロ秒でした。メモリの使い方も先に見たとおりで、あふれたら捨てるだけのページキャッシュは、RAM を超えた瞬間に痛み出す Redis よりも劣化が穏やかです。
一方で、Redis が当たり前にくれるものを自分で持つ必要はあります。複数のアプリサーバーから共有する棚としては使えませんし、TTL(有効期限)も、期限切れの掃除も、SQLite にはただのテーブルとして自分で作ることになります。書き込みが 1 人ずつである以上、「毎リクエストで書くキャッシュ」(アクセスカウンタやセッションの毎回更新など)には向きません。
実際の世の中の使われ方も、この線引きどおりに分布しています。複数のサーバーから共有する大規模キャッシュの定番は Redis や memcached で、たとえば Twitter はタイムラインなどのキャッシュに 1 万インスタンス超・RAM 105TB 規模の Redis 群 を運用していた報告があります。一方「1 台の中に埋め込むキャッシュ」としての SQLite は、実は多くの人のポケットの中で毎日動いています。iPhone アプリの HTTP キャッシュ(URLCache )は、SQLite ファイル(Cache.db)として保存されています。サーバー側でも、経費精算 SaaS の Expensify が SQLite を核にした自前のデータベース Bedrock で全社データを捌いていたり、Cloudflare が D1 などでエッジのサーバー群に無数の SQLite を展開していたりと、「サーバーで SQLite」自体は珍しいものではなくなっています。ただしどの例も「マシン(またはユーザー)ごとに自分の SQLite を持つ」形で、複数のマシンから 1 つの SQLite ファイルを共有する形は誰もやっていません。
まとめると、単一ホストで、読みが大半で、期限管理が単純なら、SQLite のキャッシュは十分に現実的で、ネットワークを渡らない分だけ Redis より速いことすらあります。横に並べたサーバー群から共有したくなった日が、素直に Redis へ移る日です。
検証環境と再現手順
この記事の実験は、すべて使い捨ての Docker コンテナで行いました。イメージは python:3.12-slim(Debian に Python 3.12 が入ったもの。SQLite はエンジンが Python の標準ライブラリ sqlite3 に同梱されていて、バージョンは 3.46)。ホストは Apple シリコンの Mac ですが、Python も SQLite もコンテナの中のものを使うので、ホスト側に必要なのは Docker だけです。
記事中ではコードを断片的にしか見せていないので、全実験(読み取り速度・:memory: との比較・コミット速度の表・ロックの再現・接続の開き直しコスト)を上から順に 1 本で再現する検証スクリプトの全文を置いておきます。verify.py としてダウンロードもできます。
import sqlite3, os, sys, time, random
# DBはコンテナ内のパスに作る(重要: -v でマウントした先に作らない)
WORK = "/tmp/bench-data"
os.makedirs(WORK, exist_ok=True)
def fresh(path, journal="DELETE", synchronous=None, populate=0):
"""毎回まっさらなDBを作って返す"""
for ext in ("", "-wal", "-shm", "-journal"):
try:
os.remove(path + ext)
except FileNotFoundError:
pass
con = sqlite3.connect(path)
con.execute(f"PRAGMA journal_mode={journal}")
if synchronous:
con.execute(f"PRAGMA synchronous={synchronous}")
con.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)")
if populate:
with con:
con.executemany("INSERT INTO kv VALUES (?, ?)",
((f"k{i}", "x" * 100) for i in range(populate)))
return con
print(f"SQLite {sqlite3.sqlite_version} / Python {sys.version.split()[0]} / {sys.platform}")
print()
# ---- 実験1: 読み取り速度(ファイルDB vs :memory:) ----
def bench_reads(con, n=200_000):
random.seed(42)
keys = [f"k{random.randrange(100_000)}" for _ in range(n)]
cur = con.cursor()
t0 = time.perf_counter()
for k in keys:
cur.execute("SELECT v FROM kv WHERE k = ?", (k,))
cur.fetchone()
return n / (time.perf_counter() - t0)
fpath = os.path.join(WORK, "read.db")
fcon = fresh(fpath, "WAL", populate=100_000)
mcon = sqlite3.connect(":memory:")
mcon.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)")
with mcon:
mcon.executemany("INSERT INTO kv VALUES (?, ?)",
((f"k{i}", "x" * 100) for i in range(100_000)))
print(f"読み取り ファイルDB : {bench_reads(fcon):>9,.0f} クエリ/秒")
print(f"読み取り :memory: : {bench_reads(mcon):>9,.0f} クエリ/秒")
mcon.close()
print()
# ---- 実験2: コミット速度(ジャーナルモード・同期設定・まとめ書きの効き方) ----
def bench_commits(journal, synchronous, batch, n=500):
con = fresh(os.path.join(WORK, "bench.db"), journal, synchronous)
t0 = time.perf_counter()
if batch: # n件を1つのトランザクションで
with con:
for i in range(n):
con.execute("INSERT INTO kv VALUES (?, ?)", (f"k{i}", "x" * 100))
else: # 1件ずつコミット
for i in range(n):
with con:
con.execute("INSERT INTO kv VALUES (?, ?)", (f"k{i}", "x" * 100))
rate = n / (time.perf_counter() - t0)
con.close()
return rate
for label, args in [
("1件ずつ(既定: ロールバックジャーナル)", ("DELETE", "FULL", False)),
("1件ずつ(WALモード)", ("WAL", "FULL", False)),
("1件ずつ(WAL + NORMAL)", ("WAL", "NORMAL", False)),
("500件一括(既定のまま)", ("DELETE", "FULL", True)),
("500件一括(WAL + NORMAL)", ("WAL", "NORMAL", True)),
]:
print(f"コミット {label} {bench_commits(*args):>11,.0f} insert/秒")
print()
# ---- 実験3: ロック(書き手2人 / 読み手がいる最中のコミット) ----
lpath = os.path.join(WORK, "lock.db")
for journal in ("DELETE", "WAL"):
con = fresh(lpath, journal)
con.execute("INSERT INTO kv VALUES ('a', '1')")
con.commit()
con.close()
w1 = sqlite3.connect(lpath, timeout=0)
w2 = sqlite3.connect(lpath, timeout=0)
w1.execute("BEGIN IMMEDIATE")
w1.execute("UPDATE kv SET v = 'w1' WHERE k = 'a'")
try:
w2.execute("BEGIN IMMEDIATE")
print(f"ロック[{journal}] 書き手2人: 2人目も成功(想定外)")
except sqlite3.OperationalError as e:
print(f"ロック[{journal}] 書き手2人: 2人目 → {e}")
w1.rollback(); w1.close(); w2.close()
r = sqlite3.connect(lpath, timeout=0)
w = sqlite3.connect(lpath, timeout=0)
r.execute("BEGIN")
r.execute("SELECT * FROM kv").fetchall()
try:
w.execute("BEGIN IMMEDIATE")
w.execute("UPDATE kv SET v = 'w' WHERE k = 'a'")
w.commit()
print(f"ロック[{journal}] 読み手がいる最中のコミット: 成功")
except sqlite3.OperationalError as e:
print(f"ロック[{journal}] 読み手がいる最中のコミット: {e}")
r.close(); w.close()
print()
# ---- 実験4: 接続の開き直しコスト(PHPの毎リクエスト相当) ----
N = 2000
t0 = time.perf_counter()
for i in range(N):
c = sqlite3.connect(fpath)
c.execute("SELECT v FROM kv WHERE k = ?", (f"k{i % 100000}",)).fetchone()
c.close()
open_each = (time.perf_counter() - t0) / N
c = sqlite3.connect(fpath)
t0 = time.perf_counter()
for i in range(N):
c.execute("SELECT v FROM kv WHERE k = ?", (f"k{i % 100000}",)).fetchone()
reuse = (time.perf_counter() - t0) / N
c.close()
fcon.close()
print(f"接続 毎回開閉: {open_each*1e6:6.1f} マイクロ秒/回 使い回し: {reuse*1e6:5.1f} マイクロ秒/回")
実行は 3 ステップです。Python のインストールは不要です(コンテナの中のものが使われます)。
mkdir sqlite-bench && cd sqlite-bench
# 上のコードを verify.py という名前でこのディレクトリに保存してから:
docker run --rm -v "$PWD":/work:ro python:3.12-slim python3 /work/verify.py
手元で走らせたときの出力はこうでした。記事中の表や本文の数値は、すべてこの出力から取っています。
SQLite 3.46.1 / Python 3.12.13 / linux
読み取り ファイルDB : 440,560 クエリ/秒
読み取り :memory: : 766,528 クエリ/秒
コミット 1件ずつ(既定: ロールバックジャーナル) 254 insert/秒
コミット 1件ずつ(WALモード) 1,501 insert/秒
コミット 1件ずつ(WAL + NORMAL) 63,449 insert/秒
コミット 500件一括(既定のまま) 170,243 insert/秒
コミット 500件一括(WAL + NORMAL) 1,043,741 insert/秒
ロック[DELETE] 書き手2人: 2人目 → database is locked
ロック[DELETE] 読み手がいる最中のコミット: database is locked
ロック[WAL] 書き手2人: 2人目 → database is locked
ロック[WAL] 読み手がいる最中のコミット: 成功
接続 毎回開閉: 29.1 マイクロ秒/回 使い回し: 1.8 マイクロ秒/回
追試するときに数値を大きく歪める落とし穴が 2 つあるので、実際にはまった順に共有しておきます。
1 つめは、DB ファイルを -v でマウントしたホスト側のディレクトリに作らないこと。マウント越しの読み書きはファイル共有の仕組み(virtiofs など)を経由するため、SQLite ではなくマウントの性能を測ることになってしまいます。実際、うっかりマウント先に DB を作ったときは、一括トランザクションが 20 分の 1 の速さに見えました。DB は /tmp などコンテナの中のパスに作るのが正解です(上のコマンドで :ro を付けているのは、この事故のうっかり防止です)。
2 つめは、OS が変わると絶対値は桁ごと変わること。同じ実験を macOS ホストで直接動かすと、1 件ずつのコミットが毎秒約 3,700 件と、Linux コンテナの 15 倍近くも速く見えました。この差にはコンテナの仮想ディスク経由という I/O 経路の違いも混ざっているので厳密な切り分けではありませんが、macOS の fsync がディスクへの書き切りまでは保証しないこと(完全に書き切らせるには F_FULLFSYNC という別の呼び出しが用意されています)は Apple 自身が文書化しています。少なくとも、macOS 上で出た速いコミット数値を耐久性込みの実力として読むのは危険です。数値は環境の産物なので、この記事も絶対値ではなく行と行の比率で読んでください。
SQLite の WAL モードの土台になっている「先にログへ書き終えてから本体へ」という考え方は、PostgreSQLはなぜクラッシュしても壊れないのか:WAL(先行書き込みログ)をpsqlで見る で仕組みから実験まで追いかけています。あわせてどうぞ。
