📮

トランザクションの中でAPIを叩いてデータ不整合になった話と、Outboxパターン

に公開

はじめに

こんにちは!

先日、Laravelで開発中に 「DBの更新とAPI送信を1つのトランザクションでまとめて書く」 コードから、動作が不安定になってしまうことがありました。なお、本記事のコード例は Laravel(DB::transaction / Http ファサード)を前提にしています。

DB::transaction(function () {
    Order::create([...]);                          // DBに保存
    Http::post('https://payment-service/...', ...); // 課金APIを叩く
});

「ロールバックされるなら安全じゃん」と思ったのですが、ふたを開けてみると DBをロールバックできても、API先で起きた処理は取り消せない という、もっと厄介な落とし穴がありました。具体的には「Orderが存在しないのに、課金だけ走ってしまう」というデータ不整合です。

今回はその原因と、解決策のひとつである トランザクションアウトボックス(Outbox)パターン を、図解と一緒に整理してみます。

まずは全体像を図で

先に全体像を見ておくとイメージしやすいです。

何が起きていたのか:ロールバックしても外の処理は取り消せない

トランザクションの中で外部APIを叩くと、DB側のロールバックがAPI先には届かない ことが致命的になります。

DB::transaction(function () use ($request) {
    // 1. 注文をDBに保存(この時点ではまだcommitされていない)
    $order = Order::create($request->validated());

    // 2. 課金APIを叩く → Payment側では実際に課金が完了する
    Http::post('https://payment-service/charge', [
        'order_id' => $order->id,
        'amount'   => $order->amount,
    ]);

    // 3. このあとDB側で例外が起きると…
    SomeOtherModel::create([...]); // ← ここで失敗するとトランザクション全体がロールバック
});

このとき何が起きるか:

  1. Order::createロールバックでDBから消える
  2. でも 2. の 課金APIはすでにPayment側で完了している
  3. Payment側はこちらのDBを見ていないので、ロールバックが起きたことを知らない
  4. 結果:Orderが存在しないのに、課金だけされた状態 が残る
[自分のサービスのDB]              [Payment Service]
  Order ❌(ロールバックで消えた)      課金 ✅(完了済みで取り消せない)

ポイントは、トランザクションの「ロールバック」は自分のDBの中でしか効かない ということです。一度外に出した処理(HTTP送信・メール送信・キュー投入)は、こちらの都合では取り消せません。

もうひとつの問題:トランザクションを長引かせること自体がDBに優しくない

ロールバック時の不整合がもっとも怖いポイントですが、実はそれ以前に 「外部API呼び出しの間ずっとトランザクションが開きっぱなしになる」 こと自体が、DBサーバーにとってじわじわ重い負担になります。

外部APIは数百msから数秒かかることもあります。その間ずっとトランザクションを抱えていると、

  • 行ロック・テーブルロックが解放されない → 他のリクエストが詰まる
  • DBコネクションを掴みっぱなしになる → コネクションプールが枯渇しやすい
  • レプリケーション遅延やデッドロックの原因にもなる

本来ミリ秒で終わるはずのトランザクションが、外部APIの応答時間に引っ張られて何倍にも伸びる、というイメージです。トラフィックが増えたときに 「なぜか急に詰まる系」の障害 として表面化しがちで、原因特定にも時間がかかります。

ちなみに、トランザクション中に他のWorkerなどがDBを読みにくると commit前の古い・あるいは存在しないデータを参照してしまう という、いわゆる「書きかけのメモ」問題も起こり得ます。長時間トランザクションだとこれも顕在化しやすくなります。

とはいえ、連続処理として外部通信したい場面はある

「外部通信は commit 後で」と言われても、実務では 「DB保存に成功したら必ず外部へ通知したい」 ケースは普通にあります。

  • 注文登録 → 決済サービスへ通知
  • 会員登録 → 外部のポイントシステムにユーザー追加
  • 重要なデータ更新 → 監査ログサーバーへイベント送信

「どっちかが失敗したら全部失敗扱いにしたい」 という気持ちもあります。だからこそ、トランザクション内にAPIを書きたくなる気持ちもわかる。でも、それをやると先ほどの「ロールバックしても課金は取り戻せない」問題が起きる。

ここで検討する選択肢のひとつが トランザクションアウトボックスパターン です。

Outboxパターンとは?

Outbox は、DB更新と「送信したいメッセージ」を 同じトランザクションでOutboxテーブルにINSERTする パターンです。実際の送信は、別のWorkerが commit後に Outboxを読みにきて実行します。

比喩で言うと、「家の玄関に送信箱を置いておくだけ」。自分は外まで走らず、書きかけのメモを誰にも見せない。郵便屋さん(Worker)が定期的に集荷にきて、確実に外の世界へ届けてくれる仕組みです。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\CreateOrderRequest;
use App\Models\Order;
use App\Models\OutboxMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;

class CreateOrderController extends Controller
{
    public function store(CreateOrderRequest $request): JsonResponse
    {
        DB::transaction(function () use ($request) {
            // 1. 注文をDBに保存
            $order = Order::create($request->validated());

            // 2. 同じトランザクション内でOutboxへ書き込むだけ!
            //    HTTPもMailもここでは呼ばない(外には触れない)
            OutboxMessage::create([
                'message_type' => 'order.created',
                'payload'      => [
                    'order_id' => $order->id,
                    'user_id'  => $order->user_id,
                ],
                'status'       => 'pending',
            ]);
        });
        // ↑ ここでcommitされてはじめて、Outboxの行が他から見えるようになる

        return response()->json(['message' => 'Order created']);
    }
}

そして commit後の世界で Outboxを読みにくる側の処理がこちらです。実装は Laravel Job、Console Command、Supervisor配下のWorker、スケジューラなど環境によって変わるので、ここでは中身のロジックだけ示します。

// commit後にOutboxを読み、実際の送信を行う処理本体

public function handle(): void
{
    // pendingのメッセージを順番に処理
    OutboxMessage::where('status', 'pending')
        ->limit(100)
        ->get()
        ->each(function (OutboxMessage $message): void {
            try {
                $this->send($message);
                $message->update(['status' => 'sent']);
            } catch (\Throwable $e) {
                // 失敗してもOutboxに残るのでリトライできる
                Log::error('Outbox dispatch failed', ['id' => $message->id]);
            }
        });
}

private function send(OutboxMessage $message): void
{
    match ($message->message_type) {
        'order.created' => Http::post(
            'https://payment-service/orders',
            $message->payload
        ),
        // 他のメッセージタイプもここで分岐
    };
}

Outboxパターンのポイント

  • ✅ DB更新と「送信予約」が 同じトランザクション で必ず整合する
  • ✅ Workerは commit後 にOutboxを読むので、書きかけのメモ問題が起きない
  • ✅ 送信失敗してもOutboxに残るのでリトライが容易
  • ❌ Workerプロセスを別途運用する必要がある
  • ❌ 受信側で 冪等性(同じイベントを2回受け取っても問題ない作り)が必要

まとめ:判断の目安

トランザクション内でAPI Outboxパターン
ロールバック時の整合性 崩れる(DBは戻るが課金は残る) 保たれる(commit後に送信)
トランザクションの長さ 外部API応答に引きずられて長くなる DB操作だけなので短い
DBサーバーへの負荷 ロック保持・コネクション占有が長引く 最小限
実装の手間 シンプル(だが地雷) Workerが必要
向いている場面 基本的に避けたい DB更新+外部通信が必須なとき

Outboxパターン図解

おわりに

今回の学びをまとめると、こんな感じです。

  • トランザクションの中では 登録処理だけ にする
  • API送信・メール送信・キュー投入などの外部通信は commit後 に行う
  • とはいえ「DB更新と外部通知を連続処理でセットにしたい」場面は実務でよくある
  • そんなときの選択肢のひとつが Outboxパターン

「DBはロールバックできても、外に出した処理は取り戻せない」 というイメージを持っておくと、トランザクション境界を考えるときの判断がラクになります。「あれ、いま自分はトランザクションの中から外の世界に手を出してないか?」と立ち止まれるようになるとベスト。

冒頭の図解でも確認できるので、迷ったら見返してみてください📮

ソーシャルデータバンク テックブログ

Discussion