Outbox パターン

DB の変更とイベント発行を 1 つのトランザクションで保証するメッセージング信頼性パターン

設計パターンイベント駆動

Outbox パターンとは

Outbox パターンは、DB の変更とイベント発行を 1 つのトランザクションで保証するパターンである。DB に書き込んだがイベントが発行されない (またはその逆) という不整合を防ぐ。同じパターンを トランザクショナルアウトボックス とも呼び、CDC やポーリングでの取り出し方、Outbox レコードの後始末といった実装寄りの論点はそちらに詳しく書いてある。ここでは何が防げて何が防げないのかに絞る。

問題: 二重書き込み

メッセージブローカーは DB のトランザクションに参加しない。1 回のコミットで DB の行とブローカーへの送信を同時に確定させる手段がないため、どちらを先に置いても間に窓が残る。DB を先に書けば発行漏れになり、発行を先にすればコミット失敗後に実体のないイベントが下流へ流れる。

❌ DB 書き込み + イベント発行が別トランザクション:
  1. DynamoDB に注文を保存 → 成功
  2. EventBridge にイベント発行 → 失敗
  → 注文は保存されたがイベントが失われる

Outbox パターンの仕組み

DB への書き込みとイベント発行を原子的に行うため、ビジネスデータと Outbox レコードを同一トランザクション (DynamoDB の TransactWrite) で書き込む。その後、DynamoDB Streams が Outbox の変更を検知し、Lambda がイベントを EventBridge に発行する。DB 書き込みが成功した変更は、発行待ちのレコードとして DB に残るため、遅れても取りこぼされない。

1. DynamoDB に注文 + Outbox レコードを TransactWrite
2. DynamoDB Streams が Outbox の変更を検知
3. Lambda が Outbox レコードを読み取り、EventBridge に発行
→ DB 書き込みとイベント発行が原子的

効いているのは、発行すべきイベントを外部への送信から DB の中の行へ置き換えた点である。トランザクションに参加できない相手 (ブローカー) を境界の外へ追い出すと、残る 2 つの操作はどちらも同じ DB への書き込みになり、1 つのコミットに収まる。送信の責任は後段の別プロセスへ移り、そこが落ちても未発行のレコードが DB に残っているので再試行できる。原子性が保証されるのは「業務データの変更」と「発行の予定」の間であって、発行そのものが 1 回で済むという意味ではない。

DynamoDB での実装

DynamoDB では TransactWriteItems が業務データと Outbox レコードをまとめて書く役を担う。対象にできるのは同一アカウント・同一リージョンの複数テーブルで、1 回に最大 100 の書き込みアクション・合計 4 MB までという制限がある。同じ項目を 1 つのトランザクション内で 2 回操作すると検証エラーになるので、Outbox のキーは業務データのキーと衝突しない形で組む。

await db.transactWrite({
  TransactItems: [
    { Put: { TableName: 'orders', Item: { PK: `ORDER#${id}`, ...order } } },
    { Put: { TableName: 'outbox', Item: {
      PK: `OUTBOX#${id}`,
      eventType: 'OrderCreated',
      payload: JSON.stringify(order),
      createdAt: Date.now(),
    } } },
  ],
});
// 両方が成功 or 両方が失敗 (原子性)

Outbox vs 直接イベント発行

直接発行との差は、失敗した瞬間に何が残るかに現れる。

観点直接発行Outbox
原子性担保できない (DB とブローカーへの二重書き込みになる)担保できる (1 トランザクションに収まる)
複雑さ低い
遅延低いやや高い (Streams 経由)
信頼性低い高い

いつ使うか

判断は、イベントが 1 通失われたときに後から気づいて直せるかどうかに集約される。

場面判断理由
イベントが 1 件でも失われると業務が食い違うOutbox にする書き込みと発行を同一トランザクションに載せるため、DB に残った変更に対応するイベントが必ず後続処理へ渡る
入金や注文のように後追いでの復旧が難しいOutbox にする直接発行では DB 側だけ成功した状態が残り、記録とイベントのどちらが正しいかを人手で突き合わせることになる
通知や集計の更新で、遅れても最終的に揃えばよい直接発行にリトライを添えるStreams を経由する分の遅延と Outbox の運用を負う価値が薄く、失敗時に再送すれば実害が収まる

保証されないこと

Outbox を入れても配信が 1 回だけになるわけではない。DynamoDB Streams のレコード自体はストリーム内に 1 度しか現れないが、それを読む Lambda のイベントソースマッピングは各イベントを少なくとも 1 回処理する仕様で、公式ドキュメントも重複処理が起こり得るとして関数を冪等に作るよう明記している。発行に成功した直後、処理済みの印を付ける前に落ちれば同じイベントがもう一度出る。したがって下流のコンシューマー側で一意な ID による重複排除が必須になる (冪等性)。

順序も部分的な保証にとどまる。ストリームのレコードは同じ項目の変更については実際の順に並ぶが、別々の項目の前後関係は保証されない。注文 A と注文 B のイベントが入れ替わって届く前提で下流を設計する。

Outbox テーブルを放置すれば無限に太るため、発行済みレコードの削除や TTL を併せて設計する必要がある。ここで急ぐと、発行に失敗したまま期限切れでレコードが消えて事故が静かに埋まる。滞留しているレコードの件数と最古の作成時刻を監視する方が先だ。

この記事は役に立ちましたか?

関連用語

関連する記事