実際に実装可能な分散システムにおける冪等性
重複する副作用を防ぐ
分散システムにおける冪等性(べきとうせい)は、ネットワークが嘘をつき、キューがリトライし、クライアントがパニックになり、オペレーターがリプレイを実行した後に、あなたを救う性質です。本番システムでは、重複配信は普通のことです。重複した副作用こそがバグです。
HTTPは、複数の同一リクエストが1つのリクエストと同じ意図された効果を持つメソッドを冪等メソッドとして定義しています。そのため、PUT、DELETE、および安全なメソッドはプロトコルセマンティクス上で冪等であり、通信障害後に自動的にリトライできます。

この定義は有用ですが、不十分です。実際のアーキテクチャにおいて、冪等性はHTTPの雑学回答ではありません。それはビジネス上の保証です。顧客が「支払い」ボタンを1回押した場合、コミットとレスポンスの間にタイムアウトが発生したからといって、2回請求することはできません。ワーカーが在庫を更新し、メッセージのACKを送信する前にクラッシュした場合、ブローカーがメッセージを再配信したからといって、在庫を2回減らすことはできません。これが基準です。
私が何度も目にする誤りは、冪等性をトランスポート機能として扱い、システムの性質として扱わないことです。キューの重複除去、HTTP動詞、クライアントのリトライは役立ちますが、同じビジネス意図が2番目の副作用を生み出す設計を救うものではありません。これらの統合決定がサービス境界や永続化のトレードオフにどのように適合するかについての広範な枠組みが必要な場合は、App Architecture in Production: Integration Patterns, Code Design, and Data Accessから始めましょう。
本番環境で重複が発生する場所
重複は、チームが粗雑なために現れるわけではありません。分散システムがリトライし、順序を入れ替え、リプレイするからこそ現れるのです。
クライアントは作成リクエストを送信し、サーバーはそれをコミットできますが、レスポンスはワイヤ上で消失する可能性があります。これが、HTTPが冪等メソッドを区別し、StripeやPayPalなどの決済APIがPOSTのような非安全メソッドに対して明示的な冪等メカニズムを提供する理由です。
メッセージブローカーは問題をさらに明白にします。少なくとも1回配信(at-least-once delivery)とは、同じメッセージに対してコンシューマーが繰り返し呼び出され、ハンドラーがデータベースを更新するのに成功してもACK前に失敗し、ブローカーが同じメッセージを再度配信することを意味します。
Webhookも同様です。GitHubは、webhook配信は順序通りでない場合があり、失敗した配信は自動的に再配信されず、各配信にはリプレイ保護に使用するべき一意のX-GitHub-Delivery GUIDが含まれていると述べています。チャットエンドポイントを相互作用の境界線として見る実用的なアーキテクチャビューについては、Chat Platforms as System Interfaces in Modern Systemsを参照してください。
より強い保証を宣伝するシステムでさえ、あなたに作業を任せます。Kafkaは冪等プロデューサーでKafkaログ内の重複エントリを防ぎ、トランザクションとread_committedコンシューマーでKafka内の読み込み-処理-書き込みフローに対して正確に1回(exactly-once)配信を提供できます。しかし、Kafka自身の設計ドキュメントは、外部システムはオフセットと出力との調整を依然として必要とすることを明確にしています。Google Cloud Pub/Subの正確に1回配信はプルサブスクリプションに限定され、クラウドリージョン内で有効であり、依然としてクライアントがACKが成功するまで処理の進行を追跡することを要求します。
私の意見に基づく要約はシンプルです。トランスポートがリトライすると仮定します。オペレーターがリプレイすると仮定します。Webhookが遅れて到着すると仮定します。繰り返しされる意図が2番目のビジネス効果を生み出せないように書き込みパスを設計します。
実際に信頼するAPI契約
冪等性キーはどのようにして重複APIリクエストを防ぐのか
ミューティング操作に対して私が信頼する唯一のAPI契約は、呼び出し側が提供する意図とサーバーサイドの永続化です。
AWSは呼び出し側が提供するリクエスト識別子を推奨し、サービスが冪等性トークンをミューティング作業と組み合わせてアトミックに記録する必要があると警告しています。Stripeはキーに対して最初のステータスコードとレスポンスボディを保存し、後続のパラメータを元のリクエストと比較し、リトライに対して同じ結果を返します。PayPalはサポートされているPOST APIでPayPal-Request-Idを使用し、同じヘッダーを持つ前のリクエストの最新ステータスを返します。
これにより、実用的な契約が導き出されます:
- クライアントはビジネス操作に対して冪等性キーを生成します。
- サーバーはキーをテナントと操作名でスコープします。
- サーバーは同じキーが異なるペイロードで再利用されないようにリクエストハッシュを保存します。
- サーバーは
pending、completed、failedなどの状態を記録します。 - 同じキーでのリトライは、保存された結果を返すか、それへの安定したポインタを返します。
- 同じキーと異なるペイロードでのリトライは、明確に失敗します。
IETFのIdempotency-Keyヘッダー草案が存在しますが、2026年5月9日現在、IETF Datatrackerでは公開されたRFCではなく、期限切れのInternet-Draftとしてリストされています。実際には、ヘッダー名は事実上の規約として依然として広く有用ですが、標準が完了したふりをするのではなく、独自のAPIで契約を文書化すべきです。
キーは何を表すべきでしょうか?意図です。HTTPの試行ではありません。TCP接続ではありません。リトライカウンターではありません。ユーザーが「注文123を1回作成する」を意味する場合、その同じコマンドに対するすべてのリトライは同じキーを再利用する必要があります。ユーザーが「2番目の注文を行う」を意味する場合、それは異なるキーを使用する必要があります。
リクエストIDはトレース用です。冪等性キーは正確性用です。それらを混同すると、ダッシュボードは整然として見えますが、お金は2回移動します。
なぜPUTだけでは不十分なのか
いいえ、HTTP PUTだけでは操作を冪等にするには不十分です。
はい、RFC 9110はPUTに冪等セマンティクスを与えています。しかし、PUTハンドラーが新しい下流イベントを発生させ、すべてのリトライでメールを送信し、外部プロバイダーに再度請求する場合、ルート名が立派に見えていても、実装はビジネス契約を侵害しています。
動詞の選択は、クライアントが意図を理解するのを助けます。しかし、意図を実装するわけではありません。
リソースモデルが完全に置換またはアップサートスタイルの操作に本当に適合する場合に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日間保持します。これらの数字は大きく異なります。なぜなら、正しい保持期間はビジネス上の決定であり、プロトコルのデフォルトではないからです。
キューが5分しか保持しないからといって、キーを5分間だけ保持する場合、あなたは冪等性を設計しているのではなく、トランスポートの制限をビジネスレイヤーにコピーしているに過ぎません。
少なくともこれらのウィンドウの最大値に対して冪等レコードを保持します:
- クライアントリトライのホライズン
- キューのドライブリトライのホライズン
- Webhookリプレイのホライズン
- オペレーターリプレイのホライズン
- お金の移動操作のための決済または補償のホライズン
決済、予約、プロビジョニングの場合、それは通常、分ではなく、時間または日を意味します。
AWSは、完全に同意する2つのアンチパターンも指摘しています。時計のずれと衝突により信頼できないため、キーとしてタイムスタンプを使用しないでください。また、パフォーマンスとスケーラビリティに悪影響を与えるため、すべてのリクエストの重複除去レコードとしてリクエストペイロード全体を盲目的に保存しないでください。安全にリプレイするために必要な最小限のレスポンス状態に加えて、正規化されたリクエストハッシュを保存します。最初のレスポンスをバイト単位で再現する必要がある場合、Stripeのように標準的なレスポンスボディを保存します。
冪等性を現実のものにするデータベースパターン
永続化レイヤーがレースを1回だけ勝てるようになったとき、冪等性は現実のものになります。
PostgreSQLはここで2つの重要なプリミティブを提供します。一意制約は1つ以上の列で一意性を強制し、INSERT ... ON CONFLICTは一意性違反で失敗する代わりに代替アクションを定義することを可能にします。PostgreSQLはまた、ON CONFLICT DO UPDATEが並行性下でアトミックなinsert-or-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バックされたサービスにも適用されます。
「キーを選択; 欠落している場合注文を挿入」といったナイーブなチェックしてから実行するシーケンスは行わないでください。並行性下では、2つのリクエストがチェックを通過し、両方が副作用を作成する可能性があります。一意制約はオプションではありません。それは、あなたのアーキテクチャを楽観的な民話から、負荷下で証明できるものに変えるメカニズムです。
レビューで私が使用するルールはこうです。重複除去の決定がミューテーションと同じトランザクション境界によって保護されていない場合、あなたは冪等性を持っていません。希望を持っているだけです。
メッセージ、イベント、Webhookは独自の境界を必要とする
コンシューマーはどのようにして重複イベントとメッセージを処理するのか
メッセージコンシューマーにとって、古典的なパターンが依然として正しいものです。処理済みメッセージIDをビジネス更新と同じデータベーストランザクションで記録します。Chris Richardsonは、重複がクリーンに失敗し、無視できる subscriber とメッセージ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の正確に1回のサポートは、Kafka自身のトランザクションモデル内に留まる場合に優れていますが、Kafkaのドキュメントは依然として外部宛先は協調を必要とすると警告しています。SQS FIFOは、5分間の重複除去ウィンドウ内でのみ重複送信を減らします。Pub/Subの正確に1回は依然として、サブスクライバーが進行を追跡し、ACKが失敗したときの重複作業を避けることを期待します。
正確に1回は通常、ローカルな最適化です。冪等な副作用がシステムの保証です。
重複除去をアウトボックスパターンと組み合わせる
サービスがローカル状態を更新し、イベントも公開する場合、冪等な消費だけでは不十分です。ローカルトランザクションがコミットした後、イベントを安全に外部に出す方法も必要です。
これが、トランザクショナルアウトボックスパターンが重要な理由です。Chris Richardsonは、基本的なアイデアをビジネス更新と同じトランザクションでアウトボックステーブルにイベントを書き込み、非同期的に公開することとして説明しています。Debeziumは、アウトボックスパターンがサービスの内部状態と他のサービスによって消費されるイベント間の不整合を回避すると述べています。NServiceBusはさらに進み、アウトボックス処理が着信メッセージの重複を除去し、ゾンビレコードやゴーストメッセージを回避する方法を示しています。
データを所有し、統合イベントを公開するサービスに対して私が推奨するアーキテクチャは次の通りです:
- 冪等性キーの下でコマンドを検証し、永続化します。
- ビジネス状態とアウトボックスイベントを1つのローカルトランザクションで書き込みます。
- CDCまたはアウトボックスディスパッチャーにイベントを公開させます。
- 下流のコンシューマーも冪等であるようにします。
アウトボックスは、冪等なコンシューマーの必要性を除去しません。それは、データベースコミットとブローカー公開が1つの魔法の分散トランザクションであるというふりをする必要性を除去します(通常はできません)。
Webhookは単にブランディングが優れたメッセージに過ぎない
着信Webhookを、信頼できないネットワークエッジからのメッセージと同様に扱います。
GitHubは、配信が順序通りに到着しない場合があり、X-Hub-Signature-256を使用して真正性を検証することを推奨し、X-GitHub-Deliveryを一意の配信識別子として提供しています。また、再配信は同じ配信IDを再利用すると述べています。
したがって、アーキテクチャはシンプルです:
- まず署名を検証する
- デリバリGUIDを重複除去キーとして使用する
- 副作用の前に受領を永続化する
- 到着順序を仮定するのではなく、ハンドラーを順序感知にする
- 重い作業をキューに enqueue し、速やかに返す
Webhookハンドラーが受領を記録する前に直接ビジネステーブルに書き込む場合、それは本番環境向けではありません。単に重複ミスをより速く行うだけです。
サガとワークフローエンジンも依然として冪等性を必要とする
サガと耐久性のあるワークフローエンジンは、問題を削除しません。それらはそれを可視化します。
Temporalは、Activitiesが失敗またはタイムアウト後にリトライされる可能性があるため、Activitiesを冪等になるように記述することを推奨しています。そのドキュメントは、ワーカーが外部副作用を正常に完了したが、完了を報告する前にクラッシュし、その結果Activityが再度実行されるという境界ケースを特に指摘しています。Temporalはまた、下流サービス呼び出し時に安定した冪等性キーとして Workflow Run ID と Activity ID の組み合わせを使用することを提案しています。これをサービスオーケストレーションに適用している場合、Go Microservices for AI/ML Orchestrationが広範なワークフローのトレードオフをカバーしています。
これはまさに正しいメンタルモデルです。ワークフローエンジンは実行履歴を保持し、リトライを調整できます。しかし、アプリケーションが冪等なステップと冪等な補償を提供しない限り、カードの請求を取り消したり、メールの送信を取り消したりすることはできません。
これはサガにも適用されます。Temporal自身のサガガイダンスは、ステップが失敗したときに実行される補償アクションを説明しています。これらの補償も冪等である必要があります。「支払いを返金する」が2回実行されると、新しいバグを作成することで元のバグを解決している可能性があります。
ここでの私のルールは残酷でシンプルです。外部世界に触れるすべてのActivity、すべてのコマンドハンドラー、すべての補償は、自然的に冪等であるか、下流システムに実際の冪等性キーを保持するかのどちらかであるべきです。
本番環境前に冪等性をテストする方法
ほとんどのチームはハッピーパスをテストし、リトライが発生したとき驚きます。それでは不十分です。
少なくともこれらのケースに対して自動テストを持つべきです:
- サーバーがミューテーションをコミットしたが、レスポンスがクライアントに到達しない
- 2つの同一リクエストが同じ冪等性キーでレース状態になる
- 同じキーが異なるペイロードで再利用される
- コンシューマーがデータベース作業をコミットし、ACK前にクラッシュする
- Webhookが同じ配信IDでリプレイされる
- アウトボックスディスパッチャーが同じイベントを1回以上公開する
- ワークフローActivityが外部呼び出しを完了し、完了が報告される前にクラッシュする
- 冪等レコードが期限切れになり、実際の遅延リトライが到着する
AWSは、成功したリクエスト、失敗したリクエスト、および重複リクエストを含む包括的なテストスイートを明確に推奨しています。そのアドバイスは平凡ですが、絶対に正しいです。
私は1つさらに失敗ドリルを追加します。リプレイされたレスポンスが最初の結果と意味的に同等であることを検証します。AWSは遅れて到着するリトライについて議論し、基盤となる状態が変更された後でも元の意味を保持するレスポンスを主張しています。これは「追加の副作用が起きなかった」と「呼び出し側依然として一貫した契約を持っている」の違いです。
実際のシステムを救う意見に基づくルール
アーキテクチャレビューで私が強制するルールは以下の通りです。
第一に、冪等性キーはトランスポート試行ではなく、ビジネス意図に属します。
第二に、すべてのキーをテナントと操作でスコープします。グローバルキー空間は、無関係なリクエストが衝突する方法です。
第三に、重複除去の決定をミューテーションとアトミックに永続化します。それが真でない場合、設計は間違っています。
第四に、同じキーで異なるペイロードのリトライを拒否します。StripeとAWSは良い理由のためにこれを行っています。
第五に、最短のキューウィンドウではなく、ビジネスプロセスの完全なリプレイホライズンに対してキーを保持します。
第六に、プロデューサーをアウトボックスと、コンシューマーをメッセージID追跡と組み合わせます。片方だけでは設計の半分です。
第七に、ビジネスアクションが同じ場合、同じ操作アイデンティティを下流に伝播します。AWSは、処理チェーンに沿って冪等性トークンを渡すことを明確に推奨しています。
第八に、正確に1回のマーケティングが冪等な副作用の必要性を除去するとは決して仮定しないでください。
厳しすぎるように聞こえる場合、それは良いことです。冪等性は、楽観的なアーキテクチャが本番環境の現実と出会う場所です。どこでも複雑さは必要ありません。しかし、重複した副作用がお金、状態、または信頼に害を及ぼす可能性のある場所では、冪等性は契約の一級の部分であるべきです。