Laravel lockForUpdate()の使い方|排他ロック(悲観的ロック)とトランザクションの実装・注意点

実装・応用テクニック

LaravelのlockForUpdate()は、SQLのSELECT ... FOR UPDATE文を発行してレコードに排他ロック(悲観的ロック)をかけるメソッドです。ECサイトの在庫引き当てや口座残高の更新など、複数ユーザーの同時アクセスによるデータ競合(二重更新・不整合)を確実に防ぐために使われます。

【最重要ポイント】lockForUpdate()は必ずDB::transaction()の内部で実行する必要があります。トランザクション外で呼び出すと、クエリ実行直後にロックが解除されてしまい効果がありません。

この記事では、Eloquentおよびクエリビルダでの正しい書き方(findやwhereとの組み合わせ)、実践的な同時更新防止コード、sharedLock()との違い、デッドロック回避と自動リトライ設定まで詳しく解説します。

lockForUpdate()とは?悲観的ロックの仕組み

lockForUpdate()は、LaravelのクエリビルダおよびEloquentで提供されている悲観的ロック(Pessimistic Locking)のメソッドです。実行するとSQLのSELECT ... FOR UPDATEが発行され、対象レコードが排他ロックされます。

排他ロックがかかっている間、他のトランザクションからの更新や、別のlockForUpdate()によるロック取得は現在のトランザクションがコミット(またはロールバック)されるまで待機(ブロック)されます。

どんな場面で使うべきか?

  • 在庫の減算・引き当て(限定商品の同時注文による在庫マイナスを防ぐ)
  • 口座残高・ポイントの増減(複数端末からの同時引き出しによる二重利用を防ぐ)
  • 連番・整理券・チケットの採番処理

【最重要】必ずトランザクション内で実行する

lockForUpdate()を使う上で最も重要なルールは、必ずトランザクション(DB::transaction()など)のスコープ内で呼び出すことです。

データベースの行ロックは、トランザクションが終了(COMMITまたはROLLBACK)するまで保持されます。もしトランザクション外でlockForUpdate()を実行すると、SELECT文の完了と同時に自動コミットされてロックが即座に解放されるため、排他ロックの意味がなくなってしまいます。

use IlluminateSupportFacadesDB;
use AppModelsProduct;

// 正しい使い方:DB::transaction 内で実行する
DB::transaction(function () {
    $product = Product::where('id', 1)->lockForUpdate()->first();
    
    // トランザクションが完了するまで $product は他から更新・ロックされない
    $product->stock -= 1;
    $product->save();
});

lockForUpdate()の基本的な書き方・実装例

1. Eloquentモデルでの使い方(find / where / first)

Eloquentでは、クエリチェーンの途中にlockForUpdate()を挟むことで簡単に排他ロックを取得できます。

where() と first() の組み合わせ

use AppModelsUser;
use IlluminateSupportFacadesDB;

DB::transaction(function () {
    $user = User::where('id', 1)->lockForUpdate()->first();
    
    $user->balance -= 1000;
    $user->save();
});

find() / findOrFail() との組み合わせ

find()やfindOrFail()の前にlockForUpdate()を呼び出すことも可能です。

DB::transaction(function () {
    // lockForUpdate() を呼んでから find() を実行
    $product = Product::lockForUpdate()->findOrFail(1);
    
    $product->stock -= 1;
    $product->save();
});

複数行のロック(get)

条件に合致する複数レコードを一度にロックしたい場合はget()を使います。

DB::transaction(function () {
    $tickets = Ticket::where('event_id', 10)
        ->where('status', 'available')
        ->lockForUpdate()
        ->limit(2)
        ->get();

    foreach ($tickets as $ticket) {
        $ticket->update(['status' => 'reserved']);
    }
});

2. クエリビルダ(DBファサード)での使い方

Eloquentを使わず、DB::table()によるクエリビルダでも同様に記述できます。

use IlluminateSupportFacadesDB;

DB::transaction(function () {
    $user = DB::table('users')
        ->where('id', 1)
        ->lockForUpdate()
        ->first();

    DB::table('users')
        ->where('id', 1)
        ->update(['balance' => $user->balance - 1000]);
});

実践シナリオ:在庫引き当てによるデータ競合防止

同時アクセス時にlockForUpdate()がある場合とない場合の違いを、ECサイトの在庫引き当て処理を例に見てみましょう。

ロックがない場合に起きる問題(レースコンディション)

  1. 商品在庫が残り「1個」の状態で、ユーザーAとユーザーBが同時に注文ボタンを押す。
  2. 処理A・処理Bがほぼ同時に在庫数(1個)を読み取る。
  3. 処理Aが「在庫あり」と判定して在庫を0に減算しコミット。
  4. 処理Bも読み取り時の「在庫1個」をもとに「在庫あり」と判定し、在庫を0(またはマイナス)に減算してコミット。
  5. 結果:在庫1個に対して2つの注文が成立してしまい、在庫の不整合(過剰注文)が発生する。

lockForUpdate() を使った解決コード

use AppModelsProduct;
use AppModelsOrder;
use IlluminateSupportFacadesDB;
use Exception;

function purchaseProduct(int $userId, int $productId, int $quantity)
{
    return DB::transaction(function () use ($userId, $productId, $quantity) {
        // 対象商品を排他ロックして取得
        $product = Product::lockForUpdate()->findOrFail($productId);

        // 最新の在庫数をチェック
        if ($product->stock < $quantity) {
            throw new Exception('申し訳ありません。在庫が不足しています。');
        }

        // 在庫を減算
        $product->stock -= $quantity;
        $product->save();

        // 注文レコード作成
        $order = Order::create([
            'user_id'    => $userId,
            'product_id' => $productId,
            'quantity'   => $quantity,
            'status'     => 'completed',
        ]);

        return $order;
    });
}

処理AがlockForUpdate()でロックを取得している間、処理BのlockForUpdate()は処理Aのトランザクション完了まで待機します。処理Aがコミットされた直後に処理Bが実行され、処理Bは最新の在庫数「0個」を取得するため、正しく在庫不足例外を投げて二重注文を防ぐことができます。

Seeder(シーダー)を活用した同時実行テストとデータ準備

排他ロックやトランザクション処理が正しく動作するかをテストする際は、開発環境であらかじめ十分な商品在庫データやユーザー残高レコードを用意しておく必要があります。

LaravelのSeederとFactoryを連携させることで、大量のテストデータや初期マスタデータを瞬時に生成し、複数プロセスからの同時アクセスを再現したシミュレーションを安全に行えます。Seederの作成・実行手順や環境ごとの切り分け運用については、【Laravel】Seeder完全ガイド|作成・実行・Factory連携から本番運用の注意点まで を参考にしてください。

sharedLock()(共有ロック)との違いと使い分け

LaravelにはlockForUpdate()のほかに、sharedLock()メソッドも用意されています。用途に合わせて使い分けることが重要です。

メソッド SQL ロック種別 他トランザクションの読み取り 他トランザクションの書き込み 主な用途
lockForUpdate() SELECT ... FOR UPDATE 排他ロック 待機(FOR UPDATE時) 待機(ブロック) 更新前提のデータ取得(在庫・残高など)
sharedLock() SELECT ... LOCK IN SHARE MODE(MySQL)
SELECT ... FOR SHARE(PostgreSQL)
共有ロック 許可(共有可能) 待機(ブロック) 参照のみで、他からの更新を防ぎたい場合

更新を行う処理にはlockForUpdate()を使用し、「他のトランザクションによる書き込みを防ぎつつ、現在の値を確実に参照したい(更新はしない)」場合にはsharedLock()を選択します。

lockForUpdate()利用時の注意点とデッドロック対策

1. デッドロックの発生防止(ロック順序の統一)

複数のテーブルや複数レコードを同時にロックする場合、処理によってロックを取得する順番が異なるとデッドロックが発生します。

  • 処理A: ユーザー1をロック → ユーザー2をロック
  • 処理B: ユーザー2をロック → ユーザー1をロック

この場合、互いに相手のロック解放を待ち合って処理が止まってしまいます。複数のレコードをロックする際は、常にID昇順(例: orderBy('id'))などでロック取得の順序を統一するのが鉄則です。

2. Laravelのトランザクション自動リトライ機能

万が一デッドロックが発生した場合に備え、DB::transaction()の第2引数にリトライ試行回数を指定できます。

// デッドロック発生時に最大3回まで自動再試行する
DB::transaction(function () {
    $user = User::lockForUpdate()->find(1);
    $user->balance -= 100;
    $user->save();
}, 3);

デッドロック検知時にトランザクションがロールバックされ、自動的に指定回数まで再実行されます。

💡 デッドロック自動再試行と手動制御の実装詳細:

デッドロック発生時の安全な再試行アルゴリズム(指数バックオフ・ジッター)、retry() ヘルパー関数との併用、手動トランザクション(beginTransaction)の例外処理については、以下の完全ガイドで詳しく解説しています。
👉 Laravel DBトランザクション完全ガイド|デッドロック自動リトライ(retry)と手動制御の実装

3. トランザクション内の処理時間は最小限にする

ロックがかかっている間、同じレコードにアクセスする他のユーザーは待機させられます。トランザクション内で外部APIのHTTPリクエスト送信やメール送信、重い画像処理などを行うと、ロック時間が長引いてシステム全体のパフォーマンスが著しく低下します。外部通信や通知処理はトランザクションの外側やキュー(Job)で行いましょう。

4. WHERE句のインデックス設定

SELECT ... FOR UPDATEの条件にインデックスが設定されていない場合、RDBMS(MySQL InnoDBなど)はテーブル全体や広範囲のギャップをロックしてしまい、無関係なレコードの更新までブロックされるリスクがあります。ロック対象を絞り込むカラムには必ずインデックスを付与してください。

インデックスの追加($table->index())や外部キー制約の設定など、データベースのスキーマ定義をコードで安全に管理・反映する詳しい方法は、Laravel Migrationの使い方完全ガイド|作成・実行・ロールバックとよくあるエラー対処 で解説しています。

よくある質問(FAQ)

Q: find() と lockForUpdate() を同時に使う場合の推奨の書き方は?

Model::lockForUpdate()->find($id) または Model::lockForUpdate()->findOrFail($id) の順序でメソッドチェーンをつなげます。find()を先に呼んでしまうとクエリが即時実行されてしまうため、必ずfind()の前にlockForUpdate()を記述してください。

Q: トランザクション内で例外が発生した場合はどうなりますか?

DB::transaction()のクロージャ内で例外がスローされると、自動的にROLLBACKが実行され、取得されていたロックもすべて解放されます。

まとめ

LaravelのlockForUpdate()は、データの同時更新による競合や不整合を防ぐための必須メソッドです。

  • 必ずDB::transaction()の中で実行する
  • 在庫引き当てや残高更新など、更新を伴う処理で利用する
  • デッドロックを防ぐためロック順序を統一し、必要に応じてDB::transaction(..., 3)でリトライを設定する
  • トランザクション内の処理を最小限にしてロック時間を短く保つ

トランザクション全体の基本やロールバックの設計についてはLaravelトランザクションの基礎と効果的な活用方法を徹底解説、インデックス設計やスキーマ管理はLaravel Migrationの使い方完全ガイド、デッドロック自動再試行や手動制御の完全ガイドはLaravel DBトランザクション完全ガイド|デッドロック自動リトライ(retry)と手動制御の実装、トランザクションの基礎やACID特性についてはLaravel トランザクションの使い方完全ガイド、テストデータの生成と投入はSeeder完全ガイド、Eloquentでの更新全般のベストプラクティスはLaravel Eloquentでデータ更新を効果的に行う方法とベストプラクティスもあわせてご覧ください。

また、大量データのバッチ更新処理で排他ロックや chunkById() を組み合わせる手法については、Laravel大量データ処理の決定版|chunk・chunkById・lazy・cursorの使い分けとメモリ最適化 もあわせてご覧ください。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント