トランザクショナルアウトボックス

データベースへの書き込みとイベント発行を原子的に行うための分散システムパターン

分散システムイベント駆動

トランザクショナルアウトボックスとは

トランザクショナルアウトボックス (Transactional Outbox) は、データベースへのビジネスデータの書き込みとイベントの発行を原子的に行うパターンである。「注文を保存したがイベントの発行に失敗した」「イベントは発行したが注文の保存に失敗した」という不整合を防ぐ。

問題: 二重書き込み

注文を保存し、その事実を下流のサービスへ知らせる。この 2 つの処理は必ず対で現れる。

// ❌ 二重書き込み問題: DB 保存とイベント発行が別トランザクション
await db.orders.create(order);        // 1. DB に保存 (成功)
await sns.publish({ Message: order }); // 2. イベント発行 (失敗したら?)
// → 注文は保存されたが、イベントが発行されない → 下流システムが同期されない

DB とメッセージングは別のシステムなので、片方だけが成立する瞬間が必ず存在する。発行の呼び出しが失敗する場合だけでなく、発行の直前にプロセスが落ちる場合も結果は同じである。順序を入れ替えて先にイベントを発行しても解決しない。今度は保存に失敗した注文について通知が飛ぶ。両者を 1 つの分散トランザクション (2 相コミット) で括る発想もあるが、マネージドなメッセージングサービスの多くは分散トランザクションに参加する仕組みを持たないため、選択肢に上がらないことが多い。

解決: Outbox テーブル

発想は単純で、イベントを外部へ送る代わりに、同じデータベースの中のテーブルへ「送るべきイベント」として書き込む。書き込み先が 1 つのデータベースに収まれば、あとは通常のトランザクションで原子性が得られる。

// ✅ Outbox パターン: 同一トランザクションで DB 保存 + Outbox 書き込み
await db.transaction(async (tx) => {
  await tx.orders.create(order);
  await tx.outbox.create({
    id: uuid(),
    aggregateType: 'Order',
    eventType: 'OrderCreated',
    payload: JSON.stringify(order),
    createdAt: new Date(),
  });
});
// → 注文と Outbox レコードが両方成功 or 両方失敗 (発行はこの後、非同期)

Outbox テーブルに書き込まれたイベントは、別のプロセス (ポーラー) が定期的に読み取って SNS/SQS に発行し、発行できたレコードに処理済みの印を付けるか削除する。ここで原子的になっているのはビジネスデータと Outbox レコードの書き込みであって、外部への発行は依然として非同期である。トランザクションが成功した時点で確定するのは「発行されたこと」ではなく「発行すべき事実がデータベースに残ったこと」で、そこから先はポーラーの再試行が届けきる。

アーキテクチャ

アプリケーションはビジネスデータと Outbox レコードを同一トランザクションで DB に書き込む。Outbox ポーラーまたは CDC (Change Data Capture) が Outbox テーブルの変更を検知し、メッセージングサービス (SNS、EventBridge) にイベントを発行する。トランザクションの原子性が守るのは DB への 2 つの書き込みで、そこから先の到達はポーラーや CDC の再試行が担う。この分担のため、イベントは遅れて届くことはあっても取り落とされない。

[アプリケーション]
  ↓ 同一トランザクション
[DB: orders テーブル] + [DB: outbox テーブル]
                              ↓ ポーリング or CDC
                        [Outbox ポーラー / CDC][SNS / SQS / EventBridge][下流サービス]

DynamoDB での Outbox

DynamoDB はトランザクション (TransactWriteItems) で複数テーブルへの書き込みを原子的に行える。対象にできるのは同一アカウント・同一リージョンの最大 100 アイテムまでで、合計サイズは 4 MB を超えられない。

await ddb.send(new TransactWriteCommand({
  TransactItems: [
    { Put: { TableName: 'Orders', Item: order } },
    { Put: { TableName: 'Outbox', Item: {
      id: uuid(),
      eventType: 'OrderCreated',
      payload: JSON.stringify(order),
      ttl: Math.floor(Date.now() / 1000) + 86400,
    }}},
  ],
}));

DynamoDB Streams で Outbox テーブルの変更を検知し、Lambda でイベントを発行する。ストリームのレコードは同じアイテムについては実際の変更順に並ぶため、1 つの注文に関するイベントが入れ替わることはない (別々のアイテム同士の前後関係は保証されない)。

上の例では Outbox のアイテムに ttl を付けて 1 日で消えるようにしている。テーブルを無限に太らせないための工夫だが、発行に失敗したまま期限が来ればイベントは黙って消える。未処理レコードの滞留時間を監視する仕組みを先に用意してから TTL を付ける、という順序を守りたい。

なお DynamoDB では Orders テーブル自身のストリームを読めば、Outbox を挟まずに変更を拾える。それでも Outbox を置くのは、外へ出したいドメインイベントの粒度や形が行の変更と一致しないときである。行の差分をそのまま流すと、下流がこちらのテーブル構造に依存してしまう。

CDC vs ポーリング

取り出し方は大きく 2 通りで、自分でテーブルを読みに行くか、DB が書いているトランザクションログ由来の変更通知に乗るかである。

方式仕組み遅延複雑さ
ポーリング定期的に Outbox テーブルをスキャン秒〜分低い
CDCDB のトランザクションログを読み取りミリ秒〜秒中程度
DynamoDB StreamsDynamoDB のネイティブ CDCミリ秒〜秒低い

DynamoDB Streams を使えば、ポーラーを自前で実装する必要がない。リレーショナルデータベースでは Debezium が同じ位置を占め、Outbox テーブルの行を宛先トピックへ振り分ける Outbox Event Router という変換も用意されている。

ポーリングを選ぶ場合、間隔を詰めるほど遅延は縮むが、その分だけ空振りのクエリが増える。ポーラーを複数並べて可用性を上げるなら、同じレコードを 2 つのプロセスが拾わないよう取得時の排他 (行ロックや条件付き更新) が要る。

イベントの重複配信

Outbox パターンでは、イベントが重複配信される可能性がある (at-least-once)。発行に成功した直後、処理済みの印を付ける前に落ちれば、同じレコードがもう一度発行される。この隙間を無くすことはできないので、下流のコンシューマーはべき等に作る。イベントに一意な ID を持たせ、処理済みの ID を記録して 2 回目以降を捨てるのが定石で、Outbox レコードの id をそのままイベント ID として運べばこの判定が素直に書ける。

トランザクショナルアウトボックスの関連書籍も参考になる。

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

関連用語

関連する記事