メインコンテンツへスキップ
友田 陽大
信頼性・非同期・リアルタイム
アーキテクチャ設計
AWS
冪等性
決済
メッセージング

トランザクショナル・アウトボックスパターン:DB更新とイベント発行を原子的にし、取りこぼし・二重発行を断つ

分散システムの二重書き込み問題(dual-write)を解くトランザクショナル・アウトボックスパターンの実装ガイド。業務更新と同一トランザクションでoutboxに書き、リレー(ポーリング/CDC)で確実に発行、downstreamは冪等に。順序保証・at-least-once・整合化(reconciliation)までを実コードで解説します。

公開日
読了時間
25分
著者
友田 陽大
シェア
目次

「DBを更新したら、その変更をイベントとして発行する」——要件は一行です。注文を確定したら通知サービスへ。決済を記録したら集計サービスへ。在庫を減らしたら出荷サービスへ。マイクロサービスでも、モジュラモノリスの非同期処理でも、この「業務更新 → イベント発行」は至るところに現れます。

そして、ほとんどの実装が静かに壊れています。「DBにINSERTしてから、キューにpublishする」——この素直な2行のコードが、ネットワーク断・プロセスクラッシュ・リトライのもとでイベントの取りこぼし幽霊イベントを生みます。本番で「通知が来ないことがある」「たまに二重で課金される」という障害は、たいていここが震源です。

この記事は、その根本原因である二重書き込み問題(dual-write problem)を正面から解き、トランザクショナル・アウトボックスパターンで「DB更新とイベント発行を原子的に(atomically)」成立させる実装ガイドです。題材として、私が招待制B2B SaaS(木材流通DX、Stripe Connect マーケットプレイス決済、経済産業大臣賞受賞)で、課金調整をネットワーク断やリトライ下でも二重課金・取りこぼしを防ぐ形に組み上げた設計判断も交えます。

この記事のルール:パターンの定義・構造は microservices.io(Chris Richardson)と AWS Prescriptive Guidance(2026年6月時点) の記述に基づきます。特定ツール(Debezium / DynamoDB Streams など)に依存する箇所は「例」と明示します。仕様は改定されるため、本番投入前に必ず公式ドキュメントで最新の記述を確認してください。コードは実運用で使える形に整えていますが、シークレットは環境変数前提です(ハードコード厳禁)。


0. なぜ「DBに書いてからpublish」は壊れているのか

まずメンタルモデルを固定します。ここを腹落ちさせれば、残りは実装の話だけです。

次のコードは、世界中で書かれている「素直な」実装です。これが二重書き込み問題そのものです。

# ❌ 壊れている:2つの別システムへの書き込みは原子的にできない
def place_order(order: Order) -> None:
    db.execute("INSERT INTO orders (...) VALUES (...)")  # ① DBへコミット
    db.commit()
    broker.publish("OrderPlaced", order.to_event())       # ② ブローカーへpublish

db.commit()broker.publish() は、まったく別のシステム(リレーショナルDBとメッセージブローカー)への書き込みです。両者をまたぐ「ひとつの原子的な操作」は存在しません。だから、次の2つの破綻が必ず起きます。

破綻パターンA:イベントの取りこぼし(lost event)

①が成功した直後、②を実行する前にプロセスがクラッシュする/ネットワークが切れる/ブローカーが一時的に落ちている。DBには注文が記録されたのに、イベントは永遠に発行されない。 下流サービスは変更を知りません。AWS の言葉を借りれば「the downstream service will not be aware of the change, and the system can enter an inconsistent state(下流は変更に気づかず、システムは不整合状態に陥る)」。

破綻パターンB:幽霊イベント(ghost event)

逆もあります。②のpublishは通った(あるいは通ったように見えた)が、①のトランザクションが何らかの理由でロールバックされる、あるいはpublish後のコミットに失敗する。実在しない変更のイベントが下流に流れ、存在しない注文の決済が走る。 AWS はこれを「data could get corrupted(データが破損し得る)」と表現しています。

「順序を入れ替えればいい」も効かない

「じゃあ先にpublishしてからDBに書けば?」——これも壊れます(パターンBが悪化するだけ)。「publishが失敗したらDBをロールバックすればいい」——publishが成功したのに応答が返らなかったケース(タイムアウト)を区別できません。リトライすれば二重発行、諦めれば取りこぼし。2つのシステムにまたがる以上、書き込み順序をどう入れ替えても原子性は得られないのです。

2PC(二相コミット)はなぜ採らないのか

理論上は分散トランザクション(XA / 2PC)で両者を束ねられます。が、microservices.io は明確に否定します。曰く、多くのブローカーやDBが2PCをサポートせず、サービスをDBとブローカーの両方に密結合させてしまう。2PCはコーディネータが単一障害点になり、レイテンシも可用性も悪化します。「動くが、本番で採りたくない」——これが現場の総意です。

核心:「DBコミット」と「メッセージpublish」は別システムへの2つの書き込みであり、原子的に束ねられない。片方だけ成功すると不整合(取りこぼし or 幽霊イベント)になる。解は、書き込み先を1つに減らすこと——イベントを「同じDBトランザクション」でoutboxテーブルに書き、別プロセスがそれを確実に発行する。


1. パターンの定義:イベントもDBに書く

トランザクショナル・アウトボックスの解決策を、Chris Richardson の原文どおりに置きます。

サービスは、ビジネスエンティティを更新するトランザクションの中で、メッセージをデータベースに保存する。「A separate process then sends the messages to the message broker(別プロセスがそのメッセージをブローカーへ送る)」。これにより、トランザクション境界の外で送信したときにメッセージを失う dual-write 問題を回避する。

構造はたった2つの部品です。

  1. outbox テーブル:発行したいイベントを格納する、業務DBと同じデータベース内のテーブル。業務更新と同一トランザクションでINSERTする。
  2. メッセージリレー(message relay):outboxテーブルから未発行イベントを読み、ブローカーへ発行し、発行済みにマークする別プロセス

肝は 「書き込み先を1つに減らした」 ことです。業務テーブルとoutboxテーブルは同じDBにあるので、両者の更新は1つのローカルトランザクションで原子的にコミットできる。トランザクションがコミットすれば、業務変更とイベントは必ず両方残る。ロールバックすれば、どちらも残らない。AWS の表現では「Guarantees message delivery if and only if database transactions succeed(DBトランザクションが成功した場合に限り、かつその場合は必ず、メッセージ配信を保証する)」。

publishの成否は、もはやトランザクションの一部ではありません。「いつか確実に発行する」責務はリレーに移譲され、リレーはoutboxという永続化された台帳を相手にするので、何度落ちても再開できます。

┌─────────────────────────── 同一トランザクション ───────────────────────────┐
│  UPDATE orders SET status='PAID' ...                                       │
│  INSERT INTO outbox (event_type, payload, ...) VALUES ('OrderPaid', ...)   │
└──────────────────────────────── COMMIT ────────────────────────────────────┘
                                     │
                        (別プロセス=リレー)
                                     ▼
                  outbox を読む → ブローカーへ publish → published_at を更新
                                     │
                                     ▼
                          SQS / Kafka / EventBridge → 下流(冪等consumer)

2. outbox テーブルの設計

スキーマから始めます。過不足なく——足りないと運用で詰まり、盛りすぎると保守コストが上がります(KISS / YAGNI)。

CREATE TABLE outbox (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- 単調増加 = 発行・順序の基準
    aggregate_type TEXT        NOT NULL,   -- 例: 'Order' / 'Payment'(集約の種類)
    aggregate_id   TEXT        NOT NULL,   -- 例: order_id(同一集約のイベント順序キー)
    event_type     TEXT        NOT NULL,   -- 例: 'OrderPaid'(消費側のルーティング)
    payload        JSONB       NOT NULL,   -- イベント本体(自己完結させる)
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at   TIMESTAMPTZ              -- NULL = 未発行(リレーが埋める)
);

-- 未発行行だけを高速に拾う部分インデックス(テーブルが育ってもスキャンが軽い)
CREATE INDEX idx_outbox_unpublished
    ON outbox (id) WHERE published_at IS NULL;

各カラムの意図を、設計判断として明示します。

  • id(単調増加):発行の取り出し順かつ順序保証の基準。AWS も「preserves the order of messages by using timestamps and sequence numbers(タイムスタンプとシーケンス番号で順序を保つ)」と述べており、連番は順序の生命線です。
  • aggregate_id:「同じ注文に対するイベントは発行順を守る」ための順序キー(第7章)。SQS FIFO の MessageGroupId や Kafka のパーティションキーに対応します。
  • payload(自己完結):consumer がこのイベント単体で処理を完結できるだけの情報を入れる。「IDだけ入れて後で本体を取りに来させる」設計は、取りに来た時点でDB状態が変わっていて競合を生みます。イベントは発生時点のスナップショットを運ぶのが原則です。
  • published_at(NULL=未発行):リレーの進捗台帳。WHERE published_at IS NULL部分インデックスで、テーブルが何百万行に育っても未発行の数十行だけを軽く拾えます(パフォーマンス)。

設計判断(DRY):「業務テーブルごとに outbox を分けるか、全社で1つにするか」。私は基本1つの outbox に集約します。リレーが相手にするテーブルが1つで済み、発行ロジック・監視・クリーンアップを単一の関心事に保てるからです(SRP)。aggregate_type で種類は区別できます。


3. 同一トランザクションで業務更新とoutbox挿入を書く

ここがパターンの心臓です。業務更新とoutbox INSERTを、必ず同じトランザクションに入れます。フレームワークの「暗黙のコミット」に流されないよう、トランザクション境界を明示します。

Python(SQLAlchemy 2.x)

"""業務更新と outbox 挿入を同一トランザクションで行う。
どちらか一方だけがコミットされる状態は構造的に発生し得ない。"""
from sqlalchemy.orm import Session

def mark_order_paid(session: Session, order_id: str, amount: int) -> None:
    with session.begin():  # この with を抜けるとき一括 COMMIT / 例外なら一括 ROLLBACK
        order = session.get(Order, order_id, with_for_update=True)
        order.status = "PAID"
        order.paid_amount = amount  # ① 業務変更

        session.add(OutboxEvent(                          # ② イベントを“同じTx”でoutboxへ
            aggregate_type="Order",
            aggregate_id=order_id,
            event_type="OrderPaid",
            payload={"order_id": order_id, "amount": amount},
        ))
    # ここに到達した時点で①②は両方コミット済み。publish はまだしていない(それはリレーの仕事)

TypeScript(Prisma の interactive transaction)

// 業務更新と outbox 挿入を 1 つの $transaction に閉じる。
// publish はここで“やらない”——それがこのパターンの肝。
await prisma.$transaction(async (tx) => {
  await tx.order.update({
    where: { id: orderId },
    data: { status: "PAID", paidAmount: amount }, // ① 業務変更
  });

  await tx.outbox.create({                          // ② 同一Txでイベントを永続化
    data: {
      aggregateType: "Order",
      aggregateId: orderId,
      eventType: "OrderPaid",
      payload: { orderId, amount },
    },
  });
});
// $transaction を抜けた時点で①②は不可分にコミット済み

この関数のどこにも broker.publish() が無いことに注目してください。それが正しい姿です。アプリケーションコードの責務は「業務変更+発行意図をDBに原子的に刻む」ことまで。実際の発行は完全に分離された別プロセス(リレー)が担います(SRP)。

アンチパターン(microservices.io が drawback として明記):「Developers may forget to publish messages after database updates(開発者が更新後の発行を忘れる)」。つまりoutbox INSERTを書き忘れる事故。対策は、業務更新を行うリポジトリ層で「状態遷移メソッドが必ずドメインイベントを返す → 呼び出し側がoutboxに積む」ことを型で強制する、あるいはアプリ層を1か所に集約してpublish漏れをコードレビューの対象にできる構造にすることです。

3.1 ペイロード設計:イベントは「発生時点の事実」を運ぶ

payload に何を入れるかは、後から効いてくる設計判断です。原則は 「consumer がこのイベント単体で処理を完結できる、発生時点のスナップショット」

{
  "event_type": "OrderPaid",
  "event_version": 1,
  "occurred_at": "2026-06-24T09:30:00Z",
  "order_id": "ord_123",
  "amount": 12000,
  "currency": "JPY"
}
  • event_version:イベントの形は必ず進化します。バージョン番号を最初から入れておけば、consumer は「v1 は旧フィールド、v2 は新フィールド」と分岐でき、送信側と受信側を独立にデプロイできる(ETC:変更を局所化)。後から足すのは互換性の地獄なので、day 1 から入れる数少ない「先回り」です。
  • occurred_at(イベント発生時刻)created_at(outbox に書かれた時刻)とは別概念。consumer が「いつ起きた事実か」を判断する根拠になります。
  • 「IDだけ入れて後で取りに来させる」は避ける:consumer が本体を取りに来た時点で、DBの状態はすでに次へ進んでいるかもしれません。イベントは過去の事実であり、現在の状態への参照ではない——この区別が、競合とrace condition を構造的に防ぎます。

関連パターン(出典の文脈):このスナップショット型イベントを突き詰めると Event Sourcing(状態そのものをイベント列で表現する)に至り、AWS も applicability に挙げています。また、複数サービスにまたがる更新を束ねたいなら Saga(補償トランザクション)と組み合わせます。outbox は「Saga / Domain Event を確実に発火させる土台」——microservices.io が "Triggers the need: Saga, Domain Event" と位置づけるのは、この依存関係です。本記事はあくまで発火の確実性にフォーカスし、Saga 自体は別稿に譲ります(YAGNI:今解くべきは二重書き込み)。


4. メッセージリレー(1):ポーリングパブリッシャ

outboxに溜まったイベントを発行する最初の戦略が Polling Publisher です。microservices.io の定義はシンプルで、「Publish messages by polling the database's outbox table(outboxテーブルをポーリングして発行する)」

実装(Python・ポーリングリレー)

"""未発行イベントを id 昇順で取得 → publish → published_at を更新。
落ちても“未発行のまま”の行が残るので、再起動すれば必ず再開する(at-least-once)。"""
import time

BATCH = 100

def relay_once(session, broker) -> int:
    # ① 未発行を id 昇順(=発生順)で取得。行ロックで多重起動の競合を防ぐ
    rows = session.execute(text("""
        SELECT id, aggregate_id, event_type, payload
        FROM outbox
        WHERE published_at IS NULL
        ORDER BY id ASC
        LIMIT :batch
        FOR UPDATE SKIP LOCKED
    """), {"batch": BATCH}).all()

    for r in rows:
        # ② ブローカーへ発行。発行キー(順序)には aggregate_id を使う
        broker.publish(
            topic=r.event_type,
            key=r.aggregate_id,
            body=r.payload,
            # consumer 側の重複排除キー。outbox.id は決定的で再実行しても変わらない
            dedup_id=str(r.id),
        )
        # ③ 発行できたものから published_at を埋める
        session.execute(
            text("UPDATE outbox SET published_at = now() WHERE id = :id"),
            {"id": r.id},
        )
    session.commit()
    return len(rows)

def run_relay(session_factory, broker, interval: float = 1.0) -> None:
    while True:
        with session_factory() as session:
            published = relay_once(session, broker)
        if published == 0:
            time.sleep(interval)  # 空振り時だけ待つ(busy-loop を避ける)

このリレーがなぜ安全か

3つの設計判断が信頼性を作っています。

  1. FOR UPDATE SKIP LOCKED:リレーを冗長化(複数インスタンス)しても、同じ行を2つのインスタンスが同時に掴まない。片方がロックした行はもう片方がスキップする。水平スケール+単一障害点の排除を1行で実現します。
  2. 「publish → markの順」が at-least-once を生む:publishは成功したがUPDATE前にクラッシュすると、その行は未発行のまま残り、次回再発行されます。取りこぼしはゼロ、その代償が二重発行の可能性——これが at-least-once セマンティクスです。逆順(先にmark→publish)にすると取りこぼしが起きるので絶対にこの順序を守ります。
  3. dedup_id=outbox.id:再発行されても、id は決定的で不変なので、consumer は「同じ dedup_id は1回だけ処理」で重複を吸収できる(第6章)。

ポーリングの限界も正直に

microservices.io が挙げる drawback はそのまま現場の悩みです。「Tricky to publish events in order(順序どおりの発行が難しい)」——並列化すると順序が崩れる(対策は第7章)。加えてポーリング間隔のトレードオフ:短くすればレイテンシは下がるがDB負荷が上がり、長くすれば逆。多くの業務系では1秒前後が無難な出発点です。利点は明快で、「Works with any SQL database(あらゆるSQL DBで動く)」——追加インフラなしで今日から始められます。


5. メッセージリレー(2):トランザクションログテーリング(CDC)

もう1つの戦略が Transaction Log Tailing(CDC=変更データキャプチャ)です。microservices.io の定義は 「Tail the database transaction log and publish each message/event inserted into the outbox to the message broker(DBのトランザクションログを追跡し、outboxに挿入された各イベントをブローカーへ発行する)」

ポーリングがアプリ目線で「テーブルをSELECTする」のに対し、CDCはDB目線でコミットログ(変更履歴)そのものを尾行します。DBが書く一次資料を読むので、ポーリングのラグもDBへの問い合わせ負荷もありません

代表的な実装(いずれも例として):

  • PostgreSQL の WAL(論理レプリケーション)を Debezium(例)が読み、Kafka へ流す。
  • MySQL の binlog を同様に尾行する。
  • DynamoDB Streams(例):item単位の変更を時系列ストリームで捕捉し、Lambda が SQS / EventBridge へ転送する。AWS Prescriptive Guidance が「Using change data capture (CDC)」として正面から推す構成です。

CDCのもう一つの利点は、AWS が言う「saves the overhead of creating another table(別テーブルを作る手間を省ける)」点。DynamoDB のように item の属性にイベントを埋めてStreamsで流せば、outboxテーブルすら不要にできるケースもあります。

ただし microservices.io は drawback も明記します:「Requires database specific solutions(DB固有の実装が要る)」「Tricky to avoid duplicate publishing(重複発行を避けるのが難しい)」。WALやbinlogの扱いはDBごとに違い、CDC基盤(Debezium / Kafka Connect など)の運用知識が要ります。**「Guaranteed to be accurate(正確性が保証される)」**という強い利点と、運用の重さがトレードオフです。


6. リレー戦略の選択:Polling vs CDC

「どちらが正解」ではなく、要件で選ぶ。意思決定テーブルにします。

判断軸Polling PublisherTransaction Log Tailing(CDC)
実装容易性◎ SQLとスケジューラだけ。今日始められる△ CDC基盤(Debezium等)の構築・運用が要る
追加インフラ不要(既存DBのみ)要(コネクタ/ストリーム/Kafka等)
発行レイテンシ△ ポーリング間隔ぶん遅れる◎ コミット直後にほぼリアルタイム
DB負荷△ 定期的にSELECTが走る◎ ログを尾行するだけで問い合わせ負荷ゼロ
順序保証△ 並列化すると崩れやすい◎ ログ=コミット順なので素直に守りやすい
DB依存◎ どのSQL DBでも動く△ WAL/binlog/Streams等、DB固有
スループット△ ポーリング律速◎ 高スループットに強い
向くケース中小規模・素早く始めたい・運用を増やしたくない大規模・低レイテンシ必須・既にKafka/CDC基盤がある

私の実務上の指針(YAGNI)

まず Polling から始めるのが、ほとんどの案件で正しい順序です。理由は「あらゆるSQL DBで動き、追加インフラがゼロで、ロジックが目に見える」から。CDCは強力ですが、Debezium + Kafka Connect の運用はそれ自体が一つのプロダクトになります。「秒単位のレイテンシで困っていない」「Kafkaをまだ持っていない」段階でCDCを入れるのは、解いていない問題への投資です(YAGNI)。

実際、私が手掛けた木材流通DXの課金調整は、EventBridge の定期実行で動く整合化 Lambda——構造としては Polling 寄りのリレー+整合化——で「ネットワーク断やリトライ下でも二重課金・取りこぼしを防ぐ」形を作りました。低レイテンシよりも確実に・冪等に反映されることが最優先の決済系では、シンプルで監視しやすいポーリング系が強い。レイテンシが本当に効くフェーズが来てから、CDCへ移行を検討すればよいのです。


7. at-least-once と順序保証:下流の前提条件

outboxパターンが保証するのは **at-least-once(少なくとも1回)であって、exactly-once ではありません。ここを誤解すると本番で事故ります。「発行は最低1回、ときに2回以上」**を前提に、下流を設計します。

7.1 consumer は冪等でなければならない(必須)

AWS も microservices.io も声を揃えます。AWS いわく「we recommend that you make the consuming service idempotent by tracking the processed messages(処理済みメッセージを追跡して consumer を冪等にせよ)」。リレーの再実行・SQS標準キューの重複配信・CDCの重複——重複は構造的に避けられないので、消費側で吸収します。

最も確実なのは、「重複排除マーカーを業務更新と同一トランザクションに入れる」こと。outboxで「発行側」を原子的にしたのと鏡像の発想を、消費側でも使います。

-- 処理済みイベントの台帳(消費側)。主キーが重複排除そのもの
CREATE TABLE processed_events (
    event_id     TEXT PRIMARY KEY,        -- 発行側の outbox.id を dedup_id として運ぶ
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""consumer:重複排除マーカーの INSERT と業務更新を“同一トランザクション”で。
すでに処理済みなら主キー衝突で弾く → 何度配信されても副作用は1回だけ。"""
def handle_order_paid(session, event) -> None:
    try:
        with session.begin():
            # ① まず重複排除マーカーを入れる(同じ event_id は2回目で必ず衝突)
            session.execute(
                text("INSERT INTO processed_events (event_id) VALUES (:id)"),
                {"id": event.dedup_id},
            )
            # ② 業務側の副作用(マーカーと同一Tx。ハンドラが落ちれば両方ロールバック)
            apply_payment_settlement(session, event.payload)
    except UniqueViolation:
        # すでに処理済み = 重複配信。何もせず正常終了(冪等)
        return

この「マーカーと業務更新が同一トランザクション」が効くのは、ハンドラが途中で落ちればマーカーも書かれないから。マーカーが残っていない=未処理なので、再送されれば正しく再処理されます。「先にマーカーだけコミット → 業務処理で失敗」だと永久に未処理になる事故が起きるので、必ず同一トランザクションにします。

関連テクニック(決済プラットフォーム eco-pay):私が決済基盤で使ったのが、まさにこの鏡像——Stripe Webhook の冪等マーカーを業務更新と同一トランザクション内に含める設計です。ハンドラが失敗すればマーカーも書かれないので、Stripe の再送で正しく再処理される。「発行側の outbox」と「消費側の冪等マーカー」は、どちらも**『副作用と台帳を同一トランザクションで原子化する』**という同じ原理の応用です。

別案件(木材流通DX)の Stripe Webhook では、DynamoDB の条件付き書き込み(attribute_not_exists)で重複排除し、金額はサーバ側で再解決しました。リレーショナルなら主キー衝突、DynamoDBなら条件付き書き込み——**「一意制約に重複排除をやらせる」**のが、言語・DBを問わない王道です。

冪等な消費の設計を深掘りしたい方へ:発行されたイベントを冪等に消費する側(SQS の可視性タイムアウト・DLQ・EventBridge ルーティング・冪等キーの持ち方)は、SQS+Lambda+EventBridgeで冪等な非同期処理 に実装パターンをまとめています。本記事=確実に「発行」する側、あちら=確実に「消費」する側として補完して読んでください。

7.2 順序保証:必要なところにだけ、強く

AWS は「Send messages in the same order in which the service updates the database(DB更新と同じ順でイベントを送れ)」と言いますが、全イベントの全順序を守る必要は普通ありません。守るべきは**「同じ集約に対するイベントの相対順序」**だけです(例:同じ注文の Created → Paid → Shipped)。

  • 発行順の基準:outbox を id ASC(連番=発生順)で取り出す。
  • 集約内順序の維持aggregate_id を発行キーにする。SQS FIFO なら MessageGroupId、Kafka なら同じパーティション、になり、同じキーは順序が保たれる
  • 並列化との両立aggregate_id 単位でパーティショニングすれば、異なる集約は並列発行しつつ、同一集約は直列にできる。これがスループットと順序の両取りの定石です。

「全イベントを完全順序で1本のキューに流す」は、スループットを殺すうえ、たいてい過剰な要求です(YAGNI)。consumer が冪等で、かつ集約内順序さえ守られていれば、多くの業務は正しく動きます。


8. 本番運用:取りこぼしを「構造的に」ゼロへ

パターンを入れただけでは本番品質になりません。私が決済系で実際に効かせた運用装備を、優先度順に並べます。

8.1 整合化ジョブ(reconciliation)— 最後の安全網

リレーが完璧でも、運用の長期では「マーク漏れ」「未知のバグ」「外部ブローカーの異常」は起こり得ます。だから定期的な整合化ジョブを、独立した安全網として必ず置きます。

-- “異常に古いのに未発行”の行を検出 = リレーが取りこぼしている兆候
SELECT id, aggregate_id, event_type, created_at
FROM outbox
WHERE published_at IS NULL
  AND created_at < now() - INTERVAL '5 minutes'
ORDER BY id ASC;

これを EventBridge の定期実行(例)などで回し、「一定時間を超えて未発行の行」を回収・再発行します。木材流通DXの課金調整は、まさにこの**整合化 Lambda(EventBridge 定期実行)**で、ネットワーク断やリトライの取りこぼしを定期的に拾い直す形にしました。リアルタイムのリレー(即時性)と、定期の整合化(網羅性)の二段構えが、決済系の「取りこぼし0」を構造的に支えます。

8.2 可観測性:未発行件数をアラームにする

監視すべき最重要メトリクスは**「未発行イベントの最古齢」と「未発行件数」**です。

  • 未発行の最古 created_at がしきい値(例:5分)を超えたらアラート → リレーが止まっている兆候。
  • 未発行件数が単調増加 → リレーがスループット不足。
  • リレーの1周あたり発行件数・失敗件数・publish レイテンシを構造化ログで出す。

「outbox に溜まり続けている」は、下流が静かに飢えている最も早いシグナルです。ここにアラームが無いoutbox実装は、半分しか完成していません。

8.3 outbox のクリーンアップ(TTL / アーカイブ)

発行済み行を放置すると outbox は単調に肥大し、リレーのスキャンも遅くなります。発行済みは定期的に消す(コスト効率)。

-- 発行済みかつ一定期間を過ぎた行を削除(監査が要るならアーカイブ後に)
DELETE FROM outbox
WHERE published_at IS NOT NULL
  AND published_at < now() - INTERVAL '7 days';

監査要件があるなら、削除前に低コストストレージ(例:S3)へアーカイブします。DynamoDB なら TTL 属性で自動失効させるのが定石です。保持期間は「整合化ジョブが見直す窓 + 監査要件」から逆算して決めます。

8.4 リレーの再実行安全性(再起動・冗長化)

リレーはいつ落ちても・何個動いても正しくなければなりません。

  • 再起動耐性:状態は全てoutboxの published_at にある。プロセスはステートレスなので、再起動=未発行から再開、で済む。
  • 多重起動耐性FOR UPDATE SKIP LOCKED(第4章)で行の二重掴みを防ぐ。
  • 毒メッセージ対策:特定の行で publish が繰り返し失敗すると全体が詰まる。attempts カラムを足してN回失敗したら隔離(dead-letter 行へ退避)し、後続を止めない設計にします(reliability:単一行の失敗が全体を止めない)。

9. まとめ:outbox チートシート

迷ったときの早見表です。

  • 二重書き込み問題の本質:「DBコミット」と「publish」は別システムへの2書き込み。原子的に束ねられない → 片方失敗で取りこぼし or 幽霊イベント
  • :イベントを業務更新と同一トランザクションoutbox テーブルに書く → 書き込み先が1つになり原子性が成立。
  • 発行は別プロセス(リレー)published_at IS NULL を拾い → publish → mark。publish→markの順が at-least-once を作る。
  • リレー戦略:まず Polling(どのSQLでも動く・追加インフラ不要)。低レイテンシ・大規模・既存Kafkaがあるなら CDC(Debezium / DynamoDB Streams は例)。
  • 下流は冪等が必須:重複排除マーカーを消費側の業務更新と同一トランザクションで。一意制約(主キー / 条件付き書き込み)に重複排除をやらせる。
  • 順序:全順序は不要。aggregate_id をキーに同一集約だけ直列(SQS FIFO の MessageGroupId / Kafka パーティション)。
  • 本番装備:整合化ジョブ(取りこぼしの定期回収)・未発行件数アラーム・クリーンアップ(TTL/アーカイブ)・SKIP LOCKED での冗長化・毒メッセージ隔離。

「DBに書いてからpublishする」——この一行に潜む二重書き込み問題は、放っておくと**「たまに通知が来ない」「ごく稀に二重課金」という、再現しづらく信頼を削る障害になります。outbox パターンは、その不確実性を「同じトランザクションに書く」というたった1つの構造変更**で根絶やしにします。

私は招待制B2B SaaS(木材流通DX、Stripe Connect マーケットプレイス決済、経済産業大臣賞受賞)で、課金調整をトランザクショナル・アウトボックス+整合化 Lambda で「ネットワーク断やリトライ下でも二重課金・取りこぼしを防ぐ」形に組み上げ、Webhook は別 Lambda + DynamoDB の条件付き書き込み(attribute_not_exists)で重複排除し、金額はサーバ側で解決しました。お金が動く処理を、ネットワークが切れても・プロセスが落ちても・リトライが重なっても、確実に一度だけ反映する——その設計から実装・運用・監視まで、一人 × 生成AI(Claude Code)を武器に速く・安全に伴走します。

「自社の『DB更新 → イベント発行』を、取りこぼし・二重発行ゼロで組み直したい」——要件の整理段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通DXのB2B SaaS(トランザクショナル・アウトボックス+整合化Lambdaで課金調整を確実に反映)

ケーススタディを見る