SQLite WALモード設定ガイド:仕組みからPrismaでの実装まで

SQLite WALモード設定ガイド:仕組みからPrismaでの実装まで

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。

SQLiteをWebアプリのバックエンドで使っていると、複数のリクエストが重なったタイミングでロックエラーが出たり、書き込みのたびに読み取りが止まったりする場面に出くわすことがある。その原因の多くはジャーナルモードの設定にある。WAL(Write-Ahead Logging)モードに切り替えるだけで、並行アクセス時の挙動が大きく変わる。この記事では、WALモードの仕組みを前提知識なしから説明し、実際の設定手順とPrisma環境での適用方法を具体的にまとめる。

デフォルトモード(DELETE)の限界

SQLiteはデフォルトで DELETE ジャーナルモードを使う。このモードでは、変更前のデータを「ロールバックジャーナルファイル」(database.db-journal)に書き出してから本体を更新する。

この方式の問題は排他制御が厳しい点にある。書き込みトランザクションが走っている間、読み取りはブロックされる。読み取りが進行中でも書き込みはブロックされる。ECサイトのバックエンドのように、商品一覧の読み取りと注文の書き込みが混在するアクセスパターンでは、この待ち合わせが積み重なる可能性がある。

操作 DELETEモード WALモード
読み取り中に書き込み ❌ ブロックされる ✅ 並行可能
書き込み中に読み取り ❌ ブロックされる ✅ 並行可能
複数の書き込み同時 ❌ ブロックされる ❌ ブロックされる

重要な点として、WALモードでも複数の同時書き込みは直列化される。読み書きの同時実行が可能になるのがWALの主な利点であり、「書き込みが速くなる」わけではない。

WALモードの仕組み

WAL(Write-Ahead Logging)は「先書きログ」という意味だ。データベースの変更を本体ファイルに直接書くのではなく、まずWALファイル(database.db-wal)に追記していく方式をとる。

書き込み時:
  変更内容 → database.db-wal に追記(シーケンシャル書き込み)

読み取り時:
  database.db-wal の最新スナップショット + database.db を合わせて返す

読み取りクライアントは「自分が読み始めた時点のスナップショット」を参照するため、書き込みが進んでいても影響を受けない。これがMVCC(Multi-Version Concurrency Control)的な動作を実現する仕組みだ。

WALファイルは溜まり続けるので、チェックポイントと呼ばれる処理で定期的にWALの内容を本体ファイルにマージする。公式ドキュメントによると、デフォルトでは1000ページ(通常4MB相当)を超えたタイミングで自動チェックポイントが走る(wal_autocheckpoint の既定値が1000であることによる)。チェックポイント中も読み取りはWALファイルを参照し続けるため、読み取りは止まらない。

主要なPRAGMA設定

WALモードを使うときにセットで確認すべきPRAGMAを列挙する。

-- WALモードに切り替え(データベースファイルに永続化される)
PRAGMA journal_mode=WAL;

-- 書き込み同期の強度(WALモードではNORMALが一般的)
PRAGMA synchronous=NORMAL;

-- ロック待ちのタイムアウト(ミリ秒)
PRAGMA busy_timeout=5000;

-- ページキャッシュサイズ(負値はKB単位)
PRAGMA cache_size=-64000;  -- 64MB

-- チェックポイントのトリガーページ数
PRAGMA wal_autocheckpoint=1000;

journal_mode=WAL

journal_mode=WAL は接続単位ではなくデータベースファイルに永続化される。一度設定すれば次回以降の接続でも有効だが、設定済みかどうかをアプリ起動時に確認・強制する実装にしておくほうが管理しやすい。

synchronous=NORMAL

synchronous=FULL(DELETEモードのデフォルト)は書き込みのたびにOSにfsync要求を出す。NORMALはチェックポイント時にのみfsyncする。公式ドキュメントによると、WALモードでNORMALを使った場合、アプリクラッシュによるデータ破損リスクはなく、OSやハードウェアがクラッシュした際に直近の書き込みが失われる可能性があるとされている。どの耐久性水準を選ぶかは運用環境のリスク判断による。

設定値 fsyncのタイミング 主な用途
FULL 各書き込み後 最高耐久性が必要な場合
NORMAL チェックポイント時 WALモードでの一般的な選択
OFF なし テスト・一時DBのみ

busy_timeout

複数のプロセスやスレッドが同じDBに接続する場合、書き込みロックの競合が起きたときSQLiteはデフォルトで即座に SQLITE_BUSY エラーを返す。busy_timeout にミリ秒を指定すると、その時間だけリトライしてからエラーにする。Webアプリでは5000(5秒)前後が目安とされることが多い。環境によって適切な値は異なる。

Prismaでの設定方法

PrismaでSQLiteを使う場合、接続初期化時にPRAGMAを発行する必要がある。Prismaの接続文字列にPRAGMAを直接埋め込む機能はないため、アプリ起動時に $executeRaw で設定するのが実装上の現実的な方法だ。

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query"] : [],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

/** アプリ起動時に一度だけ呼ぶ */
export async function initDb() {
  await prisma.$executeRaw`PRAGMA journal_mode=WAL`;
  await prisma.$executeRaw`PRAGMA synchronous=NORMAL`;
  await prisma.$executeRaw`PRAGMA busy_timeout=5000`;
  await prisma.$executeRaw`PRAGMA cache_size=-64000`;
}

Next.js 14以降では instrumentation.ts(プロジェクトルートに配置)でこの初期化を呼ぶことができる。

// instrumentation.ts
export async function register() {
  // Edge Runtimeでは動かないのでNode.js環境限定で実行
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { initDb } = await import("./lib/prisma");
    await initDb();
  }
}

あわせて、SQLiteへの同時書き込みを1本に絞るために接続URLに connection_limit=1 を設定しておくことが推奨されている。

# .env
DATABASE_URL="file:./prisma/dev.db?connection_limit=1&socket_timeout=5"

これによりPrismaからの書き込みが直列化され、WALの書き込みロック競合を最小化できる。

注意点と落とし穴

WALファイルはセットでバックアップする

WALモードを有効にすると、database.db と同じディレクトリに database.db-wal と database.db-shm が生成される。この2ファイルはメインDBと一体であり、バックアップはこの3ファイルをセットでコピーする必要がある。database.db だけコピーしても整合性が取れない。

安全なバックアップ方法の一つは、チェックポイントを完了させてからファイルをコピーすることだ。

-- WALの内容を本体に書き戻し、WALファイルをゼロに戻す
PRAGMA wal_checkpoint(TRUNCATE);

あるいは VACUUM INTO 'backup.db' を使うとアトミックなコピーが取れる(PrismaであればEXECUTE_RAWで実行できる)。

ネットワークファイルシステムでは使わない

WALモードはSHMファイルを使ったメモリマップに依存しており、NFSなどのネットワークファイルシステム上では正常に動作しないことが公式ドキュメントで明記されている。ローカルストレージ専用だ。

書き込み負荷の根本的な上限は変わらない

WALモードでも「1プロセスだけが書き込める」という制約は同じだ。秒間数百件の書き込みが継続するような負荷には向かない。そのスケールに達したらPostgreSQLやMySQLへの移行を検討すべき時期だ。個人・小規模なWebサービスであれば、WALモード+適切なPRAGMA設定で十分実用になる可能性が高いが、あくまで環境によって異なる。

まとめ

SQLite WALモードの要点を整理する。

PRAGMA 推奨値 理由
journal_mode WAL 読み書きの並行実行を有効化
synchronous NORMAL WALモードでの標準的な耐久性と速度のバランス
busy_timeout 5000(ms) 一時的なロック競合でエラーにしない
cache_size -64000(64MB) ページキャッシュでI/Oを削減

まず手元のSQLiteデータベースで次のコマンドを実行し、現在のモードを確認してみてほしい。

PRAGMA journal_mode;

delete が返ってきたら、WALへの切り替えを検討する価値がある。切り替え自体は1行のPRAGMAで完了し、既存データには影響しない。

この記事で触れたもの

よくある質問

SQLite WALモードに切り替えると既存のデータは消えますか?

既存データは保持されます。`PRAGMA journal_mode=WAL;`はジャーナル方式を変えるだけで、本体のデータベースファイルの内容は変更しません。切り替え後は`database.db-wal`と`database.db-shm`が新たに生成されます。

WALモードにするとどのくらい速くなりますか?

「速くなる」というより「読み書きが並行して進められるようになる」という変化です。読み書き混在のアクセスパターンでは待ち時間が減る傾向がありますが、効果は環境や負荷パターンによって異なります。書き込み専用の単純な処理ではDELETEモードのほうが速い場合もあります。

PrismaでSQLiteを使うとき、connection_limitはなぜ1にするのですか?

SQLiteは同時書き込みをサポートしないため、複数接続から同時に書き込もうとするとロック競合が頻発します。`connection_limit=1`でPrismaからの書き込みを直列化することで、`SQLITE_BUSY`エラーの発生を最小化できます。

WALモードのチェックポイントはいつ実行すればいいですか?

デフォルトでは1000ページを超えると自動実行されるため、通常は手動操作不要です。バックアップ前に`PRAGMA wal_checkpoint(TRUNCATE);`を手動実行してWALファイルをゼロに戻す使い方が代表的な手動実行シナリオです。