メインコンテンツまでスキップ

第30章:Outbox① なぜ必要?(DB更新と送信のズレ問題)📤🧾

1. まず結論:Outboxは「二重書き(Dual Write)の地獄」を避けるための保険🛟😵‍💫

分散システムでよくあるのが、このセットです👇

  • DBに保存する(注文を作る) 🗃️
  • メッセージを送る(在庫サービスへ依頼イベント) 📨

この2つを「別々に」やると、途中で落ちた瞬間にズレます💥 これを一般に Dual Write(2系統への書き込み)問題って呼びます。(AWS ドキュメント)

Outbox(トランザクショナルOutbox)パターンは、 **“DB更新と同じトランザクションで、送るべきメッセージをOutboxテーブルに確実に残す”**ことで、ズレを回避します✅(microservices.io)


2. “ズレ問題”が起きると何がヤバい?(CampusCafeあるある)☕📱💥

CampusCafeで「注文→在庫→決済→通知」って流れがありましたよね。

ここで 注文サービスがやりがちな処理👇

  1. 注文をDBに保存 🗃️
  2. OrderPlaced を発行して在庫サービスへ伝える 📨

これが落ちると…こうなる😇

落ち方起きることユーザー体験(最悪)
DBは成功、送信は失敗注文は存在するのに、在庫が確保されない画面は「注文できた」っぽいのに裏で止まる😵
DBは失敗、送信は成功在庫だけ確保される(幽霊注文)在庫が減るのに注文が見えない👻📦
送信は成功、でもクライアント再送同じ注文が2回分飛ぶ二重注文・二重確保の地獄📨📨💣

このへん、**“ネットワークは普通に切れる/遅れる/再送される”**が前提なので、いつでも起きます📡🐢


3. Outboxのアイデア:送信を「DBの外」へ追い出す🏃‍♀️📤

Outboxの考え方はシンプル✨

ポイントはここ👇

  • 「DB更新」と「送信」を同じ“同期処理”で完璧にやろうとしない🙅‍♀️

  • まず DBトランザクションの中で

    • 注文データ ✅
    • 送るべきイベント(Outboxレコード)✅ を 一緒に保存する
  • 実際の送信は、別プロセス/別スレッドが 後で確実に送る📮🔁

図で見るとこう👇(microservices.io)

cap_cs_study_030_atomic_outbox_save

  • リクエスト処理(同期)

    • Orders 更新 🗃️
    • Outbox に「送る予定」記録 🧾✅(同じトランザクション)
  • Outboxリレー(非同期)

    • Outbox を読んでブローカーへ送信 📨
    • 送れたら“送信済み”にする(運用は次章で!)🧹

Microsoftのガイダンスでも、信頼できるイベント配信の方法として「トランザクショナルOutbox」が紹介されています。(Microsoft Learn)


4. じゃあOutboxテーブルに「何を保存する」の?🗃️🧩

第30章のミニゴールはここです👇 Outboxに入れる“最低限セット”を決められるようになること🎯✨

おすすめの最低限(まずはこれでOK)👇

  • Id:OutboxメッセージID(GUID)🔑
  • OccurredAt:いつ起きたイベント?⏰
  • Type:イベント種類(例:OrderPlaced)🏷️
  • AggregateId:関連する注文ID(例:OrderId)🧾
  • Payload:イベント本文(JSON)📦
  • Headers:相関ID(CorrelationId)や TraceId(任意)🧵

「送ったかどうか(Pending/Sent/Failed)」みたいな状態管理は次章(第31章)で運用として育てます🧹📈


5. まず“ダメな例”→ Outbox版へ(C#イメージ)🧠💡

❌ ダメな例:DB保存と送信を直列にやる(落ちたらズレる)

public async Task<Guid> PlaceOrderAsync(PlaceOrderRequest req)
{
var order = Order.Create(req);

db.Orders.Add(order);
await db.SaveChangesAsync(); // ✅ DBは保存できた

await bus.PublishAsync(new OrderPlaced(order.Id)); // 💥 ここで落ちたら「DBだけ成功」

return order.Id;
}

✅ Outboxの考え方:同じトランザクションでOutboxへ“送る予定”を書き込む

public sealed class OutboxMessage
{
public Guid Id { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public string Type { get; set; } = default!;
public Guid AggregateId { get; set; }
public string PayloadJson { get; set; } = default!;
}

public async Task<Guid> PlaceOrderAsync(PlaceOrderRequest req)
{
var order = Order.Create(req);

// ここでは "送信" しない。送る予定を Outbox に書くだけ。
var evt = new OrderPlaced(order.Id);

using var tx = await db.Database.BeginTransactionAsync();

db.Orders.Add(order);

db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
OccurredAt = DateTimeOffset.UtcNow,
Type = nameof(OrderPlaced),
AggregateId = order.Id,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(evt),
});

await db.SaveChangesAsync();
await tx.CommitAsync(); // ✅ 注文 + Outbox が「一緒に」確定

return order.Id;
}

この時点では「Outboxに確実に残った」だけ。 実際の送信(Outboxを読んでPublishする)は次章で“運用として完成”させます📤🔁✨


6. ミニ演習:CampusCafeのOutboxに入れる“イベント候補”を決めよう☕📚✨

以下の機能で、Outboxに残すべきイベントに丸をつけてみてね😊✅

  • 注文作成:OrderPlaced ☕🧾 ✅(だいたい必要)
  • 在庫確保依頼:ReserveStockRequested 📦 ✅
  • 決済開始:PaymentRequested 💳 ✅
  • 決済成功:PaymentSucceeded
  • 決済失敗:PaymentFailed ❌/✅(通知したいなら✅)
  • 通知:NotifyUserRequested 🔔 ✅(通知はAP寄りでOutboxと相性良い)

判断のコツ🎛️

  • 「他サービスが動くための合図」=Outbox候補になりやすい📣
  • 「後からでも送れればOK」=Outboxと相性よし🐢✨
  • 「送れなかったら業務が止まる」=なおさらOutboxが欲しい😵‍💫

7. AI活用(Copilot / Codex向け)🤖✨

そのままコピペで使えるプロンプト例👇

  • 「CampusCafeの注文サービス向けに、Outboxテーブルの最小スキーマ案を作って。Id/Type/Payload/OccurredAt/AggregateId/Headersを含めて」🧱🗃️
  • 「EF Coreで、Ordersの保存とOutboxMessageの追加を同一トランザクションにする実装例を出して」🧩✅
  • 「OutboxのPayloadに入れるJSONの設計方針(バージョニング、必須フィールド)を初心者向けに説明して」📦📝

8. ここだけは押さえる!Outbox“必要性”チェックリスト✅🧾

  • DB更新とイベント送信を 別々に成功/失敗しうる(=ズレる)📡💥
  • Outboxは 同一トランザクションで“送る予定”をDBに残すことでズレを消す✅(microservices.io)
  • 送信は「後で」別プロセスがやる(再送できる形にする)🔁📤(microservices.io)
  • 実運用では「少なくとも1回届く」前提になるので、受け手側の冪等性も大事🛡️📬 (Outboxは“送信側の地獄”を減らすもの、全部を魔法で解決はしない🪄❌)

9. 小まとめ😊✨

Outboxが必要になる理由はただ1つ! **「DB更新」と「メッセージ送信」を別々にやると、落ちた瞬間にズレて地獄を見るから」**😵‍💫📤

次章では、このOutboxを現実に回すために 再送・掃除・監視(Pending/Sent/Failed、滞留、失敗率…)の“運用の肌感覚”を作っていきます🧹🔁📈(docs.particular.net)