SQLiteの「書き込みが同時1本に直列化される」という制約は、単一ノードのアプリでは体感できません。それどころか、在庫競合のような一番神経を使う排他制御を、MySQLより素朴なコードで正しく書けるようにします。この記事では「直列化」の正体を掘り下げ、唯一の事故パターンと、本番投入の判断基準までを整理します。
対象読者は、本番DBはMySQLかPostgres一択で、SQLiteは「ローカル用・おもちゃ」という認識のWebエンジニアです。「書き込みが同時1つだけ」と聞いて本番投入をためらった経験があるなら、この記事はそのための判断材料になります。
「おもちゃ」の時代は終わりつつある
前提の変化から。Rails 8は「PaaS不要」を掲げ、SQLiteを本番運用の前提に据えた構成を標準にしました。ジョブキューのSolid Queue、キャッシュのSolid Cache、WebSocketのSolid Cableがいずれも追加ミドルウェアなしで動き、デフォルトのデータベースとしてSQLiteで本番を回せる形で出荷されています。
Rails 8.0: No PaaS Required(Rails公式リリース情報)Deploying modern web apps – with all the provisions needed to be fast and secure while easily updateable – has become so hard that many developers don’t dare do it without a PaaS (platform-as-a-service). But that’s ridiculous. Nobody should have to pay orders of magnitude more for basic computing just to make deployment friendly and usable. That’s a job for open source, and Rails 8 is ready to solve it. So it’s with great pleasure that we are now ready with the final version of Rails 8.0, after a successful beta release and several release candidates!rubyonrails.orgサーバーレス側でも、CloudflareのマネージドDBであるD1はSQLiteベースです。「SQLiteはプロトタイプ用」という認識のまま止まっていると、この流れの意味が読めません。
Cloudflare D1 公式ドキュメントBuild serverless SQL databases on Cloudflare's global network and query them from Workers and Pages projects.developers.cloudflare.comそれでも引っかかるのが、冒頭の「書き込みは同時1本だけ」です。まずこの制約の正体を正確に見ます。
「書き込み直列化」の正体はファイル全体のロック1本
SQLiteのロック単位は、テーブルでも行でもなくDBファイル全体です(SQLite公式: File Locking And Concurrency)。書き込みトランザクションを実行できるのは常に1本だけで、ユーザAが orders テーブルに書いている間は、ユーザBの users テーブルへのINSERTも待たされます。テーブルや行が違っても関係ありません。
MySQL(InnoDB)との対比で言うと、InnoDBは行ロックとMVCCの組み合わせなので、別々の行を触るUPDATEなら何本でも並列に走ります。この並列性がSQLiteにはない。比喩にすると「レジが1台しかない」状態です。
ただし実態は「1台しかないけど、そのレジが異常に速い」です。1回の書き込みトランザクションは、WALモードとNVMe級のストレージならサブミリ秒〜1ms程度で終わります(環境依存の目安です)。直列でも毎秒数千件を捌ける計算になります。
ここで押さえたいポイントが3つあります。
- 「書けない」のではなく「待つ」だけ。ロックが握られるのは書き込みトランザクションの実行中だけです。ユーザがフォームを編集している間ずっとではなく、保存ボタンを押した瞬間のコンマ数ミリ秒だけです。
- 読み取りはWALならノーロック。誰かがどこかにINSERTしている最中でも、他の全員はどのテーブルでも普通にSELECTできます。読み手はスナップショット分離で、トランザクション開始時点の一貫したビューを見続けます。
- 待ちは通常1ms未満。次の節の思考実験で見るとおり、Webアプリのレイテンシ全体の中では誤差です。
思考実験: ECサイトで同時に別々の商品を買われたら
「同時に別々の商品を買われたら、片方はロック解除待ちで遅く感じるのでは?」という疑問で考えてみます。
答えは、両方ともすんなり買えて、待たされた側も待たされたことに一切気づけない、です。注文確定リクエストの処理時間を支配するのは決済APIの応答(一般に数百ms規模)で、DBのロック待ち1msは誤差の誤差です。どちらの注文が先に確定するかすら決済APIの応答順で入れ替わるレベルで、体感上はほぼ対称になります。
つまり単一ノードのアプリで「書き込み直列化」がユーザ体験に現れることは、実質ありません。現れるとしたら、次の1つの事故パターンを踏んだときだけです。
唯一の事故パターン: 長いトランザクション
危ないのは、注文レコードの作成と決済API呼び出しを同じDBトランザクションに入れる設計です。
BEGIN;
INSERT INTO orders ...;
-- ここで決済APIを呼んで応答を待つ(数百ms〜数秒)
UPDATE orders SET status = 'paid' ...;
COMMIT;
この形にすると、決済APIの応答を待つ数百ms〜数秒のあいだ書き込みロックを握り続けます。その間に購入しようとした他の全ユーザの書き込みが詰まり、busy_timeout を超えたところで SQLITE_BUSY エラーが連鎖します。1msで返すはずのレジに、電話をかけながら居座る客が現れた状態です。
正しい形は分割です。「決済を先に通してから、注文確定と在庫減算を短いトランザクションで書く」か、「在庫の仮押さえ(短いtx)→決済→確定(短いtx)」の三段に割ります。外部API呼び出しをトランザクションに入れないのはどのDBでも良い設計ですが、SQLiteでは守らないと即死する、という強制力の違いがあります。
在庫競合はむしろ楽になる
ここからが面白いところで、同じ商品を同時に買われるケース、つまり在庫の競合は、SQLiteだとむしろ楽になります。
writerが直列である以上、BEGIN IMMEDIATE で始めたトランザクションの中で在庫チェック→減算をやれば、2人が同じ在庫を二重に引き当てる事態は原理的に起きません。MySQLで書く SELECT ... FOR UPDATE のような明示的な行ロック取得が丸ごと不要です。
さらに、writerが同時に1本しかいない構造上、writer同士のデッドロックも原理的に起きません。デッドロック検知→リトライという定型処理が丸ごと消えます。
分離レベルも実質SERIALIZABLEです(SQLite公式: Isolation In SQLite)。高機能だからではなく、「書き込みが常に1本に直列化される」構造上、書き込み同士の競合異常がそもそも起きようがないためです。Postgresのように分離レベルの選択で悩む余地がありません。
並列性を捨てる代わりに、問題のクラスそのものが単純になる。これが「書き込み直列化は弱点ではない」の中身です。
守る掟は2つだけ
本番でSQLiteを使うときに守ることは、実質2つに収まります。
掟1: 書き込みを含むトランザクションは BEGIN IMMEDIATE で始める。 デフォルトの BEGIN(DEFERRED)は最初の書き込みまでロックを取りません。そのため、トランザクション内で読み→書きと進んだ瞬間にロック昇格が失敗して SQLITE_BUSY を食らいやすい。書き込み予定のトランザクションは最初からIMMEDIATEでロックを取るのが定石です(SQLite公式: BEGIN TRANSACTION)。Goなら mattn/go-sqlite3 のDSNに _txlock=immediate を付けると、db.Begin() がすべてIMMEDIATEになります。
掟2: 書き込みトランザクションを長く保持しない。 前述のとおり、トランザクション内で外部APIを呼ぶのは厳禁です。書き込みトランザクションの中身は「手元のデータをまとめて書くだけ」に絞ります。
設定面は、次のプラグマセットが本番の出発点になります。
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
耐久性について1点だけ。WAL + synchronous=NORMAL は、電源断のタイミングによって「直近数コミットが巻き戻る」可能性だけあります(DBファイル自体は壊れません)。決済記録のように1件も失えないデータを扱うなら、FULL にする判断もあります。
書き込みが多いテーブルはファイルを分ける
ロックの単位は正確には「DBファイルごと」なので、ファイルを分ければwriterは並列になります。
Rails 8の標準構成はまさにこのパターンで、メインDB・ジョブキュー(Solid Queue)・キャッシュ(Solid Cache)をそれぞれ別のSQLiteファイルに分けています。キューの高頻度書き込みがメインDBのロックを食わない設計です。ログや解析イベントのような書き込み過多のテーブルだけ別ファイルに逃がすのは、定番の手になっています。
ただし注意点として、ファイルをまたぐとトランザクションのアトミック性は保証されません。整合性が必要なテーブル群は同じファイルにまとめるのが原則です。
地味な癖も1つ挙げておくと、SQLiteは ALTER TABLE の制約が強めで、カラム変更を伴うマイグレーションには一手間かかります。頻繁にスキーマを変える初期フェーズでは、この点は把握しておいたほうがいいです。
採用判断: どこまでSQLiteで行けるか
最後に判断基準です。
- LambdaなどのサーバーレスFaaS: 実行環境が揮発するため、クラシックな「ローカルファイルのSQLite」の置き場がありません。この文脈でのSQLiteは、実質D1やTursoのような「SQLite-as-a-Service」を選ぶ話になります。
- 1インスタンスで動くバックエンド: SQLiteが素直に選択肢に入ります。Goなら modernc.org/sqlite を使えばCGo不要でビルドも単純です。バックアップはLitestreamでS3互換ストレージへ継続レプリケーションする構成が定番です。
- 複数ノードから同時に書きたい: この要件が入った瞬間にPostgresです。逆に言えば、そうなるまではSQLiteで始めて、困ったら移行で間に合います。
「レジは1台。ただしそのレジは異常に速い。レジを増やしたくなったら、それはSQLiteを卒業する日」。単一ノードで完結するアプリなら、SQLiteはもう本番のメインDBとして普通に選べます。ためらう理由だった「書き込み直列化」は、正体を知れば、むしろ設計を単純にしてくれる性質です。





