結論

Transactional Outbox パターンは、業務テーブルの更新と「送りたいイベント」の保存を1つのデータベーストランザクションで行い、別プロセスのリレーが outbox テーブルから取り出して配送する設計です。DB 更新とメッセージ送信を別々に行う「二重書き込み」で起きる不整合を、原子性を DB に預けることで無くします。代わりに配送は「少なくとも1回」になるため、受け手の冪等性とセットで成立します。

この記事でわかること

  • 二重書き込みが壊れる5つの経路を PostgreSQL 16 とプロセス停止で再現した結果
  • FOR UPDATE SKIP LOCKED のリレー・冪等キー・順序保証・LISTEN/NOTIFY を実装して測った数値
  • outbox の掃除(DELETE と VACUUM・パーティション)と、採用しない判断の基準

2026年11月4日更新|PostgreSQL 16.15・Python 3.13(psycopg 3.3.7)・Redis 7.0.15 で実行確認

登場人物は3つです。業務トランザクションの中で outbox に書く「書き手」、outbox を読んで配送し配送済みの印を付ける「リレー」、同じイベントが2回届いても結果が変わらない「冪等なコンシューマ」です。

flowchart TD
    A[アプリ] -->|1つのトランザクション| T[(orders + outbox)]
    T -->|SKIP LOCKED で取り出す| R[リレー]
    R -->|HTTP / Redis Stream| C[コンシューマ]
    R -.->|published_at を付ける| T
    C -->|event_id で重複を捨てる| P[(processed_events)]

よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
二重書き込み問題とは DB 更新と送信のどちらか一方だけが成功して不整合になること。5経路すべて再現できた 二重書き込み問題を再現する
outbox パターンの仕組み 同一トランザクションで outbox に INSERT → リレーが配送 → published_at Transactional Outbox パターンとは
リレーの取り出し方 ORDER BY id LIMIT n FOR UPDATE SKIP LOCKED。部分インデックスで 0.1ms ポーリング・リレーの実装
重複は起きるか 起きる(送信後・印を付ける前に落ちると再送)。受け手の冪等キーで吸収 少なくとも1回配送になる理由
順序は守れるか 同じ集約の順序は守れる。リレーを複数にするなら集約でパーティション 順序保証とパーティション
配送の遅れはどれくらい 1秒ポーリングで中央値 508ms、LISTEN/NOTIFY で 6ms 配送遅延とスループットの実測
outbox が太る DELETE しても VACUUM では縮まない。日次パーティションの DROP が速い outbox の掃除
CDC(Debezium)との違い ポーリングの代わりに WAL を読む。outbox テーブルの形は同じ CDC(Debezium)との比較
採用しなくてよい場合 同じ DB で完結する・同期呼び出しの失敗をそのまま返してよい場合 採用する・しないの判断表

確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB の共用機)の上で、PostgreSQL 16.15 を自分の作業領域に initdb して 127.0.0.1 のポートに起動し、データベース t242db に注文テーブル・outbox テーブル・コンシューマ用スキーマを作りました。アプリ・リレー・配送先はすべて Python 3.13.18 と psycopg 3.3.7 で書き、配送先は 127.0.0.1 の自作 HTTP サーバー(標準ライブラリの http.server)と、自分で起動した Redis 7.0.15 の Stream(redis-py 8.1.0)です。プロセスの停止は os._exit(1) で再現し、遅延・件数は PostgreSQL 側の clock_timestamp() と pg_stat_database で数えています。Kafka と Debezium はこの環境で動かしていないため、Debezium の Outbox Event Router と Kafka の順序の話は公式ドキュメントで確かめた範囲だけを書いています。パターンの定義は microservices.io と AWS Prescriptive Guidance、NOTIFY の動作は PostgreSQL 16 の公式マニュアルで確かめました。図は mermaid 11.16.0 で描画を確かめています(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Transactional Outboxパターン|二重書き込みの再現と実装・実測」

二重書き込み問題を再現する(DB更新とメッセージ送信を別々に行うと何が壊れるか)

この節では、「注文を INSERT して、出荷サービスに HTTP で通知する」という素朴な実装が、どの瞬間に落ちるとどう壊れるかが、実際の件数で分かります。

DB とメッセージ基盤は別のシステムなので、両方への書き込みをまとめて成功・失敗させる手段がありません。 これが二重書き込み(dual write)問題です。microservices.io は「データベースとメッセージブローカーにまたがる従来の分散トランザクション(2PC)は現実的でない」と書いています。送る順番を入れ替えても解決しないことを、5つの経路で確かめました。

with psycopg.connect(DSN) as conn:
    cur = conn.cursor()
    cur.execute("INSERT INTO orders(customer_id, amount) VALUES (%s, 1200) RETURNING id", (customer,))
    order_id = cur.fetchone()[0]
    conn.commit()
    if scenario == "crash-after-commit":
        os._exit(1)                 # COMMIT の直後、送信の前に落ちる
    send(order_id)                  # 出荷サービスへ POST /events

配送先は「届いたぶんだけ出荷指示を作る」素朴なコンシューマです。5つのシナリオを1回ずつ流した結果が次の表です。

シナリオ orders に残った 出荷指示が作られた 結果
COMMIT → 送信(正常) 注文1:あり あり 一致
COMMIT 直後にプロセスが落ちる 注文2:あり なし 注文はあるのに出荷されない
送信 → COMMIT 前にプロセスが落ちる 注文3:なし あり 存在しない注文が出荷される
送信 → 制約違反で ROLLBACK 注文4:なし あり 同上(UniqueViolation)
COMMIT → 送信先に接続できない 注文5:あり なし 呼び出し元には例外が返る

集計すると、注文は3件、出荷指示も3件で「件数は合っている」のに、中身を突き合わせると存在しない注文の出荷が2件、出荷されない注文が2件でした。

 orders | shipments | ghost_shipments | orders_without_shipment
--------+-----------+-----------------+-------------------------
      3 |         3 |               2 |                       2

接続できなかったときの原文はこうです。アプリは例外を呼び出し元に返しますが、注文はすでに COMMIT されています。利用者は「失敗した」と思って再注文し、注文が2件になる経路でもあります。

urllib.error.URLError: <urlopen error [Errno 111] Connection refused>

順番を変えても直らない

NG「COMMIT してから送る」は送信前に落ちると通知が消える。「送ってから COMMIT する」はロールバックすると幽霊の通知が残る
→
代わりに送信そのものをトランザクションの中に入れない。「送りたい」という事実を同じトランザクションで outbox テーブルに書き、送るのは別プロセスに任せる

try/except で送信を再試行しても、プロセスごと落ちる経路は塞げません

Transactional Outbox パターンとは(仕組みと登場人物)

この節では、パターンの定義と、3つの登場人物がそれぞれ何に責任を持つかが分かります。

microservices.io の定義では、「業務エンティティを更新するトランザクションの中でメッセージをデータベースに保存し、別のプロセス(Message relay)がそれをブローカーへ送る」のが Transactional Outbox です。 利点として「2PC を使わない」「データベーストランザクションがコミットされたとき、そのときに限ってメッセージが送られることが保証される」、欠点として「開発者が outbox への書き込みを忘れうる」ことが挙げられています。AWS Prescriptive Guidance も同じ構造を「dual write 問題を解決するパターン」として説明しています。

1書き手(アプリ)

業務テーブルと outbox を同じトランザクションで書く。送信はしない。ROLLBACK すれば outbox の行も消える。

2リレー(Message relay)

未配送の行を取り出して配送し、配送済みの印を付ける。ポーリング型と、WAL を読む CDC 型(Debezium)がある。

3冪等なコンシューマ

リレーは同じイベントを2回送ることがある。event_id を処理済み表に入れてから副作用を起こし、2回目は捨てる。

保証の中身を言葉にすると、「コミットされた業務変更に対応するイベントは、いつか必ず、1回以上配送される」です。「ちょうど1回」ではありません。microservices.io は「Message relay はメッセージを2回以上発行しうる」「メッセージコンシューマは冪等でなければならない」と明記しており、この記事の後半で実際に重複を起こして確かめます。

outbox テーブルの設計と同一トランザクションでの書き込み

この節では、outbox テーブルの列と部分インデックスの設計、アプリ側の書き方、ROLLBACK とプロセス停止で outbox にも何も残らないことの確認結果が分かります。

outbox は「イベントの封筒」を入れる表です。 列は Debezium の Outbox Event Router が期待する形(id・aggregatetype・aggregateid・type・payload)に寄せておくと、後でポーリングから CDC に替えるときに表を作り直さずに済みます。

CREATE TABLE outbox (
  id              bigserial   PRIMARY KEY,
  event_id        uuid        NOT NULL DEFAULT gen_random_uuid(),  -- 冪等キー
  aggregate_type  text        NOT NULL,   -- 例: orders(トピック名の元)
  aggregate_id    text        NOT NULL,   -- 例: 注文ID(順序を守る単位)
  event_type      text        NOT NULL,   -- 例: OrderCreated
  payload         jsonb       NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT clock_timestamp(),
  published_at    timestamptz,            -- NULL = 未配送
  attempts        integer     NOT NULL DEFAULT 0,
  next_attempt_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  last_error      text
);
-- 未配送の行だけを持つ部分インデックス
CREATE INDEX outbox_unpublished_idx ON outbox (id) WHERE published_at IS NULL;
列 役割 無いと何が起きるか
event_id(uuid) 受け手の冪等キー。配送先が変わっても同じ値 受け手が重複を見分けられない
aggregate_id 順序を守る単位・Kafka のキー・パーティションの分け方 複数リレーで順序が崩れる(後述の実測)
published_at 未配送の判定。IS NULL を部分インデックスに 配送済み行が増えるほどポーリングが遅くなる
attempts・next_attempt_at・last_error 再試行回数・バックオフ・原因の記録 落ちた配送先に毎回全件を投げ続ける
created_at 遅延の計測・パーティションの境界 掃除の基準が無い

部分インデックスの効き目(配送済み20万行・未配送50行)

配送済みの行が20万件たまった状態で、リレーの取り出しクエリを EXPLAIN (ANALYZE, BUFFERS) しました。部分インデックスは 16kB で、取り出しは 0.1ms です。同じ表でインデックスを落とすと、20万行を読み飛ばす Seq Scan になり 34ms・7,979 ブロックでした。

-- 部分インデックスあり
 Limit (actual time=0.024..0.073 rows=50 loops=1)   Buffers: shared hit=60
   ->  LockRows
         ->  Index Scan using outbox_unpublished_idx on outbox (rows=50)
 Execution Time: 0.103 ms

-- 部分インデックスなし
 Limit (actual time=34.187..34.225 rows=50 loops=1)   Buffers: shared hit=7979
   ->  LockRows -> Sort (Sort Key: id)
         ->  Seq Scan on outbox (rows=50)   Rows Removed by Filter: 200000
 Execution Time: 34.261 ms

アプリ側は「同じ conn.transaction() の中で2つ INSERT する」だけ

def create_order(conn, customer_id):
    with conn.transaction():          # 1つのトランザクション
        cur = conn.cursor()
        cur.execute("INSERT INTO orders(customer_id, amount) VALUES (%s, 1200) RETURNING id", (customer_id,))
        order_id = cur.fetchone()[0]
        cur.execute("""INSERT INTO outbox(aggregate_type, aggregate_id, event_type, payload)
                       VALUES ('orders', %s, 'OrderCreated', %s)""",
                    (str(order_id), Jsonb({"order_id": order_id, "amount": "1200"})))
        return order_id                # ここで COMMIT。送信はしない

ROLLBACK すると両方消えることを psql で確かめました。

BEGIN;
INSERT INTO orders(customer_id, amount) VALUES (7, 980) RETURNING id;          -- id = 1
INSERT INTO outbox(aggregate_type, aggregate_id, event_type, payload)
  VALUES ('orders', '1', 'OrderCreated', '{"order_id": 1}') RETURNING id, event_id;  -- id = 1
ROLLBACK;
SELECT (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM outbox) AS outbox;
 orders | outbox
--------+--------
      0 |      0

COMMIT の前に os._exit(1) でプロセスを落とした場合も同じで、orders も outbox も 0 件でした。二重書き込みで起きていた「存在しない注文の通知」は、この時点で起こりえなくなります。

ポーリング・リレーの実装(FOR UPDATE SKIP LOCKED・バッチ・再試行)

この節では、リレーの取り出しクエリと1ループの流れ、配送先が 500 を返したときの再試行とバックオフの実物が分かります。

取り出しは「未配送・再試行時刻を過ぎている・上限回数未満」の行を id 順に LIMIT 件、FOR UPDATE SKIP LOCKED でロックして行います。 PostgreSQL の公式マニュアルは SKIP LOCKED を「複数のコンシューマがキューのような表にアクセスするときのロック競合を避けるために使える」と説明しています。ロックはトランザクションの中だけ有効なので、リレーが途中で落ちても行は自動的に解放され、「取り出し中のまま残った行を回収する」仕組みが要りません。

SELECT id, event_id, aggregate_type, aggregate_id, event_type, payload
  FROM outbox
 WHERE published_at IS NULL
   AND next_attempt_at <= clock_timestamp()
   AND attempts < 5
 ORDER BY id
 LIMIT 100
 FOR UPDATE SKIP LOCKED;
1

トランザクションを開き、上のクエリで最大100件をロックする

他のリレーがロック中の行は飛ばす。0件なら間隔を待つ(または NOTIFY を待つ)。

2

1件ずつ配送する(HTTP POST か XADD)

成功した id を集める。失敗したら attempts を +1、last_error を記録、next_attempt_at を 200ms × 2^attempts 後にする。

3

成功分に published_at を付けて COMMIT

UPDATE outbox SET published_at = clock_timestamp() WHERE id = ANY(%s)。ここまでに落ちると、送った分も未配送のまま残る(=再送される)。

配送先に「次の3リクエストだけ 500 を返す」よう仕込んで、イベント2件を流したログと outbox の状態です。

[relay 0] outbox id=1 failed: HTTP Error 500: Internal Server Error
[relay 0] outbox id=2 failed: HTTP Error 500: Internal Server Error
[relay 0] outbox id=1 failed: HTTP Error 500: Internal Server Error
[relay 0] exit. sent_total=2

 id | attempts |              last_error               | published_at | next_attempt_at
----+----------+---------------------------------------+--------------+-----------------
  1 |        3 | HTTP Error 500: Internal Server Error | 03:36:57.129 | 03:36:56.991
  2 |        2 | HTTP Error 500: Internal Server Error | 03:36:56.602 | 03:36:56.536

id=1 は 200ms・400ms 待って3回目で届き、id=2 は 200ms 待って2回目で届きました。attempts < 5 を超えた行は取り出し対象から外れ、そのまま「配送できなかったイベントの一覧」(デッドレター)になります。SELECT * FROM outbox WHERE published_at IS NULL AND attempts >= 5 を監視に載せ、原因を直してから UPDATE outbox SET attempts = 0 で戻すのが運用の形です。

少なくとも1回配送になる理由(リレーのクラッシュを再現)

この節では、「送った後・印を付ける前」にリレーが落ちると何が起きるかを、実際の受信ログで確かめた結果が分かります。

配送と published_at の更新は別システムへの2操作なので、ここにも小さな二重書き込みがあります。 ただし壊れ方は一方向で、「送ったのに未配送のまま残る → もう一度送る」にしかなりません。「送っていないのに配送済みになる」は、UPDATE を配送の後に置く限り起きません。

イベント5件に対し、3件送ったところでリレーを os._exit(1) で落とし、再起動しました。

[relay 0] sent 3 events, crashing BEFORE marking published
 id | aggregate_id | published
----+--------------+-----------
  2 | 2            | f
  3 | 3            | f          ← 3件送ったが、印は1件も付いていない
  4 | 4            | f
  5 | 5            | f
  6 | 6            | f
[relay 0] exit. sent_total=5     ← 再起動後は5件すべてを送る

素朴なコンシューマ(届いたぶんだけ出荷指示を作る)の受信ログと出荷指示です。

 seq | order_id | outbox_id | received_at
-----+----------+-----------+--------------
   1 | 2        |         2 | 03:36:44.273
   2 | 3        |         3 | 03:36:44.279
   3 | 4        |         4 | 03:36:44.284
   4 | 2        |         2 | 03:36:44.479   ← 再送
   5 | 3        |         3 | 03:36:44.485   ← 再送
   6 | 4        |         4 | 03:36:44.490   ← 再送
   7 | 5        |         5 | 03:36:44.497
   8 | 6        |         6 | 03:36:44.502

 order_id | shipments
----------+-----------
 2        |         2   ← 二重出荷
 3        |         2
 4        |         2
 5        |         1
 6        |         1

注文2・3・4が二重に出荷されました。これは outbox パターンの欠陥ではなく、パターンが約束しているのは「少なくとも1回」であり、重複を消す責任は受け手にあるという設計の分担です。AWS のガイドも「イベント処理サービスは重複したメッセージを送りうるので、処理済みメッセージを記録して消費側を冪等にすることを推奨する」と書いています。

冪等なコンシューマの作り方(冪等キーと処理済みテーブル)

この節では、event_id を主キーにした処理済みテーブルで重複を捨てる実装と、同じ再送シナリオで二重出荷が0件になった結果が分かります。

要点は、「処理済みの記録」と「副作用」を受け手側の1つのトランザクションに入れることです。 記録だけ先に COMMIT すると、副作用の前に落ちたときにそのイベントが永久に処理されません。

CREATE TABLE consumer.processed_events (
  event_id     uuid PRIMARY KEY,
  processed_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
with psycopg.connect(DSN) as conn, conn.transaction():
    cur = conn.cursor()
    cur.execute("INSERT INTO consumer.processed_events(event_id) VALUES (%s) ON CONFLICT DO NOTHING",
                (body["event_id"],))
    if cur.rowcount == 0:                 # 既に処理済み → 副作用を起こさず 200 を返す
        return self._reply(200, {"result": "duplicate-ignored"})
    cur.execute("INSERT INTO consumer.shipments(order_id, event_id) VALUES (%s,%s)",
                (body["aggregate_id"], body["event_id"]))
# ここで COMMIT。記録と副作用が一緒に確定する

同じ「3件送って落とす → 再起動」を、このコンシューマで繰り返した結果です。

 http_received | distinct_events
---------------+-----------------
             8 |               5      ← HTTP は8回届いた

 order_id | shipments
----------+-----------
 1        |         1
 2        |         1      ← 出荷指示はすべて1件
 3        |         1
 4        |         1
 5        |         1

重複に 200 を返しているのも意図的です。4xx や 5xx を返すと、リレー側は失敗とみなして再試行を続けます。受け手にとって「もう済んでいる」は成功です。

  • 1冪等キーは送り手が採番するoutbox の event_id(uuid)をそのまま使う。受信側で生成すると再送を見分けられない
  • 2処理済みの記録と副作用を同じトランザクションに副作用が外部 API(メール送信など)なら、先に記録して COMMIT → 送信 → 失敗時は記録を消す、の順を設計する
  • 3重複には 200 を返すエラーを返すとリレーが再試行し続け、デッドレターに溜まる
  • 4処理済み表にも保持期間を決めるoutbox 側の再送は数分〜数時間以内に収まる。processed_at を基準に期限切れの行を消す

冪等キーの設計そのものは、関連記事「冪等性とは?二重課金を防ぐ冪等キーとリトライ設計」で扱っています。受け手が Webhook のときの署名検証と再試行は、関連記事「Webhookとは?実装までわかる|署名検証・リトライ・冪等性を実行ログで解説」で扱っています。

順序保証とパーティション(複数リレーで順序が崩れる実測)

この節では、リレーを2つ・4つに増やすと同じ注文のイベントの順序がどれだけ崩れるか、aggregate_id でパーティションすると 0 になること、配送失敗時の順序の守り方が分かります。

リレーが1つなら ORDER BY id で outbox に入った順に送られます。2つ以上にすると、SKIP LOCKED は「互いに違う行」を取るだけで、同じ注文の1件目と2件目が別のリレーに渡り、後のリレーが先に届けることがあります。 300注文 × 3イベント(OrderCreated → OrderUpdated2 → OrderUpdated3)を流し、受信側で「同じ注文の中で outbox_id が戻った回数」を数えました。

リレー数 パーティション 順序が崩れた注文(300中) 900件の所要(2回計測)
1 なし 0 6.2〜6.9 秒
2 なし 25〜30 5.2〜5.3 秒
4 なし 30 4.7 秒
2 aggregate_id のハッシュ 0 6.4〜7.1 秒
4 aggregate_id のハッシュ 0 5.5〜9.9 秒

パーティションは取り出しクエリに1条件を足すだけです。ワーカー i(全 N)は自分の担当の集約だけを取ります。

  AND abs(hashtext(aggregate_id)) % 4 = 1     -- 4ワーカー中の1番

所要時間は 2 CPU の共用機でコンシューマ(リクエストごとに DB 接続)が頭打ちになっており、リレーを増やしても1割ほどしか速くなりませんでした。順序を守りたいなら、まずリレー1つで間に合うかを測り、増やすときは集約でパーティションする、が判断の順です。Kafka に配送する場合は、Debezium の Outbox Event Router が aggregateid をメッセージのキーに使い、同じ集約のイベントが同じパーティション内で順序を保つように設計されています(公式ドキュメント)。

配送に失敗したときの順序

リレー1つでも、1件目の配送が失敗して再試行待ちになる間に2件目が送られると順序が崩れます。「同じ集約に、自分より前の未配送の行があれば取り出さない」条件を足すと守れます。

  AND NOT EXISTS (SELECT 1 FROM outbox o2
                   WHERE o2.aggregate_id = outbox.aggregate_id
                     AND o2.published_at IS NULL AND o2.id < outbox.id)

注文1の1件目だけ 500 にして流した受信順です。条件なしでは注文1の2・3件目が先に届き、条件ありでは注文2を先に進めつつ注文1は1→2→3の順で届きました。

条件なし : order1#2 , order1#3 , order2#1 , order2#2 , order2#3 , order1#1
条件あり : order2#1 , order2#2 , order2#3 , order1#1 , order1#2 , order1#3

この条件は「1件詰まると、その集約の後続が全部止まる(head-of-line blocking)」ことと引き換えです。順序が要らないイベント(通知メールなど)には付けないのが設計判断です。

配送遅延とスループットの実測(ポーリング間隔・LISTEN/NOTIFY・バッチ)

この節では、COMMIT から配送先に届くまでの遅延がポーリング間隔でどう変わるか、LISTEN/NOTIFY でどこまで縮むか、バッチサイズで処理量がどう変わるかが分かります。

ポーリングの遅延は「間隔の半分が中央値、間隔が最大」になります。 200ms おきに注文を40件作り、received_at − outbox.created_at を集計しました。

リレーの待ち方 中央値 95パーセンタイル 最大
1000ms ポーリング 507.6 ms 951.5 ms 1003.0 ms
500ms ポーリング 263.9 ms 491.1 ms 515.4 ms
100ms ポーリング 55.1 ms 99.6 ms 105.8 ms
LISTEN/NOTIFY(予備の間隔 1000ms) 6.2 ms 9.8 ms 14.7 ms
LISTEN/NOTIFY(予備の間隔 5000ms) 6.2 ms 12.9 ms 26.9 ms
6.2msNOTIFY での配送遅延の中央値1秒ポーリングは 507.6ms
約100回/10秒100ms ポーリングが空振りで発行するトランザクションNOTIFY 待ちは 10 回ほど
6,125件/秒Redis Stream へのバッチ100配送バッチ1では 1,762 件/秒

NOTIFY は outbox への INSERT に文(statement)単位のトリガーを付けるだけです。PostgreSQL の公式マニュアルは「トランザクションの中で実行された NOTIFY は、そのトランザクションがコミットされるまで、そしてコミットされた場合に限って配信される」と書いています。つまりリレーは未コミットの行を取りに行って空振りすることがなく、同じトランザクション内の同一チャネル・同一ペイロードの通知は1つにまとめられます。

CREATE FUNCTION outbox_notify() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN PERFORM pg_notify('outbox_new', ''); RETURN NULL; END $$;
CREATE TRIGGER outbox_notify_trg AFTER INSERT ON outbox
  FOR EACH STATEMENT EXECUTE FUNCTION outbox_notify();
conn = psycopg.connect(DSN, autocommit=True)
conn.execute("LISTEN outbox_new")
while True:
    if poll_once(conn) == 0:                      # 0件なら通知を待つ(上限 1 秒で念のため再確認)
        for _ in conn.notifies(timeout=1.0, stop_after=1):
            pass

NOTIFY だけに頼らず予備のポーリングを残すのは、リレーが再接続した直後など LISTEN していない間の通知は届かないためです。イベントが来ない10秒間に発行されたトランザクション数は、100ms ポーリングで約100回、NOTIFY 待ち(予備1秒)で約10回でした。

バッチサイズと処理量

1万件を Redis Stream に配送したときの所要と、HTTP 配送の比較です。バッチを1件にすると、1件ごとに SELECT … FOR UPDATE と UPDATE と COMMIT が走るぶん遅くなります。

配送先・バッチ 件数 所要 件/秒
Redis Stream・1件 10,000 5.68 秒 1,762
Redis Stream・10件 10,000 1.88 秒 5,308
Redis Stream・100件 10,000 1.63 秒 6,125
Redis Stream・1000件 10,000 1.61 秒 6,211
HTTP(自作サーバー)・100件 2,000 10.20 秒 196

HTTP が遅いのは配送先がリクエストごとに DB へ接続する作りだからで、パターンの差ではありません。ただしバッチを大きくするほど「送ったのに印が付かないまま落ちる」ときの再送件数も大きくなります。100 前後で頭打ちになるので、それ以上は増やす理由がありません。

Redis Stream へ配送する(XADD・コンシューマグループ・XPENDING)

この節では、配送先を HTTP から Redis Stream に替えたときのリレーの差分と、ブローカー側でも「少なくとも1回」が前提になっていることが分かります。

リレー側の差分は deliver() の中身だけです。 ストリーム名は Debezium の既定(outbox.event.<aggregatetype>)に合わせました。

R.xadd(f"outbox.event.{aggregate_type}",
       {"event_id": str(event_id), "aggregate_id": aggregate_id,
        "event_type": event_type, "payload": json.dumps(payload), "outbox_id": outbox_id})
$ redis-cli -p 64242 XLEN outbox.event.orders
3
$ redis-cli -p 64242 XRANGE outbox.event.orders - + COUNT 1
1793731446400-0
event_id      a283ea7f-77f4-49ca-b391-ed533a0cfa9d
aggregate_id  1
event_type    OrderCreated
payload       {"seq": 1, "amount": "1200", "order_id": 1}
outbox_id     1

コンシューマグループで読み、ACK せずにワーカーが落ちた状況を作ると、保留(pending)に残り、別のワーカーが XAUTOCLAIM で引き取ります。ここでも同じイベントが2つのワーカーに届きうるので、受け手の冪等キーは変わらず必要です。

$ redis-cli -p 64242 XGROUP CREATE outbox.event.orders shipping 0
$ redis-cli -p 64242 XREADGROUP GROUP shipping worker-1 COUNT 2 STREAMS outbox.event.orders '>'
...(2件届く。worker-1 は XACK せずに落ちたとする)
$ redis-cli -p 64242 XPENDING outbox.event.orders shipping
2  1793731446400-0  1793731446400-1  worker-1 2
$ redis-cli -p 64242 XAUTOCLAIM outbox.event.orders shipping worker-2 0 0-0 COUNT 10
...(worker-2 が同じ2件を受け取る)
$ redis-cli -p 64242 XINFO GROUPS outbox.event.orders
name shipping / consumers 2 / pending 2 / lag 1

Kafka に配送する場合の手順は、関連記事「Kafka入門|KRaftでトピック作成から消費まで最短手順」で扱っています。SQS(標準キューは少なくとも1回配信)に配送するときの可視性タイムアウトとの関係は、関連記事「Amazon SQSの使い方|送受信・削除と可視性タイムアウト、FIFO」で扱っています。

よくある落とし穴(シーケンスの穴・ロックの保持・ペイロードの肥大)

この節では、動いているように見えて取りこぼしや遅延を生む設計と、その再現結果・直し方が分かります。

いちばん見つけにくいのは「最後に見た id より大きい行を取る」方式の取りこぼしです。 bigserial の値は INSERT 時に確定し、COMMIT の順とは一致しません。2つのセッションで再現しました。

A inserted id=41 (uncommitted), B inserted id=42 (committed)
watermark relay sees: [42]                      ← last = 42 に進めてしまう
A committed id=41
watermark relay (id > 42) sees: []  <- id 41 is never delivered
published_at IS NULL relay sees: [41, 42]       ← 印で判定すれば拾える
症状 原因 直し方
たまにイベントが届かない 「id > 最後に見た id」で取り出している。遅く COMMIT した小さい id を飛ばす published_at IS NULL で判定する。CDC なら WAL の順なので起きない
リレーを増やしたら順序が崩れた SKIP LOCKED は行を分けるだけで集約を分けない aggregate_id のハッシュで担当を分ける(前節)
配送先が遅いと他のリレーまで遅くなる 取り出しトランザクションを配送の間ずっと開いている。長いトランザクションは VACUUM も妨げる バッチを小さくする・配送のタイムアウトを短くする。長い配送なら claimed_at を先に COMMIT する方式に替え、stale な行の回収を足す
ポーリングがだんだん遅くなる 配送済みの行が増え、部分インデックスが無い(Seq Scan で 34ms) WHERE published_at IS NULL の部分インデックス+定期的な掃除
outbox だけ書き忘れた経路がある microservices.io が挙げる欠点そのもの 「業務テーブルの更新」と「outbox への INSERT」を1つのリポジトリ関数にまとめ、直接 UPDATE する経路を無くす
payload が大きく WAL と表が太る 集約全体を毎回入れている 変更の要点+ID にして、受け手が必要なら API で取り直す。または CDC で表の行をそのまま流す
再送が無限に続く 受け手が重複に 4xx/5xx を返す 重複は 200。恒久的な失敗(検証エラー)は attempts 上限でデッドレターへ

「取り出し中」を UPDATE で記録する方式(claimed_at)と、トランザクション内ロックで持つ方式(この記事)の選び方は次のとおりです。

トランザクション内ロック(SKIP LOCKED)

落ちればロックが消えるので回収処理が要らない

配送が速い(数十ms)ときに向く

配送の間トランザクションが開く

claimed_at を先に COMMIT

配送が長い・外部 API の応答が遅いときに向く

落ちると claimed のまま残るので、一定時間で回収する処理が要る

回収までの時間ぶん配送が遅れる

分散システム全般の再試行と冪等性の落とし穴は、関連記事「分散システムの落とし穴8選|冪等性・リトライ・CAP定理」で扱っています。

outbox の掃除(DELETE と dead tuple・VACUUM・パーティション)

この節では、配送済みの行を DELETE したときに表の大きさがどうなるか、VACUUM と VACUUM FULL の違い、日次パーティションを DROP する方式の速さが、20万行の実測で分かります。

DELETE は行を「死んだ行(dead tuple)」にするだけで、表のファイルは縮みません。 配送済み20万行(62MB)を消して確かめました。

操作 所要 n_dead_tup 表の大きさ
DELETE 200,000 行 216.8 ms 200,000 62 MB(変わらない)
VACUUM 76.8 ms 0 62 MB(空きとして再利用される)
もう一度 200,000 行 INSERT ― ― 62 MB(増えない)
VACUUM FULL(排他ロック) 301.3 ms ― 58 MB(詰め直し)

流量が一定なら、「配送済みを定期的に DELETE し、autovacuum に任せる」で表の大きさは一定に保たれます。VACUUM が末尾を切り詰められないのは、未配送の新しい行がファイルの末尾にあるためで、通常の運用では VACUUM FULL は要りません。問題になるのは、掃除を止めていた期間に膨らんだ表を戻したいときだけです。

パーティションで DROP する

流量が多いなら、created_at の日次 RANGE パーティションにして、保持期間を過ぎたパーティションを DETACH → DROP します。20万行(52MB)のパーティションを落とすのに 0.7ms+9.9ms でした。dead tuple も出ません。

CREATE TABLE outbox_p (
  id bigint GENERATED ALWAYS AS IDENTITY, event_id uuid NOT NULL DEFAULT gen_random_uuid(),
  aggregate_type text NOT NULL, aggregate_id text NOT NULL, event_type text NOT NULL,
  payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  published_at timestamptz,
  PRIMARY KEY (created_at, id)              -- 主キーにパーティションキーを含める
) PARTITION BY RANGE (created_at);
CREATE INDEX ON outbox_p (id) WHERE published_at IS NULL;
CREATE TABLE outbox_p_2026_11_03 PARTITION OF outbox_p FOR VALUES FROM ('2026-11-03') TO ('2026-11-04');
CREATE TABLE outbox_p_2026_11_04 PARTITION OF outbox_p FOR VALUES FROM ('2026-11-04') TO ('2026-11-05');

ALTER TABLE outbox_p DETACH PARTITION outbox_p_2026_11_03;   -- Time: 0.677 ms
DROP TABLE outbox_p_2026_11_03;                               -- Time: 9.885 ms

DROP する前に「そのパーティションに未配送の行が無い」ことを確かめる1行を掃除ジョブに入れておきます。未配送が残っていればパーティションを残し、警告を出すのが安全側です。

SELECT count(*) FROM outbox_p_2026_11_03 WHERE published_at IS NULL;   -- 0 なら DROP してよい

CDC(Debezium のログテーリング)との比較

この節では、ポーリング・リレーを Debezium(変更データキャプチャ)に置き換えるとき何が変わり、何が変わらないかが、公式ドキュメントの範囲で分かります。

CDC は「outbox テーブルを SELECT で見張る」代わりに「outbox テーブルへの INSERT を WAL(トランザクションログ)から読む」方式です。 microservices.io は Message relay の実装として Transaction log tailing と Polling publisher の2つを挙げています。outbox に書く側のアプリの形は同じで、変わるのはリレーだけです。Kafka と Debezium はこの環境で動かしていないため、以下は Debezium 公式ドキュメント(Outbox Event Router)で確かめた範囲です。

観点 ポーリング・リレー(この記事) CDC(Debezium Outbox Event Router)
未配送の検出 SELECT … FOR UPDATE SKIP LOCKED(または NOTIFY) 論理レプリケーションで WAL の INSERT を読む(COMMIT 順)
配送済みの印 published_at を UPDATE 不要。送った直後に DELETE してよく、SMT は outbox 表の DELETE を自動的に除外する
トピック・キー 自分で決める 既定で outbox.event.<aggregatetype>、キーは aggregateid
遅延 ポーリング間隔に依存(実測 6ms〜1秒) WAL を読み続けるので間隔待ちが無い
必要なもの DB と配送先だけ Kafka Connect・Debezium コネクタ・DB の論理レプリケーション設定(wal_level=logical・レプリケーションスロット)
配送の保証 少なくとも1回 少なくとも1回(コンシューマの冪等性は同じく必要)

Debezium のドキュメントは、outbox テーブルを「キューのように扱い、変更は INSERT で行う」こと、UPDATE が来たときの扱いは table.op.invalid.behavior(既定 warn)で決まることを書いています。ポーリング方式の published_at UPDATE は、CDC に移るときには「送った行を DELETE する」に置き換えるのが自然です。

1

まずポーリング

DB と配送先だけで動く。小〜中規模はこれで間に合うことが多い。

この記事の構成

2

遅延が気になったら NOTIFY

トリガー1つで中央値 6ms。基盤を増やさずに済む。

予備のポーリングは残す

3

Kafka が既にあるなら CDC

Kafka Connect の運用に乗せられる。表の形は同じでよい。

公式ドキュメントの範囲

4

レプリケーションスロットに注意

コネクタが止まると WAL が溜まり続ける。監視対象に入れる。

PostgreSQL 側の運用

採用する・しないの判断表(同期で済む場合・Saga・2PC との違い)

この節では、outbox を入れる前に確かめる順番と、入れない方がよい状況、近い名前のパターンとの違いが分かります。

outbox が要るのは「DB の変更と、別システムへの通知の両方が、片方だけでは成立しない」ときだけです。 次の順に考えると判断がぶれません。

  1. 1通知先が同じ DB の別テーブルか → 普通のトランザクションoutbox も リレーも要らない。同じトランザクションで両方 UPDATE する
  2. 2同期呼び出しの失敗を、そのまま利用者に返してよいか → 同期で済む先に相手を呼び、成功したら COMMIT する。相手が落ちていれば注文も失敗にする設計が許されるなら、それが最も単純
  3. 3通知の取りこぼしが業務上許されるか → 素朴な送信+監視分析用のログ送信など。欠けたら後で突合で埋められるもの
  4. 4コミットされた変更を必ず届けたい → Transactional Outbox出荷指示・請求・在庫引き当て・他サービスの投影の更新。受け手の冪等性とセット
  5. 5複数サービスの DB を順に更新し、途中で失敗したら戻したい → Saga各ステップのイベント発行に outbox を使う。AWS のガイドも「サービスをまたぐなら Saga」と書いている
手段 何を保証するか 向く場面・制約
Transactional Outbox コミットされた変更のイベントを少なくとも1回配送 1つの DB と1つ以上の配送先。受け手は冪等に
2相コミット(2PC / XA) DB とブローカーを原子的にコミット 両方が XA に対応し、コーディネータを運用できる場合。microservices.io は「現実的でない」としている
Saga 複数サービスの局所トランザクションの連鎖と補償 サービス間の業務フロー。各ステップの通知には outbox を使う
Event Sourcing イベント自体が正本。別途の outbox は不要 microservices.io が「代替の解決策」として挙げる。設計全体が変わる
Listen to yourself / 先に送る ブローカーに先に書き、自分も購読して DB を更新 DB 更新が遅延する。読み取り直後の一貫性が要る画面には向かない

導入の費用は、表1つ・リレー1プロセス・受け手の処理済み表・掃除ジョブ・デッドレターの監視です。小さくはないので、「落ちたら失敗にしてよい」なら同期呼び出しを選ぶのは正当な判断です。トランザクションの境界をどこで切るかは、関連記事「トランザクション設計の考え方|境界の決め方と長い処理の分割手順」で扱っています。

よくある質問

Q. outbox テーブルは業務テーブルと同じデータベースに置く必要がありますか?

はい。同じトランザクションで書けることがパターンの前提なので、同じデータベース(同じ接続・同じトランザクション)に置きます。別のデータベースに置くと、それ自体が二重書き込みになります。

Q. 「ちょうど1回(exactly once)」にはできませんか?

リレーと配送先の間では「少なくとも1回」が限界で、この記事でも送信後・印を付ける前の停止で再送が起きました。ちょうど1回「に見える」結果は、受け手の冪等性(処理済み表)で作ります。ブローカーが重複排除の機能を持っていても、受け手側の冪等性は残しておくのが安全です。

Q. リレーは何個動かすべきですか?

まず1つで測ります。この記事の環境では2つにしても1割しか速くならず、順序は 300 注文中 25〜30 件で崩れました。増やすなら aggregate_id のハッシュで担当を分け、同じ集約が同じリレーに行くようにします。

Q. ポーリング間隔はどれくらいが妥当ですか?

遅延の中央値は間隔の半分、最大は間隔とほぼ同じでした(1秒で 507.6ms/1003.0ms)。数百 ms が要るなら LISTEN/NOTIFY を足す方が、間隔を 100ms に詰めて空振りのクエリを増やすより DB にやさしい結果でした。

Q. 配送先が長時間落ちていたらどうなりますか?

outbox に未配送の行が溜まり、attempts の上限に達した行は取り出し対象から外れます。復旧後に UPDATE outbox SET attempts = 0, next_attempt_at = clock_timestamp() WHERE published_at IS NULL で戻せば、id 順に配送が再開します。溜まっている間も業務トランザクションは影響を受けません。

Q. 1つの業務トランザクションで複数のイベントを書いてもよいですか?

はい。同じ aggregate_id なら id 順に配送されます。この記事の順序の実験は1注文に3イベントを書いて行いました。

Q. payload には何を入れるべきですか?

受け手が処理に必要な最小限(ID・変更の種類・変更後の要点)と、スキーマのバージョンです。集約全体を毎回入れると表と WAL が太ります。受け手が詳細を要るなら API で取りに行く形(通知+取得)も選べます。

Q. 配送済みの行はいつ消せますか?

published_at が付いた行は、再送の必要が無くなった時点で消せます。監査や再配送(リプレイ)に使うなら数日〜数週間残し、日次パーティションで DROP するのが軽い方法です。DELETE 方式では autovacuum が追いつくかを n_dead_tup で見ます。

Q. ORM(JPA・SQLAlchemy など)でも同じことができますか?

できます。要点は「業務エンティティの保存と outbox エンティティの保存を同じトランザクション境界に入れる」ことだけです。AWS のガイドの例も Spring の @Transactional メソッドの中で2つの repository の save を呼んでいます。

Q. CDC にすれば受け手の冪等性は要らなくなりますか?

要ります。Debezium から Kafka、Kafka から受け手のどちらの区間でも再配送は起きえます。CDC で変わるのはリレーの作りと遅延であって、配送の保証の種類は変わりません。

まとめ

  • DB 更新とメッセージ送信を別々に行うと、送る順番をどう変えても「存在しない注文の通知」か「通知されない注文」のどちらかが起きる。5経路すべてをプロセス停止と ROLLBACK で再現できた。
  • Transactional Outbox は、送りたい事実を同じトランザクションで outbox テーブルに書き、別プロセスのリレーが配送する。ROLLBACK や COMMIT 前の停止では outbox にも何も残らない。
  • リレーは published_at IS NULL の部分インデックス+FOR UPDATE SKIP LOCKED で 0.1ms で取り出せる。配送後・印を付ける前に落ちると再送されるので、受け手は event_id の処理済み表で冪等にする(HTTP 8回受信・出荷指示 5件)。
  • 複数リレーは aggregate_id でパーティションしないと順序が崩れる(300注文中 25〜30)。遅延はポーリング間隔の半分が中央値で、LISTEN/NOTIFY なら 6ms。
  • outbox は DELETE しても表は縮まず、空きが再利用されるだけ。流量が多ければ日次パーティションの DROP。同じ DB で完結する・同期の失敗を返してよい場合は、outbox を入れない判断が正しい。

参考・出典

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Transactional Outboxパターン|二重書き込みの再現と実装・実測」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり