SQLite で社内システムを作れば楽勝だと思っていた。
ところが worker を 2 つ増やしただけで、画面に時々こんな文字が出始める:
database is locked
さらに厄介なのは: 「高い並行性が必要な操作」をしているわけではなく、ただの普通のバックグラウンド処理だということ。
- job を拾う
- heartbeat を更新する
- 結果を書き戻す
どの書き込みも小さくて単純。
それでもロックする?
SQLite って本当に使えるの?
SQLite にまだ慣れていないなら、先に次の記事を読むのがおすすめ:
なぜこんなに「ロック」しやすいのか?
SQLite の並行モデルは複雑ではありませんが、現実はシビアです:
- 同時に writer は 1 つだけ
- 読み取りはだいたい速い
- 書き込みが入った瞬間に「ロックの世界」に入る
複数 worker のバックグラウンドシステムで問題になるのは、「大量に書く」ことではなく:
みんなが同時に、少しだけ書きたい。
例えば:
- job の claim(状態変更)
- heartbeat(タイムスタンプ更新)
- 結果やエラーコードの書き込み
どれも小さいですが、worker が増えると、この小さな書き込みが同じタイミングで同じ write lock を奪い合います。
そして、見ての通り:またロック。
まず 2 種類のエラーを分ける
BUSY か LOCKED か?
実務で見る database is locked の裏側は、だいたい 2 パターンです:
SQLITE_BUSY
- 別の connection が書き込み中
- 今はロックが取れない
- 待てば取れる可能性がある
ここで busy_timeout が効きます。
SQLITE_LOCKED
- 同一 connection 内で statement / transaction がまだ終わっていない
- あるいは、良くないタイミングで connection を再入している
- 待っても無駄
busy_timeout を設定しているのに「秒で落ちる」なら、だいたいこちらです。
この場合、直すべきは timeout ではなく、connection の使い方です。
Write-Ahead Logging
WAL(Write-Ahead Logging)は、複数 worker の SQLite ではほぼ標準装備です。
変わる点は明確:
- reader が writer を止めにくくなる
- 読み取りが多く、たまに書くケースがかなりスムーズになる
理由はシンプルです:
writer は変更を -wal ファイルに書き込み、reader は自分の snapshot を読むため、干渉が大きく減ります。
ただし WAL でも 変わらない ことがあります:
同時に writer は結局 1 つだけ。
だからよくある症状は:
- worker 1 つ:問題なし
- worker 複数:たまにロック競合が起きる
並行圧力が出始めた自然な結果で、完全に解決ではないですが、少なくとも改善はします。
WAL が有効かどうかは、次で確認できます:
PRAGMA journal_mode;
wal が返ってきて初めて有効です。
busy_timeout
「少し待つ」は許容できても、「即エラー」は困ることがあります。
SQLite がロック競合に当たったときのデフォルト挙動はとても直球です:
ロックが取れない → すぐエラー
busy_timeout はこれを次に変えます:
ロックが取れない → 少し待ってリトライ → それでも無理ならエラー
複数 worker のシステムでは、この差が重要です。
Python の簡単な例:
import sqlite3
def open_db(path: str, *, busy_ms: int = 5000) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute(f"PRAGMA busy_timeout = {busy_ms};")
return conn
Python の sqlite3 モジュールでは、次のように書くこともできます:
sqlite3.connect(path, timeout=5.0)
本質的には同じなので、どちらか一方で OK です。
本当のポイント
ここまでやっても、まだ不定期に起きることがあります。
根本的に潰すポイントは:
トランザクションを短くする!
WAL と busy_timeout は衝突を緩和しますが、悪い習慣までは救えません。
よくあるアンチパターンはこんな感じ:
BEGIN;
-- job を拾う
-- 長い処理(I/O / 計算 / 外部呼び出し)
-- 結果を書く
COMMIT;
これは他の worker にこう言っているのと同じです:
しばらく write lock を握るので、みんな並んでね。
良くないので、やめましょう。
正しい考え方は、流れを分けることです:
- claim(占有):状態変更だけして即コミット
- 結果の書き込み:必要になったときに、短い transaction をもう一回
claim の典型的な書き方はこんな感じ:
BEGIN IMMEDIATE;
SELECT id
FROM jobs
WHERE queue = :queue
AND status = 'QUEUED'
ORDER BY created_at ASC
LIMIT 1;
UPDATE jobs
SET status = 'RUNNING'
WHERE id = :id
AND status = 'QUEUED';
COMMIT;
BEGIN IMMEDIATE:最初に「書けるか」を確認し、ダメなら早めに引くAND status = 'QUEUED':最もシンプルで効果的な CASUPDATEの更新行数 ≠ 1 → claim 失敗なのでリトライ
ここで重要なのは:ロックを握る時間を短くすること。
よくある落とし穴
-
WAL を有効にすれば全部 OK と思う
そうはなりません。
WAL が解決するのは「読書きが互いにブロックする」問題であって、「書き込み同士の競合」ではありません。
対策: WAL +
busy_timeout+ 短い transaction。3 つ全部必要。
-
claim 条件にインデックスがない
BEGIN IMMEDIATEの後で全表スキャンを始めると、他の worker はただ待つしかありません。対策: claim に使う条件へインデックスを貼る:
CREATE INDEX IF NOT EXISTS idx_jobs_queue_status_created_at
ON jobs(queue, status, created_at);
-
複数スレッドで同じ connection を共有する
だいたい得られるのはエラーだけで、性能は上がりません。
対策: thread / process ごとに connection を 1 本、または明示的にアクセスを直列化する。
-
信頼できない共有ファイルシステムに SQLite を置く
一部の NFS やネットワークドライブでは、ロック挙動が予測不能になります。
対策: DB をローカルディスクに置く。できないなら技術選定を見直す。
他に試せる方向:
- queue の状態と結果書き込みを分離(別 table / 別 DB)
- 結果書き込みをバッチ化して transaction 回数を減らす
- 本当に複数 writer が必要なら、client/server DB を使う
SQLite は便利ですが、すべての並行圧力を肩代わりしてくれるわけではありません。
まとめ
WAL と busy_timeout は、SQLite を「雑に書いても大丈夫」にする魔法ではありません。
現実的な条件下で、衝突に対する余裕を少し増やすだけです。
覚えておくことは 3 つ:
- WAL は読書きのブロッキングを減らすが、書き込み同士の並行は解決しない
busy_timeoutは衝突を「待ち」に変えるが、前提として transaction が短い必要がある- write lock を取る SQL を最小化し、connection 初期化で PRAGMA を設定する
そうすれば database is locked は、「真っ赤な画面」から「たまにリトライするだけ」になるはずです。
参考資料
☕ 1杯のコーヒーが支えになります
AIやフルスタックの情報発信を続けるため、ご支援お願いします。
AI・開発・運用まで一括対応
アイデアからリリースまで、技術面はまるごとお任せください。
対応内容
- 技術相談 + 開発 + デプロイ
- 継続サポート & 拡張
🚀 次のプロジェクト、始めましょう!
カスタム開発や長期支援をご希望の方は、ぜひご相談ください。
