注文は保存されたものの、注文明細がデータベースに書き込まれていない。あるいは、在庫カウンターが減ったのに、決済ゲートウェイがタイムアウトし、顧客のカードには請求が行われたが注文レコードが存在しない、といった状況。これらは、発生するまでは「エッジケース(例外的なケース)」のように思えますが、実際にはそうではありません。負荷の高いアプリケーションでは、これらは日常的な悩みの種となります。

その根本的な原因は、ほとんどの場合同じです。一連のデータベースへの書き込みが、個別の独立したイベントとして扱われていることです。一つが失敗しても、他の書き込みはそのまま残ってしまいます。解決策は「データベース・トランザクション」であり、Laravelにおけるそのツールが DB::transaction() です。

トランザクションが実際に保証するもの

データベース・トランザクションは、複数の操作を単一の「作業単位(unit of work)」として結合します。データベースエンジンは、その内部にあるすべての操作が「完全にコミットされる」か、あるいは「完全にロールバックされる」かのどちらかであることを保証します。中途半端な状態は存在しません。

銀行振込を例に考えてみましょう。システムは一方の口座から引き落とし、もう一方の口座に入金しなければなりません。引き落としの後に、入金に失敗したとしても、お金が虚空に消えてしまうことはありません。銀行は引き落としを取り消します。この取り消しが「ロールバック」です。両方のステップが成功すれば、振込は「コミット」され、新しい残高が永続的に保存されます。

この「全か無か(all-or-nothing)」の挙動こそが、データの整合性を保つ鍵となります。これがないと、一部の失敗によって書き込み途中のレコードがテーブル内に散乱し、それらのレコードは自動的なクリーンアッププロセスが存在しないため、データベース内で「腐ったデータ」として残り続けてしまいます。

Laravelによる実装の仕組み

素のSQLであれば、BEGINCOMMITROLLBACK を自分で記述し、トランザクションが宙ぶらりんの状態にならないよう、考えられるすべてのエラーをキャッチすることを忘れてはなりません。Laravelは、こうした定型的な処理(ボイラープレート)を単一のメソッドにまとめています。

DB::transaction() にクロージャを渡します。Laravelはトランザクションを開始し、コードを実行します。クロージャが例外を投げずに終了すれば、自動的にコミットされます。何らかの失敗が発生した場合、Laravelは例外をキャッチしてすべてをロールバックし、エラーを再スローします。これにより、ロギングやエラーハンドリングが期待通りに機能します。

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

もし外部キーの欠落やデータベース接続の切断によって決済の挿入に失敗した場合、注文、注文明細、および在庫の変更はすべて取り消されます。理由もなく在庫が消えたり、支払いが完了していないのに出荷を要求する注文が残ったりすることはありません。

トランザクションが役立つ場面

一部のワークフローは、トランザクションによる安全性に完全に依存しています。チェックアウトの例が最も分かりやすいですが、このパターンは至る所で見られます。

ユーザー登録。 ユーザー行を作成し、次にプロフィール行、次にデフォルト設定を作成する場合。バリデーションの例外的なケースでプロフィールの挿入に失敗すると、プロフィールを持たないユーザーが「ゴーストアカウント」として残ってしまいます。すべてのユーザーがプロフィールを持っていることを前提としているページは、クラッシュするか、壊れたUIを表示することになります。

一括インポート。 50件のレコードを挿入するCSVアップロードにおいて、26行目の日付形式が不正だったために、25件だけが残ってしまうようなことがあってはなりません。バッチをトランザクションで囲むことで、インポート全体を一つのクリーンな単位として失敗させることができます。管理者は、どの行が部分的に漏れたのかを何時間もかけて探す代わりに、ファイルを修正して再度試行するだけで済みます。

在庫と会計。 あるテーブルが物理的なリソースを追跡し、別のテーブルが金銭やクレジットを追跡している場合、その両者は連動して動かなければなりません。これらを切り離してしまうと、照合できない監査結果や、事実と異なるレポートを招くことになります。

手動で制御する必要がある場合

クロージャによるアプローチでほとんどのケースはカバーできますが、時にはより高度な制御が必要になることがあります。サービスクラス内での複雑な条件分岐や、実行時にコミットするかどうかを決定する必要がある場合、単一のクロージャでは扱いにくく感じることがあります。そのような場合は、自分でトランザクションを管理できます。

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

catch ブロック内の順序に注意してください。まずロールバックし、その後にスローします。ロールバックする前にスローしてしまうと、接続上でトランザクションが開いたままになってしまいます。これは行のロック、他のクエリのデッドロック、あるいはコネクションプールの枯渇を引き起こす可能性があります。手動トランザクションは強力ですが、クリーンアップの責任は開発者に委ねられます。

トランザクション内に副作用を含めない

これは、本番環境でチームを苦しめるルールです。トランザクションが取り消せるのは、データベースの操作だけです。送信済みのメールを取り消したり、クラウドストレージからファイルを削除したり、決済APIを通じて返金したりすることはできません。

もしトランザクションのクロージャ内に Mail::send() の呼び出しを配置し、その2行後にデータベースがロールバックされたとしても、そのメールは顧客の受信トレイに届いてしまいます。受信者は、システム上に存在しない注文の請求書を受け取ることになります。これは、Slack通知、S3へのファイルアップロード、あるいはWebhookの送信についても同様です。

正しい順序は以下の通りです:

  1. トランザクションを完了させ、必要なIDや結果を取得する。
  2. その後に初めて、外部へのサイドエフェクトを実行する。

例えば、確認メールのキューイングはトランザクション内ではなく、コミット後に行います:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

外部呼び出しをトランザクションの外に保つべき、もう一つの理由は「時間」です。トランザクションはロックを保持し、データベース接続を占有します。トランザクション内でStripe APIのレスポンスを3秒間待つということは、3秒間も不要なロック時間を引き延ばしていることになります。トランザクションは、簡潔かつ迅速に保つべきです。

なぜこのバグは潜伏するのか

単一ユーザーでローカルデータベースを使用する開発環境では、関連するインサート処理はほぼ常に成功します。ネットワークは安定しており、ディスク容量が不足することも、競合する負荷もありません。通常は正常に動作するため、コードは正しく見えてしまいます。

本番環境は異なります。2人の顧客が全く同じミリ秒に注文を送信したり、デプロイ中にキューワーカーがジョブの途中で再起動したり、決済プロバイダーが30秒間タイムアウトしたりすることがあります。トランザクションがなければ、これらの事象によって孤立したレコード(orphan records)や不整合な合計値が発生し、その追跡には多大な労力を要します。最悪なのは、標準的な機能テストではこれらをほとんど検出できないことです。なぜなら、失敗のパターンは単純なロジックエラーではなく、タイミングに依存し、インフラストラクチャに紐付いているからです。

修正方法は決して特殊なものではありません。機械的なものです。2つ以上の関連する書き込み操作を見かけたら、それらをトランザクションで囲みます。時間が経てば、リクエストのバリデーションと同じくらい、自動的に行える習慣になるはずです。

反射的な習慣にする

DB::transaction() は複雑さを増すものではありません。むしろ、事後的に不完全なデータをクリーンアップしようとする、隠れた複雑さを取り除いてくれるものです。一連のデータベース操作がひとまとまりであるならば、最初からそのように扱いましょう。そうすることで、アプリケーションは高負荷時でも整合性を保ち、エラーログはクリーンなまま維持され、データベースが中途半端なレコードの墓場になることも防げます。