第18章:到達保証の話(Outboxはだいたい“At-least-once”)📬🔁
この章でわかること 🎯✨
- 「最低1回は送る」ってどういう意味か(そして、なぜ重複するのか)😅
- 「送れたかどうか、実は曖昧」問題の正体 🤷♀️
- Outboxを採用したときに、どこで重複が生まれるかを絵で説明できるようになる 🖼️
- 次章の「冪等性」が、なぜ“必須科目”になるのかが腑に落ちる 🧠💡
1. 配達保証の3兄弟 📦👨👩👧👦
メッセージ配達の世界には、ざっくり3つの「保証レベル」があるよ〜😊✨
1) At-most-once(最大1回)🎯
- 重複は起きない(嬉しい)🙆♀️
- でも 落ちたら終わり(配達されない可能性がある)😇
2) At-least-once(最低1回)📬
- 落とさない方向に寄せる(嬉しい)🛡️
- ただし 重複は起こり得る(現実)👯
3) Exactly-once(ちょうど1回)🏆
- 夢のように見えるけど、エンドツーエンドで本当にやるのは難しい💦
- だいたいは「システムとアプリが協力して、結果的に“そう見せる”」方向に落ち着くよ〜🧩
RabbitMQの公式ドキュメントでも、ack(受領確認)を使うと at-least-once になり、使わないと at-most-once になるという整理が書かれてるよ。(rabbitmq.com)
2. Outboxが狙う保証はどれ?🎯📦
Outboxの基本ゴールは、超シンプルに言うとこれ👇
- ✅ 業務DBの更新がコミットされたなら、その通知も“いつか必ず”外に出る
- ❌ でも、“1回だけ”を完全保証する仕組みではない
つまり Outbox は、だいたい at-least-once の世界観で設計されることが多いよ〜📬🔁
「DB更新とメッセージ送信を同時に原子的にやりたいけど、DBとブローカーをまたぐ分散トランザクション(2PC)は現実的じゃない」…って背景が、Transactional Outboxの前提としてよく語られるポイントだよ。(microservices.io)
3. 「最低1回」は、なぜ重複するの?👯💥

ここが超大事〜!!!😺✨ 重複が起きる理由は、だいたい “失敗したように見える” からだよ。
典型パターン:送れたのに、送れてないように見える 🤯
イメージ図いくね👇
Relay(配送係) ブローカー/送信先
| send(msg) --------------------> | (実は受け取って保存した)
| |
| (ACK待ち) |
| ・・・ネットワークが途切れる・・・ |
| Timeout!(失敗だと思う) |
| |
| retry send(msg) ----------------> | (同じmsgがまた届く)
- ブローカー側は受け取ってる ✅
- Relay側はACKが返らなくて「失敗した」と判断 ❌
- だから再送して 重複 👯
これ、めちゃ普通に起こるよ〜😅
4. 「送れたかどうか」は意外と曖昧 🤷♀️🌀
送信処理って、結果がだいたい3種類あるのね👇
A. 成功が確定 ✅
- ACKも返ってきた
- 送信先が「受け取った」と言ってる
B. 失敗が確定 ❌
- 認証エラー、リクエスト不正、宛先不存在…など
- 直してからじゃないと永遠に成功しないタイプ 😇
C. 不明(グレー)🤷♀️ ← ここが主役
- タイムアウト
- 接続が切れた
- 返事が来る前にプロセスが落ちた
- 送信先が受け取ったかどうか、こちらからは確定できない
そして at-least-once を取りに行く設計は、 この「不明」を “成功してたかもしれないけど、とにかく再送する” に倒すのが基本なんだ〜📬🔁
5. Outboxで重複が生まれる“穴”はここ 🕳️👀
Outboxはざっくり二段階だよね👇
- 業務テーブル更新+Outbox行追加(同一トランザクション)🔒
- RelayがOutboxを読んで外へ送信し、Outboxを送信済みにする 🚚📩
この 2) が、どうしても「穴」になるよ〜😅
重複が生まれる王道シナリオ 🥲
- Relayが外部へ送信 ✅
- でも「Outboxを送信済みに更新する」前に落ちる 💥
- 次回起動でまた同じOutboxを拾って再送 👯
これってつまり、 外部送信 と Outbox行の状態更新 を“完全に同時”にはできない(外部はDBトランザクションに入らない)からだよね🤝
6. 「じゃあExactly-onceって無理なの?」への現実的な答え 🧠✨
エンドツーエンド(DB更新+外部副作用まで含めて)で「ちょうど1回」をやるのは難しい、ってのが基本姿勢だよ〜💦 たとえばKafkaの設計ドキュメントでも、Exactly-onceには「消費位置と出力の協調(必要なら2PC的な話)」が絡む、というニュアンスが説明されてるよ。(kafka.apache.org)
ただし! 「特定の範囲では重複排除できる」機能は存在するよ〜🙌
例:Azure Service Bus の重複検出 🧽✨
Azure Service Bus は、MessageId の履歴を一定期間保持して、同じIDのメッセージを落とす「重複検出」があるよ。 ドキュメント上も「設定した時間窓の中で exactly once delivery を保証する」と書いてある。(Microsoft Learn)
でもね、ここが超ポイント👇
- ブローカー側で落としてくれても、アプリ側の副作用(DB更新や外部API呼び出し)を全部“ちょうど1回”にするには、結局アプリ設計も必要になりがち 😅
- だから「冪等性」はやっぱり重要になるよ〜🧷✨
7. ミニ実験:重複が“自然に”起きるのを体感しよう 🧪👯
本物のブローカーを使わなくても、「ACKが消える」を疑似的に作ると理解が爆速だよ〜🚀
7.1 偽のPublisherを作る 🤖📩
public interface IEventPublisher
{
Task PublishAsync(Guid outboxId, string payloadJson, CancellationToken ct);
}
// 「実は送れてるのに、たまにタイムアウトする」やつ😈
public sealed class FlakyPublisher : IEventPublisher
{
private readonly Random _rng = new();
public Task PublishAsync(Guid outboxId, string payloadJson, CancellationToken ct)
{
Console.WriteLine($"📨 SEND outboxId={outboxId}");
// 40%の確率で「ACKが返らなかった」ことにする(=送れたか不明🤷♀️)
if (_rng.NextDouble() < 0.4)
{
Console.WriteLine($"⚠️ ACK LOST outboxId={outboxId}");
throw new TimeoutException("Ack timeout (simulated)");
}
Console.WriteLine($"✅ ACK OK outboxId={outboxId}");
return Task.CompletedTask;
}
}
7.2 Relay側はどう動くべき?🚚🔁
- タイムアウトしたら「失敗扱い」でリトライ
- すると、同じ outboxId が複数回 SEND されるログが出るよ 👯
- それが at-least-once のリアル📬🔁
8. 実務の合言葉:重複はバグじゃなくて “仕様” 😺📌
Outboxを採用した瞬間、チームの共通認識はこれになるよ👇
- ✅ 「重複は起こる」前提で作る
- ✅ “1回だけに見せる”のは 受け手(または途中)で吸収する
- ✅ そのための代表選手が 冪等性🧷✨
9. 次章につなぐ一言 🧠✨
この章の結論はシンプルだよ〜📌
- Outboxは「落とさない」を取りにいく
- その代償として「重複が起こり得る」
- だから次章のテーマはこうなる👇
👉 「2回届いても、1回分として扱える」=冪等性(Idempotency) ✅🧷
(次章で、OutboxIdを“鍵”にして重複を消す方法をやるよ〜🔑✨)