【Laravel】トランザクション(DB::transaction)でデータ整合性を守る

laravelアイキャッチ Laravel

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

前回はページネーションの実装方法を解説しました。

今回は、複数のテーブルを更新する処理で必ず意識しておきたい「トランザクション」を扱います。

「一部だけ更新が成功して、一部だけ失敗する」という状態は、データベースの整合性を壊す典型的な原因です。

LaravelのDB::transaction()を使えば、この問題をシンプルに防げます。

トランザクションとは何か

なぜ複数テーブルの更新で問題が起きるのか

「注文を作成すると同時に、在庫数を減らす」というよくある処理を例に考えてみましょう。

<?php

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

// 在庫を1つ減らす
$product = Product::find($productId);
$product->stock -= 1;
$product->save();

一見問題なさそうですが、注文の作成に成功した直後、在庫更新のタイミングで通信エラーやサーバーダウンが起きたらどうなるでしょうか。

「注文レコードだけ存在するのに、在庫は減っていない」という中途半端な状態がデータベースに残ってしまいます。

こうしたデータ不整合は、後から手動で気づくのが難しく、発覚したときには売上や在庫数がずれた状態で運用が続いてしまっているケースも珍しくありません。

トランザクションの役割

トランザクションとは、複数のデータベース処理を「ひとまとまりの操作」として扱う仕組みです。

トランザクションの中の処理がすべて成功した場合だけ、変更内容がデータベースに反映されます。

途中でエラーが起きた場合は、それまでの変更もすべて取り消され(ロールバック)、処理を始める前の状態に戻ります。

つまり「全部成功する」か「全部失敗して何も変わらない」かの二択にできるのが、トランザクションの最大のメリットです。

DB::transaction()の基本的な使い方

クロージャを使った書き方

LaravelではDB::transaction()にクロージャを渡すだけで、トランザクションを簡単に扱えます。

<?php

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

DB::transaction(function () use ($userId, $productId) {
    Order::create([
        'user_id' => $userId,
        'product_id' => $productId,
    ]);

    $product = Product::find($productId);
    $product->decrement('stock');
});

クロージャの中に書いた処理は、すべて同じトランザクション内で実行されます。

途中で例外(Exception)が発生すると、Laravelが自動的にロールバックを行い、Order::create()で作られたレコードも含めて、すべての変更が取り消されます。

例外が発生しなければ、クロージャの処理が終わった時点で自動的にコミット(変更の確定)が行われます。

手動でロールバックを制御する

在庫が足りない場合など、業務ロジック上の理由で処理を中断したいときは、クロージャの中で例外を投げるだけでロールバックが実行されます。

<?php

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

DB::transaction(function () use ($userId, $productId) {
    $product = Product::find($productId);

    if ($product->stock <= 0) {
        throw new Exception('在庫がありません');
    }

    Order::create([
        'user_id' => $userId,
        'product_id' => $productId,
    ]);

    $product->decrement('stock');
});

throw new Exception(...)を書くだけで、それより前に行われた変更もすべてロールバックされます。

「DBを直接操作してロールバックする」といった処理を自分で書く必要がない点が、DB::transaction()を使う大きなメリットです。

つまずきやすいポイント

❌ Before:try-catchで例外を握りつぶしてしまう

私が実際にやってしまった失敗が、トランザクション内の例外をtry-catchで握りつぶしてしまうパターンです。

<?php

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

DB::transaction(function () use ($userId, $productId) {
    try {
        $product = Product::find($productId);

        if ($product->stock <= 0) {
            throw new Exception('在庫がありません');
        }

        Order::create([
            'user_id' => $userId,
            'product_id' => $productId,
        ]);

        $product->decrement('stock');
    } catch (Exception $e) {
        // ここでログだけ出して処理を続けてしまっていた
        logger()->error($e->getMessage());
    }
});

このコードは、catchブロックで例外を捕まえてログに出すだけで処理を止めていません。

DB::transaction()にとっては「クロージャがエラーなく最後まで実行された」ことになるため、ロールバックされずにコミットされてしまいます

在庫チェックで弾いたはずなのに、なぜか注文レコードだけが作られてしまう不具合に気づくまで、原因を探るのに丸1日かかりました。

✅ After:例外は再スローしてトランザクションに伝える

<?php

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

DB::transaction(function () use ($userId, $productId) {
    try {
        $product = Product::find($productId);

        if ($product->stock <= 0) {
            throw new Exception('在庫がありません');
        }

        Order::create([
            'user_id' => $userId,
            'product_id' => $productId,
        ]);

        $product->decrement('stock');
    } catch (Exception $e) {
        logger()->error($e->getMessage());
        throw $e; // ロールバックさせるために必ず再スローする
    }
});

ログを残したい場合でも、catchブロックの最後で必ずthrow $e;をして例外を上位に伝える必要があります。

トランザクション内でtry-catchを使うときは、「例外を握りつぶしていないか」を必ず確認する癖をつけましょう。

トランザクションの応用

手動制御(beginTransaction / commit / rollBack)

クロージャの中で条件分岐が複雑になり、途中で処理を中断したい場合は、手動でトランザクションを制御する方法もあります。

<?php

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

DB::beginTransaction();

try {
    $product = Product::find($productId);

    if ($product->stock <= 0) {
        DB::rollBack();
        return response()->json(['message' => '在庫がありません'], 422);
    }

    Order::create([
        'user_id' => $userId,
        'product_id' => $productId,
    ]);

    $product->decrement('stock');

    DB::commit();
} catch (\Exception $e) {
    DB::rollBack();
    throw $e;
}

DB::beginTransaction()でトランザクションを開始し、正常終了時はDB::commit()、異常時はDB::rollBack()を明示的に呼び出します。

「エラーレスポンスを返しつつロールバックもしたい」というような、クロージャの戻り値だけでは表現しにくい制御が必要な場合に向いています。

ただし、commit()rollBack()の呼び出し漏れが起きやすいというデメリットもあるため、基本はDB::transaction()のクロージャ形式を優先し、どうしても細かい制御が必要な場合だけ手動制御を選ぶのがおすすめです。

リトライ回数を指定する

デッドロックのような一時的なエラーに備えて、DB::transaction()の第2引数にリトライ回数を指定することもできます。

<?php

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    // 何らかの更新処理
}, 3); // デッドロック時に最大3回まで自動リトライ

アクセスが集中する処理(在庫の減算など)では、こうしたリトライ設定を入れておくと、一時的な競合によるエラーを自動で吸収できます。

まとめ

この記事のポイント

  • トランザクションは複数のDB操作を「全部成功」か「全部失敗」のどちらかにまとめる仕組み
  • DB::transaction()にクロージャを渡すだけで、例外発生時に自動でロールバックされる
  • クロージャ内でtry-catchを使う場合、例外を握りつぶすとロールバックされずコミットされてしまうため、必ず再スローする
  • 細かい制御が必要な場合はbeginTransaction/commit/rollBackによる手動制御も選べる

次に読むべき記事

データの整合性を守る方法がわかったところで、次は削除処理を安全に扱う「ソフトデリート」を見ていきましょう。

→ 次の記事:ソフトデリート(SoftDeletes)の使い方

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