SQLiteのBEGIN DEFERRED・IMMEDIATE・EXCLUSIVE:トランザクション途中のSQLITE_BUSYへの対処
busy_timeoutを設定していてもSQLiteのトランザクション途中の書き込みがSQLITE_BUSYで失敗する理由、BEGIN IMMEDIATEで競合点を開始時に移す方法、失敗箇所ごとの再試行範囲を公式ドキュメントに基づいて整理します。
SQLiteでBEGINとだけ書くと、トランザクションはDEFERREDで始まります。このデフォルトのせいで、busy_timeoutを十分に長くしていても、トランザクション途中のUPDATEがすぐにSQLITE_BUSYで失敗することがあります。本記事では、3種類のBEGINが実際に何を変えるのか、読み取りトランザクションが書き込みに切り替わる瞬間に何が起きるのか、そしてエラーの発生箇所によって何を再実行すべきかを扱います。
内容はSQLite公式ドキュメントに基づいています。主にBEGIN TRANSACTIONのページ、結果コードのページ、分離レベルのページ、busy handlerのページを参照し、2026-10-15時点で確認しました。
3種類のBEGINの違い
BEGINのドキュメントによると、デフォルトはDEFERREDです。
| 形式 | 開始時にすること | ドキュメント上の注意点 |
|---|---|---|
BEGIN / BEGIN DEFERRED |
自動コミットを止めるだけで、実際のトランザクションはデータベースへの最初のアクセス時に始まる | 最初の文がSELECTなら読み取りトランザクションとして始まり、その後の書き込み文で書き込みトランザクションに昇格するか、SQLITE_BUSYを返す |
BEGIN IMMEDIATE |
書き込み文を待たずに、すぐ書き込みトランザクションを開始 | 別の接続がすでに書き込みトランザクション中なら、BEGIN IMMEDIATE自体がSQLITE_BUSYで失敗することがある |
BEGIN EXCLUSIVE |
IMMEDIATEと同様に、すぐ書き込みトランザクションを開始 |
WALモードではIMMEDIATEと同じ。それ以外のジャーナルモードでは、トランザクション中ほかの接続からの読み取りも止める |
DEFERREDトランザクションの最初の文が書き込みなら、最初から書き込みトランザクションとして始まります。問題になるのは「先に読み、その値を見て書く」というよくあるパターンです。
読み取りから書き込みへの昇格が失敗する理由
BEGINのページは、読み取りトランザクション中に書き込み文が来ると「可能であれば」書き込みトランザクションに昇格すると説明しています。別の接続がすでにデータベースを変更したか、変更の途中であれば昇格はできず、その書き込み文はSQLITE_BUSYで失敗します。原因はジャーナルモードによって少し異なります。
WALモード:スナップショットが古くなっている
分離レベルのページによると、WALモードの読み取りトランザクションは開始時点のスナップショットを見続けます。その間に別の接続が書き込みをコミットすると、この接続のスナップショットはもう最新ではありません。この状態で書き込みに昇格しようとするとSQLITE_BUSY_SNAPSHOT(拡張コード517)で失敗します。ドキュメントは、そのまま書き込みを許すとデータベースファイルの履歴が分岐してしまい、SQLiteはそれをサポートしないと説明しています。書き込むには、まずROLLBACKなどでスナップショットを手放し、新しいBEGINでやり直す必要があります。
つまり、待てば解消するロック待ちではありません。相手の書き込みが終わっても自分のスナップショットは過去のままなので、同じトランザクションの中で粘るのではなく、トランザクションを終えて最初から実行し直す必要があります。
ロールバックジャーナルモード:デッドロック回避
WAL以外のモードのロック段階はロックのページにまとめられています。読み取る接続はSHARED、書き込みを準備する接続はRESERVED(同時に1つだけ)を取り、実際にファイルへ書き込むには、ほかのSHAREDがすべて外れた後にEXCLUSIVEが必要です。
busy handlerのページは、まさにこの状況を例に挙げています。一方のプロセスはSHAREDをRESERVEDに上げようとし、もう一方はRESERVEDをEXCLUSIVEに上げようとすると、互いに相手を止めてしまいます。両方がbusy handlerで待つとどちらも進めないため、SQLiteはbusy handlerを呼ばずに、1つ目のプロセスへすぐSQLITE_BUSYを返します。ドキュメントの言い方では、そのプロセスに読み取りロックを手放させるためです。
busy_timeoutはbusy handlerの上に実装されています(sqlite3_busy_timeout、PRAGMA busy_timeout)。そのため、busy handlerが呼ばれないこのケースでは、タイムアウト値に関係なくエラーがすぐに返ります。対処はWALモードと同じで、読み取りロックを持っているトランザクションをロールバックし、最初から実行し直します。
BEGIN IMMEDIATEで何が変わるか
結果コードのページのSQLITE_BUSYの項は、SQLITE_BUSYがトランザクションの開始時、途中の書き込み、コミット時のどこでも起こりうると説明したうえで、途中でのSQLITE_BUSYを避けるにはBEGINの代わりにBEGIN IMMEDIATEを使うよう勧めています。BEGIN IMMEDIATE自体はSQLITE_BUSYを返すことがありますが、成功した場合は、次のCOMMITまでの同じデータベースへの以降の操作でSQLITE_BUSYが返らないことが保証されています。分離レベルのページも、いずれ書き込むと分かっているトランザクションは、SQLITE_BUSY_SNAPSHOTを避けるためにBEGIN IMMEDIATEで始めるよう案内しています。
実務的に言えば、競合の発生点がトランザクションの途中からBEGINの1行に移ります。BEGIN IMMEDIATEが失敗した時点ではまだ何も読み書きしていないので再試行は単純で、ここでの待ちは通常の書き込み競合なのでbusy_timeoutが意図どおり働く余地があります。
代償もあります。書き込みトランザクションは同時に1つしか有効になれないため、BEGIN IMMEDIATEの後で時間のかかる処理をすると、その間ほかの書き込み接続は待つかSQLITE_BUSYを受け取ります。これはドキュメントが数値で示しているものではなく、「書き込みは1つだけ」という規則から導かれる推論です。読み取り専用のトランザクションはBEGIN(DEFERRED)のままにし、書き込む可能性のあるトランザクションだけをIMMEDIATEに変え、その中からネットワーク呼び出しのような遅い処理は外すのが妥当です。
BEGIN EXCLUSIVEはWALモードではIMMEDIATEと同じなので、あえて選ぶ理由はありません。ロールバックジャーナルモードではほかの接続の読み取りまで止めるため、読み取りを止める必要が本当にある場合を除き、IMMEDIATEが基本の選択です。
失敗箇所ごとの再試行範囲
| どこで失敗したか | 状態 | すること |
|---|---|---|
BEGIN IMMEDIATE |
まだ何も処理していない | 少し待ってBEGIN IMMEDIATEから再試行 |
DEFERREDトランザクション途中の書き込み文(SQLITE_BUSY、SQLITE_BUSY_SNAPSHOT) |
先に読んだ値が古い、またはデッドロック回避で拒否された | ROLLBACKし、読み取りを含むトランザクション全体を最初から再実行 |
COMMIT |
BEGINのページによると、ほかの接続の読み取りが原因で失敗した場合、トランザクションは有効なまま残る | ロールバックせず、COMMITだけを後で再試行 |
SQLITE_LOCKED |
別の接続ではなく、同じ接続(または共有キャッシュ)内での衝突 | 別の接続を待つ問題ではないので、同じ接続や共有キャッシュ側の原因を確認 |
2行目で失敗した書き込み文だけを再実行してはいけない理由は、WALの説明ですでに述べました。その文より前のSELECTの結果は、ほかの接続のコミット前の値だからです。ロールバックジャーナルモードでも、SQLiteはこの接続に読み取りロックを手放すよう求めている状況なので、トランザクションを保ったまま同じ文を繰り返しても意味がありません。
COMMITでのSQLITE_BUSYについては、ドキュメント間でやや書き方が異なります。結果コードのページはBEGIN IMMEDIATEが成功すれば次のCOMMITまでSQLITE_BUSYは起きないとし、BEGINのページは、別のスレッドやプロセスが読み取り接続を開いているとCOMMITがSQLITE_BUSYを返しうるとしています。ロックのページが同じ状況(ほかのプロセスのSHAREDロックによるコミット失敗)をロールバックジャーナルモードの前提で説明しており、WALモードでは読み取りが書き込みを止めないことから、このコミット失敗は主にロールバックジャーナルモードのケースと考えるのが自然です。ただし、この結論は複数のページを合わせて読んだうえでの解釈で、1つのページが直接そう述べているわけではありません。いずれにしても、COMMITでSQLITE_BUSYを受け取ったら、トランザクションがまだ有効かを先に確認してからCOMMITだけを再試行するほうが安全です。
sqlite3 CLIで再現する
2つのターミナルで同じファイルを開きます。以下の例はWALモードを前提にしています。WALは一度設定するとファイルに保持されます(PRAGMA journal_mode)。
準備:
-- sqlite3 demo.db
PRAGMA journal_mode=WAL;
CREATE TABLE counters(name TEXT PRIMARY KEY, n INTEGER NOT NULL);
INSERT INTO counters VALUES ('a', 0);
次に、ターミナルAとBでそれぞれsqlite3 demo.dbを起動し、以下の順に入力します。
-- A
BEGIN;
SELECT n FROM counters WHERE name = 'a'; -- 0。読み取りトランザクション開始
-- B(自動コミット)
UPDATE counters SET n = n + 1 WHERE name = 'a'; -- WALではAの読み取りに止められない
-- A
UPDATE counters SET n = 1 WHERE name = 'a'; -- SQLITE_BUSY系のエラー
ROLLBACK;
Aの最後のUPDATEは、AのスナップショットがBのコミットより前のものなので、書き込みに昇格できません。エラーメッセージの文言はCLIのバージョンによって異なる場合がありますが、結果コードで言えばSQLITE_BUSYの拡張コードであるSQLITE_BUSY_SNAPSHOTの状況です。Aが失敗した文だけを再実行するのではなく、ROLLBACKしてSELECTからやり直す必要があるのはこのためです。
同じ処理をBEGIN IMMEDIATEに変えると、次のようになります。
-- A
BEGIN IMMEDIATE;
SELECT n FROM counters WHERE name = 'a';
-- B
SELECT n FROM counters WHERE name = 'a'; -- WALでは読み取り可能
UPDATE counters SET n = n + 1 WHERE name = 'a'; -- 書き込みは1つだけ:待機設定がなければSQLITE_BUSY
-- A
UPDATE counters SET n = n + 1 WHERE name = 'a';
COMMIT;
今回、競合で負けるのはBの書き込み開始時点です。Bで.timeout 5000のように待ち時間を設定しておけば、その間にAがコミットした場合、Bは待った後に処理を進められます。.timeoutはCLIのヘルプで「ロックされたテーブルをMSミリ秒の間開こうと試みる」コマンドとして説明されています(CLIのドキュメント)。アプリケーションコードではPRAGMA busy_timeout = 5000;やsqlite3_busy_timeout()が同じ役割を果たします。5000は例であり、推奨値ではありません。
アプリケーションに適用するとき
- 読んだ値をもとに書き込むトランザクションは
BEGIN IMMEDIATEで始めます。ドライバやORMが代わりにBEGINを発行する場合は、それがどの形式のBEGINなのかをドライバのドキュメントで確認してください。 - 接続ごとに
busy_timeoutを設定しつつ、それだけでトランザクション途中のSQLITE_BUSYがなくなるとは期待しないでください。 - 再試行の単位はSQL文ではなく、トランザクション全体を包む関数にします。ドキュメントで確認できる例外は、ほかの接続が読み取りを開いているために
COMMITがSQLITE_BUSYで失敗した場合です。BEGINのページによると、このときトランザクションは有効なまま残るので、COMMITだけを再試行すれば足ります。それ以外のCOMMITの失敗では、何を再試行するかを決める前に、トランザクションがまだ開いているかを確認してください。sqlite3_get_autocommit()のページは、SQLITE_BUSYを含む一部のエラーの後ではトランザクションが自動的にロールバックされることがあり、それを知る方法はこの関数だけだと説明しています。sqlite3_txn_state()は、接続がトランザクションなし、読み取りトランザクション、書き込みトランザクションのどの状態にあるかを返します。ドライバ経由で使っている場合は、ドライバが提供するトランザクション状態の確認手段を使います。トランザクションが消えていれば、全体を再実行します。 - WALモードでも
SQLITE_BUSYは完全にはなくなりません。WALのページは、別の接続が排他ロックモードで開いている場合、最後の接続が閉じる際に短時間後片付けをしている場合、クラッシュ後のリカバリ中を例外として挙げています。WALファイルの肥大化とチェックポイントについてはSQLite WALチェックポイントの記事で別に扱っています。
busy_timeoutが役に立つのは、待てば解消する競合だけです。読み取りから書き込みへの昇格失敗は待っても解消しない種類なので、書き込むトランザクションは最初からBEGIN IMMEDIATEで書き込みの意図を示す、というのがSQLiteのドキュメントが示す方法です。