Outboxパターンとは?DB更新とイベント発行を一致させる実装方式を解説【2026年版】

注文をデータベースに保存したのに、後続サービスへの通知だけが届かない。逆に通知は流れたのに、肝心のレコードが残っていない。この食い違いが起きるのは、データベースへの書き込みとメッセージブローカーへの送信が別々の操作だからです。Outboxパターン(トランザクショナルアウトボックス)は、送りたいイベントを業務データと同じデータベースの専用テーブルへ書き、同一トランザクションでコミットして食い違いを消します。本記事では列構成、リレーの排他制御、ポーリング方式とCDC方式の分かれ目、受信側の重複排除、採用しないほうがよい条件までを実装者の目線で整理しました。

まとめ:Outboxパターンの採用条件と方式選択の結論

Outboxパターンは、データベースの状態変更を外部へのイベント発行と取りこぼしなく一致させたいときに使う設計です。業務テーブルの更新とoutboxテーブルへのINSERTを1つのトランザクションに入れ、別プロセスのリレーがoutboxを読んでブローカーへ送ります。コミットが唯一の真実になるため、送信失敗は再送で回復でき、未コミットのイベントが外へ漏れることもありません。

方式の選択は流量で決まります。1日あたり数万件規模までなら、outboxテーブルを定期的にSELECTするポーリング方式で十分です。配信遅延を100ミリ秒台に抑えたいなら、Debeziumのような変更データキャプチャ(CDC)でトランザクションログを読む方式に移ります。ただしCDCはKafka Connectなどの基盤と監視体制を前提にするので、運用担当を置けないチームでは負債になりがちです。

採用しない判断も同じだけ効きます。単一データベース内で完結する処理、取りこぼしが業務上許容できる通知、月間数千件程度の低頻度連携では、リレーの追加が投資に見合いません。以降の章で、この結論に至る列設計・実装・比較・失敗パターンを順に見ていきます。

二重書き込みで生じる不整合とOutboxパターンが担保する到達性

DB更新とメッセージ送信が分かれる際に起きる2種類の不整合パターン

注文サービスが注文レコードを保存し、直後にメッセージブローカーへ「注文確定」イベントを送る実装を考えます。この2操作は別々のシステムへの書き込みなので、片方だけ成功する余地が常に残ります。起きる不整合は2種類です。

1つ目は、コミットは成功したのにブローカーへの送信が失敗するケース。注文は存在するのに在庫引当も配送手配も動かず、業務が途中で止まります。2つ目は送信が成功したのにトランザクションがロールバックされるケースで、存在しない注文に在庫が引き当てられます。前者はエラーログに痕跡が残り、後者は誰も気づかないまま在庫がずれるので、実務では後者が厄介です。

順序を入れ替えても解決しません。送信を先にすれば、失敗する組み合わせが入れ替わるだけだからです。2フェーズコミットでまとめる手もありますが、主要なメッセージブローカーがXAトランザクションを実用的な性能で提供しておらず、参加者のどれかが停止するとロックが残ります。単一DB内でのACIDの保証範囲はトランザクションの原子性と分離レベルを参照してください。

同一トランザクションでoutboxテーブルへ書く解法の前提条件

Outboxパターンの発想は単純です。ブローカーへ送りたいメッセージを、業務データと同じデータベースのoutboxテーブルへINSERTし、業務テーブルの更新と同じトランザクションでコミットします。コミットが通れば注文レコードとイベント行の両方が残り、ロールバックすれば両方とも消えるのです。書き込み先が1つのデータベースに閉じるため、原子性はデータベース自身が保証してくれます。

この解法の前提は2つあります。1つは業務テーブルとoutboxテーブルが同一のトランザクション境界に入ること。別スキーマでも、同じ接続で1つのトランザクションとしてコミットできるなら成立します。もう1つは、ブローカーへの実際の送信をトランザクションの外に出すことです。送信をトランザクション内で待つと、ブローカーの遅延がそのままロック保持時間になります。

送信は「リレー」と呼ばれる別プロセスが担います。未送信行を取り出してブローカーへ送り、成功したら送信済みの印を付けるだけの部品です。この分離により、ブローカーが数分停止していても業務処理は止まりません。

At-least-once配信と受信側の重複排除が対になる理由

Outboxパターンが保証するのは「少なくとも1回は届く」(at-least-once)であって、「ちょうど1回」ではありません。送信した直後、送信済みフラグを更新する前にプロセスが落ちれば、再起動後に同じ行がもう一度送られます。ブローカー側の応答が届かず再送する場合も同じです。

この重複は送信側で消さず、受信側で弾きます。各イベントに一意なIDを持たせ、受信側は処理済みIDを記録して既知のIDなら黙って捨てる。「ちょうど1回」を送信側だけで実現しようとすると、ブローカーと受信側にまたがる分散合意が必要になり、消したはずの二重書き込み問題が形を変えて戻ってくるからです。

つまりOutboxパターンの導入は、受信側の冪等な処理設計とセットで完成します。イベント単位でどう冪等性を担保するかの一般的な手法は冪等性の意味とAPIでの担保方法にまとめました。本記事では受信側にinboxテーブルを置く書き方を後段で扱います。

outboxテーブルのスキーマ設計とリレー実装で決まる4つの論点

集約ID・イベント型・ペイロードを含むDebezium既定の列構成

列構成に迷ったら、Debeziumの Outbox Event Router が既定で期待する形を出発点にすると外しません。公式ドキュメントが示す既定の列は、id(uuid)、aggregatetype(varchar 255)、aggregateid(varchar 255)、type(varchar 255)、payload(jsonb)の5つです。

aggregatetypeは送り先トピックの決定に使われ、既定ではoutbox.event.に続けてこの値がトピック名になります。aggregateidはイベントのキーになり、同じ集約のイベントを同一パーティションへ揃える根拠として働くのです。payloadには受信側が必要とする情報を自己完結した形で入れてください。ここを注文IDだけにすると、受信側が毎回送信元へ問い合わせに来る設計になります。

ポーリング方式なら、これにstatuscreated_at、再送回数のattemptsを足します。この2列には複合インデックスを張ってください。未送信行の取得は常に状態と作成時刻での絞り込みになるため、インデックスが無いと行数の増加がリレーの遅延に化けます。なお状態そのものをイベント列から再構築したいなら、それはイベントソーシングによる状態再生の設計の領域です。outboxテーブルは送信待ちの一時置き場であって、永続的な履歴台帳ではありません。

SKIP LOCKEDによるリレー多重起動時の排他とバッチ取得

リレーを冗長化して2台以上動かすと、同じ未送信行を複数のプロセスが掴んで重複送信が増えます。PostgreSQLならFOR UPDATE SKIP LOCKEDで解決できます。この構文は9.5から利用でき、他のトランザクションがロック中の行を待たずに読み飛ばすため、リレーが何台いても互いに別の行を処理する形になるのです。

取得件数は1回のバッチで100〜500行を目安にしてください。1行ずつ取ると往復回数がそのまま遅延になります。逆に数千行を1トランザクションで抱えると、失敗時の巻き戻し範囲が広がって重複送信が増えるので割に合いません。

同一集約のイベント順序が崩れる条件とパーティションキー設計の指針

「注文作成」の直後に「注文キャンセル」を出したのに、受信側でキャンセルが先に着く。この順序逆転はリレーの並列度を上げた瞬間に起こります。SKIP LOCKEDで複数のリレーが別々の行を掴むと、同じ注文に属する2件が別プロセスから同時に送られるからです。

対策は2段構えです。1つ目はブローカー側のパーティションキーにaggregateidを使うこと。Kafkaはパーティション内の順序を保証するので、同じ注文IDのイベントは書き込まれた順に読まれます。Amazon SQSのFIFOキューならメッセージグループIDに同じ値を入れてください。2つ目はリレー側で、同一の集約IDに未送信行が複数ある場合は最も古い1件だけを対象にすることです。

ただし順序保証が要らない場面で無理に守らないでください。集約ごとの直列化はスループットの上限を集約単位に固定します。受信側が「バージョン番号が古いイベントは無視する」形で書けるなら、順序を捨てるほうが素直です。

送信済みレコードのパージ間隔とテーブル肥大化を防ぐ運用設計の勘所

本番で最初に問題化するのは、性能ではなくテーブルの肥大化です。送信済み行を消さないまま運用すると、1日10万件の連携で3か月後には900万行を超えます。検索自体はインデックスが効くので急には遅くなりませんが、VACUUMの負荷とバックアップ時間がじわじわ効いてきます。

送信済み行は、再調査に必要な期間だけ残して定期的に削除します。目安は7日から30日。それ以上の監査証跡が必要なら、別のイベント履歴テーブルかログ基盤へ退避してください。削除はcreated_atで範囲を切り、1回あたり数千行ずつのバッチにします。数百万行を1文のDELETEで消すと、ロックとWAL生成が跳ね上がってリレー自体が詰まるからです。

PostgreSQLなら作成時刻で月次パーティションを切り、古いパーティションをDETACHして落とす形が扱いやすい構成です。DELETEと違って断片化が残らず、実行時間もほぼ一定になります。

ポーリング方式とCDC方式で分かれる遅延・DB負荷・運用コスト

ポーリング方式が持つ実装コストの低さと秒単位に残る配信遅延の上限

ポーリング方式は、リレーが一定間隔でoutboxテーブルをSELECTして未送信行を拾う構成です。アプリケーションと同じ言語で書けて追加の基盤も要らず、既存のジョブスケジューラや常駐ワーカーがあるなら数十行足すだけで動きます。

代わりに配信遅延はポーリング間隔に張り付きます。間隔1秒なら平均遅延は0.5秒、最悪で1秒強。間隔を100ミリ秒まで詰めれば遅延は縮みますが、1日あたりのクエリ回数が86万回を超え、そのほとんどが0件を返す空振りになります。レプリカへ逃がすとレプリケーション遅延の分だけ発見が遅れるので、書き込み系のインスタンスを読む前提で間隔を決めてください。

DebeziumやDynamoDB StreamsでCDC方式を組む際の前提

CDC方式は、データベースのトランザクションログを読んで変更を検知します。PostgreSQLならWAL、MySQLならbinlogが対象で、outboxテーブルへのINSERTがそのままイベントとして流れる仕組みです。ポーリングのクエリが消えるためデータベースへの追加負荷はほぼ無く、遅延も数十ミリ秒台まで落ちます。

代表的な実装はDebeziumで、2026年8月時点の安定版は3.5系(3.5.2.Finalが2026年6月2日リリース)、3.7系はまだAlphaの段階です。前述の Outbox Event Router を有効にすると、集約種別の列からトピック名を組み立て、payloadの中身をそのままメッセージ本文として送り出します。生の変更イベントを受信側に見せずに済むのです。

前提になるのはKafka ConnectとKafkaクラスタ、そしてそれらの監視です。コネクタが止まればイベントは1件も流れないので、コネクタの状態とレプリケーションスロットの遅延を監視対象に入れてください。スロットが滞留するとWALが削除されず、ディスクが埋まってデータベースごと停止する事故につながります。

1日あたりの流量とチーム体制から2つの方式を選び分ける判断基準

2方式の差を、実装判断に使える粒度で並べます。

観点 ポーリング方式 CDC方式
配信遅延 0.1〜5秒(間隔依存) 数十〜数百ミリ秒
DBへの追加負荷 定期SELECTの分が増える ログ読み取りのみ
必要な追加基盤 なし(アプリ内で完結) Kafka Connect等
新規の監視対象 未送信行の滞留のみ コネクタとスロット遅延
初期実装の規模 数十行のワーカー 基盤構築で数人日
向く流量 日次数万件まで 日次数百万件以上

判断基準は明快です。日次の連携イベントが数万件までで、業務上1秒程度の遅延が許されるならポーリング方式を選んでください。CDC基盤の構築と監視に費やす数人日が、得られる遅延短縮に見合いません。既にKafka Connectを運用しているチームなら、流量が小さくてもCDC方式のほうが総コストは下がります。

PostgreSQLとDynamoDBで変わる実装手段と受信側の重複排除

PostgreSQLでpgmqやSKIP LOCKEDを使うときの構成例

PostgreSQLはOutboxパターンとの相性が最も良い選択肢です。排他・ペイロード格納・パージ・CDC対応がいずれも標準機能で揃うため、テーブルを1本足すだけで追加のミドルウェアなしにポーリング方式が完成します。

キューとしての管理機能まで持たせたいなら、PostgreSQLをメッセージキューとして扱う拡張を使う手もあります。可視性タイムアウトやリトライ回数の管理を自前で書かずに済むぶん、リレーのコードは薄くなるのです。詳細はpgmqでPostgreSQLをメッセージキュー化する構成で扱いました。ただし拡張を入れられないマネージドサービスもあるため、採用前に対応状況を確認してください。

DynamoDB Streamsで単一テーブルに載せる場合の制約

DynamoDBのTransactWriteItemsには最大100項目・4MBという上限があります。そのため業務項目とoutbox項目を同じテーブル・同じパーティションキー配下に置き、1回のTransactWriteItemsで書く構成を採ります。ソートキーをORDER#OUTBOX#で分ける、いわゆる単一テーブル設計です。

変更の取り出しはDynamoDB Streamsが担い、Lambdaがトリガーで起動してブローカーやEventBridgeへ転送します。ポーリングを自分で書かずに済む点は楽ですが、制約が3つ残ります。保持期間は24時間で、これを超えて滞留したイベントは失われるというのが1点目。1つのシャードへの読み取りは並列度が制限されるため、単一パーティションに書き込みが集中すると詰まります。そしてLambdaの失敗が続くとバッチ全体が再試行され、後続のイベントが止まるのです。失敗率とIteratorAgeは最初から監視対象に入れてください。

受信側にinboxテーブルを設けて重複イベントを弾く実装の書き方

at-least-once配信を前提にする以上、受信側の重複排除は必ず要ります。実装は単純で、受信側のデータベースに処理済みイベントIDを記録するinboxテーブルを1本用意するだけです。列はイベントIDを主キーにして、処理日時を添えれば足ります。

処理の流れは、受け取ったイベントIDをinboxへINSERTし、主キー制約違反が返ったら処理済みとみなして確認応答を返す形です。INSERTが通ったら業務処理を実行し、両方を同じトランザクションでコミットします。順序を逆にして業務処理の後にINSERTすると、その間に落ちたときの重複を防げません。

inbox側もパージが要ります。保持期間は、ブローカーの再送が起こりうる最大期間より長く取ってください。目安はSQSの標準キューで最大14日、Kafkaならトピックの保持期間に合わせます。なお処理を業務的に巻き戻す必要があるなら、それはSagaパターンによる補償トランザクション設計の領域です。Outboxは確実に届ける役割、Sagaは失敗後に戻す役割で、実務では併用します。

Outboxパターンを採用しない条件と受託開発で引くべき線引き

単一DB内で完結する処理でOutboxが過剰投資になる3つの境界

1つ目の境界は、更新と後続処理が同じデータベース内で完結する場合です。注文テーブルと在庫テーブルが同一データベースにあるなら、両方を1トランザクションで更新すれば済みます。将来の分割予定があっても、それが具体的な計画になるまで先回りで置く必要はありません。

2つ目は、イベントの取りこぼしが業務上許容できる通知です。管理者向けのSlack通知、閲覧履歴の記録、レコメンド用の行動ログといった用途で、数千件に1件失われても業務が壊れないなら、リレーとパージ運用を足す価値はありません。送信失敗をログに残して終わりで足ります。

3つ目は、連携が月間数千件程度で、かつ日次バッチで整合を取り直している場合です。夜間バッチが差分を突き合わせて再送する仕組みが既にあるなら、それが実質的なリレーの役割を果たしています。ここにOutboxを重ねると同じ整合性を2つの仕組みが担うことになり、障害時にどちらが正なのか判断できなくなります。

Outbox導入で失敗する3パターンと事前に潰しておく確認項目

実装が動いた後に問題化する典型が3つあります。

  • 受信側の冪等性を後回しにし、重複イベントで在庫が二重に引き当てられる
  • パージを設計せず、outboxテーブルが数千万行に膨れてバックアップが夜間に終わらなくなる
  • リレーの停止を監視しておらず、数時間分のイベントが未送信のまま滞留したことに障害報告で気づく

この3つはいずれも、設計時点の確認で潰せます。着手前に「受信側は同じイベントを2回受けても壊れないか」「送信済み行はいつ誰が消すか」「未送信行の最古の経過時間をどこで見るか」の3問に答えてください。3問目は、最大滞留時間をメトリクスとして出し、閾値(たとえば5分)を超えたらアラートを上げる設定まで落とします。この指標はリレーの停止・ブローカーの障害・処理失敗を1本で拾える箇所です。

採用を決めた後に見積もるべき追加工数と監視・手動介入の運用体制

ポーリング方式なら、outboxテーブルの追加・リレーの実装・受信側のinbox実装・パージのバッチで、既存システムがある前提でも数日規模を見込んでください。工数が伸びるのは実装そのものではなく検証のほうです。リレーを強制終了して重複が弾かれるか、ブローカーを停止して復旧後に流れ切るかといった失敗注入のテストに、正常系と同程度の時間がかかります。

運用面では、最終的に人が触る経路を1本残してください。何度再送しても受信側が受け付けないイベントは必ず出ます。再送回数が上限に達した行を別テーブルへ退避し、担当者が再投入するか破棄するかを決める導線です。これが無いと、1件の不正なペイロードが後続を全て止めます。

サービス間の連携設計をどこまで作り込むかは、システムの分割方針と運用体制で答えが変わります。既存システムへのOutboxパターン導入や、外部サービスとのイベント連携の設計から実装までを外部と進めるなら、API開発・システム連携の受託開発で対応が可能です。現行の構成と流量をもとに、ポーリングとCDCのどちらで組むかの判断からご相談いただけます。

よくある質問

Outboxパターンの実装で実際に質問の多い論点を5つ取り上げます。

Outboxパターンとイベントソーシングは何が違うのですか?

目的が別です。Outboxパターンは「データベースの変更を外部へ確実に届ける」ための送信機構で、outboxテーブルは送信待ちの一時置き場にすぎません。送信が終われば消して構わないデータです。イベントソーシングは状態そのものをイベントの列として保存し、再生して現在の状態を得る設計で、イベントは永続的な唯一の真実になります。両者は併用でき、イベントストアへの追記と外部通知の切り離しにOutboxを使えます。

Outboxパターンだけでデータの整合性は保てますか?

保てるのは「イベントが確実に届く」ところまでです。届いた先のサービスで処理が失敗したとき、送信元のデータを元に戻す仕組みは含まれません。注文を確定したが在庫引当に失敗した、といった業務的な巻き戻しが必要なら、補償トランザクションを設計するSagaパターンを組み合わせます。Outboxが配送の確実性、Sagaが失敗後の後始末という役割分担で捉えてください。

ポーリング間隔はどのくらいに設定すればよいですか?

業務が許容する遅延から逆算します。ユーザー操作への即時性が求められる連携なら100〜500ミリ秒、バックオフィスの連携なら1〜5秒が目安です。固定間隔より、0件が続いたら伸ばし行が取れたら縮める指数バックオフのほうが、平常時のDB負荷と繁忙時の遅延を両立できます。詰める前に滞留時間を計測し、本当に間隔が原因かを確認してください。主因が1バッチの処理件数だった例も少なくありません。

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

同一トランザクションでコミットできる範囲にある必要があります。同じインスタンス内であれば、スキーマを分けても問題ありません。むしろ業務ドメインと送信基盤でスキーマを分けておくと、パージ権限やバックアップ方針を別々に決められます。別インスタンスに置くと書き込みが再び別トランザクションになり、二重書き込み問題がそのまま復活するのです。

Debeziumを使わずにCDC方式を実現する方法はありますか?

あります。AWSならDynamoDB Streams、Google CloudならFirestoreのトリガー、Azureならchange feedが同等の役割を担い、いずれもマネージドサービスとして用意済みです。論理レプリケーションを自前のクライアントで購読する方法もありますが、スロット管理と再開位置の制御を自分で書くことになります。規模が小さいうちはポーリング方式に留めるほうが総コストは下がるはずです。

関連記事

資料請求

RELATED POSTS 関連記事