
2026/05/07 20:02
イデンプト性(冪等性)を保つのは容易ですが、2 つ目のリクエストが異なる場合に複雑になります。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
真のイデムポテンシーの実現は、単なるレスポンスキャッシュの超える範囲にあり、コマンドの所有権を明示的に追跡し、正則な意図を検証し、並行性と部分的な失敗に対応するために安全な再実行可能な結果を管理する堅牢な戦略が必要です。基本的なストレージメカニズムまたは一意キー制約だけでは不十分であり、これらは通常、並行したリトライの際や実行間でのレスポンスが異なる場合に失敗します。耐久性のあるシステムは、特定のカテゴリに一致する完了した再実行と、ロックが必要である新規要求(
ON CONFLICTなどの原子挿入と衝突処理を用いる)を区別し、重複処理を防ぐ必要があります。重要なのは、異なる正則なコマンドのためにキーを再利用する場合、古くなったデータを返すかマルチテナント間の衝突を引き起こすのではなく、ハードエラー(例:409 Conflict)をトリガーさせることです。
将来のシステムでは、不安定な下流操作を管理するために、リクエストハッシュ(正規化されたDTOから認証トークンなどの機密データを除外して導出)、ロックタイムスタンプ、テナントごとに明示的なスコープを含むテーブルなどの特定スキーマパターンを実装する必要があります。部分的なローカル成功で下流の状態がまだ不明な場合に処理するためには、アウトボックス/インボックスパターンのような回復パスを実装することが不可欠です。これらの厳格なパターンを採用することで、組織はネットワーク障害中の信頼可能な状態一貫性を確保し、高価なデータ複製を排除し、セキュリティリークを防ぎ、信頼性のない基本的なストレージ実践を超えることができます。
本文
IDempotency(冪等性)について、人々はまるでそれが解決済み問題かのように話します。リクエストに Idempotency-Key を付与し、レスポンスを保存しておき、再試行の際にその結果を再現すればいいだけだ、というわけです。はい、それは実現可能です。最も状況がうまくいっているケース(ハッピーパス)においては、それほど複雑でもありません。クライアントが送るリクエスト例を示します:
POST /payments Idempotency-Key: abc-123 Content-Type: application/json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" }
サーバーはこのリクエストに含まれるキー
abc-123 を以前に見たかどうかを確認します。まだ見たことがなければ、支払処理を作成します。すでに見ていた場合は、それまでのレスポンスを返します。この実装はデモでは十分すぎるほど動作しますが、私が問題と考えるのは「これが困難な部分である」という前提です。実は困難なのはここではなく、その次のリクエストから始まります。なぜなら、2 番目のリクエストが常に最初のものの完全な再現(クリーンなリプレイ)ではないからです。
ケースにはいくつか種類があります。
- 完了したリプレイの場合:以前の結果を返して問題ありません。
- 競合する再試行の場合:最初の処理はまだ実行中で、2 番目のリクエストが到達しているかもしれません。この場合、冪等性のレイヤーは並行性制御の一部となります。
- 部分的なローカル成功の場合:最初のリクエストでローカルの支払記録は作成されましたが、イベント発行前にクラッシュしたかもしれません。すると、ローカル側のデータと外部側の副作用(side effects)の状態が一致しません。
- ダウンストリームでの状態不明の場合:最初のリクエストで決済プロバイダーを呼び出し、プロバイダー側も承諾しましたが、結果を記録する前にプロセスが停止したかもしれません。この場合、データベースだけでは資金移動の有効性を判断できません。
さらに、より複雑なケースもあります。2 番目のリクエストには同じキーが使われていますが、内容が異なります:
{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" }
キーは同じですが、金額が変化しています。これが IDempotency(冪等性)の問題を本当に面白くするケースです。これは再試行なのか?クライアントのバグなのか?新しいオペレーションなのか?サーバーは古いレスポンスをリプレイすべきか、リクエストを拒否すべきか、それとも(キー+内容)を新たな識別子と扱うべきか?明確な文書化があれば、いずれかのポリシーを選んでも構いませんが、サーバーには明確な「意見(スタンス)」を持つ必要があります。私の偏向としては、副作用を伴う API においては「同一のスコープ内でのキーに異なる正規化されたコマンドが含まれる場合」はエラーとして扱うべきです。これによりクライアント側のバグは早期に発見できます。10 ユーロの支払いを安全に再試行していると信じているクライアントが、サーバーによって 2 番目のリクエストが別の意味を持つものとして静かに解釈されるような状況になることはあってはいけません。
ここで重要なのは、リプレイキャッシュだけで説明できないケース群です:
- 完了したリプレイ
- 競合する再試行
- 部分的なローカル成功
- ダウンストリームの状態が不明
- 同じキーだが異なる正規化されたコマンド
- キーなしの重複操作(デュプリケート)
- トークン有効期限後の再試行
- デプロイ、スキーマ変更、サービスホップ、リージョンフェイルオーバー後の再試行
もしあなたの設計が「完了した同一コマンドの再試行」のみを扱っているのであれば、それは単なるリプレイキャッシュにすぎません。一部のエンドポイントには十分かもしれませんが、冪等性全体の問題ではありません。「冪等性」とは、一度適用しても複数回適用しても意図された効果(effect)が同じであることです。この定義はシンプルですが、それを保証するのは「効果」そのものの実装です。HTTP はメソッドレベルの意味を提供しますが、
PUT /users/123/email を何度も送信してもリソースの状態が変わらないなら冪等性を持ちますし、すでに削除されたセッションを再 DELETE しても「セッションが存在しない」という状態が維持されれば冪等性と言えます(DELETE は通常 404 を返す場合もありますが、効果としては冪等です)。
しかし、ハンドラーはビジネスが気にする重複の副作用を生み出す可能性があります:重複した監査記録、重複したドメインイベント、重複したメール送信、重複したプロバイダー呼び出し、請求や不正検知ロジックに影響を与える重複したメトリクスなどです。POST はデフォルトでは冪等性を持たずませんが、サーバーがそれを保存し適切な振る舞いを強制すれば実現可能です。キーは「主張された操作」を識別するものです。リクエストの同等性、リプレイポリシー、またはダウンストリームのデデュプリケーション(重複除去)を定義するものではありません。ユニーク制約は一種類の重複を防ぐことはできますが、それだけでクライアントに正しい再試行結果を保証するわけではありません。
例えば
(account_id, merchant_reference) がユニークであれば支払明細書の行を二重にはできませんが、再試行時に汎用的な 500 エラーが返された場合でも、クライアントは支払い成功の有無を知ることはできません。行が存在したのにレスポンス内容が異なっていたり、イベントが二回発行されたり、台帳のエントリが重複していたりすれば、呼び出し元が気にする「冪等性」という意味での操作は成り立っていないことになります。
覚えておくべきこと POST /payments のための永続的な冪等性記録には、以下の 3 つの質問に答えられる必要があります:
- このキーの主権者は誰か?
- 最初のコマンドは何を意味していたか?
- リプレイできる結果は何か?
PostgreSQL 風の SQL で言えば、最小限のテーブルは以下のような外観になります:
create table idempotency_requests ( tenant_id text not null, operation_name text not null, idempotency_key text not null, request_hash text not null, status text not null, response_status int, response_body jsonb, resource_type text, resource_id text, error_code text, created_at timestamptz not null, updated_at timestamptz not null, expires_at timestamptz not null, locked_until timestamptz, primary key (tenant_id, operation_name, idempotency_key) );
キーは意図的にグローバルにしない限り、世界で一意ではありません。通常、それにする必要もありません。バグを持つクライアントが
abc-123 を生成して衝突させても、それは自分自身とだけ衝突すればよく、他のテナントとは関係ないはずです。スコープはテナント、ユーザー、アカウント、メーチャー、API クライアント、あるいはその組み合わせに設定します。これも意図的に選択してください。オペレーション名による不意の再利用を防ぎます。支払作成用のキーが、返金作成用にも自動的に適用されるべきではありません。request_hash はサーバー側の最初のコマンドに関する記憶です。これがないと、同じキーでも異なるボディが入って曖昧になってしまいます。異なるコマンドに対する最初のレスポンスをリプレイするか、古いキーを使って新しいオペレーションを実行するか、いずれもクライアントが再試行していると思い込んでいるのであれば問題があります。
IN_PROGRESS は内部実装の詳細ではありません。再試行の間に最初の処理が実行権を持っている場合もあります。この振る舞いは明示的でなければなりません:
| 既存の記録 | 正規化されたコマンドと同じか? | 推奨される挙動 |
|---|---|---|
| - | はい(お金) | を挿入して実行 |
| COMPLETED | はい | 保存したレスポンスのリプレイか、文書化された同等の結果を返す |
| 任意の既存記録 | いいえ | 冪等性の衝突エラーで拒否 |
| IN_PROGRESS(新鮮) | はい | 待機するか 202 を返すか、409 + Retry-After を返す |
| IN_PROGRESS(古くさい) | はい | 所有権を回復し、盲目的に再度実行しない |
| FAILED_REPLAYABLE | はい | 保存されたエラーをリプレイ |
| FAILED_RETRYABLE | はい | ポリシーに従って再試行可能にする |
| UNKNOWN_REQUIRES_RECOVERY | はい | 整合性チェックをトリガーするか、pending/recovery ステータスを返す |
| 有効期限切れ/削除済み | 不明 | 文書化された有効期限の挙動に従う |
レスポンスフィールドが存在するのは、冪等性は単に重複した書き込みを防ぐためだけではないからです。クライアントには答えが必要です。完全なレスポンスボディを保存することもでき、作成されたリソースへの参照を保存してレスポンスを再構築することもできます。どちらの選択もそれぞれの難しさがあります。完全なレスポンスを保存すれば忠実なリプレイが可能ですが、同時に PII(個人識別情報)、署名済み URL、ワンタイムトークン、カード保持者関連データ、あるいは本来保存するつもりはなかったフィールドなどを残してしまいます。リソース参照から再構築する場合の方が軽量ですが、リソースが作成後に変更された場合、異なる表現を返す可能性があります。これは API 契約の選択事項です。「作成時のレスポンスをリプレイする」のか「現在の支払い情報を返す」のか、どちらも有効な API デザインです。ただしそれは同一のデザインではありません。
同じキー、異なるコマンド これは冪等性レイヤーが明確に捉えなければならないバグです。最初のリクエスト:
{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" }
2 つ目のリクエスト:
{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" }
Idempotency-Key はどちらも
abc-123 ですが、金額が異なります。元のレスポンスをそのまま返すのは簡単です。しかし、それは深刻なクライアントのバグを隠してしまいます。呼び出し元は 100 ユーロの支払いを求めていますが、サーバーは 10 ユーロの支払いの結果を返しています。呼び出し側がレスポンスに注意深く比較しないと、100 ユーロの支払いが成功したと誤って信じてしまいます。これは冪等性ではありません。これは再解釈です。副作用を伴う API では、完了済みかどうかに関係なく、スコープされたキーを異なる正規化されたコマンドで再利用するのはハードエラー(409 など)として扱うべきです。
HTTP/1.1 409 Conflict
HTTP/1.1 409 Conflict Content-Type: application/json { "errorCode": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST", "message": "This idempotency key was already used with a different request." }
409 Conflict は防御可能なデフォルトです。スコープされたキーに対するサーバーの記憶した意味とリクエストが衝突しているためです。一部の API では 400 または 422 を使用しますが、重要なのは機械可読で安定したエラーメッセージを提供し、異なるコマンドに対して静かにリプレイをすることはしないことです。
一般的なクライアント側のバグ例:
(悪い) idempotencyKey = cartId POST /payments amount=10.00 key=cart_123 POST /payments amount=15.00 key=cart_123 (良い) idempotencyKey = paymentAttemptId
サーバーはカートキーがどの支払いを表すべきか推測してはいけません。API を設計する際には、
(key + content hash) が操作の識別子を定義するというポリシーも有効です。そうすればそれは冪等性の鍵としては機能せず、複合的な操作識別子の一部になります。クライアントにはそれが明白である必要があります。危険なのは中間点で、クライアントは安全に再試行していると思い込んでいるのに、サーバーが 2 番目のリクエストを別の意味として静かに解釈してしまうパターンです。
コマンドのハッシュ化、バイト数のハッシュ化ではない 生データのバイト同比較では、JSON API には厳しすぎることが多い。以下の 2 つのボディは通常、同等と見なされるべきです:
{ "amount": "10.00", "currency": "EUR" } { "currency": "EUR", "amount": "10.00" }
フィールド順序や空白は関係ありません。ただし、デフォルト値については注意が必要です:
{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR" }
対して:
{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "channel": "web" }
channel: "web" がサーバーのデフォルトであれば、これらは同じ論理的コマンドとみなすべきでしょうか?判断してからハッシュ化に含める必要があります。未知のフィールドも罠になります。API 側が未知の JSON フィールドを無視している場合、最初のリクエストに "foo": "bar" を含めても、2 番目のリクエストでは含まれていない場合、これらを同じとするべきでしょうか?本当に無視されるならはいですが、デプロイ後に意味を持つ可能性があるならいいえです。実用的なルールは:検証されたコマンドのハッシュ化を行い、生の HTTP ボディのハッシュ化を行わないことです。
適切なフローは以下の通りです:
- リクエストをバージョン管理された要求 DTO またはコマンドにパースする。
- API が同等とみなす値(金額、列挙型の大文字小文字、デフォルトフィールド、タイムスタンプの精度など)を正規化する。
- 伝送だけのメタデータを除外する。
- パスパラメータとオペレーション名を含める。
- オペレーションに影響するセマンティックなヘッダー(API バージョンなど)を含める。
- レスポンスの形状しか影響しないヘッダー(例:
)については、コマンドハッシュに含めるか、リプレイ契約の一部とするか、含めないかを判断する。Prefer: return=minimal - Authorization と Idempotency キー自体を除外する。
- 正規形式でシリアライズする。
- 安定したアルゴリズムでハッシュ化する。
支払いの例であれば、フィンガープリントには以下の情報が含まれます:
- オペレーション:
create_payment - アカウント ID:
acc_1 - 金額:
10.00 - 通貨:
EUR - メーチャー参照:
invoice-7781 - チャネル:
web - API バージョン:
2026-05-01
金額、タイムスタンプ、生成されたデフォルト値、ロケール依存の書式、デプロイ時に追加されたフィールドには注意してください。リクエストハッシュは契約です。計算方法を変更すると、古い再試行の結果が異なって見えるようになります。
最初の挿入が実行権限を決定する 二つの同一なリクエストがほぼ同時に 2 つの API インスタンスに到達した場合:
POST /payments Idempotency-Key: abc-123
同じ正規化されたコマンド、同じテナント、同じエンドポイント。各スレッド単一でのテストは通っていても、この実装は破れています:
existing = find_by_key(key) if existing does not exist: create_payment() insert_idempotency_record()
両方のリクエストとも既存の行がないことに気づく可能性があります。どちらも副作用を実行しようとするでしょう。スコープされたキーに対する原子的な挿入やユニーク制約がなければ、2 つのインスタンスとも自分が実行権を持つと判断する可能性があります。「insert-first」型のパターン:
insert into idempotency_requests (tenant_id, operation_name, idempotency_key, request_hash, status, created_at, updated_at, expires_at, locked_until) values (:tenant_id, 'create_payment', :idempotency_key, :request_hash, 'IN_PROGRESS', now(), now(), now() + interval '24 hours', now() + interval '30 seconds') on conflict do nothing;
正確な構文はデータベース固有ですが、重要なのは
(tenant_id, operation_name, idempotency_key) に対する原子的な所有権の取得です。その後:
if rows_inserted == 1: -- このリクエストが実行権を持つ else: existing = load idempotency row if existing.request_hash != request_hash: return 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST if existing.status == COMPLETED: return replay(existing.response_status, existing.response_body) if existing.status == IN_PROGRESS and existing.locked_until > now(): return 202 or 409 + Retry-After if existing.status == IN_PROGRESS and existing.locked_until <= now(): -- 所有権の回復を試みる(これも原子的手順でなければならない) if existing.status == UNKNOWN_REQUIRES_RECOVERY: trigger reconciliation or return pending/recovery response
所有権の回復も原子的に取得する必要があります。さもなくば、2 つの再試行リクエストとも「古い所有者は死亡した」と判断し、どちらも回復を試みる可能性があります。単純なローカルケースでは、所有者は支払処理を作成し、冪等性記録を完了させるまでを 1 つのトランザクションで完結させます:
begin transaction insert idempotency row as IN_PROGRESS insert payment row pay_789 insert outbox event PaymentCreated(pay_789) update idempotency row: status = COMPLETED resource_type = payment resource_id = pay_789 response_status = 201 response_body = {...} commit
これが理想的な姿です:冪等性行、ビジネス行、アウトバックスイベントをカバーする単一のデータベーストランザクションです。外部の副作用はその形を変えます。プロバイダー呼び出しの間、データベーストランザクションを開状態に保つのは通常悪い考えです。コミットしてからもプロバイダー呼び出しを行うと、ローカルステートは
IN_PROGRESS だが実行はトランザクション外で行われていることになります。そこでプロセスがクラッシュした場合、再試行による回復が必要になります。ここで必要なのは、操作ステートマシンと回復ワーカーであり、単なるリクエストテーブルではありません。Redis の SET NX EX が全体解決策として提案されることがありますが、それはあくまで実行のガードに過ぎません:
SET idempotency:tenant_1:create_payment:abc-123 value NX EX 30
これは重複する並行実行を減らすには役立ちますが、操作結果の永続記憶ではありません。Redis ロックが有効期限切れになりながらプロバイダー呼び出しが完了している場合、別のリクエストが入り込んでしまいます。また、プロバイダーで成功しましたがレスポンスを保存する前にプロセスが停止した場合、ロックは再試行に何が起きたかを知るのを助けません。Redis ロックはダウンストリームのリソースを守るためにはフェンシングや永続的所有権が必要です。Redis も有用ですが、操作結果を記憶するための代わりにはなりません。
プロバイダーのタイムアウトが保証の限界 重要な失敗パスは特異なものではありません:
- API が
を受け取る。POST /payments - 冪等性行として
で挿入する。IN_PROGRESS - ローカル支払い
を作成する。pay_789 - ダウンストリームの決済プロバイダーを呼ぶ。
- プロバイダー側でリクエストを受け取り成功する。
- API 側でタイムアウトしたりクラッシュしたりしてプロバイダーのレスポンスを失う。
- クライアントが同じキーで再試行する。
プロバイダー側でリクエストを受信したが、API プロセスが結果を記録する前に死亡した場合、データベースでは資金が移動したかどうかを推測できません。ローカルステートマシンは以下のようなものかもしれません:
RECEIVED -> LOCAL_PAYMENT_CREATED -> PROVIDER_REQUEST_SENT -> PROVIDER_CONFIRMED -> COMPLETED -> UNKNOWN_REQUIRES_RECOVERY
再試行の挙動はステートに依存します。もし
COMPLETED を発見したらリプレイします。PROVIDER_REQUEST_SENT の新しいものを見つけたら、202 Accepted、409 Conflict + Retry-After を返すか、一時的にブロックして完了を待ちます。一つの挙動を選び、それを文書化してください。クライアントは再試行すべきか、ポーリングすべきか、待つべきか知る必要があります。古くさい PROVIDER_REQUEST_SENT を発見したら、pay_790 の作成を行わないでください。新しい識別子でプロバイダーを呼ぶこともありません。安定したダウンストリーム操作 ID を使って回復してください:
- 支払い ID:
pay_789 - プロバイダー冪等性キー:
provider_payment_pay_789
その後、回復ワーカーまたは再試行リクエストは以下を行うことができます:
に対する回復所有権を取得するpay_789- プロバイダーがサポートしている場合、
でプロバイダーを照会するprovider_payment_pay_789 - 確認されたらプロバイダー操作を確認済みとする
- 冪等性記録を
とするCOMPLETED - レスポンスを保存または再構築する
- レスポンスをリプレイするか、文書化された最終ステータスを返す
プロバイダーから回答が得られない場合は
UNKNOWN_REQUIRES_RECOVERY とマークします。プロバイダーに冪等性キーがなく照会 API もなければ、システムには運用上のギャップが生じます。それでも受け入れる選択もできますが、その場合、ローカルの冪等性テーブルは外部の影響を保護していません。単純に重複したローカルリクエスト処理のみを防ぐだけです。支払いのような操作では、クライアントの冪等性キーは必ずしもダウンストリームに送信されるキーとは一致しません。ダウンストリームの呼び出しには、再試行、クラッシュ、整合性チェック後のステータスも耐えうる安定した識別子が必要です。さもないと、2 番目のローカル試行は単なる 2 つ目のプロバイダー試行になってしまいます。私は API に特定の理由がある場合を除き、425 Too Early を使用するのは避けたいです。多くのクライアントはその特殊な処理をしません。202 Accepted、409 Conflict + Retry-After、あるいは操作ステータスのエンドポイントを返す方が説明しやすいです。
リプレイは便利さではなく契約 完了した冪等性のリクエストに対して、同じステータスとボディをリプレイすることは最も予期せぬ挙動ではありません:
HTTP/1.1 201 Created Idempotent-Replayed: true Content-Type: application/json { "paymentId": "pay_789", "status": "PENDING", "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" }
Idempotent-Replayed: true というカスタムレスポンスヘッダーはデバッグに役立ちますが、クライアントがこれに依存させるのは避けたいです。現在のリソースステートからレスポンスを再構築する誘惑がありますが:
load payment pay_789 return current representation
しかし、最初のレスポンスが以下の通りだったと仮定してください:
{ "paymentId": "pay_789", "status": "PENDING" }
そして 10 分後、決済完了後に再試行が行われた場合:
{ "paymentId": "pay_789", "status": "SETTLED" }
これは有用かもしれませんが、これはリプレイではありません。リソースの新鮮な読み込みです。API 契約が「冪等性のある再試行は元々の作成結果を返す」と定めている場合、それを行うのに十分なデータを保存する必要があります。スキーマ変更はこの状況を悪化させます。Verison 2 のレスポンス:
{ "paymentId": "pay_789", "status": "PENDING" }
バージョン 3 のレスポンス:
{ "id": "pay_789", "state": "PENDING", "createdAt": "2026-05-07T10:00:00Z" }
デプロイ後にクライアントが再試行した場合、保存されたバージョン 2 のレスポンスを受け取るべきか、再構築されたバージョン 3 のレスポンスを受け取るべきか?どちらも合理的に説明できますが、異なる契約です。一般的な妥協案として、以下を保存する:
resource_type = paymentresource_id = pay_789response_status = 201response_schema_version = v2
そして、正確なリプレイが求められるエンドポイントのみで完全なレスポンスボディを保存します。ボディを保存する場合は、冪等性テーブルを「無害なキャッシュ」のように扱うのではなく、「機密データを格納するストレージ」として扱ってください。
あなたのキューコンシューマーにも同じバグがある HTTP はヘッダーが見えるので注目を集めますが、大量の重複副作用は後に発生し、コンシューマー、アウトバックスパビッシャー、インボックスプロセッサ、通知ワーカーで起こります。支払いサービスが以下を公開すると仮定します:
{ "eventId": "evt_100", "type": "PaymentCreated", "paymentId": "pay_789", "accountId": "acc_1", "amount": "10.00", "currency": "EUR" }
コンシューマーがこれを二回受け取ります。これでは 2 つのメールを送信したり、2 つの台帳エントリを作成したり、プロバイダーを 2 回通知してはなりません。デデュプリケーションキーにはイベント ID、メッセージ ID、操作 ID、アグリゲート ID とバージョン、または台帳支払い用のビジネスキー
ledger_payment_pay_789 などが使われます。適切な答えは副作用次第です。コンシューマーインボックステーブル:
consumer_inbox - consumer_name - message_id - status - processed_at - error_code unique(consumer_name, message_id)
しかし、メッセージを処理済みとマークするのは容易ではありません。メールを送信する前に処理済みにしてクラッシュすると、再試行で永遠にメール送信がスキップされます。メールを送信してから処理済みとマークしてクラッシュすると、再試行時に再度メールを送信してしまう可能性があります。通常のアプローチは、サイドエフェクトを送信する前に永続化することです:ユニークキーを持つメール通知行を挿入し、その後送信プロセスがその行を読むようにします。台帳エントリには自然な冪等性キーが存在することが多いです:
unique(ledger_entry_type, source_payment_id)
PaymentCreated(pay_789) を二回処理して同じ台帳エントリを二回作成しようとすると、2 番目の試行は既存のエントリに解決されます。多くの生産環境のキュー統合は、コンシューマーの観点からは「少なくとも一度」の挙動です。ブローカーが強力な配信セマンティクスを宣伝しても、ビジネスサイドエフェクトにはデデュプリケーションが必要です。「正確に一度」の配信は「正確に一度」のビジネス効果ではありません。後者は永続的な操作 ID、ユニーク制約、冪等性のある書き込み、回復パスから通常得られます。アウトバックス/インボックスは標準的な形です:
同じデータベーストランザクションで:
insert payment row pay_789 insert outbox event PaymentCreated(pay_789)
パブリッシャー:
unpublished outbox event を読み取る eventId でイベントを公開する アウトバックスイベントに published とマークする
コンシューマー:
eventId またはビジネス操作キーでデデュプリケーションを行う ユニーク制約の後ろにサイドエフェクトを書く
冪等性は一部の重複を防ぎますが、悪質なメッセージや壊れたプロバイダー、デッドレター処理、回復作業を取り除くわけではありません。有効期限も API 契約の一部です**
冪等性記録は永久に存在することはできません。サーバーが 24 時間の冪等性ウィンドウを保証した場合、25 時間後の再試行では新しい操作を作成する必要があります。これは許容される場合がありますが、数日のキュー再試行をクライアントが驚くこともあります。リプレイウィンドウは製品/API の意思決定であり、単なる片付け設定ではありません。完了した記録は:
: 2026-05-07T10:00:00Zcreated_at
: 2026-05-08T10:00:00Zexpires_at
: COMPLETEDstatus
有効期限切れ後には、レスポンスボディを削除してメタデータをさらに長く保持する場合があります:
idempotency_keyscopeoperation_namerequest_hashresource_idcreated_atexpires_at
これにより、機密レスポンスペイロードを保持せずに診断を支援できます。古くさい
IN_PROGRESS には特別な処理が必要です:
: IN_PROGRESSstatus
: pay_789resource_id
: 2026-05-07T10:00:00Zupdated_at
: 2026-05-07T10:00:30Zlocked_until
: 2026-05-07T10:45:00Znow
再試行がこの状態を見つけた場合、盲目的に再度実行してはなりません。回復所有権を取得し、
pay_789 を確認し、必要に応じてダウンストリームを照会して、操作を COMPLETED、FAILED_RETRYABLE、または UNKNOWN_REQUIRES_RECOVERY に移動させる必要があります。片付けジョブが古いために進行中の記録を削除してはなりません。古い進行中エントリには、挟まったワーカー、プロセスクラッシュ、整合性チェックを待っている操作など可能性があります。これらを削除すると、重複したサイドエフェクトが許容されてしまいます。
delete from idempotency_requests where expires_at < now();
より良い選択肢は小規模バッチでの削除、
expires_at によるパーティショニング、リプレイウィンドウ後の古い時間パーティションのドロップ、レスポンスボディとメタデータに対して別々の保持ポリシーを使用することです。リプレイカウントは主に容量計画に関するものです。異なるボディの再利用、古くさい IN_PROGRESS エントリ、有効期限切れの再試行、不明の状態こそがバグを見つけます。
idempotency.replay.countidempotency.conflict.different_request.countidempotency.in_progress.age.maxidempotency.expired_retry.countidempotency.unknown_state.count
失敗時のリプレイはポリシー決定事項
危険なミスは、すべての失敗を「再試行に安全」か「完了」という 2 つのパターンに分類することです。純粋な構文検証の失敗には通常、冪等性保存は不要です。JSON が不正または必須フィールドがない場合、リクエストを繰り返しても再度失敗します。ビジネス的な拒絶は異なります。判断が変更可能な状態(残高照会、在庫、アカウントステータス、不正検知ルールなど)に依存する場合、最初の決定がこの冪等性キーに対してバインディングされるか、クライアントは新しいキーで再試行する必要があるかを決定します。決定的な拒絶はリプレイ可能です:
{ "errorCode": "INSUFFICIENT_FUNDS", "message": "The account has insufficient funds for this payment." }
ただし、5 秒後にアカウントの残高が変動した場合、その拒絶をリプレイすること自体が API の意図とは異なる可能性があります。認証失敗は冪等性記録を作成すべきではありません。権限化失敗については注意が必要です:再試行時には必ずしも元の記録を作成したスコープ/プリンシパルに解決する必要があります。別の呼び出し者が別の呼び出し者の冪等性キーを使って操作が発生したかを発見することを許してはいけません。後の権限変更が既に完了した権限化された操作のリプレイをブロックするかは、製品とセキュリティの意思決定事項です。レート制限は通常、完了した冪等性の結果として記録すべきではありません。後での再試行が許可される場合があります。サイドエフェクト前のサーバーエラーは通常、再試行を許容できます。サイドエフェクト後のサーバーエラーは危険です。支払を作成しましたがレスポンスのシリアライズに失敗した場合、再試行で別の支払いを作成してはなりません。プロバイダーを呼び出しレスポンスを失った場合、再試行には回復ステートが必要であり、楽観性だけでは済まれません。
実用的な内部ステートセット:
IN_PROGRESSCOMPLETEDFAILED_REPLAYABLEFAILED_RETRYABLEUNKNOWN_REQUIRES_RECOVERYEXPIRED
全ての内部ステートを直接公開しないでください。しかし、内部ではすべての失敗を「完了」か「未完了」と假装するのは回復を難しくします。
1 つのトランザクションで操作をカバーできない場合
有用な区別はモノリシックかマイクロサービスかの違いではありません。「操作を永続的なトランザクションでカバーできるかどうか」です。1 つのデータベーストランザクションで冪等性行、支払行、アウトバックス記録をカバーできれば、ローカル部分は直感的です:
insert idempotency row insert payment row insert outbox event mark idempotency completed commit
パブリッシャーはアウトバックス配信を再試行できます。コンシューマーはイベント ID またはビジネス操作キーでデデュプリケーションを行います。ローカル書き込みパスは考えるのが容易です。サイドエフェクトが境界を超えると、反復可能な作業の可能性がある各境界に独自の重複抑制ルールが必要です。
Idempotency-Key: abc-123 を受け付けるアップストリーム API は、エッジで重複 HTTP 支払い作成リクエストを防げますが、自動的には重複した台帳エントリ、通知、プロバイダー呼び出し、読み取りモデルの更新を防ぐわけではありません。より良いモデルは、安定な操作識別子を維持することです:
- クライアント冪等性キー:
abc-123 - 支払い操作 ID:
payop_456 - 支払い ID:
pay_789 - 台帳エントリ ID:
ledger_payment_pay_789 - メールデデュプリケーションキー:
receipt_payment_pay_789 - プロバイダー冪等性キー:
provider_payment_pay_789
名前は何もありません。重要なのは、各サイドエフェクトに適した永続的な識別子を持っていることです。アクティブ・アクティブなマルチリージョン展開では、リージョン固有の冪等性テーブルは同じスコープ内の鍵を持つ再試行を保護するだけです。すべてのリクエストをホームリージョンにルーティングするか、冪等性記録のための強く一貫性を保つ共有ストアを使用するか、またはクロスリージョンレースを生き残るダウンストリームビジネス制約に頼るかを選択する必要があります。非同期複製だけでは、2 つのリージョンが他の書き込みを見る前に同じキーを受け入れることを許容します。高スループットの API では、冪等性テーブルがホットパスになる可能性があります。レスポンスボディは高価になります。片付けがトラフィックと競合する可能性があります。必要に応じてテナント、ハッシュ、または時間によってパーティショニングしてください。リプレイウィンドウを理解してください。重複の害悪が正当化される場合を除き、グローバルテーブルをボトルネックにしないでください。
汎用冪等性レイヤーを作らない場合
コストはヘッダーではなく、背後にある永続記憶と回復挙動です。重複が発生しても無害で明確な管理者アクションに対して、支払いグレードの冪等性レイヤーを構築しないでください。読み取り専用操作については、通常冪等性キーはノイズを加えます。重複する分析イベントのコストがほとんどなく、ダウンストリームで修正できる場合、重い冪等性テーブルは誤ったトレードオフです。一部の操作にはビジネスキーの方がランダムなキーよりも優れています:
unique(account_id, merchant_reference)
ビジネスルールが「アカウントあたりメーチャー参照ごとに 1 つの支払いしか許さない」というものであれば、この制約はクライアントが無意識に新しいランダムキーで再試行しても重複をキャッチします。ランダムな冪等性キーは、クライアントが同じキーを再試行のために再利用する場合のみ役立ちます。他の操作についてはリソースモデルを変更してください:
PUT /accounts/acc_1/settings/default-currency { "currency": "EUR" }
このリクエストを繰り返しても設定は EUR に留まります。サイドエフェクトについて考える必要がありますが、操作の形状自体が役立っています。クライアント生成キーは、クライアントが同一操作の再試行を識別できる場合に有用です。適切に生成されたランダムキーは通常十分ですが、タイムスタンプだけのキー、カウンター、機密データから派生したキーは適していません。キーを呼び出し元とオペレーションにスコープし(例:
tenant_id, operation_name, idempotency_key)、不良なクライアントが自分自身とだけ衝突するようにします。クライアントが各試行で新しいキーを生成する必要がある場合、ビジネスキーまたはサーバー生成の操作リソースを使用する必要があります。重複したサイドエフェクトによる害の度合い、再試行の可能性、事後的に重複を検出する難易度を評価して、必要な機械の量を決定してください。重複が資金移動や人間の通知、プロバイダー呼び出し、限られた在庫の消費、会計の腐敗に関与している場合は設計努力を惜しみません。無害で稀で片付けやすい場合は、小さい機構を使用します。
テストに値する失敗モード
以下のテストは、一打楽なパスの単体テスト十数個よりも望ましいです。同じキー、同じ正規化されたコマンド、完了済み:最初のリクエストで支払いを作成:
POST /payments Idempotency-Key: abc-123
返却:
201 Created で paymentId = pay_789。同じ正規化されたコマンドとキーを持つ 2 つ目のリクエストは、保存された結果または文書化された同等の結果を返します。pay_790 を作成しません。第 2 の PaymentCreated イベントを発行しません。
同じキー、異なる正規化されたコマンド:
{ "amount": "10.00", "currency": "EUR" }
対して:
{ "amount": "100.00", "currency": "EUR" }
同じキー。期待される挙動:安定した機械可読の冪等性衝突エラーで拒否する。ログに記録し、カウントする。
2 つの並行する同一リクエスト:同じキーとコマンドを持つ 2 つのリクエストを同時に開始する。期待される挙動:一方が実行権を握る。他方は
IN_PROGRESS を見て待機してリプレイするか、再試行後のレスポンスを返す。サイドエフェクトは一度実行される。このテストがユニーク制約や原子性挿入なしで通る場合、テスト自体に疑いを持つべきです。
ダウンストリーム成功後のタイムアウト:プロバイダー成功を模擬し、クライアントがレスポンスを受け取る前にクラッシュする。期待される挙動:再試行は新しい操作識別子でプロバイダーを呼ぶべきではない。ローカル完了状態を発見するか、プロバイダー冪等性状態を照会するか、回復モードに入るべきです。
キューからの重複メッセージ:
PaymentCreated(pay_789) を二回配信する。期待される挙動:1 つの台帳エントリ、1 つのメール通知、1 つのプロバイダー通知。最初の試行が途中で失敗した場合、再試行は完了した作業を複製せずに欠落した永続作業を完了させる必要があります。
有効期限切れまたは古くさい状態:冪等性記録の有効期限後に再試行する。記録が古くさい
IN_PROGRESS 状態で再試行する。レスポンススキーマ变更后に再試行する。別のリージョンからの再試行(展開がそれを許す場合)。これらは特異なケースではありません。ネットワークを介した再試行の通常の端縁です。
出荷前のチェックリスト
- 同じスコープ内のキーと異なる正規化されたコマンドを拒否する。
- スコープされたキーに対してユニーク制約または原子性挿入を使用する。
- 検証されたコマンドのハッシュ化を行い、生の JSON バイトのハッシュ化を行わない。
を API 可視挙動として扱う。IN_PROGRESS- 新鮮、古くさい、完了、再試行可能な失敗、リプレイ可能な失敗、および不明なステートを定義する。
- リプレイ契約を満たすのに十分なレスポンスデータを保存する。
- ダウンストリーム呼び出しも冪等性を持たせるか、整合性チェックを行う。
- イベントとキューが関与している場合、アウトバックス/インボックスパターンを使用する。
- メッセージを永続的なサイドエフェクトが存在する前に処理済みとマークしない。
- 有効期限ウィンドウを API 契約の一部として定義する。
- 必要に応じて機密レスポンスボディとメタデータを別々に保持する。
- 並行重複、ダウンストリーム成功後のタイムアウト、部分的な失敗、有効期限、スキーマ変更リプレイをテストする。
- 異なるボディの再利用、古くさい
、有効期限切れの再試行、不明な状態、リプレイレートを監視する。IN_PROGRESS
2 番目のリクエストは証明されるまで単純な繰り返しではない
冪等性の簡単なバージョンは、キーが以前に見られたことを記憶します。有用なバージョンは、キーの意味を記憶します。POST /payments の場合、それはスコープされた操作、正規化されたコマンド、実行状態、結果となるリソースまたはレスポンス、有効期限ウィンドウ、そして不確実性を重複した副作用に変えないのに十分な失敗状態を意味します。2 番目のリクエストは再試行かもしれません。同じキーに装着した別の操作かもしれません。最初のリクエストと競合しているかもしれません。プロバイダーが成功しましたがプロセスが失敗した後にも到着しているかもしれません。片付けジョブによって起こり全ての記憶が消された後にも到着しているかもしれません。サーバーはこのケースの一つであることを証明する必要があります。キーは保証ではありません。保証は、サーバーが最初の操作を正確に思い出してリプレイし、不一致を拒否するか、推測する代わりに回復できることです。