Transactional outbox: DB の更新とイベント送信を同じトランザクションに入れる
「DB に保存してから Kafka などに publish」は、間でプロセスが落ちるとイベントが消える。イベントを同じ DB の outbox テーブルに同じトランザクションで書き、別のプロセスが送信する。
何が起きるか
go
db.Save(order) // ① 注文を保存(コミット済み)
kafka.Publish(event) // ② イベント送信①と②の間でクラッシュ・デプロイ・ネットワーク断が起きると、注文はあるのに「注文作成」イベントが出ない。逆順にすると、イベントは出たのに注文が無い、という状態になる。DB とメッセージブローカーをまたぐ 1 つのトランザクションは基本的に作れない。
やり方
- 業務データと同じ DB に
outboxテーブルを作る。sqlCREATE TABLE outbox ( id uuid PRIMARY KEY, topic text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), sent_at timestamptz ); - 業務データの更新と outbox への INSERT を 同じトランザクション で行う。どちらか片方だけ残ることがなくなる。
sql
BEGIN; INSERT INTO orders (...) VALUES (...); INSERT INTO outbox (id, topic, payload) VALUES (gen_random_uuid(), 'order.created', '{...}'); COMMIT; - リレー(別のワーカー)が
sent_at IS NULLの行を読み、ブローカーに送ってからsent_atを埋める。複数ワーカーならSELECT ... FOR UPDATE SKIP LOCKEDで取り合いを避ける。 - 消費側はイベント ID で重複を除く(処理済み ID を保存しておく)。
注意点
- リレーは「送った後、
sent_atを書く前」に落ちることがあるので、配送は 少なくとも 1 回(重複あり) になる。消費側の重複除去は必須。 - outbox は送信済みの行を定期的に消すか、パーティションで古い分を落とす。
- リレーの代わりに、Debezium などで DB の変更ログ(WAL)から outbox を読む方法もある。
- 順序が大事なら、同じ集約(注文 ID など)のイベントは同じパーティションキーで送る。