テック技報最新号へ →
コード

Transactional outbox: DB の更新とイベント送信を同じトランザクションに入れる

「DB に保存してから Kafka などに publish」は、間でプロセスが落ちるとイベントが消える。イベントを同じ DB の outbox テーブルに同じトランザクションで書き、別のプロセスが送信する。

@amitsinha__07 · 投稿 2026-11-01
#architecture#database#messaging#infra

何が起きるか

go
db.Save(order)         // ① 注文を保存(コミット済み)
kafka.Publish(event)   // ② イベント送信

①と②の間でクラッシュ・デプロイ・ネットワーク断が起きると、注文はあるのに「注文作成」イベントが出ない。逆順にすると、イベントは出たのに注文が無い、という状態になる。DB とメッセージブローカーをまたぐ 1 つのトランザクションは基本的に作れない。

やり方

  1. 業務データと同じ DB に outbox テーブルを作る。
    sql
    CREATE TABLE outbox (
      id          uuid PRIMARY KEY,
      topic       text NOT NULL,
      payload     jsonb NOT NULL,
      created_at  timestamptz NOT NULL DEFAULT now(),
      sent_at     timestamptz
    );
  2. 業務データの更新と outbox への INSERT を 同じトランザクション で行う。どちらか片方だけ残ることがなくなる。
    sql
    BEGIN;
    INSERT INTO orders (...) VALUES (...);
    INSERT INTO outbox (id, topic, payload) VALUES (gen_random_uuid(), 'order.created', '{...}');
    COMMIT;
  3. リレー(別のワーカー)が sent_at IS NULL の行を読み、ブローカーに送ってから sent_at を埋める。複数ワーカーなら SELECT ... FOR UPDATE SKIP LOCKED で取り合いを避ける。
  4. 消費側はイベント ID で重複を除く(処理済み ID を保存しておく)。

注意点

  • リレーは「送った後、sent_at を書く前」に落ちることがあるので、配送は 少なくとも 1 回(重複あり) になる。消費側の重複除去は必須。
  • outbox は送信済みの行を定期的に消すか、パーティションで古い分を落とす。
  • リレーの代わりに、Debezium などで DB の変更ログ(WAL)から outbox を読む方法もある。
  • 順序が大事なら、同じ集約(注文 ID など)のイベントは同じパーティションキーで送る。

出典

X の元の投稿を開く