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

第31章:Outbox② 運用の肌感覚(再送・掃除・監視)🧹🔁📈


31.1 Outboxは「作っただけ」だとすぐ詰まるよ😵‍💫📦

Outbox(Transactional Outbox)は、**DB更新とイベント送信の“ズレ事故”**を避けるための定番パターンだよね📤🧾 基本は「DBトランザクションで Outbox に“送る予定”を書いておいて、あとで確実に送る」方式。(microservices.io)

でも…運用が雑だとこうなる😇

  • 送信失敗が続く → Outboxが溜まる🧊
  • 溜まる → DBが重い/遅い → さらに失敗 → 雪だるま☃️
  • 毒メッセージ(永久に失敗するやつ)で 無限リトライ地獄♾️🔥
  • 送れた/送れてないが分からない → 誰も安心できない🥺

なのでこの章は「運用の肌感覚」を作るよ〜!✨


31.2 CampusCafeでのOutbox運用イメージ☕📱📨

例:注文が入ったら、注文DBの更新と同時に Outbox に OrderPlaced を積む。

  • DB更新:注文を Created にする🧾
  • Outbox:OrderPlaced(在庫サービスへ送る予定)を Pending で保存📨

在庫サービスが一時ダウンしてても、注文を受け付けて(A寄り)あとで送る選択ができる。 ただしその代わり、Outboxの滞留と再送を運用でさばく必要がある…って感じ😊🧠


31.3 Outboxレコードの状態設計(最重要)🚦✨

まずは Outbox に「状態」を持たせるよ。おすすめはこの5つ👇

状態意味次に起きること
Pending送信待ち送信処理が拾う📤
InFlightいま送信中(ロック中)成功→Sent / 失敗→Failed
Sent送信済み後で掃除対象🧹
Failed送信失敗(再送待ち)NextAttemptAt 以降に再挑戦🔁
Dead毒メッセージ隔離(DLQ的)人が見る👀

さらに運用で効く“メタ情報”も入れると強いよ💪

  • AttemptCount:何回失敗した?🔢
  • NextAttemptAtUtc:次いつ再送する?🕒
  • LockedUntilUtc:並列実行で二重処理しないためのロック⛓️
  • LastError:最後の失敗理由(短くでOK)🧾
  • OccurredAtUtc:発生順(並べ替え用)🗂️

31.4 再送(Retry)は“やさしく”設計する🔁🌿

31.4.1 再送の基本ルール🧠

  • 一時的な失敗(ネットワーク、相手が一瞬落ちた、タイムアウト)→ 再送OK✅
  • 永続的な失敗(入力不正、送信先設定ミス、契約違反)→ 再送しても無駄❌ → Dead

そして、バックグラウンド処理の再送はだいたい ✅ 指数バックオフ + ジッター が鉄板だよ(混雑を増やさないため)📉✨ (Microsoft Learn)

  • 指数バックオフ:1秒→2秒→4秒→8秒…みたいに待つ⏳
  • ジッター:ちょっとランダムにずらす🎲(全員同時に再送しないため)

31.4.2 「最大回数」と「上限待ち」を決めよう🧯

  • MaxAttempts:例)10回
  • MaxDelay:例)30秒〜5分で上限
  • Deadへ送る条件:例)AttemptCount >= MaxAttempts で隔離

31.5 二重送信は“起きる前提”😇📨📨

Outboxは「確実に送る」に強いけど、基本的に世界は 少なくとも1回(at-least-once) になりがち。 つまり 二重送信は普通に起きうる 🥺

だから運用としては、

  • 送る側(Outbox):重複して送ることがある
  • 受ける側(コンシューマ):冪等で受ける(第29章の復習)🛡️

このペアが大事だよ〜!


31.6 DLQ(Dead Letter)的な扱い:毒メッセージを隔離☠️📮

Dead は「捨てる箱」じゃなくて “隔離して人が判断する箱” だよ👀✨

31.6.1 Dead に入れる代表例

  • 仕様違反(必須項目がない、型が違う)📛
  • 送信先が永久に無い(Topic名ミスなど)🫠
  • 10回失敗してもダメ(MaxAttempts超え)🥲

31.6.2 Dead の運用フロー(おすすめ)

  1. Dead が増えたらアラート🚨

  2. 一覧で原因を見る(LastError)👀

  3. 対応を選ぶ

    • 修正して 再キューPendingに戻す)🔁
    • 仕様的に無理なら 破棄(監査ログだけ残す)🗑️

31.7 掃除(Cleanup)と肥大化対策🧹🗄️

Outboxは放置すると増える一方📈 なので「保持期間」を決めよう!

  • Sent:例)7日で削除🧹
  • Failed/Dead:例)30日保持(調査のため)🕵️‍♀️
  • 監査が必要なら「別テーブル/別ストレージにアーカイブ」📦

さらに性能のために ✅ StatusNextAttemptAtUtc で探しやすい索引(Index) を意識すると運用が安定しやすいよ🧠✨ (Outboxは「送信対象の抽出」がボトルネックになりやすいからね)


31.8 監視(Monitoring)で“詰まり”を即発見📈🚨

監視は「CPU/メモリ」より、まず Outboxの健康状態

31.8.1 最低限ほしい監視項目(まずこれ)✅

  • outbox_pending_count:Pending+Failedが何件?📦
  • outbox_oldest_age:一番古い未送信は何分前?🕰️
  • outbox_publish_success_total:成功回数✅
  • outbox_publish_failure_total:失敗回数❌
  • outbox_dead_total:Dead行き回数☠️

.NETでは System.Diagnostics.Metrics でメトリクス計測できるよ。(Microsoft Learn) OpenTelemetry で Prometheus/Grafana につなぐ定番ルートもあるよ。(OpenTelemetry)

31.8.2 アラートの例(CampusCafe向け)🔔

  • outbox_oldest_age > 5分 → 「在庫/決済連携が詰まってるかも」🚨
  • outbox_dead_total が増加 → 「毒メッセージ発生」☠️
  • failure_rate > 20% が5分続く → 「送信先が落ちてる」🧯

31.9 最小実装:Outbox Dispatcher(ポーリング送信)🧩💻

ASP.NET Core のバックグラウンド処理は Hosted ServiceBackgroundService)で書けるよ。(Microsoft Learn) (Windowsサービスとして動かす形も作れるよ)(Microsoft Learn)

ここでは「学習用にわかりやすく」最小構成にしてるよ😊 本番でスケールアウトする場合は“ロックの取り方”をより厳密にするのがポイント(後述)🔐

31.9.1 Outboxエンティティ例🚦

public enum OutboxStatus
{
Pending = 0,
InFlight = 1,
Sent = 2,
Failed = 3,
Dead = 4,
}

public sealed class OutboxMessage
{
public Guid Id { get; set; } = Guid.NewGuid();

public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;

public string Type { get; set; } = default!; // 例: "OrderPlaced"
public string PayloadJson { get; set; } = default!; // JSON

public OutboxStatus Status { get; set; } = OutboxStatus.Pending;

public int AttemptCount { get; set; } = 0;
public DateTime NextAttemptAtUtc { get; set; } = DateTime.UtcNow;

public DateTime? LockedUntilUtc { get; set; }
public string? LastError { get; set; }

public DateTime? SentAtUtc { get; set; }
public string? DeadReason { get; set; }
}

31.9.2 再送待ち時間(指数バックオフ + ジッター)🎲⏳

public static class RetryDelay
{
public static TimeSpan ComputeDelay(int attemptCount)
{
// attemptCount: 1,2,3... を想定
var expSeconds = Math.Pow(2, Math.Min(attemptCount - 1, 10)); // 爆発しすぎ防止
var baseDelay = TimeSpan.FromSeconds(Math.Min(expSeconds, 60)); // 上限60秒

// ジッター(0〜250ms)
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 250));
return baseDelay + jitter;
}
}

バックオフ+ジッターは「バックグラウンド処理の再送」に推奨されやすいよ。(Microsoft Learn)

31.9.3 送信部(ダミー)📤

cap_cs_study_031_outbox_relay_worker

public interface IEventBus
{
Task PublishAsync(string type, string payloadJson, CancellationToken ct);
}

31.9.4 Dispatcher(BackgroundService)🔁

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;

public sealed class OutboxDispatcher : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(1);
private const int BatchSize = 20;
private const int MaxAttempts = 10;
private readonly TimeSpan _lockFor = TimeSpan.FromSeconds(30);

public OutboxDispatcher(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DispatchOnce(stoppingToken);
}
catch
{
// 学習用:握りつぶし。実務ではログに出すよ🧾
}

await Task.Delay(_pollInterval, stoppingToken);
}
}

private async Task DispatchOnce(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IEventBus>();

var now = DateTime.UtcNow;

// 1) 送信対象を拾う(期限が来ていて、ロックされてないもの)
var targets = await db.OutboxMessages
.Where(m =>
(m.Status == OutboxStatus.Pending || m.Status == OutboxStatus.Failed) &&
m.NextAttemptAtUtc <= now &&
(m.LockedUntilUtc == null || m.LockedUntilUtc < now))
.OrderBy(m => m.OccurredAtUtc)
.Take(BatchSize)
.ToListAsync(ct);

if (targets.Count == 0) return;

// 2) いったんロック(学習用の簡易版)
foreach (var m in targets)
{
m.Status = OutboxStatus.InFlight;
m.LockedUntilUtc = now.Add(_lockFor);
}
await db.SaveChangesAsync(ct);

// 3) 送信して結果を書く
foreach (var m in targets)
{
try
{
await bus.PublishAsync(m.Type, m.PayloadJson, ct);

m.Status = OutboxStatus.Sent;
m.SentAtUtc = DateTime.UtcNow;
m.LockedUntilUtc = null;
m.LastError = null;
}
catch (Exception ex)
{
m.AttemptCount += 1;
m.LastError = ex.Message;

if (m.AttemptCount >= MaxAttempts)
{
m.Status = OutboxStatus.Dead;
m.DeadReason = $"MaxAttempts({MaxAttempts}) exceeded";
m.LockedUntilUtc = null;
}
else
{
m.Status = OutboxStatus.Failed;
m.NextAttemptAtUtc = DateTime.UtcNow + RetryDelay.ComputeDelay(m.AttemptCount);
m.LockedUntilUtc = null;
}
}
}

await db.SaveChangesAsync(ct);
}
}

⚠️運用メモ(ちょい上級)🔐

複数台でDispatcherを動かすなら、上の「簡易ロック」だと競合しやすいよ💦 本番では「DBで原子的に“取り分け(claim)”する」方式(UPDATE ... OUTPUT など)にすると強い。 ここは環境(DB種類)で実装が変わるので、「まず概念→次にDB別実装」でOK😊


31.10 ミニ演習:CampusCafeのOutbox運用を設計しよう🧠📝

演習A:状態設計🚦

Pending / InFlight / Sent / Failed / Dead に加えて、次を決めてね👇

  • MaxAttempts は何回?🔢(例:10)
  • MaxDelay は何秒?⏳(例:60秒)
  • Dead に入ったら誰が見る?どこで見る?👀

演習B:監視の閾値を決める📈🚨

次の3つに「赤信号」を付けよう!

  • outbox_oldest_age が何分でアラート?🕰️
  • dead_total が何件増えたらアラート?☠️
  • failure_rate の許容は?❌

演習C:毒メッセージ対応フローを作る☠️➡️🛠️

  • 例:Dead を一覧表示 → LastError を見て原因分類 → 「再キュー / 破棄 / 修正して再送」 この一連を文章で書いてみてね📝✨

31.11 AI活用(Copilot / Codex向け)プロンプト集🤖✨

① 状態・カラムのレビューをさせる🔍

  • 「Outboxの状態を Pending/InFlight/Sent/Failed/Dead にしたい。必要なカラム(AttemptCount/NextAttemptAt/Lock/LastError等)を提案して、運用の観点で理由も付けて」

② アラート設計を手伝わせる🚨

  • 「Outboxの監視項目(滞留、最古の年齢、失敗率、Dead件数)について、CampusCafe(注文・在庫・決済・通知)でおすすめの閾値案を出して」

③ “毒メッセージ”の分類を作らせる☠️

  • 「OutboxがDeadになる原因を『再送して直る系/直らない系』に分類して、判断フローを作って」

④ スケールアウトの方向性を整理させる🧠

  • 「Outbox Dispatcherを複数インスタンスで動かす場合の“取り分け(claim)”戦略を、DBロック/楽観的同時実行/UPDATEで取り分け、の観点で比較して」

31.12 チェックリスト(この章のゴール)✅✨

  • Outboxの状態(Pending/InFlight/Sent/Failed/Dead)が決まってる🚦
  • リトライは指数バックオフ+ジッターで、上限と回数がある🔁🎲 (Microsoft Learn)
  • Dead は隔離&人が見て再キューできる導線がある☠️📮
  • Sent の保持期間が決まってて掃除できる🧹
  • 「滞留件数」「最古の年齢」「失敗率」「Dead増加」を監視できる📈🚨
  • バックグラウンド処理はHosted Serviceで動く(まずはここ!)🔧