【SQLite】トランザクションと同時アクセスで気をつけたいこと

SQL

こんにちは、かつコーチです。

前回はSQLite独自のデータ型・型アフィニティを解説しました。

今回は、SQLiteを実運用する上で最も誤解が多い同時アクセスの挙動について解説します。

「SQLiteは同時に書き込むと落ちる」という話を聞いたことがある方も多いと思いますが、正確には少し違います。

トランザクションの基本挙動と合わせて、正しく理解していきましょう。

SQLiteにおけるトランザクション

自動コミットとBEGIN〜COMMITの基本

SQLiteは、明示的にトランザクションを開始しない限り、1つのSQL文ごとに自動でコミットする自動コミットモードで動作します。

複数のSQL文をまとめて1つの処理として扱いたい場合は、標準SQLと同様にBEGINCOMMITで囲みます。

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;

途中でエラーが起きた場合はROLLBACKで処理前の状態に戻せる点は、MySQL・PostgreSQLと同じです。

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- ここで異常を検知した場合
ROLLBACK;

ロックの3段階:SHARED・RESERVED・EXCLUSIVE

SQLiteのファイルベースという特性は、ロックの仕組みにも表れています。

サーバー型DBのような複雑な行ロックではなく、ファイル単位のロックを段階的に取得する方式です。

ロックの種類内容
SHARED読み取り時に取得。複数プロセスが同時に保持できる
RESERVED書き込みを予約した状態。1プロセスのみ保持できるが、他は読み取り継続可
EXCLUSIVE実際に書き込みを行う際に取得。取得中は他の読み書きがすべてブロックされる

デフォルト設定(後述するWALモードを使わない場合)では、誰かがEXCLUSIVEロックを取得している間、他のプロセスは読み取りすら待たされます。

これが「SQLiteは同時アクセスに弱い」と言われる大きな理由です。

同時アクセスで気をつけたいこと

SQLITE_BUSYエラーとの向き合い方

書き込みロックが取得できない状態で別プロセスが書き込もうとすると、SQLiteはSQLITE_BUSYというエラーを返します。

sqlite3.OperationalError: database is locked

私が個人開発で、複数のバッチスクリプトを同時にcronで動かしていたとき、まさにこのdatabase is lockedエラーに何度も遭遇しました。

原因は単純で、1つのバッチが書き込み中に、もう1つのバッチも同じSQLiteファイルへ書き込もうとしていたためです。

❌ Before:ロック競合をそのまま例外として落としてしまう

import sqlite3

conn = sqlite3.connect("app.sqlite3")
conn.execute("UPDATE logs SET status = 'done' WHERE id = ?", (log_id,))
conn.commit()
# database is lockedで例外発生 → バッチが異常終了

✅ After:busy_timeoutを設定してリトライを待つ

import sqlite3

conn = sqlite3.connect("app.sqlite3", timeout=10)
# timeout秒以内であれば、ロック解放を自動的に待ってからリトライする
conn.execute("UPDATE logs SET status = 'done' WHERE id = ?", (log_id,))
conn.commit()
-- SQL側からタイムアウトを設定する場合
PRAGMA busy_timeout = 10000;  -- ミリ秒単位

timeoutbusy_timeout)を設定しておくと、ロックが解放されるまで指定秒数だけ自動的に待ってからリトライしてくれるため、単発の競合であればエラーにならずに処理が完了します。

この設定を入れてから、私の環境ではバッチの異常終了がほぼなくなりました。

読み取りと書き込みが同時に発生するケース

デフォルトのロールバックジャーナルモードでは、書き込み中は読み取りも待たされることを説明しました。

これがWebアプリケーションのように「常に読み取りリクエストが来る」システムでは、レスポンスの遅延として表面化しやすいポイントです。

-- 現在のジャーナルモードを確認する
PRAGMA journal_mode;
-- => delete (デフォルトのロールバックジャーナルモード)

この「読み取りと書き込みの競合」を大きく緩和する仕組みがWALモードです。

WALモードについては次々回の記事で詳しく扱いますが、同時アクセスが発生するアプリでSQLiteを使う場合は、ほぼ必須の設定だと考えてください。

よくあるつまずきポイント:トランザクションを開きっぱなしにする

長時間BEGINしたまま他の処理を待たせる

❌ Before:トランザクション内で外部APIを呼ぶなど、時間のかかる処理を挟む

conn.execute("BEGIN")
conn.execute("UPDATE orders SET status = 'processing' WHERE id = ?", (order_id,))
response = call_external_api()  # ここで数秒〜数十秒かかる
conn.execute("UPDATE orders SET status = 'done' WHERE id = ?", (order_id,))
conn.commit()

トランザクションを開いている間、書き込みロックを保持し続けるため、外部API呼び出しのような時間のかかる処理を挟むと、その間ずっと他のプロセスが待たされます。

✅ After:DB操作だけをトランザクション内に収める

response = call_external_api()  # トランザクションの外で先に済ませる

conn.execute("BEGIN")
conn.execute("UPDATE orders SET status = 'processing' WHERE id = ?", (order_id,))
conn.execute("UPDATE orders SET status = 'done' WHERE id = ?", (order_id,))
conn.commit()

トランザクションは「DBへの書き込みだけ」に絞り、外部通信やファイルI/Oなどの時間のかかる処理は外に出すのが鉄則です。

これはSQLiteに限らずどのDBでも重要な原則ですが、ロックの粒度が粗いSQLiteでは特に影響が大きく出ます。

まとめ

この記事のポイント

  • SQLiteは自動コミットモードが基本。まとめたい処理はBEGINCOMMITで囲む
  • ロックはSHARED・RESERVED・EXCLUSIVEの3段階で、書き込み中は他の読み書きが待たされる
  • ロック競合時はSQLITE_BUSYエラーになる。busy_timeoutの設定でリトライを待たせられる
  • トランザクション内には時間のかかる処理を入れず、DB操作だけに絞る

次に読むべき記事

同時アクセスの基本が分かったところで、次はSQLiteのクエリを高速化するインデックスと、その効果を確認する方法を見ていきましょう。

→ 次の記事:インデックスとEXPLAIN QUERY PLANの読み方

タグ: SQLite, 中級者向け, トランザクション

タイトルとURLをコピーしました