はじめに ― あの「予約大丈夫かな…?」という不安の正体
金曜の夜、友人との飲み会の席を確保しようとして、食べログやホットペッパーのような予約サービスを使う場面を想像してみてください。
画面には「予約を受け付けました」と出ているのに、しばらく待っても確認メールが届かない。
「本当に予約できているのか?」と、少し不安になった経験がある人もいるはずです。
画面の向こう側では、技術的にこんなことが起きている可能性があります。
- 予約テーブルには、ちゃんとレコードが登録されている
- しかし、メール送信システムには、その予約情報がうまく届いていない
つまり 「データベース」と「他システムへの通知(API / MQ / Kafka など)」のあいだで、状態がズレている 状態です。
この「二重書きのズレ」を、現実的なコストで抑え込むための定番パターンが、この記事のテーマであるTransactional Outboxパターンです。
では具体的にどのようにして解決できるのでしょうか。
この記事は、単なる概念紹介ではなく、
- なぜ Outbox が必要になるのか
- スキーマ設計・トランザクション設計
- 具体的な実装例
- 運用
まで一気通貫で押さえる「実装大全」を目指します。
1. 予約システムで考える「二重書き事故」
先ほどの予約の例を、コードレベルまで少しだけ落としてみます。
「予約を受け付けて、店舗側システムにイベントを送る」という処理を、素直に Laravel で書くとこうなりがちです。
public function reserve(ReserveRequest $request): void
{
// 1. DB に予約を保存
$reservation = DB::transaction(function () use ($request) {
return Reservation::query()->create([
'shop_id' => $request->shop_id,
'user_id' => $request->user_id,
'datetime' => $request->datetime,
'headcount' => $request->headcount,
]);
});
// 2. 店舗システム / メール送信システムにイベント送信
$this->messageBus->publish('reservation.created', [
'reservation_id' => $reservation->id,
'shop_id' => $reservation->shop_id,
'user_id' => $reservation->user_id,
]);
}
いかにも「悪いことをしているコード」には見えません。
トランザクションで予約を保存し、その結果を使ってメッセージを送っているだけです。
ところが、この形のまま運用していると、次のようなことが起こり得ます。
- トランザクション内で予約レコードが
INSERTされ、コミットも完了する - その直後にネットワークが途切れたり、メッセージブローカー側が落ちていて
publish()が失敗する
このとき、予約テーブルにはレコードが残っているのに、店舗システムやメール送信処理にはその予約を知らせるイベントが一切届いていない という状態になります。
これが、いわゆる「二重書き」の問題です。
ひとつのユースケースの中で、異なる 2 つのシステム(DB とメッセージング)に書き込みを行っている ため、どちらか片方だけが成功するケースを完全には排除できないのです。
では、これをTransactional Outboxでどう解決するのでしょうか。
2. Transactional Outboxとは
ここでようやく本題です。
ひと言でまとめると、Transactional Outbox パターンとは
Transactional Outboxパターンとはデータの更新とメッセージの書き込みをアトミックに行うためのパターンです。
もっというと、業務データ(予約/注文など)と「外部に送るべきメッセージ(outbox)」を同じ DB トランザクションの中でまとめて記録し、そのメッセージはあとで別プロセス等に任せることで、業務データの更新とメッセージの発行がアトミックに行われることを担保します。
具体的に、先程の予約システムをTransactional Outboxを用いると、以下の流れになります。
- トランザクションの中で、予約テーブル(reservations)に
INSERTする - 同じトランザクションの中で、「送信待ちテーブル(例:
outbox_messagesなど)」にもイベントをINSERTする - トランザクションをコミットする
- 別プロセス(ワーカーやバッチ、CDC ツールなど)が
outbox_messagesを順番に読み、メッセージブローカーや外部 API に送る
というものです。
ポイントは、予約とOutboxのINSERTがひとつのトランザクションにまとめられていることです。
トランザクションがロールバックされた場合、予約もOutboxレコードもどちらも残りません。
トランザクションがコミットされた場合、予約テーブルとOutboxテーブルの両方に、対応するレコードが必ず残ります。
つまり、「予約だけ DB に残っているのに、イベントという“痕跡”がどこにも残っていない」という状態は、トランザクションの性質上、そもそも発生しなくなります。
3. アーキテクチャ全体像
ここまでで「二重書き実装のどこが危ないのか」「Outboxパターンのざっくりした流れ」はイメージできたと思います。
では、アプリケーション全体としてはどんな構成になるのでしょうか。
アプリケーション全体としての構成を、一度図にしておきます。
ここでのポイントは、アプリケーション本体(予約受付・決済処理など)が外部システムに直接話しかけないことです。
アプリケーションは「自分のDBまで」、その先にいるブローカーや他サービスとの通信は、 別プロセス(ワーカーやバッチ、CDC ツールなど)の責務に切り出します。これにより、外部システムがダウンしていても「とりあえずOutboxに積んでおく」という選択肢が取れるようになります。
4. スキーマ設計とトランザクション設計
次に、具体的なテーブル設計を見ていきます。
Outbox自体は、ただのテーブルやコレクションです。
RDBなら専用テーブル、ドキュメントDBならドキュメントの一部としてOutbox情報を持たせる実装もあります。
RDBの場合、おおよそ次のような情報になります。
CREATE TABLE outbox_messages (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL, -- "reservations" など
aggregate_id BIGINT NOT NULL, -- "reservation_id" 等
event_type VARCHAR(100) NOT NULL, -- "reservation.created" など
payload JSON NOT NULL,
status VARCHAR(20) NOT NULL, -- "pending", "processing", "sent", "failed"
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
);
payloadをJSONにしておくと、言語やメッセージブローカーに依存しにくくなります。
ここで一番重要なのは「業務テーブルとOutbox(outbox_messages)テーブルが同じトランザクションで更新されること」です。LaravelだろうがGoだろうが、やることは変わりません。
DBが1台であれば、普通のトランザクションで十分です。2PCを使ってDBとメッセージブローカーをまたぐ必要はありません。
メッセージブローカーへの送信や、外部APIの呼び出しなどはトランザクションから完全に切り離します。
先ほどの予約コードをOutbox対応に書き換えると、擬似コードではこうなります。
public function reserve(ReserveRequest $request): void
{
// 1. DB に予約を保存
DB::transaction(function () use ($request) {
$reservation = Reservation::query()->create([
'shop_id' => $request->shop_id,
'user_id' => $request->user_id,
'datetime' => $request->datetime,
'headcount' => $request->headcount,
]);
OutboxMessage::query()->create([
'aggregate_type' => 'reservation',
'aggregate_id' => $reservation->id,
'event_type' => 'reservation.created',
'payload' => json_encode([
'reservation_id' => $reservationId,
'shop_id' => $request->shop_id,
'user_id' => $request->user_id,
]),
'status' => 'pending',
]);
});
}
5. Outbox からメッセージを配信する仕組み
ここまでで、なぜ Outbox が必要になるのか、そして業務テーブルと同じトランザクションでoutbox_messagesにレコードを書き込むところまでは整理できました。
では、ここからそのOutboxに溜まったレコードをどう配信していけばよいのでしょうか。
5-1. 配信方式
典型的な選択肢は次のとおりです。
- アプリケーションからOutboxテーブルをポーリングする方式
- CDC(Change Data Capture)ツールに任せる方式
最初の一歩としては、アプリケーションがポーリングする方式の方が導入しやすいので、今回は「アプリケーションから Outbox テーブルをポーリングする専用ワーカー方式」で見ていきます。
CDC(Change Data Capture)の詳細については、本記事では一旦触れません。
5-2. ポーリング方式の基本フロー
やること自体はシンプルで、「Outboxテーブルを定期的に見に行って、未送信データを順番に送るだけのプログラム」です。
フレームワークをあまり意識しないコードにすると、イメージはこんな感じです。
class OutboxPublisher
{
public function run(): void
{
while (true) {
$messages = $this->fetchPendingMessages(limit: 100);
if ($messages->isEmpty()) {
sleep(1);
continue;
}
foreach ($messages as $message) {
$this->publish($message);
}
}
}
private function fetchPendingMessages(int $limit): Collection
{
return OutboxMessage::where('status', 'pending')
->orderBy('created_at')
->lockForUpdate() // 重複して同一行を処理しないようにロックで取得
->limit($limit)
->get();
}
private function publish(OutboxMessage $message): void
{
$message->status = 'processing';
$message->save();
try {
$this->sendToBroker($message->event_type, $message->payload);
$message->status = 'sent';
$message->save();
} catch (\Throwable $e) {
$message->status = 'failed';
$message->save();
// ログ・メトリクス等もここで
}
}
private function sendToBroker(string $eventType, array $payload): void
{
// Kafka / RabbitMQ / SQS / gRPC / REST など、環境に合わせて実装
}
}
-
statusがpendingのレコードを、古い順にいくつか(例えば 100 件)取ってくる
※limitしている理由としては、毎回「pending を全部取ってくる」ような実装にすると、1回のループで大量のレコードをメモリに載せてしまい、DB・アプリ双方の負荷が大きくなるため、chunkさせる意味で指定している - ひとつずつ外部システムに送信する
- 成功したら
statusををsentにする - 失敗したら
statusをfailedにする
ここで大事なのは、この構造によって 「少なくとも1回は届けられる」という性質を持たせやすくなることです。
理由は単純で、まず、業務データとOutboxのレコードは同じトランザクションで永続化されているため、「予約は入っているのに、Outboxに痕跡が一切ない」という状態はそもそも発生しません。トランザクションがコミットされたなら、ビジネス側の行とOutboxの行は必ずペアで残ります。一方で、ポーリングワーカーは「Outboxに残っているレコードだけ」を順番に処理し、送信に成功したものだけをsentとして扱います。送信途中でワーカーが落ちたり、送信先が一時的にダウンしていた場合でも、そのレコードはpendingもしくはfailedのままOutboxに残り続けるため、ワーカーを再起動してもう一度走らせれば、同じレコードを再び拾って再送できます。
つまり、「Outboxにレコードがあるのに、1回も送信せずにレコードだけが削除される」経路をアプリケーション側で封じ込めているので、Outboxレコードが残っている限り、ポーリング処理を再実行することで何度でも再送を試みられる、という構造になっています。
6. 配信保証 と 冪等性
ポーリングを用いた配信方式を採用することで、未処理のOutboxを少なくとも一度は必ず配信させることに成功しました。
しかし、このままでは1つ問題が発生します。その問題は重複したメッセージが配信される可能性があるということです。
6-1. なぜ重複配信が避けられないのか
もう一度、publish()の流れだけ抜き出してみます。
class OutboxPublisher
{
// ~省略~
private function publish(OutboxMessage $message): void
{
$message->status = 'processing';
$message->save();
try {
$this->sendToBroker($message->event_type, $message->payload);
$message->status = 'sent';
$message->save();
} catch (\Throwable $e) {
$message->status = 'failed';
$message->save();
}
}
}
ここで、重複配信が発生する典型的なパターンを 1 つだけ具体的に見てみます。
-
OutboxPublisherのpublishメソッドがstatusを'processing'に更新する -
sendToBroker()がメッセージをブローカーに送る - ブローカーはメッセージをキューに積み、「受け取ったよ」という ACK を返す
- その ACK がネットワーク障害で
OutboxPublisherまで届かない - あるいは ACK が返る前に、
OutboxPublisher自体のプロセスが落ちる
ブローカー視点では「メッセージはもう受け取っている」のに、送り手側(OutboxPublisherのpublishメソッド)から見ると「成功したのか分からない」状態になります。
送り手は ACK を確認できていないので、statusをsentに更新できません。
結果として、そのレコードはprocessingやfailedのままOutboxに残り続けます。
そのため、ポーリングが起動した際に再び同一のoutboxレコードを配信してしまう可能性があるのです。
つまり、少なくとも1回配信できるようになりましたが、確実に1回配信されるとは限らないのです。
6-2. 冪等性という考え方
では、どうするか。
答えはシンプルで、同じメッセージが何回届いても、最終的な状態が変わらないように受信側を作る必要があります。
つまり、受信側で冪等性(idempotency)を担保する必要があるということです。
冪等性とは:
同じ操作を何度実行しても、最終的な結果が一度だけ実行したのと変わらない性質のこと。
具体的には、同じメッセージが 2 回、3 回届いても、結果が 1 回目と変わらないようにする という設計を受信側で行う必要があるということです。
やり方はいくつかありますが、現場でよく使われる代表的なパターンを 2 つに絞って整理しておきます。
パターン 1: 自然な一意制約をそのまま使う
もしドメイン上、「このイベントはこのキーで一意に識別されるべきだ」という値がすでにあるなら、それをそのまま冪等性に使えばよいです。
例えば、先程の「予約作成イベント」であれば、イベントのpayloadにreservation_idを設定していました。
ここから先は、それを受け取る 店舗側サービス(あるいは別の連携サービス)を想像してください。
店舗側サービスの DB には、例えばこんなテーブルがあるとします。
CREATE TABLE shop_reservations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_reservation_id BIGINT NOT NULL UNIQUE, -- 予約受付サービス側の reservation_id
shop_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
datetime DATETIME NOT NULL,
headcount INT NOT NULL
);
ここで重要なのはsource_reservation_idにUNIQUE制約が付いていることです。
これは「この店舗側予約は、あの予約受付サービスのreservation_idと 1:1 で対応している」という意味になります。
そのため、店舗側のサービスにおけるドメイン上の一意なキー(ここではreservation_id)があるなら、それをそのまま「重複判定のキー」として使えます。
冪等性を考えていない受信側の処理は次のようになります。
class ReservationCreatedHandler
{
public function handle(array $payload): void
{
ShopReservation::create([
'source_reservation_id' => $payload['reservation_id'],
'shop_id' => $payload['shop_id'],
'user_id' => $payload['user_id'],
'datetime' => $payload['datetime'],
'headcount' => $payload['headcount'],
]);
}
}
これだと、同じreservation_idのメッセージが2回届いた瞬間に一意制約エラーで落ちてしまいます。
そこで、同じ予約IDが来たら作るのではなく、既存行を更新するか無視するように変更します。
class ReservationCreatedHandler
{
public function handle(array $payload): void
{
DB::transaction(function () use ($payload) {
$reservation = ShopReservation::query()
->where('source_reservation_id', $payload['reservation_id'])
->lockForUpdate()
->first();
if ($reservation) {
// すでに連携済みなら、必要に応じて更新するだけ
$reservation->fill([
'datetime' => $payload['datetime'],
'headcount' => $payload['headcount'],
])->save();
return;
}
// 初回だけ作成する
ShopReservation::query()->create([
'source_reservation_id' => $payload['reservation_id'],
'shop_id' => $payload['shop_id'],
'user_id' => $payload['user_id'],
'datetime' => $payload['datetime'],
'headcount' => $payload['headcount'],
]);
});
}
}
こうしておけば、同じreservation_idを持つreservation.createdが2回、3回届いても、1 回目はINSERTされ、2 回目以降は既存行をUPDATEするか、場合によっては「何もしない」で終わります。
外から何度メッセージが届いても、テーブルの最終状態は1回目の結果と変わらなくなります。
ただ、これは「自然な一意キーがそもそもないイベント」の場合には、この方式だけでは対応できません。
そこで出てくるのが、もうひとつの定番パターンです。
パターン2: Idempotent Consumer / Inbox パターン
こちらは非常に汎用的な方法で、送り手が各メッセージに一意なmessage_id(もしくは idempotency_key)を付与します。受け手は、「処理済みメッセージの ID」をどこかに保存しておき、処理前にその ID の有無をチェックします。
ここではイメージしやすいように、RDB の専用テーブルを使う形で書いてみますが、
実際にはMongoDBコレクションやRedis、Kafkaのstate storeなどに置き換えても構造は同じです。
まず、受信側サービスにこんなテーブルを用意します。
CREATE TABLE processed_messages (
message_id VARCHAR(64) PRIMARY KEY,
processed_at DATETIME NOT NULL
);
実際にイベントを処理するコードは、こんなイメージになります。
class ReservationCreatedHandler
{
public function handle(EventMessage $message): void
{
$messageId = $message->idempotency_key; // outbox 側で振った ID
$payload = $message->payload;
DB::transaction(function () use ($messageId, $payload) {
// すでに処理済みなら何もしない
if (ProcessedMessage::query()->find($messageId)) {
return;
}
ShopReservation::query()->create([
'source_reservation_id' => $payload['reservation_id']
'shop_id' => $payload['shop_id'],
'user_id' => $payload['user_id'],
'datetime' => $payload['datetime'],
'headcount' => $payload['headcount'],
]);
// 「この message_id は処理済み」という事実を残す
ProcessedMessage::query()->create([
'message_id' => $messageId,
'processed_at' => now(),
]);
});
}
}
これで、フローはこうなります。
- まだ
message_idがprocessed_messagesに存在しなければShopReservationのINSERTを実行する - 同じトランザクションで
processed_messagesにmessage_idをINSERTする - その後、ブローカー側の都合で同じメッセージが再配信されても、再度
handle()が呼ばれたときにはprocessed_messagesに同じmessage_idがあるので、早い段階でreturnして業務処理をスキップする
結果として、ブローカー等の配信側が少なくとも1回で、受信側では同じメッセージを何度か届けてきても、確実に1回だけという状態を実現できます。
運用でのポイント
7-1. 行き場のないメッセージをどう扱うか
今まで見てきたとおり、送り手側はOutboxに残っていれば、何度でもsentになっていないメッセージを送り直せるようになっていました。
そのためstatusがfailedになったレコードも成功するまで再ポーリングによってリトライすることが可能でもあるということです。
ただ、一度failedとなったメッセージは再試行により必ず成功するのでしょうか?
7-1-1. 一時的エラーと恒久的エラーを分ける
リトライ戦略の際に考えるポイントは「どのエラーはリトライして意味があるのか」という切り分けです。
大きくは以下の2つに分かれます。
-
一時的エラー
タイムアウト、接続エラー、一時的な 5xx、429(レートリミット)などのエラーで、これらは時間をおけば成功する可能性があります。 -
恒久的エラー
4xx 系(バリデーションエラー、認証・認可エラー、不正なリクエスト形式など)のエラーで、データ不整合が原因で、これはいくらリトライしても同じエラーが返ってしまいます。
この2種類を同じようにリトライすると、いくらやっても直らない恒久的エラーに対して、無限リトライしてしまうことにつながります。
恒久的エラーをいつまでもOutboxから再送し続けるのは、システムにも相手にも良くありません。
そこで出てくるのがDead Letterという考え方です。
7-1-2.Dead Letter
Dead Letterとは一言で言うと
これは自動リトライしても無理そうだから、いったん退避させて手動で処理する対象に回そう
というものです。
メッセージングシステムでは Dead Letter Queue(DLQ)という名前で登場することが多く、リトライで救えないメッセージを隔離して可視化する仕組みとして広く使われています。
Outboxでもこの考え方を取り入れ、ある程度までは自動リトライで粘り、それでもダメなら「これは自動処理では無理」と判断して、別枠に隔離することを行います。その上で、隔離されたメッセージは、人間が内容を見て手動修正・再投入などを判断するようにします。
このようにすることで運用がだいぶ楽になります。
7-1-3.テーブル設計を見直す
元の Outboxテーブルに、次のようなカラムを足します。
ALTER TABLE outbox_messages
ADD COLUMN retry_count INT NOT NULL DEFAULT 0,
ADD COLUMN last_error_code VARCHAR(32) NULL,
ADD COLUMN last_error_reason TEXT NULL,
retry_countは何回リトライしたか。最大回数を超えたら「もう無理」と判断するためのカウンタです。
last_error_code、last_error_reasonは最後に失敗したときの情報で必要に応じて外部システムのステータスコードやエラー種別などを入れます。
さらにstatusカラムにdeadステータスを追加するようにします。
7-1-4. 実装例
先ほどから見ていたポーリング側の publish() を少しだけ拡張します。
class OutboxPublisher
{
public function run(): void
{
while (true) {
$messages = $this->fetchSendableMessages(limit: 100);
if ($messages->isEmpty()) {
sleep(1);
continue;
}
foreach ($messages as $message) {
$this->publish($message);
}
}
}
private function fetchSendableMessages(int $limit): Collection
{
return OutboxMessage::query()
->whereIn('status', ['pending', 'failed'])
->orderBy('created_at')
->limit($limit)
->get();
}
private function publish(OutboxMessage $message): void
{
$message->status = 'processing';
$message->save();
try {
$this->sendToBroker($message->event_type, $message->payload);
$message->status = 'sent';
$message->last_error_code = null;
$message->last_error_reason = null;
$message->save();
} catch (\Throwable $e) {
$message->retry_count++;
$errorCode = $this->toErrorCode($e);
$isTransient = $this->isTransientError($errorCode);
if ($isTransient && $message->retry_count < 5) {
// まだ望みがありそうな一時的エラーとして再試行対象とする
$message->status = 'failed';
} else {
// 恒久エラー or リトライ上限超えの場合はDead Letterとする
$message->status = 'dead';
$message->dead_at = now();
}
$message->last_error_code = $errorCode;
$message->last_error_reason = $e->getMessage();
$message->save();
}
}
private function sendToBroker(string $eventType, array $payload): void
{
// Kafka / RabbitMQ / SQS / REST / gRPC など環境に合わせて実装
}
private function toErrorCode(\Throwable $e): string
{
// 例えば HTTP ステータスや独自コードにマッピングする
// 実際には例外の種類やレスポンス内容から決める
return 'NETWORK_ERROR';
}
private function isTransientError(string $errorCode): bool
{
// 自分たちのシステムに合わせて「一時的」とみなすエラー種別を定義する
return in_array($errorCode, [
'NETWORK_ERROR',
'TIMEOUT',
'REMOTE_5XX',
'RATE_LIMIT',
], true);
}
}
まず、fetchSendableMessages()はpendingとfailedのレコードだけを対象にし、deadが入っているものは最初からクエリの対象外とします。この時点で、Dead判定されたメッセージは本流の配信フローから完全に切り離されます。
次に、送信が失敗した場合はretry_countをインクリメントしつつ、そのときの例外やステータスコードからエラー種別(errorCode)を決め、「一時的なエラーなのか、それとも恒久的なエラーなのか」を判定します。一時的なエラーだと判定できて、なおかつリトライ回数が上限に達していなければ、statusをいったんfailedに戻しておき、次回のポーリングループで再試行させます。逆に、恒久的なエラーだと判断された場合や、すでにリトライ回数の上限を超えている場合は、その時点で statusをdeadに変更します。
こうしておくことで、「まだ回復の余地がありそうなメッセージ」は自動リトライの対象として Outboxに残り続け、「どうやっても成功しなさそうなメッセージ」はDeadとして対比させることが可能となります。
7-2. 増え続けるOutboxテーブルへの対応
Outboxテーブルは、イベントが発生するたびに増加するため、時間が経つにつれてデータ量も増加していきます。そうすると、レコード数が膨大となってしまい、クエリに影響を与える可能性があります。
とはいえ、監査・コンプライアンスの観点で「過去にどんなイベントを出したか」を長期保管したい場合もあるでしょう。
この、性能のためにレコードを削除したい要求と監査のためには残したいという相反する要求をどう折り合いをつけるかが 運用の設計ポイントです。
7-2-1. 基本的な考え方
現実的な落としどころとしては、Outbox に流れてくるイベントを「用途の違う二つの保存領域」に分けて捉えるのがよいでしょう。
ひとつは、アプリケーションが日常的に参照・更新する領域です。
先ほどから例に挙げているoutbox_messagesテーブルがここに当たります。
連携処理の状態確認や障害調査で「今まさに使う可能性がある」イベントだけを残すイメージです。例えば「直近 7〜30 日分くらいは DB に置いておく」といったラインを、運用チームと相談して決めておきます。
もうひとつは、長期保管を目的とした領域です。
日常の運用で直接参照することは少ないけれど、「将来の監査やトラブル調査で必要になるかもしれない」イベントをここに移しておきます。
例えば、
- 同じ DB 内の別スキーマ・別テーブル(outbox_messages_archive など)に書き出す
- オブジェクトストレージ(S3 など)に JSON / Parquet 形式でエクスポートし、Athena や BigQuery から検索できるようにする
どこに置くかは、組織で既に使っている分析基盤や監査要件にかなり依存しますが、共通している考え方はひとつです。
アプリケーションが普段叩くクエリの対象となる領域から、長期保管のイベントを物理的に切り離すことで、日常運用のパフォーマンスを守りつつ、「必要なら過去のイベントをちゃんと辿れる」状態を両立させる、ということです。
8. 最後に
ここまで読んだあなたなら、もう自分のシステムの怪しい箇所が何ヶ所か頭に浮かんでいるかもしれません。
予約や注文、入金、在庫更新、ユーザー状態の変更などと同じタイミングで、イベントを投げたり、別コンポーネントや別サービスに通知したりしている処理があるかもしれません。
「ここで片側だけ失敗したら、ちょっと怖いよな……」と薄々感じていながら、例外が起きたときはログを吐いて終わりにしているだけの場所などなど。
そういう箇所を、まずは 1 カ所だけ選んでみてください。
そこに この記事で見てきた最低限の形でいいので採用してみる。この記事全体は、その 1 カ所をちゃんと作り切るための「設計〜実装〜運用までの一式」を、できるだけ具体的に並べたつもりです。
最初の一歩はシンプルで十分です。まずは業務テーブルと同じトランザクションで Outbox テーブルに書き込むようにする。
次の段階で、素朴なポーリングワーカーを用意して pending を順に送っていく。
配信が「少なくとも 1 回」になったところで、今度は受信側に一意キーや Inbox テーブルを用意して「確実に 1 回」に近づけていく。
最後に、リトライ回数や Dead 判定、アーカイブの方針を運用しながら少しずつチューニングしていく。
そんな順番で進めていけば、いきなり完璧を目指さなくても、現実的な落としどころにたどり着けます。
この記事が、その最初の一歩を踏み出すときのヒントになっていたら嬉しいです。
そして、ここまで付き合って読んでくださったことに、感謝します。ありがとうございました!!