実際に機能する分散システムにおける冪等性
重複する副作用を回避する
分散システムにおける冪等性(Idempotency)とは、ネットワークが嘘をつき、キューが再送を試み、クライアントがパニックし、オペレーターがリプレイボタンを押した際に、あなたを救済する性質です。本番環境では、重複したメッセージの配信は正常な挙動です。問題となるのは、重複した副作用の発生です。
HTTPプロトコルにおいて「冪等なメソッド」とは、同じリクエストを複数回送信しても、サーバーに対して与える意図された効果が1回だけ送信した場合と同じになるものを指します。そのため、PUT、DELETE、および安全なメソッド(safe methods)はプロトコルのセマンティクスにおいて冪等性とみなされ、通信障害が発生した際に自動的にリトライすることができます。

この定義は有用ですが、十分ではありません。実際のアーキテクチャにおいて、冪等性は単なるHTTPのトリビアルな知識ではありません。それはビジネス上の保証です。顧客が「支払う」ボタンを1回押したのに、タイムアウトが発生してコミットとレスポンスの間に隙間が生じたという理由で、2回課金されてはなりません。ワーカーが在庫を更新し、メッセージのACK(確認応答)を行う前にクラッシュした場合、ブローカーが同じメッセージを再配信したという理由で、在庫を2回減算されてはなりません。これが基準です。
私が繰り返し目にする誤りは、冪等性をトランスポート層の機能として扱っている点です。キューの重複排除、HTTPのメソッド選択、クライアントのリトライは助けになりますが、同じビジネス意図が2回目の副作用を生み出してしまうような設計では、それらはいずれも救済にはなりません。これらの統合決定がサービス境界や永続性に関するトレードオフにどのように関わるかというより広範な枠組みについては、App Architecture in Production: Integration Patterns, Code Design, and Data Accessをお読みください。
本番環境で重複が生まれる場所
重複は、チームが無駄だからといって現れるわけではありません。分散システムがリトライ、再順序化、リプレイを行うため、重複は発生します。
クライアントが作成リクエストを送信し、サーバーがそれをコミットしても、レスポンスがネットワーク上で行方不明になることがあります。まさにそのために、HTTPは冪等なメソッドを区別しており、StripeやPayPalのような決済APIは、POSTなどの安全でないメソッドに対して明示的な冪等性のメカニズムを提供しています。
メッセージブローカーは、この問題をさらに顕著にします。アットリーストワン(At-least-once)配信とは、同じメッセージに対してコンシューマーが複数回呼び出される可能性があり、ハンドラーがデータベースへの更新には成功したが、ACKを行う前に失敗し、ブローカーがそのメッセージを再度配信してしまうことを意味します。
ウェブフックも同様です。GitHubは、ウェブフックの配信が順序不同で届く場合があること、失敗した配信は自動的に再配信されないこと、各配信にはリプレイ対策に使用する一意の X-GitHub-Delivery GUIDが付与されていることを明記しています。チャットエンドポイントを相互作用の境界として捉える実用的なアーキテクチャの観点については、Chat Platforms as System Interfaces in Modern Systemsをご覧ください。
より強力な保証を謳うシステムでさえ、依然として実装側の作業が必要です。Kafkaは、冪等なプロデューサーを使用してKafkaログ内での重複エントリを防ぐことができ、トランザクションと read_committed コンシューマーを用いてKafka内部の読み取り-処理-書き込みフローに対してエクスクルーシブリーオンリー(Exactly-once)配信を提供できます。しかし、Kafka自身の設計ドキュメントでは、外部システムではオフセットと出力との調整が依然として必要であると明確に示されています。Google Cloud Pub/Subのエクスクルーシブリーオンリー配信は、プルサブスクリプション、クラウドリージョン内でのみ制限されており、クライアントがACKが成功するまで処理の進行状況を追跡する必要があります。
私の主観的な要約はシンプルです。トランスポート層がリトライすることを前提とし、オペレーターがリプレイすることを前提とし、ウェブフックが到着が遅くなることを前提とします。書き込み経路を設計し、繰り返された意図が2回目のビジネス効果を生成できないようにしてください。エラー設計は密接に関連しています。エラーがどのようにラップされ、翻訳され、リトライ可能かリトライ不可能かに分類されるかは、同じ境界の規律の一部です。Go Error Handling Architecture: Boundaries and Patternsでは、リトライ可能なエラーの分類、境界での翻訳、リトライロジックが健全な判断を下すことを可能にするセンチネルパターンについて扱っています。リトライが不健康な依存関係に連続してヒットした場合、circuit breaker at the integration boundaryは、リトライの嵐が重複作業を増幅させる前に、素早く失敗します。
私が実際に信頼するAPI契約
冪等性キーはどのようにして重複したAPIリクエストを防ぐのか
変異操作(mutating operations)に対して私が信頼する唯一のAPI契約は、呼び出し元が提供する意図とサーバー側の永続化です。
AWSは、呼び出し元が提供するリクエスト識別子の使用を推奨し、サービスが冪等性トークンを変異作業とともにアトミックに記録しなければならないと警告しています。Stripeは、あるキーに対する最初のステータスコードとレスポンスボディを保存し、後続のパラメータを元のリクエストと比較し、リトライに対して同じ結果を返します。PayPalは、サポートされているPOST APIで PayPal-Request-Id を使用し、その同じヘッダーを持つ前のリクエストの最新ステータスを返します。
これにより、実用的な契約が生まれます:
- クライアントはビジネス操作に対して冪等性キーを生成します。
- サーバーはそのキーをテナントおよび操作名でスコープします。
- サーバーはリクエストハッシュを保存し、同じキーが異なるペイロードに対して再利用できないようにします。
- サーバーは
pending(保留中)、completed(完了)、またはfailed(失敗)などの状態を記録します。 - 同じキーでのリトライは、保存された結果を返すか、それへの安定したポインタを返します。
- 同じキーだが異なるペイロードでのリトライは、明確なエラーとして失敗します。
IETFには Idempotency-Key ヘッダーのドラフトがありますが、2026-05-09現在、IETF Datatrackerでは公開済みのRFCではなく、期限切れのインターネットドラフトとしてリストされています。実務的には、ヘッダー名は事実上の標準として依然として有用ですが、標準が完成したふりをするのではなく、独自のAPIで契約を文書化する必要があります。
キーは何を表すべきでしょうか?「意図」です。HTTPの試みでも、TCP接続でも、リトライカウンターでもありません。ユーザーが「注文123を1回作成する」という意味なら、その同じコマンドに対するすべてのリトライは同じキーを再利用しなければなりません。ユーザーが「2回目の注文を出す」という意味なら、それは異なるキーを使用しなければなりません。
リクエストIDは追跡用です。冪等性キーは正しさ(correctness)のためです。これらを混同すると、ダッシュボードはきれいに見えますが、お金が2回動いてしまいます。
なぜPUTでは不十分なのか
いいえ、HTTPのPUTだけでは操作を冪等にするには不十分です。
はい、RFC 9110はPUTに冪等なセマンティクスを与えています。しかし、あなたのPUTハンドラーが新しいダウンストリームイベントを出力し、毎回メールを送信し、外部プロバイダーに対して再度課金するのであれば、ルートの名前がどれほど格式高く見えても、あなたの実装はビジネス契約を違反しています。
メソッドの選択は、クライアントが意図を理解するのに役立ちます。しかし、それはあなたのために意図を実装するものではありません。
リソースモデルが真に完全な置き換えまたはUPSERTスタイルの操作に適合する場合にPUTを使用してください。コマンドやアクションを作成している場合はPOSTを使用してください。しかし、ネットワーク境界をまたいでリトライされる可能性のある任意の変異操作については、明示的な冪等性契約を文書化してください。変更可能なアクションがチャットワークフローからトリガーされる場合、同じ契約が Slack Integration Patterns for Alerts and Workflows および Discord Integration Pattern for Alerts and Control Loops に適用されます。隠れた副作用こそが、アーキテクチャが死に至る場所です。
冪等性キーはどれくらいの期間保存すべきか
トランスポートチームが望むよりも長く。
Stripeは、キーを少なくとも24時間後に破棄可能だと述べています。PayPalは、保持期間はAPI固有であり、最大45日間続く例を示しています。Amazon SQS FIFOは、5分間のウィンドウ内でのみ重複排除を行います。GitHubは、手動の再配信のために最近の配信を3日間保持しています。これらの数値が wildly(極めて)異なるのは、適切な保持期間がプロトコルのデフォルトではなく、ビジネスの判断によるものです。
キューに合わせてキーを5分間しか保持しない場合、あなたは冪等性を設計しているのではなく、トランスポートの制限をビジネス層にコピーしていることになります。
冪等性レコードは、少なくとも以下のウィンドウの最大値に対して保持してください:
- クライアントのリトライ範囲
- キューのリドライブ範囲
- ウェブフックのリプレイ範囲
- オペレーターのリプレイ範囲
- お金を動かす操作に対する決済または補償範囲
決済、予約、プロビジョニングにおいては、それは分単位ではなく、時間単位または日単位を意味することがほとんどです。
AWSは、私が完全に同意する2つのアンチパターンにも言及しています。タイムスタンプをキーとして使用しないでください。クロックスキューや衝突により信頼性が損なわれるためです。すべてのリクエストに対して、デデュプレーションレコードとしてリクエストペイロード全体を盲目的に保存しないでください。パフォーマンスとスケーラビリティを害するためです。正規化されたリクエストハッシュと、安全にリプレイするために必要な最小限のレスポンス状態のみを保存してください。最初のレスポンスをバイト単位で再現する必要がある場合は、Stripeがそうしているように、キャノニカルなレスポンスボディを保存してください。
冪等性を現実にするデータベースパターン
永続化層が競合に対してちょうど一度だけ勝利できる場合に、冪等性は現実のものとなります。
PostgreSQLは、ここで2つの重要なプリミティブを提供します。一意制約は1つまたは複数の列に対して一意性を強制し、INSERT ... ON CONFLICT は一意性違反の際に失敗するのではなく、代替アクションを定義することを可能にします。PostgreSQLはまた、ON CONFLICT DO UPDATE が競合状態下でアトミックな挿入または更新の結果を保証すると文書化しています。
つまり、あなたの冪等性層は通常、以下のようなテーブルで始まるべきです:
create table api_idempotency (
tenant_id text not null,
operation text not null,
idempotency_key text not null,
request_hash text not null,
state text not null,
status_code integer,
response_body jsonb,
resource_type text,
resource_id text,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
primary key (tenant_id, operation, idempotency_key)
);
そして、処理フローは以下のようになるはずです:
begin transaction
try insert (tenant_id, operation, idempotency_key, request_hash, state='pending')
on conflict do nothing
load row for (tenant_id, operation, idempotency_key) for update
if row.request_hash != incoming_request_hash
fail with conflict or validation error
if row.state = 'completed'
return stored response
if row.state = 'pending' and row was created by another live request
either wait briefly, or fail fast with a retryable response
perform local business mutation
store stable result in idempotency row
set state = 'completed'
commit
return result
重要なのは構文ではありません。重要なのはアトミック性です。キーの記録と変異の処理は、同時に成功するか同時に失敗しなければなりません。AWSはこれをAPIの冪等性に対して明確に示しており、同じルールがSQLベースのサービスにも適用されます。
「キーを選択し、存在しなければ注文を挿入する」のような単純なチェック・ザン・アクト(check-then-act)シーケンスを行わないでください。競合状態では、2つのリクエストがチェックを通過し、両方が副作用を作成する可能性があります。一意制約は任意のものではありません。それは、あなたのアーキテクチャを楽観的な伝承から、負荷下で証明可能なものへと変化させるメカニズムです。
私がレビューで使用するルールがあります。デデュプレーションの決定が、変異と同じトランザクション境界によって保護されていない場合、あなたは冪等性を持っていません。希望を持っているだけです。
メッセージ、イベント、およびウェブフックには独自の境界が必要
コンシューマーはどのようにして重複したイベントやメッセージを処理するか
メッセージコンシューマーにとって、古典的なパターンが依然として正解です。処理済みメッセージIDを、ビジネス更新と同じデータベーストランザクション内に記録します。Chris Richardsonは、サブスクライバーとメッセージIDに主キーを持つ PROCESSED_MESSAGES テーブルのアプローチを直接記述しており、これにより重複はきれいに失敗し、無視することができます。
多くのチームは、この明示的な processed_messages ストアをインボックステーブルと呼んでいます。ラベルは重要ではありません。ルールが重要です。受信側は、リトライが安全になにも行わない前に、すでにメッセージを処理した証拠を永続化しなければなりません。
最小限の形は以下のようになります:
create table processed_messages (
subscriber_id text not null,
message_id text not null,
processed_at timestamptz not null default now(),
primary key (subscriber_id, message_id)
);
そして、コンシューマーのフローはHTTPフローと同じくらい厳格です:
begin transaction
insert into processed_messages (subscriber_id, message_id)
values (?, ?)
on conflict do nothing
if no row inserted
rollback
ack and ignore duplicate
apply business mutation
commit
ack message
そのパターンは退屈です。それでいいのです。冪等性は退屈であるべきです。
また、ブローカーのマーケティング用語に頼ろうとするよりも、通常は優れています。Kafkaのエクスクルーシブリーオンリー機能は、Kafka自身のトランザクションモデル内に留まる場合に非常に優れていますが、Kafkaのドキュメントでは依然として、外部宛先との協調が必要であると警告しています。SQS FIFOは、その5分間のデデュプレーションウィンドウ内でのみ重複送信を減らします。Pub/Subのエクスクルーシブリーオンリーは依然として、サブスクライバーが進行状況を追跡し、ACKが失敗した場合に重複作業を避けることを期待しています。
エクスクルーシブリーオンリーは通常、ローカルな最適化です。冪等な副作用こそが、システムの保証です。
アウトボックスパターンとデデュプレを組み合わせる
あなたのサービスがローカル状態を更新し、かつイベントを公開する場合、冪等な消費だけでは不十分です。ローカルトランザクションがコミットされた後に、イベントを安全に取得する方法も必要です。
そのため、transactional outbox pattern が重要になります。Chris Richardsonは、基本的な考え方を、ビジネス更新と同じトランザクション内でアウトボックステーブルにイベントを書き込み、その後非同期で公開することとして説明しています。Debeziumは、アウトボックスパターンがサービス内部の状態と他のサービスが消費するイベント間の不整合を防ぐと述べています。NServiceBusはさらに踏み込み、アウトボックス処理が受信メッセージのデデュプレを行い、ゾンビレコードやゴーストメッセージを回避する方法を示しています。
これは、データを所有し統合イベントを公開するサービスに対する私が推奨するアーキテクチャです:
- 冪等性キーの下でコマンドを検証および永続化する。
- ビジネス状態とアウトボックスイベントを1つのローカルトランザクションで書き込む。
- CDCまたはアウトボックスディスパッチャにイベントを公開させる。
- ダウンストリームコンシューマーも冪等にしておく。
アウトボックスは、冪等なコンシューマーの必要性をなくすものではありません。データベースのコミットとブローカーのパブリッシュが通常不可能な場合に、それらが1つの魔法のような分散トランザクションであると偽る必要性をなくすものです。
ウェブフックは、ブランド化されたメッセージに過ぎない
受信ウェブフックを、信頼できないネットワーク端からのメッセージと正確に扱ってください。
GitHubは、配信が順序不同で届く場合があること、真正性を検証するために X-Hub-Signature-256 の使用を推奨していること、そして一意の配信識別子として X-GitHub-Delivery を提供していることを文書化しています。また、再配信では同じ配信IDが再利用されるとも記載されています。
したがって、アーキテクチャは単純明快です:
- まず署名を検証する
- 配信GUIDをデデュプレキーとして使用する
- 副作用の前に受信を永続化する
- ハンドラーを順序認識型にし、到着順序を仮定しない
- 重い処理をキューに入れ、素早くレスポンスを返す
ウェブフックハンドラーが、受信を記録する前にビジネステーブルに直接書き込む場合、それは本番環境に対応していません。それは、単に重複したミスをより早く行うだけです。
サガとワークフローエンジンでも冪等性が必要
サガや永続化されたワークフローエンジンは、問題を消去するものではありません。それを可視化するのです。
Temporalは、アクティビティが失敗またはタイムアウト後にリトライされる可能性があるため、アクティビティを冪等になるように書くことを推奨しています。そのドキュメントでは、ワーカーが外部の副作用に成功して完了したが、完了を報告する前にクラッシュし、アクティビティが再度実行されるというエッジケースにも言及しています。Temporalはまた、ダウンストリームサービスを呼び出す際に、ワークフロー実行IDとアクティビティIDの組み合わせを安定した冪等性キーとして使用することを提案しています。サービスオーケストレーションでこれを適用する場合、Go Microservices for AI/ML Orchestration は、より広範なワークフローのトレードオフをカバーしています。
それがまさに正しいメンタルモデルです。ワークフローエンジンは実行履歴を保持し、リトライを調整できます。しかし、あなたのアプリケーションが冪等なステップと冪等な補償を提供しない限り、カードの課金を取り消したり、メールの送信を取り消したりすることはできません。
これはサガにも適用されます。Temporal自身のサガガイダンスでは、ステップが失敗したときに実行される補償アクションを記述しています。それらの補償も冪等でなければなりません。「決済を取り消す」が2回実行されると、元のバグを解決したつもりで、新しいバグを生み出してしまいます。
私のルールは残酷でシンプルです。外部の世界に触れるすべてのアクティビティ、すべての command handler、そしてすべての補償は、自然に冪等であるか、ダウンストリームシステムに対して実際の冪等性キーを持つべきです。
本番環境前に冪等性をテストする方法
多くのチームはハッピーパスだけをテストし、リトライが発生したときに驚きます。それは不十分です。Goチームの場合、Testing Concurrent Go Code with testing/synctest では、人工的な遅延を待つことなく、リトライループとコンテキストのデッドライン動作に対する高速で決定論的なテストをどのように記述するかをカバーしています。
少なくとも以下のケースに対して自動化されたテストを持つべきです:
- サーバーが変異をコミットしたが、レスポンスがクライアントに到達しない
- 同じ冪等性キーで2つの同一リクエストが競合する
- 同じキーが異なるペイロードで再利用される
- コンシューマーがデータベース作業をコミットし、ACK前にクラッシュする
- 配信IDが同じでウェブフックがリプレイされる
- アウトボックスディスパッチャが同じイベントを複数回公開する
- ワークフローアクティビティが外部呼び出しを完了したが、完了が報告される前にクラッシュする
- 冪等性レコードが期限切れになり、正当な遅延リトライが到着する
AWSは、成功したリクエスト、失敗したリクエスト、および重複したリクエストを含む包括的なテストスイートを明示的に推奨しています。そのアドバイスは凡庸ですが、絶対的に正しいです。
私はさらに1つの障害ドリルを追加します。リプレイされたレスポンスが最初の結果と意味的に同等であることを確認します。AWSは到着が遅いリトライと議論し、基盤となる状態が変化した後でも元の意味を保持するレスポンスを主張しています。それが、「追加の副作用が発生しなかった」と「呼び出し元が依然として一貫した契約を持っている」の違いです。
実際のシステムを救う主観的なルール
アーキテクチャレビューで私が実施するであろうルールをここに示します。
まず、冪等性キーはトランスポートの試みではなく、ビジネスの意図に属します。
第二に、すべてのキーをテナントと操作でスコープします。グローバルなキー空間は、無関係なリクエストが衝突する原因となります。
第三に、デデュプレーションの決定を変異とアトミックに永続化します。それが真でない場合、その設計は誤りです。
第四に、同じキーだが異なるペイロードのリトライを拒否します。StripeとAWSは、良い理由でこれを行っています。
第五に、最短のキューウィンドウではなく、ビジネスプロセスの全リプレイ範囲に対してキーを保持します。
第六に、プロデューサーをアウトボックスと、コンシューマーをメッセージID追跡とペアリングします。どちらか一方だけなくすと、半分だけの設計になります。
第七に、ビジネスアクションが同じ場合、ダウンストリームで同じ操作IDを伝播させます。AWSは、処理チェーン全体で冪等性トークンを渡すことを明示的に推奨しています。
第八に、エクスクルーシブリーオンリーのマーケティングが冪等な副作用の必要性をなくすと決して仮定しないでください。
それが厳しすぎるように聞こえるなら、それは素晴らしいことです。冪等性は、楽観的なアーキテクチャが本番環境の現実と出会う場所です。すべてに複雑さが必要なわけではありません。しかし、重複した副作用がお金、状態、または信頼を損なう可能性のあるあらゆる場所に、冪等性は契約のファーストクラスの一部であるべきです。
これらのルールは、背景にあるAIエージェントに直接適用されます。タスクをclaim(主張)し、通知を発信し、ツール呼び出しをトリガーするポーリングエージェントは、決済APIと同様に、デデュプレキーと冪等なclaimプロトコルを必要とします。本番環境のAIアシスタント内でclaim-and-dedupeパターンがどのように機能するかについては、Polling Agents in AI Assistants: 11 Implementation Patterns をご覧ください。