Laravel DBトランザクション完全ガイド|デッドロック自動リトライ(retry)と手動制御の実装

Laravel入門
Laravel入門基本文法・構文ガイド実装・応用テクニック運用・保守・セキュリティ

Laravelでデータベースを操作する際、データの整合性と堅牢性を担保するために極めて重要な仕組みが「DBトランザクション(Transaction)」です。

特にECサイトの注文決済、残高の送金引き落とし、チケット予約、在庫の引き当てといった「複数テーブルへの書き込みや残高計算を伴うクリティカルな処理」では、処理の途中でエラーが発生した際にすべての変更を確実に巻き戻す(ロールバック)必要があります。さらに、アクセスが集中した本番環境では、複数のトランザクションが互いにリソースを奪い合う「デッドロック(Deadlock)」が実務上で頻繁に発生します。

📌 本記事でわかること

  • DB::transaction() による自動コミット・ロールバックの動作原理と戻り値の活用
  • デッドロックが発生する原因(排他ロック競合)と、第2引数 attempts による自動再試行
  • retry() ヘルパー関数を使った指数バックオフ・特定例外フィルタリングの実装
  • 手動トランザクション(beginTransaction / commit / rollBack)の堅牢な例外ハンドリング構文
  • lockForUpdate()(悲観的排他ロック)とトランザクション分離レベルを意識した決済・在庫引当コード
  • リードレプリカ(Primary/Replica構成)における Sticky Connection(書き込み優先)の挙動
  • DB::afterCommit() を使ったロールバック時の通知誤送信(幽霊通知)防止
  • DDL(CREATE/ALTER)による暗黙的コミットの罠とテスト手法

本記事では、Laravelにおけるトランザクションの基本構文から、デッドロックの発生メカニズムと自動再試行、手動制御の安全な設計、排他ロック連携、現場で避けるべきアンチパターンまで、実務コードとともに網羅的に解説します。

  1. 【結論】Laravelトランザクション実装パターン早見表
  2. 1. DB::transaction()クロージャの基本と動作原理
    1. 基本構文と自動コミット・ロールバックの仕組み
    2. トランザクション内での戻り値(Return value)の受け取り方
    3. ネストされたトランザクションとセーブポイント(Savepoint)
    4. リードレプリカ構成(Primary/Replica)とSticky Connection
  3. 2. 【超重要】デッドロック自動再試行(リトライ)の実装手法
    1. なぜデッドロックが発生するのか?(複数トランザクションの競合メカニズム)
    2. DB::transaction(callable, attempts) の第2引数を使った自動リトライ
    3. retry() ヘルパー関数を使った高度なリトライ制御
      1. retry() 関数のシグネチャ
      2. 実践コード:指数バックオフ・ジッター・特定例外フィルタリング
  4. 3. 手動トランザクション(beginTransaction / commit / rollBack)の書き方
    1. try-catch-finally を用いた堅牢な例外ハンドリング構文
    2. ロールバック漏れを防ぐ注意点とアンチパターン
    3. 長寿命プロセス(Queueワーカー・Octane)での接続リーク対策
  5. 4. 排他ロック(lockForUpdate)と組み合わせた決済・在庫引当の実践例
    1. 残高引き落とし・在庫減算処理の完全コード例
    2. トランザクション分離レベル(Isolation Level)の考慮事項
  6. 5. トランザクション内でやってはいけない注意点
    1. 外部API呼び出しや重い処理をトランザクション内に入れない(ロック保持時間の最小化)
    2. メール送信やジョブディスパッチの注意点(DB::afterCommit() の必須活用)
      1. DB::afterCommit() コールバックの利用
      2. JobクラスやMailableでの afterCommit 設定
    3. DDL実行による「暗黙のコミット(Implicit Commit)」に注意
  7. 6. よくある質問(FAQ)
    1. Q1. トランザクションとEloquentモデルイベントの実行順序はどうなりますか?
    2. Q2. PHPUnitやPestでトランザクション処理をテストするには?
  8. 7. まとめ・関連記事リンク
    1. Laravelデータベース操作の関連記事
      1. 🔒 Laravel lockForUpdate()の使い方|排他ロック(悲観的ロック)とトランザクションの実装・注意点
      2. 📘 Laravel トランザクションの使い方完全ガイド|DB::transactionの自動コミット・手動ロールバック・デッドロック再試行
      3. 🔗 Laravel Eloquentリレーション全種類まとめ|hasMany・belongsToの違いと正しい定義方法を徹底解説
      4. ⚡ Laravel キュー(Queue)と非同期処理の実装完全ガイド|database設定・Job作成・dispatchからワーカー常駐まで徹底解説
      5. 🌱 【Laravel】Seeder完全ガイド|作成・実行・Factory連携から本番運用の注意点まで
  9. 関連記事

【結論】Laravelトランザクション実装パターン早見表

Laravelにはトランザクションを扱うための複数のアプローチが用意されています。用途や要件に応じて最適な手法を選択できるよう、各パターンの特徴を整理しました。

実装パターン 自動コミット/ロールバック デッドロック再試行 主な用途・推奨場面
DB::transaction($callback) 完全自動
(Throwable検知でロールバック)
なし(1回のみ実行) 通常のCRUD処理、複数モデルの一括更新(推奨度 No.1)
DB::transaction($callback, $attempts) 完全自動 自動(指定回数まで) 在庫引当、決済、残高更新などロック競合が起きやすい処理
retry($attempts, $callback, $sleep) 併用 DB::transaction 内で自動 高度な制御(間隔・条件指定) マイクロ秒単位のウェイト調整や特定エラーコードのみ再試行したい場合
手動制御(beginTransaction 等) 手動
(try-catch-finallyが必須)
手動(ループ等で独自実装) 例外を投げずに条件分岐でロールバックしたい場合、メソッドを跨ぐ制御
DB::afterCommit($callback) コミット成功後のメール送信、通知、キュー投入の安全な実行

基本原則として、9割以上のユースケースでは DB::transaction() を使用するのがベストプラクティス です。手動制御はコミットやロールバックの記述漏れによる接続リークを引き起こすリスクがあるため、特別な要件がある場合に限定して利用します。

1. DB::transaction()クロージャの基本と動作原理

Laravelで最も安全かつ標準的なトランザクションの書き方は、Illuminate\Support\Facades\DB ファサードの transaction() メソッドにクロージャ(無名関数)を渡す方法です。

基本構文と自動コミット・ロールバックの仕組み

DB::transaction() にクロージャを渡すと、フレームワーク内部で以下のライフサイクルが厳密に実行されます。

  1. クロージャ開始時にデータベース接続に対して BEGIN(トランザクション開始)を発行
  2. クロージャ内のコードを順次実行
  3. クロージャ内で \Throwable(すべての例外およびエラー)がスローされた場合、即座に ROLLBACK を実行し、例外を上位へ再スロー
  4. 例外が発生せずにクロージャが正常終了した場合、自動的に COMMIT を実行して変更を確定
use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;

// DB::transaction による自動トランザクション制御
$order = DB::transaction(function () use ($userId, $items) {
    // 1. 注文ヘッダーの作成
    $order = Order::create([
        'user_id' => $userId,
        'status'  => 'pending',
        'total'   => 0,
    ]);

    $total = 0;

    // 2. 注文明細の作成と在庫減算
    foreach ($items as $item) {
        $product = Product::findOrFail($item['product_id']);

        if ($product->stock < $item['quantity']) {
            // 例外をスローすると、Orderの作成を含めて自動的に全ロールバックされる
            throw new \DomainException("商品 [{$product->name}] の在庫が不足しています。");
        }

        $product->decrement('stock', $item['quantity']);

        $subtotal = $product->price * $item['quantity'];
        $total += $subtotal;

        OrderItem::create([
            'order_id'   => $order->id,
            'product_id' => $product->id,
            'quantity'   => $item['quantity'],
            'unit_price' => $product->price,
            'subtotal'   => $subtotal,
        ]);
    }

    // 3. 注文合計金額の更新
    $order->update(['total' => $total, 'status' => 'confirmed']);

    // 戻り値は DB::transaction() の戻り値として返却される
    return $order;
});
💡 なぜ手動ロールバックが不要なのか?
クロージャ方式では、内部で try-catch が組まれており、PHPのあらゆるスロー可能オブジェクト(\Throwable)を捕捉します。開発者が明示的に DB::rollBack() を呼ぶ必要がなく、構文エラーや予期せぬ実行時エラーが発生してもデータベースに不完全なデータが残る心配がありません。

トランザクション内での戻り値(Return value)の受け取り方

クロージャ内で return $order; のように返した値は、そのまま DB::transaction() メソッドの戻り値として外側の変数に格納されます。作成されたモデルインスタンス、採番されたID、あるいは処理結果を表すDTO(Data Transfer Object)などを直接呼び出し元に渡すことができます。

// コントローラー等での利用例
public function store(CreateOrderRequest $request, OrderService $orderService)
{
    try {
        $order = DB::transaction(function () use ($request, $orderService) {
            return $orderService->execute($request->validated());
        });

        return response()->json([
            'message' => '注文が正常に完了しました。',
            'order_id' => $order->id,
        ], 201);

    } catch (\DomainException $e) {
        // 在庫不足などの業務例外
        return response()->json(['error' => $e->getMessage()], 422);

    } catch (\Throwable $e) {
        // その他のシステム例外
        Log::error('注文処理エラー', ['exception' => $e]);
        return response()->json(['error' => 'システムエラーが発生しました。'], 500);
    }
}

ネストされたトランザクションとセーブポイント(Savepoint)

業務ロジックが共通化されていると、あるトランザクションの中からさらに別のトランザクションを呼び出す「ネスト(入れ子)」が発生することがあります。

リレーショナルデータベース自体は通常、真のネストトランザクションをサポートしていませんが、Laravelは内部でセーブポイント(Savepoint)を利用して擬似的にネストを処理します。

DB::transaction(function () {
    // トランザクション レベル 1 (BEGIN)
    User::create([...]);

    DB::transaction(function () {
        // トランザクション レベル 2 (SAVEPOINT trans2)
        Profile::create([...]);
    });
    // レベル 2 完了 (RELEASE SAVEPOINT trans2)
});
// レベル 1 完了 (COMMIT)

現在のネストレベルは DB::transactionLevel() で取得できます。レベルが 0 であればトランザクション外、1 であれば最上位トランザクション、2 以上であればセーブポイントによるネスト状態であることを示します。

リードレプリカ構成(Primary/Replica)とSticky Connection

データベースで読み取り専用レプリカ(Read Replica)と書き込み用プライマリ(Primary/Writer)を分離して運用している環境において、Laravelは config/database.php の設定に基づき自動的にクエリを振り分けます。

トランザクションが開いている間、Laravelは自動的に「Sticky Connection(固定接続)」モードへと移行します。トランザクション内で実行された SELECT クエリはレプリカではなく、必ずプライマリ(Primary)データベースへと送信されます。これにより、レプリケーション遅延によって直前に書き込んだ自トランザクションのデータが読み取れないという致命的な不整合を完全に防止します。

2. 【超重要】デッドロック自動再試行(リトライ)の実装手法

高トラフィックなWebアプリケーションにおいて、避けて通れない最大の課題が「デッドロック(Deadlock)」です。

なぜデッドロックが発生するのか?(複数トランザクションの競合メカニズム)

デッドロックとは、「2つ以上のトランザクションが、互いに相手が保持している行ロック(Row Lock)の解放を待ち合ってしまい、処理が永久に進まなくなる状態」を指します。

RDBMS(MySQL InnoDBやPostgreSQL)はデッドロックを検知すると、処理への影響が小さい側のトランザクションを「犠牲者(Victim)」として強制終了し、エラー(MySQLエラー番号 1213: Deadlock found when trying to get lock; try restarting transaction、SQLSTATE 40001)をスローします。

【デッドロック発生の典型例:A社とB社の口座間送金】

[トランザクション 1 (A社からB社へ送金)]          [トランザクション 2 (B社からA社へ送金)]
① 口座Aをロックして残高減算                    ① 口座Bをロックして残高減算
   (口座Aの排他ロックを保持)                     (口座Bの排他ロックを保持)
          │                                                                  │
② 口座Bをロックしようとする                    ② 口座Aをロックしようとする
   (T2がロック中のため待機状態へ)                   (T1がロック中のため待機状態へ)
          └───> 互いに解放待ち(循環依存) <───┘
                💥 【MySQL検知】どちらか一方を強制エラー終了!

デッドロックはシステムの設計不備だけでなく、アクセス集中による同時実行タイミングによっても必然的に発生します。したがって、「ロック取得順序を統一してデッドロックの発生頻度を下げつつ、発生時には安全に自動再試行する仕組み」を実装することが不可欠です。

DB::transaction(callable, attempts) の第2引数を使った自動リトライ

Laravelの DB::transaction() は、第2引数に最大試行回数(attempts)を指定できます。デフォルトは 1(再試行なし)ですが、ここに 35 を指定するだけで、デッドロック発生時に自動でロールバックと再実行を行ってくれます。

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

// デッドロックが発生した場合、最大5回まで自動で最初から再試行する
$result = DB::transaction(function () use ($fromAccountId, $toAccountId, $amount) {
    // デッドロック防止のため、IDの昇順でロックを取得する
    $ids = collect([$fromAccountId, $toAccountId])->sort()->values();

    $accounts = Account::whereIn('id', $ids)
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    $fromAccount = $accounts->get($fromAccountId);
    $toAccount   = $accounts->get($toAccountId);

    if ($fromAccount->balance < $amount) {
        throw new \DomainException('残高が不足しています。');
    }

    $fromAccount->decrement('balance', $amount);
    $toAccount->increment('balance', $amount);

    return true;
}, 5); // 第2引数: 最大試行回数(attempts)
⚠️ 第2引数 attempts がリトライする条件
Laravel内部の DetectsDeadlocks トレイトにより、発生した例外が「デッドロック」または「ロックタイムアウト」であると判定された場合のみ再試行が行われます。DomainException や通常のバリデーションエラー、型エラーなどの場合は再試行されず、即座に例外がスローされます。

retry() ヘルパー関数を使った高度なリトライ制御

DB::transaction() の第2引数はシンプルで強力ですが、「再試行までのスリープ時間を徐々に伸ばしたい(指数バックオフ)」「特定のエラーコードのみ再試行したい」といった細かい制御はできません。

より柔軟な制御が必要な場合は、Laravel組み込みの retry() ヘルパー関数DB::transaction() を組み合わせます。

retry() 関数のシグネチャ

retry(
    int|array $times,                   // 試行回数、または各リトライ間隔の配列(ミリ秒)
    callable $callback,                 // 実行する処理
    int|Closure $sleepMilliseconds = 0, // スリープ時間(ミリ秒)
    callable $when = null               // リトライを許可する判定クロージャ
)

実践コード:指数バックオフ・ジッター・特定例外フィルタリング

use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Models\Inventory;

$result = retry(
    times: 5,
    callback: function (int $attempt) use ($productId, $quantity) {
        // 各試行ごとに新しいトランザクションを開始する
        return DB::transaction(function () use ($productId, $quantity) {
            $inventory = Inventory::where('product_id', $productId)
                ->lockForUpdate()
                ->firstOrFail();

            if ($inventory->available_quantity < $quantity) {
                // 業務例外:リトライ対象外にしたい
                throw new \DomainException('在庫がありません。');
            }

            $inventory->decrement('available_quantity', $quantity);
            return $inventory;
        });
    },
    // スリープ間隔(ミリ秒):指数バックオフ + ジッター(ランダム揺らぎ)
    // 1回目: 約100ms, 2回目: 約200ms, 3回目: 約400ms...
    sleepMilliseconds: function (int $attempt) {
        $baseDelay = (int) (pow(2, $attempt - 1) * 100);
        $jitter = random_int(10, 50); // 同時実行時の衝突再発を防ぐランダム揺らぎ
        return $baseDelay + $jitter;
    },
    // リトライ判定:デッドロック(SQLSTATE 40001 または エラーコード 1213)のみ再試行
    when: function (\Throwable $e) {
        if ($e instanceof QueryException) {
            $errorCode = $e->errorInfo[1] ?? null;
            $sqlState  = $e->errorInfo[0] ?? null;

            // MySQL: 1213 (Deadlock), 1205 (Lock wait timeout)
            // PostgreSQL: 40001 (serialization_failure), 40P01 (deadlock_detected)
            return in_array($errorCode, [1213, 1205], true) || in_array($sqlState, ['40001', '40P01'], true);
        }

        // DomainException や ValidationException は即座に終了(リトライしない)
        return false;
    }
);

このパターンを採用することで、ネットワークやデータベースの瞬間的な負荷スパイクを安全にやり過ごし、APIの成功率を劇的に向上させることができます。

3. 手動トランザクション(beginTransaction / commit / rollBack)の書き方

クロージャによる自動制御ではなく、開発者がプログラム側で明示的にトランザクションのスコープを制御したい場合には、手動トランザクションメソッドを使用します。

try-catch-finally を用いた堅牢な例外ハンドリング構文

手動トランザクションを実装する際は、「例外が発生したら必ず rollBack() を呼ぶ」「トランザクション状態の残留を防ぐ」 ための定石パターンを守る必要があります。

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

// 1. トランザクション開始
DB::beginTransaction();

try {
    // 処理1: ユーザー情報の更新
    $user->update(['status' => 'active']);

    // 処理2: 決済レコードの記録
    $payment = $user->payments()->create([
        'amount' => $amount,
        'status' => 'completed',
    ]);

    // 処理3: 外部連携や特殊な検証(失敗時は意図的に例外を投げる)
    if (! $this->fraudCheckService->verify($payment)) {
        throw new \DomainException('不正利用の疑いがあるため処理を中止しました。');
    }

    // すべて成功したらコミット
    DB::commit();

} catch (\Throwable $e) {
    // 例外発生時は確実にロールバック
    DB::rollBack();

    Log::error('手動トランザクション処理失敗', [
        'user_id'   => $user->id,
        'exception' => $e->getMessage(),
    ]);

    // 必要に応じて上位層へ再スロー
    throw $e;
}
🚨 catch では Exception ではなく \Throwable を指定する
PHP 7以降、重大な構文エラー、型エラー(TypeError)、ゼロ除算(DivisionByZeroError)などは \Error クラスとしてスローされます。catch (\Exception $e) と書いているとこれらのエラーを捕捉できず、ロールバックされないままトランザクションが開いた状態(接続リーク) が残ってしまいます。手動制御では必ず catch (\Throwable $e) を指定してください。

ロールバック漏れを防ぐ注意点とアンチパターン

手動トランザクションで現場でよく起きる事故が、途中で returnbreak してしまいコミットもロールバックも呼ばれないケース です。

// ❌ 危険なアンチパターン:途中のreturnでトランザクションが開きっぱなしになる
DB::beginTransaction();

try {
    $user->update([...]);

    if ($condition) {
        // ここでreturnすると、commit() も rollBack() も実行されない!
        // DB接続プールのコネクションがトランザクションを抱えたままになり、以後の別リクエストを巻き込んでロック多発
        return false;
    }

    DB::commit();
    return true;
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

条件分岐で処理を中断したい場合でも、必ず DB::rollBack() を呼んでから return するか、例外をスローして catch ブロックに集約させる設計にしてください。

長寿命プロセス(Queueワーカー・Octane)での接続リーク対策

Laravel Octaneや常駐型キューワーカー(queue:work)のような長寿命プロセス(Long-running process)環境では、1つのトランザクションがロールバック漏れを起こすと、その後のジョブやWebリクエストすべてにトランザクションが波及し、システム全体が連鎖障害に陥ります。

手動トランザクションを多用するシステムでは、以下のように finally 節でトランザクションレベルをチェックし、残留していたら強制ロールバックする安全策が有効です。

DB::beginTransaction();

try {
    // 業務処理
    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
} finally {
    // 安全装置:万が一コミットもロールバックもされずに通過した場合の回収
    if (DB::transactionLevel() > 0) {
        Log::critical('トランザクションリークを検知して強制ロールバックしました。');
        DB::rollBack();
    }
}

4. 排他ロック(lockForUpdate)と組み合わせた決済・在庫引当の実践例

トランザクションを開くだけでは、「他のトランザクションによる同じレコードの読み取り・重複更新(レースコンディション)」 は防げません。

例えば、残り1点の在庫に対して2人のユーザーが同時に購入リクエストを送った場合、両方のトランザクションが「在庫あり(1点)」を読み取ってしまい、両方とも在庫を減算して在庫数がマイナスになる問題(二重注文)が発生します。

このデータ競合を確実に防ぐため、トランザクション内で lockForUpdate()(悲観的排他ロック / SELECT ... FOR UPDATE を併用します。

残高引き落とし・在庫減算処理の完全コード例

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

class CheckoutService
{
    /**
     * 商品購入とウォレット残高引き落としを実行する
     */
    public function processCheckout(int $userId, int $productId, int $quantity): Order
    {
        // デッドロック対策として attempts に 3 を指定
        return DB::transaction(function () use ($userId, $productId, $quantity) {
            
            // 1. デッドロックを防ぐため、常に決まった順序(例: ウォレット -> 商品)でロックを取得する
            $wallet = Wallet::where('user_id', $userId)
                ->lockForUpdate()
                ->firstOrFail();

            // 2. 商品レコードの排他ロック取得(他トランザクションはコミットまで待機)
            $product = Product::where('id', $productId)
                ->lockForUpdate()
                ->firstOrFail();

            // 3. 在庫の厳密な検証
            if ($product->stock < $quantity) {
                throw new \DomainException("商品 [{$product->name}] の在庫が不足しています(残数: {$product->stock})。");
            }

            $totalAmount = $product->price * $quantity;

            // 4. 残高の厳密な検証
            if ($wallet->balance < $totalAmount) {
                throw new \DomainException("ウォレット残高が不足しています(残高: {$wallet->balance}円 / 必要額: {$totalAmount}円)。");
            }

            // 5. 在庫と残高の減算
            $product->decrement('stock', $quantity);
            $wallet->decrement('balance', $totalAmount);

            // 6. 注文レコードの確定
            $order = Order::create([
                'user_id'      => $userId,
                'product_id'   => $productId,
                'quantity'     => $quantity,
                'total_amount' => $totalAmount,
                'status'       => 'completed',
            ]);

            return $order;
        }, 3);
    }
}

トランザクション分離レベル(Isolation Level)の考慮事項

排他ロックの挙動やデッドロックの発生頻度は、データベースのトランザクション分離レベル(Isolation Level)に大きく依存します。

分離レベル ダーティリード 反復不能読み取り ファントムリード 特徴とLaravelでの考慮点
READ UNCOMMITTED 発生する 発生する 発生する 未確定データを読むため実務では非推奨
READ COMMITTED 防止 発生する 発生する PostgreSQLのデフォルト。ギャップロックが少なくデッドロックが起きにくい
REPEATABLE READ 防止 防止 InnoDBでは防止 MySQL(InnoDB)のデフォルト。ギャップロック(Gap Lock)が発生するため、インデックスのない検索でデッドロックが起きやすい
SERIALIZABLE 防止 防止 防止 完全な直列化。競合時に即座にロックやエラーが発生し並行性が著しく低下
💡 MySQL InnoDBにおける「ギャップロック」の罠
MySQLのデフォルトである REPEATABLE READ では、lockForUpdate() を実行した際に検索条件(WHERE句)にインデックスが存在しない場合、レコードだけでなくテーブル全体や行間の隙間(ギャップ)までロック してしまいます。これにより全く無関係なユーザーのINSERT処理までブロックされ、デッドロックが頻発する原因になります。排他ロックをかけるクエリでは、必ず主キー(PRIMARY KEY)またはユニークインデックスを持つカラムを指定してください。

5. トランザクション内でやってはいけない注意点

トランザクションは強力なデータ保護機能ですが、設計を誤るとシステム全体のパフォーマンス低下や重大な不具合を引き起こします。特に注意すべき2大原則と陥りがちな罠を解説します。

外部API呼び出しや重い処理をトランザクション内に入れない(ロック保持時間の最小化)

データベースのトランザクションを開いている間、接続プールからコネクションが1本専有され、ロックされたレコードは他のトランザクションをブロックし続けます。

このトランザクションの内部に、Stripeなどの外部決済API呼び出し、外部サービスへのHTTPリクエスト、AWS S3へのファイルアップロード、巨大CSVのパース処理などを入れてはいけません。

❌ NGパターン:API通信を内包

DB::transaction(function () {
    // 1. 在庫を減算(ロック発生)
    $product->decrement('stock', 1);

    // 2. 外部決済API呼び出し(2〜5秒待機)
    // この間、商品の行ロックが解放されない!
    $charge = $stripe->charges->create([...]);

    // 3. 注文保存
    Order::create([...]);
});

※APIが遅延するとDBコネクションが枯渇し、サイト全体が504タイムアウトでダウンします。

⭕ OKパターン:トランザクションを極小化

// 1. トランザクション外でAPI呼び出し
$charge = $stripe->charges->create([...]);

// 2. 成功した結果のみを高速にDBコミット
DB::transaction(function () use ($charge) {
    $product->decrement('stock', 1);
    Order::create(['charge_id' => $charge->id]);
});
// DBロック保持時間はわずか数ミリ秒!

※万が一DB保存に失敗した場合は、補償トランザクション(Stripe返金API)を実行します。

メール送信やジョブディスパッチの注意点(DB::afterCommit() の必須活用)

トランザクション内部でメール送信やキューへのジョブ投入(dispatch())を行うと、「データベースの処理はエラーでロールバックされたのに、購入完了メールだけがユーザーに届いてしまう(幽霊通知)」 という致命的なトラブルが発生します。

また、キューワーカーがジョブを取り出して処理を開始した瞬間に、元のトランザクションがまだコミット完了しておらず、データベースから対象レコードが見つからない(ModelNotFoundException)というレースコンディションも頻発します。

DB::afterCommit() コールバックの利用

この問題を根本から解決するのが DB::afterCommit() です。トランザクションが完全にコミットされた直後のみ指定した処理が実行されます。

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderCompletedMail;
use App\Jobs\SendWebhookNotification;

DB::transaction(function () use ($userId, $items) {
    $order = Order::create([...]);

    // トランザクションが正常にコミットされた場合のみ実行される
    DB::afterCommit(function () use ($order) {
        // メール送信
        Mail::to($order->user->email)->send(new OrderCompletedMail($order));
        // 非同期ジョブのディスパッチ
        SendWebhookNotification::dispatch($order->id);
    });

    // ここで例外が発生してロールバックされた場合、上記 afterCommit は破棄される!
    $this->paymentService->charge($order);
});

JobクラスやMailableでの afterCommit 設定

Laravelでは、個別のジョブやMailableクラス自体に「コミット完了まで待機する」設定を宣言することも可能です。

// 1. Jobクラスのプロパティで宣言
class ProcessPaymentWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // トランザクションのコミット後にのみワーカーへ送信
    public $afterCommit = true;
}

// 2. Mailableの送信時にメソッドチェーンで指定
Mail::to($user->email)->afterCommit()->send(new OrderCompletedMail($order));

DDL実行による「暗黙のコミット(Implicit Commit)」に注意

MySQLなどのRDBMSでは、トランザクションの途中で CREATE TABLEALTER TABLEDROP TABLETRUNCATE などのデータ定義言語(DDL)を実行すると、データベースが自動的にそれまでの変更を強制コミット(暗黙のコミット) してしまいます。

以後にエラーが発生して rollBack() を呼んでも、DDL以前のDML変更は巻き戻せません。マイグレーションスクリプトや動的なテーブル操作を伴う処理では、DDLをトランザクションで囲まないよう注意が必要です。

6. よくある質問(FAQ)

Q1. トランザクションとEloquentモデルイベントの実行順序はどうなりますか?

Eloquentの savingcreatedupdated などのモデルイベントは、データベースのトランザクションがコミットされるに発火します。そのため、created イベントリスナー内で例外が投げられた場合、その作成処理も含めてトランザクション全体がロールバックされます。モデルイベント内で外部通知やキュー投入を行う場合は、afterCommit プロパティを指定するか DB::afterCommit() 内に記述する必要があります。

Q2. PHPUnitやPestでトランザクション処理をテストするには?

Laravelのテスト環境では RefreshDatabase または DatabaseTransactions トレイトを使用します。各テストケースが自動的にトランザクション内で実行され、テスト終了時にロールバックされるため、データベースを汚さずに高速なテストが可能です。

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CheckoutTest extends TestCase
{
    use RefreshDatabase;

    public function test_checkout_decrements_stock_and_wallet(): void
    {
        // テストコード...
    }
}

7. まとめ・関連記事リンク

LaravelのDBトランザクションと排他制御を安全に設計・運用するためのチェックリストをまとめました。

✅ 安全なトランザクション設計チェックリスト

  • 自動制御を第一選択に:原則として DB::transaction() を使い、手動制御(beginTransaction)は特殊な要件に限定する。
  • デッドロック自動再試行:排他ロックや複数レコード更新を伴う処理には DB::transaction($callback, 3)retry() で自動リトライを組み込む。
  • ロック順序の統一:複数レコードをロックする際は、常にID昇順など統一された順序で lockForUpdate() を発行する。
  • トランザクションスコープの極小化:外部API呼び出しや重いファイル処理はトランザクションの外に出し、DB接続とロックの保持時間を最小にする。
  • 通知・キューのafterCommit化:ロールバック時の幽霊通知やワーカーの未コミット参照を防ぐため、DB::afterCommit()$afterCommit = true を徹底する。
  • 手動制御時の \Throwable キャッチ:手動で try-catch を書く際は、\Exception ではなく必ず \Throwable を捕捉して DB::rollBack() する。

Laravelデータベース操作の関連記事

データベース操作のパフォーマンス向上やデータ競合対策について、さらに深く学びたい方は以下の関連記事もあわせてご確認ください。

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

SELECT … FOR UPDATEによる悲観的排他ロックの基本構文、sharedLock(共有ロック)との違い、二重注文防止の実装コードを詳しく解説しています。

📘 Laravel トランザクションの使い方完全ガイド|DB::transactionの自動コミット・手動ロールバック・デッドロック再試行

トランザクションの基礎概念からACID特性、PHPUnit/PestでのRefreshDatabaseによるテスト手法まで、基礎から体系的に確認できます。

🔗 Laravel Eloquentリレーション全種類まとめ|hasMany・belongsToの違いと正しい定義方法を徹底解説

1対多、多対多、ポリモーフィックリレーションなど、トランザクション内で関連モデルを一括保存する際の基礎知識を網羅しています。

⚡ Laravel キュー(Queue)と非同期処理の実装完全ガイド|database設定・Job作成・dispatchからワーカー常駐まで徹底解説

トランザクション完了後に安全に非同期ジョブをディスパッチする設計や、Supervisorによるワーカー常駐運用の詳細を解説しています。

🌱 【Laravel】Seeder完全ガイド|作成・実行・Factory連携から本番運用の注意点まで

並行処理や排他ロックの負荷検証に必要な大量テストデータをFactoryとSeederで瞬時に準備する手順を解説しています。

レン (Wren)

こんにちは。レンです。

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

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

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

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

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

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

コメント