◀ 5.

【Laravel】DBの悲観ロック

この記事の要点
  • 悲観ロックは「読んだ行を、更新が終わるまで他の処理に触らせない」ためのロック。在庫・残高・連番の更新で使う
  • 占有ロック: ->lockForUpdate()(SQL の SELECT ... FOR UPDATE)。他のロック・更新を待たせる
  • 共有ロック: ->sharedLock()(MySQL は LOCK IN SHARE MODE、PostgreSQL は FOR SHARE)。読むのは許し、更新は待たせる
  • 必ずトランザクションの中で使う。外で使うと文の終了と同時にロックが外れ、意味がない
  • ロック対象の条件にはインデックスを。ないと多くの行がロックされ、待ちやデッドロックが増える
  • DB::transaction(..., 3) の第 2 引数でデッドロック時の再試行回数を指定できる

悲観ロックとは

同じデータを複数のリクエストが同時に更新すると、「在庫 1 個の商品が 2 人に売れる」「残高の加算が片方消える」といった不整合が起きます。悲観ロックは「衝突は起きるもの」と考え、読み取った時点で DB の行にロックをかけ、処理が終わるまで他のトランザクションを待たせる方式です。対になる楽観ロックは、ロックをかけずに更新時にバージョン番号などで衝突を検出します。

項目悲観ロック楽観ロック
仕組みSELECT 時に DB の行ロックを取得UPDATE 時に version 列などで変更の有無を確認
衝突時後から来た処理が待たされる後から来た処理が失敗し、再試行やエラー表示が必要
向いている場面在庫・残高・採番など衝突が多く、失敗させたくない処理編集画面など衝突が少なく、ロック待ちを避けたい処理

共有ロック(参照時にかけるロック)

DB::table('users')->where('votes', '>', 100)->sharedLock()->get();

ロックした行は、他のトランザクションから読むことはできますが、更新・削除や占有ロックはトランザクション終了まで待たされます。「この行を参照している間は内容を変えられたくない」場合に使います(例: 親レコードが存在することを確認してから子レコードを登録する)。

占有ロック(更新時にかけるロック)

DB::table('users')->where('votes', '>', 100)->lockForUpdate()->get();

ロックした行は、他のトランザクションからの更新・削除・共有ロック・占有ロックがすべて待たされます。「読み取った値をもとに更新する」処理では、こちらを使います。なお、ロックをかけない普通の SELECT(MySQL InnoDB の一貫性読み取り)はブロックされません。

実践例:在庫を減らす処理

ロックはトランザクションが終わる(コミットまたはロールバック)まで保持されます。Laravel では DB::transaction() のクロージャ内で使うのが基本です。

use Illuminate\Support\Facades\DB;
use App\Models\Product;

DB::transaction(function () use ($productId, $qty) {
    // 1. 行を占有ロックして最新の在庫を読む(他の同じ処理はここで待たされる)
    $product = Product::where('id', $productId)->lockForUpdate()->firstOrFail();

    // 2. 読んだ値で判定
    if ($product->stock < $qty) {
        throw new \RuntimeException('在庫不足');   // 例外でロールバックされ、ロックも解放
    }

    // 3. 更新。クロージャを抜けるとコミットされロックが外れる
    $product->decrement('stock', $qty);
}, 3); // デッドロックが起きたら最大 3 回まで実行し直す

Eloquent モデルのクエリでも、クエリビルダと同じく lockForUpdate() / sharedLock() を付けられます。

発行される SQL

メソッドMySQL / MariaDBPostgreSQL
lockForUpdate()FOR UPDATEFOR UPDATE
sharedLock()LOCK IN SHARE MODEFOR SHARE
lock('for update skip locked')文字列をそのまま付与。ロック中の行を飛ばして取得(MySQL 8.0 以降・PostgreSQL。ジョブキューの取り出しなどで使う)

よくある落とし穴

  • トランザクションの外で使う: 自動コミットの状態では SELECT 文が終わった瞬間にロックが外れる。エラーにはならないので気付きにくい
  • インデックスのない条件: InnoDB は検索で走査した行(インデックスのギャップを含む)にロックをかける。条件の列にインデックスがないと、テーブルの広い範囲がロックされ、関係ない処理まで待たされる
  • デッドロック: 処理 A が行 1→行 2、処理 B が行 2→行 1 の順でロックすると互いに待ち続け、DB が片方をエラーにする。ロックを取る順番を全処理でそろえる(ID の昇順など)、DB::transaction の再試行回数を指定する
  • ロック待ちタイムアウト: MySQL は innodb_lock_wait_timeout(既定 50 秒)を超えると Lock wait timeout exceeded エラー。トランザクション内で外部 API 呼び出しなど時間のかかる処理をしない
  • SQLite では効かない: SQLite 用の文法ではロック句が付かないため、テストを SQLite で動かしていると同時実行の問題を再現できない
  • 複数の DB 接続: DB::connection('other') のクエリは、既定接続の DB::transaction の対象外。DB::connection('other')->transaction(...) を使う

確認方法

  • 発行される SQL は ->lockForUpdate()->toSql() で確認できる(末尾に for update が付く)
  • 実際に効いているかは、2 つの端末で php artisan tinker を開き、片方でトランザクションを開始してロックしたまま止め、もう片方で同じ行をロックしようとして待たされることを確かめる
  • MySQL 8.0 では performance_schema.data_locks や SHOW ENGINE INNODB STATUS で、現在のロックと直近のデッドロックの内容を確認できる

関連

子ページ

子ページはありません

同階層のページ
  1. config/database.phpファイル
  2. .env
  3. 複数のデータベースに接続する方法
  4. DBトランザクション
  5. DBの悲観ロック

最近更新/作成されたページ