実際に機能する分散システムにおける冪等性

重複する副作用を停止する

目次

分散システムにおける冪等性(Idempotency)は、ネットワークが嘘つきになったり、キューがリトライしたり、クライアントがパニックに陥ったり、オペレーターが再生(replay)を実行したりした後に、あなたを救ってくれる性質です。本番環境のシステムにおいて、重複配信は普通のことです。しかし、重複した副作用こそがバグなのです。

HTTPでは、冪等メソッドは「複数の同一のリクエストが、1つのリクエストと同じ意図された効果をサーバーに及ぼす」と定義されています。そのため、PUT、DELETE、および安全なメソッドはプロトコルの意味論において冪等であり、通信障害後に自動的にリトライすることができます。

integration message flow: idempotency

この定義は有用ですが、不十分です。実際のアーキテクチャにおいて、冪等性は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(少なくとも1回)配信とは、同じメッセージに対して消費者(コンシューマー)が繰り返し呼び出されることを意味し、ハンドラはデータベースを更新するのに成功しても、確認応答の前に失敗してしまい、ブローカーが同じメッセージを再び配信することになります。

Webhookも同様です。GitHubは、Webhookの配信は順序不同で到達する可能性があり、失敗した配信は自動的に再配信されず、各配信には再生攻撃を防ぐために使用するべき一意のX-GitHub-Delivery GUIDが含まれていると述べています。チャットエンドポイントをインタラクションの境界として見るための実用的なアーキテクチャの視点については、Chat Platforms as System Interfaces in Modern Systemsを参照してください。

より強い保証を宣伝するシステムでさえ、あなたに作業を残します。Kafkaは冪等プロデューサーによりKafkaログ内の重複エントリを防ぐことができ、トランザクションおよびread_committed消費者によりKafka内部にとどまる読み込み-処理-書き込みフローに対してExactly-once(正確に1回)配信を提供できます。しかし、Kafka自身の設計ドキュメントは、外部システムはオフセットおよび出力との調整を依然として必要とすることを明確にしています。Google Cloud Pub/SubのExactly-once配信はプルサブスクリプションに限定され、クラウドリージョン内でのみ有効であり、確認応答が成功するまでクライアントが処理の進捗を追跡する必要があります。

私の意見に基づく要約はシンプルです。トランスポートがリトライすることを想定してください。オペレーターが再生することを想定してください。Webhookが遅れて到達することを想定してください。繰り返された意図が2番目のビジネス効果を生むことができないように、書き込みパスを設計してください。

実際に信頼できるAPI契約

冪等性キーはどのようにして重複APIリクエストを防止するのか

ミューテーション操作に対して私が信頼する唯一のAPI契約は、呼び出し元が提供する意図とサーバー側の永続化です。

AWSは、呼び出し元が提供するリクエスト識別子の使用を推奨し、サービスが冪等性トークンをミューテーション作業と同時にアトミックに記録する必要があると警告しています。Stripeは、キーに対する最初のステータスコードとレスポンスボディを保存し、後続のパラメータを元のリクエストと比較し、リトライに対して同じ結果を返します。PayPalは対応するPOST APIでPayPal-Request-Idを使用し、同じヘッダーを持つ前のリクエストの最新ステータスを返します。

これにより、実用的な契約が導き出されます:

  1. クライアントはビジネス操作に対して冪等性キーを生成します。
  2. サーバーはそのキーをテナントおよび操作名でスコープします。
  3. サーバーはリクエストハッシュを保存し、同じキーが異なるペイロードで再利用されないようにします。
  4. サーバーはpending(保留中)、completed(完了)、failed(失敗)などのステータスを記録します。
  5. 同じキーでのリトライは、保存された結果を返すか、それへの安定したポインタを返します。
  6. 同じキーで異なるペイロードでのリトライは、明確に失敗します。

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バックエンドのサービスにも適用されます。

「キーを選択; もし欠落していれば注文を挿入」といった単純なチェック-then-実行(チェックしてから実行)シーケンスは行わないでください。並行性下では、2つのリクエストがチェックを通過し、両方が副作用を作成する可能性があります。一意制約はオプションではありません。それは、あなたのアーキテクチャを楽観的な民間伝承から、負荷下で証明できるものへと変えるメカニズムです。

レビューで私が使用するルールは次の通りです。重複排除の決定がミューテーションと同じトランザクション境界で保護されていない場合、あなたは冪等性を持っていません。あなたは希望を持っているだけです。

メッセージ、イベント、Webhookは独自の境界を必要とする

コンシューマーはどのようにして重複イベントとメッセージを処理するのか

メッセージコンシューマーにとって、古典的なパターンが依然として正しいものです。処理されたメッセージIDをビジネス更新と同じデータベーストランザクションで記録します。Chris Richardsonは、PROCESSED_MESSAGESテーブルアプローチを直接説明しており、重複がクリーンに失敗して無視できるように、サブスクライバーとメッセージIDに対して主キーを使用しています。

多くのチームは、その明示的な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のExactly-onceサポートは、Kafka自身のトランザクションモデル内に留まっている場合は優れていますが、Kafkaのドキュメントは依然として、外部宛先は協力を必要とするという警告を出しています。SQS FIFOは、5分間の重複排除ウィンドウ内でのみ重複送信を削減します。Pub/SubのExactly-onceは、依然としてサブスクライバーが進捗を追跡し、確認応答が失敗した場合に重複作業を回避することを期待しています。

Exactly-onceは通常、ローカルの最適化です。冪等な副作用はシステム保証です。

重複排除をOutboxパターンとペアにする

あなたのサービスがローカルステータスを更新し、イベントも公開する場合、冪等な消費だけでは不十分です。ローカルトランザクションのコミット後、イベントを安全に外部に出す方法も必要です。

それが、トランザクショナルOutbox(アウトボックス)パターンが重要である理由です。Chris Richardsonは、基本的なアイデアをビジネス更新と同じトランザクションでイベントをOutboxテーブルに書き込み、その後非同期的に公開することとして説明しています。Debeziumは、Outboxパターンがサービスの内部ステータスと他のサービスによって消費されるイベント間の不整合を回避すると述べています。NServiceBusはさらに進んで、Outbox処理が受信メッセージの重複を排除し、ゾンビレコードやゴーストメッセージを回避する方法を示しています。

データを所有し、統合イベントを公開するサービスに対して私が推奨するアーキテクチャは次の通りです:

  1. 冪等性キーの下でコマンドを検証し、永続化する。
  2. ビジネスステータスとOutboxイベントを1つのローカルトランザクションで書き込む。
  3. CDCまたはOutboxディスパッチャがイベントを公開するのを待つ。
  4. 下流のコンシューマーも冪等に作る。

Outboxは、冪等コンシューマーの必要性を除去するものではありません。それは、データベースコミットとブローカー公開が1つの魔法のような分散トランザクションになるふりをする必要性を除去します(実際には通常できません)。

Webhookは単なるブランド付けの良いメッセージに過ぎない

受信Webhookを、信頼できないネットワークエッジからのメッセージと同じように扱ってください。

GitHubは、配信が順序不同で到達する可能性があること、X-Hub-Signature-256を使用して真正性を検証することを推奨すること、および一意の配信識別子としてX-GitHub-Deliveryを提供することを文書化しています。また、再配信は同じ配信IDを再利用すると述べています。

したがって、アーキテクチャは明快です:

  • まず署名を検証する
  • 重複排除キーとして配信GUIDを使用する
  • 副作用の前に受信を永続化する
  • 到着順序を仮定するのではなく、順序に感知したハンドラを作る
  • 重い作業をキューに入れ、速やかに返す

Webhookハンドラが受信を記録する前に直接ビジネステーブルに書き込む場合、それは本番環境準備ができていません。それは単に、重複ミスをより速く作るだけです。

Sagaとワークフローエンジンも依然として冪等性を必要とする

Sagaと耐久性のあるワークフローエンジンは、問題を削除しません。それらは問題を可視化します。

Temporalは、Activitiesは失敗またはタイムアウト後にリトライされる可能性があるため、Activitiesを冪等に書くことを推奨しています。そのドキュメントは、ワーカーが外部副作用を正常に完了したが、完了を報告する前にクラッシュし、その結果Activityが再び実行されるという端のケース(edge case)でさえ指摘しています。Temporalはまた、下流サービス呼び出し時に、Workflow Run IDとActivity IDの組み合わせを安定した冪等性キーとして使用することを提案しています。サービスオーケストレーションにこれを適用している場合、Go Microservices for AI/ML Orchestrationがより広範なワークフローのトレードオフをカバーしています。

それはまさに正しいメンタルモデルです。ワークフローエンジンは実行履歴を保持し、リトライを調整できます。しかし、アプリケーションが冪等なステップと冪等な補償を与えない限り、カードの請求を元に戻したり、送信されたメールを未送信にしたりすることはできません。

同じことがSagaにも適用されます。Temporal自身のSagaガイダンスは、ステップが失敗した時に実行される補償アクションを説明しています。それらの補償もまた冪等である必要があります。「支払いを返金する」が2回実行されると、新しいバグを作成することで元のバグを解決したことになります。

ここでの私のルールは過酷でシンプルです。外部世界に触れるすべてのActivity、すべてのcommand handler、およびすべての補償は、自然的に冪等であるか、または下流システムに真の冪等性キーを保持するべきです。

本番環境前に冪等性をテストする方法

ほとんどのチームはハッピーパスをテストし、リトライが発生した時に驚きます。それは不十分です。

少なくともこれらのケースに対して自動化テストを持つべきです:

  • サーバーがミューテーションをコミットしたが、レスポンスがクライアントに到達しない
  • 2つの同一のリクエストが同じ冪等性キーでレースする
  • 同じキーが異なるペイロードで再利用される
  • コンシューマーがデータベース作業をコミットし、ACKの前にクラッシュする
  • Webhookが同じ配信IDで再生される
  • Outboxディスパッチャが同じイベントを1回以上公開する
  • ワークフローActivityが外部呼び出しを完了し、完了が報告される前にクラッシュする
  • 冪等性レコードが期限切れになり、真の遅延リトライが到達する

AWSは、成功したリクエスト、失敗したリクエスト、および重複リクエストを含む包括的なテストスイートの使用を明示的に推奨しています。その助言は平凡ですが、絶対に正しいです。

私はさらに1つの失敗ドリルを追加します。再生されたレスポンスが最初の結果と意味的に同等であることを検証します。AWSは遅く到達するリトライについて議論し、基礎となるステータスが変更された後でも元の意味を保持するレスポンスを提唱しています。それは「追加の副作用が発生しなかった」と「呼び出し元は依然として一貫した契約を持っている」の違いです。

実際のシステムを救う意見的なルール

ここには、アーキテクチャレビューで強制するであろうルールが示されています。

第一に、冪等性キーはトランスポート試行ではなく、ビジネス意図に属します。

第二に、すべてのキーをテナントと操作でスコープします。グローバルキー空間は、無関係なリクエストが衝突する方法です。

第三に、重複排除の決定をミューテーションとアトミックに永続化します。それが真でない場合、設計は誤っています。

第四に、同じキーで異なるペイロードのリトライを拒否します。StripeとAWSは、良い理由でこれをしています。

第五に、最も短いキューウィンドウではなく、ビジネスプロセスの完全な再生ホライズンに対してキーを保持します。

第六に、プロデューサーをOutboxと、コンシューマーをメッセージID追跡とペアにします。一方だけが他方なしでは、半分だけの設計です。

第七に、ビジネスアクションが同じ場合、同じ操作アイデンティティを下流に伝播します。AWSは、処理チェーンに沿って冪等性トークンを渡すことを明示的に推奨しています。

第八に、Exactly-onceのマーケティングが冪等な副作用の必要性を除去すると決して仮定しないでください。

厳しすぎるように聞こえるなら、それは良いことです。冪等性は、楽観的なアーキテクチャが本番環境の現実と出会う場所です。どこにでも複雑さは必要ありません。しかし、重複した副作用がお金、ステータス、または信頼に害を与える可能性がある場所では、冪等性は契約の一等市民であるべきです。

参考リンク

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。