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

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

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: アーキテクチャ設計, AWS, 冪等性, 決済, メッセージング
- URL: https://tomodahinata.com/blog/transactional-outbox-pattern-reliable-event-publishing-guide

## 要点

- DBコミットとメッセージpublishは別システムへの2書き込みで原子化できず、取りこぼしか幽霊イベントを生む
- イベントを業務更新と同一トランザクションで outbox テーブルに書き、書き込み先を1つに減らして原子性を成立させる
- 発行は別プロセス（リレー）が担い、publish→markの順で at-least-once を実現する
- リレーはまず Polling（どのSQLでも動く・追加インフラ不要）、低レイテンシ・大規模なら CDC を選ぶ
- 下流は冪等が必須で、重複排除マーカーを消費側の業務更新と同一トランザクションに入れて吸収する

---

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

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

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

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

---

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

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

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

```python
# ❌ 壊れている：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）。

```sql
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）

```python
"""業務更新と 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）

```ts
// 業務更新と 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 がこのイベント単体で処理を完結できる、発生時点のスナップショット」**。

```json
{
  "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・ポーリングリレー）

```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 Publisher | Transaction 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で「発行側」を原子的にしたのと**鏡像**の発想を、消費側でも使います。

```sql
-- 処理済みイベントの台帳（消費側）。主キーが重複排除そのもの
CREATE TABLE processed_events (
    event_id     TEXT PRIMARY KEY,        -- 発行側の outbox.id を dedup_id として運ぶ
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

```python
"""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で冪等な非同期処理](/blog/aws-sqs-lambda-eventbridge-idempotent-async-processing-guide) に実装パターンをまとめています。**本記事＝確実に「発行」する側、あちら＝確実に「消費」する側**として補完して読んでください。

### 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）— 最後の安全網

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

```sql
-- “異常に古いのに未発行”の行を検出 = リレーが取りこぼしている兆候
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 は単調に肥大し、リレーのスキャンも遅くなります。**発行済みは定期的に消す**（コスト効率）。

```sql
-- 発行済みかつ一定期間を過ぎた行を削除（監査が要るならアーカイブ後に）
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更新 → イベント発行』を、取りこぼし・二重発行ゼロで組み直したい」——要件の整理段階からでも、お気軽にご相談ください。**

---

### 参考（公式ドキュメント）

- [Pattern: Transactional outbox（microservices.io / Chris Richardson）](https://microservices.io/patterns/data/transactional-outbox.html) — パターンの canonical 定義・dual-write 問題・benefits/drawbacks
- [Transactional outbox pattern（AWS Prescriptive Guidance）](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html) — intent / motivation / 実装（outbox テーブル・CDC・SQS）・at-least-once と冪等性
- [Pattern: Polling publisher（microservices.io）](https://microservices.io/patterns/data/polling-publisher.html) — outbox をポーリングして発行する戦略・利点と限界
- [Pattern: Transaction log tailing（microservices.io）](https://microservices.io/patterns/data/transaction-log-tailing.html) — トランザクションログを尾行する戦略（WAL / binlog / DynamoDB Streams）
