2025年12月20日·1分で読めます

信頼性の高いAPI連携のためのPostgreSQLのOutboxパターン

イベントをPostgreSQLに保存し、再試行、順序保証、重複排除を備えてサードパーティAPIへ確実に配信するためのOutboxパターンを学ぶ。

信頼性の高いAPI連携のためのPostgreSQLのOutboxパターン

アプリが正常でも統合が失敗する理由

アプリ内では「成功」と表示される操作でも、裏側の統合が静かに失敗していることはよくあります。データベースへの書き込みは速く信頼できる一方で、サードパーティAPIへの呼び出しはそうではありません。すると二つの世界が生まれます:システムは変更が発生したと言っているのに、外部システムは何も受け取っていない、という状態です。

典型的な例:顧客が注文を出し、アプリはそれをPostgreSQLに保存してから配送業者に通知しようとします。もし配送業者が20秒間タイムアウトしたり応答しなければ、リクエストは失敗しても注文自体は実際に存在しますが、発送は作られません。

ユーザーはこれを混乱や一貫性の欠如として体験します。イベントが欠落していると「何も起きていない」のように見え、重複したイベントは「なぜ二重請求されたの?」という問題になります。サポートチームも、問題がアプリ側なのかネットワークなのか相手側なのか判別しづらくなります。

再試行は助けになりますが、それだけで正しさを保証するわけではありません。タイムアウト後に再試行すると、パートナーが最初のリクエストを受け取っていたかどうか分からないため、同じイベントを二度送る可能性があります。順序が入れ替わってしまうと「Order shipped」が「Order paid」より先に届いてしまうこともあります。

これらの問題は通常の並行性から生まれます:複数ワーカーの並列処理、複数アプリサーバーからの同時書き込み、負荷に応じてタイミングが変わる“ベストエフォート”キュー。失敗モードは予測可能です:APIが落ちるか遅くなる、ネットワークがリクエストを落とす、プロセスが不適切な瞬間にクラッシュする、そして再試行が冪等性を強制しない限り重複を生みます。

Outboxパターンはこれらの普通の失敗に対処するために存在します。

Outboxパターンとは(平易に)

Outboxパターンは単純です:アプリが重要な変更(例:注文作成)を行うとき、同じトランザクション内で「送信すべきイベント」の小さなレコードをデータベースのテーブルに書き込みます。データベースのコミットが成功すれば、ビジネスデータとイベントレコードが一緒に存在することが保証されます。

その後、別プロセスのワーカーがアウトボックステーブルを読み、サードパーティAPIにそれらのイベントを配信します。APIが遅い、落ちている、タイムアウトする場合でも、メインのユーザーリクエストは外部呼び出しを待たないため成功扱いになります。

これにより、リクエストハンドラ内でAPIを呼ぶときに生じる厄介な状態を避けられます:

  • 注文は保存されたが、API呼び出しが失敗した。\n- API呼び出しは成功したが、アプリが注文を保存する前にクラッシュした。\n- ユーザーが再試行して同じものを二度送ってしまう。

Outboxパターンは主に、イベントの喪失、部分的な失敗(データベースはOKだが外部APIがNG)、偶発的な二重送信の防止、そして推測なしで安全に再試行できることを助けます。

全てを解決するわけではありません。ペイロードが間違っている、ビジネスルールが間違っている、あるいはサードパーティAPIがデータを拒否する場合は、引き続きバリデーション、良いエラーハンドリング、失敗イベントを検査・修正する仕組みが必要です。

PostgreSQLでのアウトボックステーブル設計

良いアウトボックステーブルはあえて地味であるべきです。書き込みやすく、読み出しやすく、誤用しにくいことが重要です。

ここでは実用的なベースラインのスキーマを示します(あなたの用途に合わせて調整してください):

create table outbox_events (
  id            bigserial primary key,
  aggregate_id  text not null,
  event_type    text not null,
  payload       jsonb not null,
  status        text not null default 'pending',
  created_at    timestamptz not null default now(),
  available_at  timestamptz not null default now(),
  attempts      int not null default 0,
  locked_at     timestamptz,
  locked_by     text,
  meta          jsonb not null default '{}'::jsonb
);

IDの選び方

bigserial(または bigint)を使うと順序付けが単純でインデックスも軽快に動きます。UUIDはシステム間での一意性には優れますが、生成順にソートされないためポーリングが予測しにくくなり、インデックスのコストも増えます。

一般的な折衷案は:idbigint として順序を保ち、システム間で共有する安定した識別子が必要な場合は別途 event_uuid を追加することです。

重要なインデックス

ワーカーは同じパターンを何度も問い合わせます。ほとんどのシステムで必要なのは:

  • 次の送信候補を順序通りに取得するための (status, available_at, id) のようなインデックス。\n- ステールしたロックを期限切れにする場合は (locked_at) のインデックス。\n- 場合によっては aggregate_id ごとに配信するための (aggregate_id, id)

ペイロードは安定させる

ペイロードは小さく予測可能に保ちます。受信側が実際に必要とするデータだけを保存し、テーブルの全列をそのまま入れないでください。バージョン(例えば meta 内のバージョン)を明示しておくとフィールドを安全に進化させられます。

meta にはテナントID、相関ID、トレースID、重複排除用キーなどのルーティングやデバッグの文脈を入れておくと後でサポートが「この注文に何が起きたか?」を答えやすくなります。

ビジネス書き込みとイベントの安全な保存方法

最も重要なルールはシンプルです:ビジネスデータとアウトボックスイベントを同じデータベーストランザクションで書くこと。トランザクションがコミットされれば両方が存在し、ロールバックされればどちらも存在しません。

例:顧客が注文をしたら、1つのトランザクションで注文行、注文明細、そして order.created のようなアウトボックス行を挿入します。どれか一つが失敗したら「作成済み」のイベントが外に漏れないようにします。

1つのイベントか複数か?

可能ならビジネス操作につき1つのイベントから始めましょう。理由はシンプルで扱いやすく、処理コストも低く済むためです。異なるコンシューマが本当に異なるタイミングやペイロードを必要とする場合のみ、複数イベントに分けます(例:フルフィルメント向けの order.created と課金向けの payment.requested)。一度の操作で多数のイベントを作ると、再試行や順序、重複の問題が増えます。

どんなペイロードを保存すべきか?

通常は次のいずれかを選びます:

  • スナップショット:操作時の主要フィールド(合計、通貨、顧客IDなど)を保存します。あとで余分な読み取りが不要になり、メッセージが安定します。\n- 参照ID:注文IDだけを保存し、ワーカーが後で詳細を読み込む方式。アウトボックスは小さく保てますが、追加読み取りが発生し、注文が編集された場合に変わる可能性があります。

実用的な中間は識別子と重要な値の小さなスナップショットを組み合わせることです。受信側が素早く動ける上、デバッグしやすくなります。

トランザクション境界は狭く保ってください。サードパーティAPIを同じトランザクション内で呼ぶことは避けます。

サードパーティAPIへのイベント配信:ワーカーループ

Create an outbox in minutes
Model your outbox table in the Data Designer and ship events without blocking checkout.
Start Building

イベントがアウトボックスに入ったら、それらを読み出してサードパーティAPIを呼ぶワーカーが必要です。ここがパターンを信頼できる統合に変える部分です。

ポーリングが通常は最も単純なオプションです。LISTEN/NOTIFY はレイテンシを下げられますが、構成要素が増え、通知が失われたときのフォールバックが必要です。多くのチームにとって、小さなバッチでの安定したポーリングは運用とデバッグが容易です。

行を安全にクレームする

ワーカーは行をクレームして、二つのワーカーが同じイベントを同時に処理しないようにすべきです。PostgreSQLでは、行ロックと SKIP LOCKED を使ってバッチを選択し、その後それらを進行中としてマークするのが一般的です。

実用的なステータスフローの例:

  • pending: 送信可能\n- processing: ワーカーがロック中(locked_bylocked_at を使用)\n- sent: 成功配信済み\n- failed: 最大試行後に停止(または手動確認のために退避)

データベースに優しくするためバッチは小さめに保ちます。10〜100行のバッチを1〜5秒ごとに実行するのが一般的な出発点です。

呼び出しが成功したら行を sent にマークします。失敗したら attempts を増やし available_at を将来に設定(バックオフ)、ロックをクリアして pending に戻します。

秘密を漏らさないログ

良いログは問題を実行可能にします。アウトボックスの id、イベントタイプ、送信先名、試行回数、所要時間、HTTPステータスやエラークラスをログに残しましょう。リクエストボディ、認証ヘッダ、完全なレスポンスは避けてください。相関が必要なら安全なリクエストIDやハッシュを保存します。

実際に役立つ順序ルール

Keep an exit to source code
Export real Go, Vue3, and Kotlin or SwiftUI code when you need full control.
Generate Code

多くのチームは「作成した順にイベントを送る」ことから始めますが、「同じ順序」は大抵グローバルではありません。グローバルなキューを強制すると、一人の遅い顧客や不調なAPIが全員を待たせてしまいます。

実用的なルールは:システム全体で順序を守るのではなく、グループごとに順序を保持することです。外部がデータをどう扱うかに合わせたグルーピングキー(customer_idaccount_idorder_id のような aggregate_id)を選び、そのグループ内の順序を保証しつつ、異なるグループは並列処理します。

並列ワーカーで順序を壊さない方法

複数のワーカーを走らせつつ、同じグループを二人のワーカーが同時に処理しないようにします。一般的なやり方は、各 aggregate_id の最も古い未送信イベントだけを常に配信し、グループ間では並列性を許可することです。

クレームルールはシンプルに保ちます:

  • 各グループについて最も早い未送信イベントのみを配信する。\n- グループ間で並列実行を許可し、グループ内は逐次処理する。\n- イベントを一つクレームして送信し、ステータスを更新してから次に進む。

1つのイベントが残りをブロックする場合

やがて“毒性”イベントが数時間失敗し続けることがあります(ペイロード不備、トークン無効化、プロバイダ停止など)。グループ内の順序を厳格に保つと、そのグループの後続イベントは待たされますが、他のグループは継続すべきです。

実用的な妥協策はイベントごとの再試行上限を設けることです。上限に達したら failed にして、そのグループだけを停止させると、壊れた顧客が全体を遅らせるのを防げます。

再試行で状況を悪化させない方法

再試行はOutboxを信頼できるものにするか、ノイズだらけにするかの分岐点です。目標は単純:再試行すれば成功しそうなときに再試行し、そうでないときは早く止めること。

指数バックオフとハードキャップを使います。例:1分、2分、4分、8分、そして停止(または最大遅延を15分に設定して継続)など。試行回数の最大値を必ず設定して、一つの悪いイベントがシステムを詰まらせないようにします。

すべての失敗が再試行対象ではありません。ルールを明確にします:

  • 再試行する:ネットワークタイムアウト、接続リセット、DNS問題、HTTP 429 や 5xx。\n- 再試行しない:HTTP 400(不正リクエスト)、401/403(認証問題)、404(エンドポイント間違い)、送信前に検出できるバリデーションエラー。

再試行状態はアウトボックス行に保存します。attempts を増やし、次回試行の available_at を設定し、安全な短いエラー要約(ステータスコード、エラークラス、切り詰めたメッセージ)を記録します。エラー欄に完全なペイロードや機密データを保存しないでください。

レート制限は特別扱いが必要です。HTTP 429 を受け取ったら Retry-After を尊重し、存在しない場合はより強めにバックオフしてリトライ嵐を避けます。

重複排除と冪等性の基本

Build a safer integration flow
Build reliable integrations with a PostgreSQL outbox and keep user requests fast.
Try AppMaster

信頼できるAPI統合を作るなら、同じイベントが二度送られることを前提にしてください。ワーカーがHTTP呼び出し後にクラッシュする、タイムアウトで成功が隠れる、再試行が遅れて最初の試行と重なる、などが起こります。Outboxはイベント喪失を減らしますが、それ自体で重複を防ぐわけではありません。

最も安全なのは冪等性です:繰り返し配信しても一度だけ配信したのと同じ結果になること。サードパーティAPIを呼ぶ際には、イベントと送信先に対して安定した冪等キーを含めます。多くのAPIはヘッダで受け付けますが、受け付けない場合はボディに入れます。

単純なキーは送信先とイベントIDの組み合わせです。イベントIDが evt_123 の場合、常に destA:evt_123 のようなキーを使います。

自分側では、宛先配信ログを保持し (destination, event_id) のような一意制約を設けて重複送信を防ぎます。二つのワーカーが競合しても、一方だけが「送信中」レコードを作成できます。

Webhookも重複する

受け取るWebhookコールバック(例:配信確認やステータス更新)も同様に扱ってください。プロバイダは再試行するため同じペイロードを複数回受け取ることがあります。処理済みのWebhook IDを保存するか、プロバイダのメッセージIDから安定したハッシュを作って重複を拒否します。

データ保持期間

成功(または受け入れる最終失敗)を記録するまでアウトボックス行は保持します。配信ログは「送ったかどうか」を問われたときの監査証跡になるので長めに保管します。

一般的な方針:

  • アウトボックス行:成功後に短い安全ウィンドウ(数日)を置いて削除またはアーカイブ。\n- 配信ログ:コンプライアンスやサポート要件に応じて数週間〜数か月保管。\n- 冪等キー:再試行が発生し得る期間以上は保持(Webhook重複のためにさらに長く)。

ステップバイステップ:Outboxパターンの実装

公開する内容を決めます。イベントは小さく、焦点が絞られ、後でリプレイできるようにします。良いルールは「イベント一つにつき一つの業務事実」、受信側が行動できるだけのデータを含めることです。

基盤を作る

イベント名を明確に決め(例:order.created, order.paid)、ペイロードのスキーマにバージョン(v1, v2 など)を付けます。バージョン管理によりフィールドを後で追加しても古いコンシューマを壊さずに済みます。

PostgreSQLのアウトボックステーブルを作り、ワーカーの検索でよく使う (status, available_at, id) のようなインデックスを追加します。

書き込みフローを更新して、ビジネスの変更とアウトボックス挿入が同じトランザクションで行われるようにします。これがコアの保証です。

配信と制御を追加する

シンプルな実装計画:

  • 長期的にサポートできるイベントタイプとペイロードバージョンを定義する。\n- アウトボックステーブルとインデックスを作成する。\n- 主データ変更と同時にアウトボックス行を挿入する。\n- 行をクレームしてサードパーティAPIに送信し、ステータスを更新するワーカーを作る。\n- バックオフによる再試行スケジュールと、試行上限後の failed 状態を追加する。

問題を早く察知できるように基本的なメトリクスも追加します:ラグ(最古の未送信イベントの年齢)、送信率、失敗率など。

単純な例:注文イベントを外部サービスに送る

Handle ordering the practical way
Keep events ordered per customer or order without slowing down the whole system.
Create Project

顧客がアプリで注文を行ったとします。外部で行うべきことが二つあります:課金プロバイダに課金してもらうこと、配送プロバイダに発送を作ってもらうこと。

Outboxパターンでは、チェックアウト内でそれらのAPIを呼びません。代わりに注文とアウトボックスイベントを同じPostgreSQLトランザクションで保存するため、「注文は保存されたが通知が届かなかった(あるいはその逆)」という事態が発生しません。

注文イベントの典型的なアウトボックス行には aggregate_id(注文ID)、event_typeorder.created など)、合計、アイテム、配送先などを含む JSONB ペイロードが入ります。

ワーカーは保留中の行を拾って外部サービスに呼び出します(定義された順序で行うか、あるいは payment.requestedshipment.requested のように別イベントで出すか)。プロバイダが落ちている場合、ワーカーは試行回数を記録し、available_at を未来に設定して次回に再試行し、処理を続けます。注文は存在し続け、新しいチェックアウトをブロックしません。

順序は通常「注文単位」か「顧客単位」です。同じ aggregate_id を持つイベントは一度に一つずつ処理するようにして、order.paidorder.created より先に着くことがないようにします。

重複排除は二重課金や二重出荷を防ぎます。サードパーティが冪等キーをサポートしていればそれを送信し、さらに送信先ごとの配信記録を保持してタイムアウト後の再試行で二重アクションが起きないようにします。

出荷前の簡単チェック

Add retries without duplicates
Set up a worker-style sender that retries safely and records delivery status.
Try It Now

統合をお金の動くところや顧客通知、データ同期に使う前に、端のケースをテストしてください:クラッシュ、再試行、重複、複数ワーカーなど。

よくある失敗を検出するチェック:

  • ビジネス変更と同じトランザクションでアウトボックス行が作成されることを確認する。\n- 送信プロセスが複数インスタンスで安全に動くことを検証する。二つのワーカーが同じイベントを同時に送れないこと。\n- 順序が重要なら、ルールを一文で定義して安定したキーで強制すること。\n- 各送信先について、重複を防ぐ方法と「送ったことの証明」をどう残すかを決める。\n- N回の試行後はイベントを failed に移し、最後のエラー要約を保持し、簡単な再処理アクションを用意すること。

現実的な注意点:Stripeはリクエストを受け入れてもワーカーが成功を保存する前にクラッシュすることがあります。冪等性がなければ再試行で二重請求が起きます。冪等性と配信記録があれば再試行は安全になります。

次のステップ:アプリを中断させずに導入するには

導入はOutboxプロジェクトが成功するか停滞するかの分かれ道です。最初は小さく始めて実際の挙動を観察し、統合レイヤ全体を危険にさらさないようにします。

まずは1つの統合と1つのイベントタイプから始めます。例:order.created を単一のベンダーAPIにだけ送るようにして、他は従来通りにします。これでスループット、レイテンシ、失敗率のベースラインをきれいに取れます。

問題を早期に見える化してください。アウトボックスのラグ(待っているイベント数と最古イベントの年齢)や失敗率(リトライ中や failed の数)に対するダッシュボードとアラートを用意します。今が遅れているかどうかを10秒で答えられれば、ユーザーに気づかれる前に問題を発見できます。

最初のインシデント前に安全な再処理プランを用意しておきます。再処理が意味するところを決めておきます:同じペイロードを再送するのか、現在のデータからペイロードを再構築するのか、手動レビューに回すのか。再送が安全なケースと人が介入すべきケースを文書化しておきます。

AppMaster(appmaster.io)のようなノーコードプラットフォームでこれを作る場合でも同じ構造が当てはまります:ビジネスデータとアウトボックス行をPostgreSQLで同時に書き、別プロセスで配信・再試行・送信/失敗マークを行います。

よくある質問

When should I use the outbox pattern instead of calling the API directly?

アウトボックスパターンは、ユーザーアクションがデータベースを更新し、かつ別システムで作業をトリガーする必要があるときに使います。タイムアウトや不安定なネットワーク、サードパーティの障害で「こちらには保存されたが相手には伝わっていない」状況が起きやすい場合に特に有効です。

Why does the outbox insert need to be in the same transaction as the business write?

ビジネス行為の行(例:注文)とアウトボックス行を同じデータベーストランザクションで書き込むことで、一つの明確な保証が得られます:両方が存在するか、どちらも存在しないかのどちらかです。これにより「API呼び出しは成功したが注文が保存されなかった」や「注文は保存されたがAPI呼び出しは失敗した」といった部分的な失敗を防げます。

What fields should an outbox table include to be practical?

実用的なデフォルトは idaggregate_idevent_typepayloadstatuscreated_atavailable_atattempts、および locked_at / locked_by のようなロック用フィールドです。これで送信、再試行スケジュール、並行処理管理がシンプルになります。

What indexes matter most for an outbox table in PostgreSQL?

一般的な基盤としては (status, available_at, id) のインデックスが重要です。これによりワーカーは送信可能なイベントを順序通りにすばやく取得できます。その他のインデックスは、本当にそのフィールドで検索する場合のみ追加してください。インデックスは挿入性能に影響します。

Should my worker poll the outbox table or use LISTEN/NOTIFY?

ほとんどのチームにとって、ポーリングが最も簡単で予測可能です。小さなバッチで短い間隔から始め、負荷や遅延に応じて調整します。LISTEN/NOTIFY は遅延を減らせますが、動作の複雑さが増し、通知が失われた場合のフォールバックが必要になります。

How do I prevent two workers from sending the same outbox event?

行レベルのロック(通常は SKIP LOCKED を使った選択)で行をクレームし、二つのワーカーが同じイベントを同時に処理しないようにします。その後、行を processing にマークし、送信し、成功なら sent、失敗なら pending に戻して available_at を将来にセットします。

What’s the safest retry strategy for outbox deliveries?

指数バックオフと試行回数の上限を設定し、一次的と見なせる障害だけを再試行します。タイムアウト、ネットワークエラー、HTTP 429/5xx は再試行対象にし、400 系(検証エラー)や認証エラーなどは再試行せず、データや設定を修正してから再処理するのが一般的です。

Does the outbox pattern guarantee exactly-once delivery?

完全に「一度だけ(exactly-once)」を保証するのは難しいので、重複送信を想定してください。送信先およびイベントごとに安定した冪等キーを含めることで、多くのAPIでは同じ処理を繰り返しても副作用を起こさないようにできます。内部的には (destination, event_id) のような一意制約で配信ログを管理すると安全です。

How do I handle ordering without slowing down the whole system?

グローバルな単一キューで順序を保とうとすると、1人の遅い顧客や障害が全体のボトルネックになります。実用的な方針はグループ単位で順序を守ることです。aggregate_idcustomer_id などのグルーピングキーを使い、同じグループ内は逐次処理しつつ、異なるグループは並列で処理します。

What should I do with a “poison” event that keeps failing?

永続的に失敗する“毒性”のあるイベントがある場合、そのイベントは最大試行回数に達したら failed にし、短いエラー要約を残して同じグループの後続イベントを一時停止します。これにより一人の壊れた顧客が全体を止めることを防げます。

始めやすい
何かを作成する 素晴らしい

無料プランで AppMaster を試してみてください。
準備が整ったら、適切なサブスクリプションを選択できます。

始める