冪等性(べきとうせい)とは?APIリトライで二重課金を防ぐIdempotency-Key設計を実例で徹底解説

冪等性をf(f(x))=f(x)というAPI版の性質として整理し、RFC 9110・StripeのIdempotency-Key仕様・IETF draftを一次資料から解説。ACK喪失率1/5/10%下で二重課金件数を実測し、なしで最大984件・ありで0件を示す。

1. 冪等性とは何か

冪等性(idempotence) とは、ある操作を1回行っても複数回行っても、結果として得られる状態が変わらないという性質を指す。数学的には、関数 f

f(f(x)) = f(x)

を満たすとき、f は冪等であるという。抽象的に聞こえるが、身近な例で考えると分かりやすい。

  • 冪等な操作の例: 「電気をつける」というスイッチ操作。すでについている電気を何度「つける」にしても、部屋の明るさは変わらない。プログラミングでは x = 5 という代入も、何度実行しても x は5のままなので冪等である。
  • 冪等でない操作の例: 「電気のオン/オフをトグルする」操作。1回押すと状態が反転するので、2回押せば元に戻ってしまう――実行回数によって結果が変わる。プログラミングでは x += 1 がその典型で、実行するたびに x の値が変わり続ける。

この性質をAPI(特にWeb API)の文脈に持ち込むと、次のように言い換えられる。

あるAPIリクエストが冪等であるとは、そのリクエストをサーバーに対して1回送っても、通信の都合で誤って複数回送ってしまっても、サーバー側に生じる「意図した効果」が変わらないことを指す。

これは筆者の独自定義ではなく、後述するHTTPの正式な仕様書であるRFC 9110が採用している定義そのものであり、次節以降で議論の土台になる。重要なのは、冪等性が「レスポンスが毎回同じ」ことを要求しているわけではない点である。1回目は 201 Created で新規リソースのIDが返り、2回目(同一リクエストの再送)は 200 OK で既存リソースの情報が返ってきても、サーバー側に生じた意図した効果――例えば「その注文のレコードが1件だけ存在する」という状態――が変わらなければ、それは冪等とみなされる。

2. なぜ冪等性が必要なのか: リトライと重複配信

冪等性が実務で重要になる最大の理由は、ネットワークごしの通信はリトライを前提に設計せざるを得ないという現実にある。 指数バックオフとジッターの記事 でも触れたように、タイムアウトとリトライは信頼性の高い分散システムを組み上げるための基本の道具立てであり、その記事の最後でも「リトライは冪等性が前提になる。決済の作成のように副作用を伴うAPIをそのままリトライすると二重処理が起こりうる」と述べた。本記事はその一文を掘り下げる内容にあたる。

リトライがなぜ「重複」を生むのかを図式化すると、次のようになる。

  1. クライアントが決済APIにリクエストを送る。
  2. サーバーは正常に処理を完了し(課金は成功している)、その結果(ACK)をクライアントに返そうとする。
  3. ところが、返り道のネットワークで応答が失われる、あるいはクライアント側がタイムアウトする。
  4. クライアントは「リクエストが届いていないかもしれない」と判断し、同じ内容のリクエストを再送する。
  5. サーバーはこれを新しいリクエストとして受け取り、再び決済処理を実行してしまう。

ここでの本質的な問題は、クライアントには「サーバーが処理を完了したかどうか」を確実に知る手段がないということである。これは通信理論における古典的な思考実験である「二将軍問題(Two Generals’ Problem)」が示す構造そのものであり、同問題は「信頼できない通信路上で、有限回のメッセージ交換によって双方が確実に合意したと保証することは原理的に不可能である」ことを示している1。ACK自体が失われうる以上、クライアントは「サーバーは処理済みだがACKが届かなかっただけ」なのか「サーバーはそもそも受信していない」のかを外部から区別できない。

これはしばしば 「exactly-once配信(正確に1回だけの配信)は不可能である」 という言い方で語られる。実際、AWSのマネージドキューサービスであるAmazon SQSの標準キューも「at-least-once配信(少なくとも1回は配信される)を保証するが、分散アーキテクチャの特性上、メッセージが複数回配信されたり順序が入れ替わったりすることがある」と公式ドキュメントで明言しており、その利用シナリオとして「メッセージが複数回届く、あるいは順序が乱れても問題ないアプリケーション」を前提にしている2

つまり業界の実務的な結論は、配信そのものをexactly-onceにしようとするのではなく、**「配信はat-least-once(重複ありうる)のまま許容し、受信側の処理を冪等にすることで、結果としてexactly-onceと同じ効果(effectively-once)を得る」**という設計に落ち着く。本記事で扱うIdempotency-Keyパターンは、その最も具体的な実装手段の1つである。

3. HTTPメソッドの冪等性: RFC 9110の定義

HTTPのメソッドがどこまで冪等であるべきかは、HTTPセマンティクスを定めるRFC 9110で明確に規定されている。同RFC §9.2.2は次のように定義している。

A request method is considered “idempotent” if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.

日本語訳すると「あるリクエストメソッドが『冪等』とみなされるのは、そのメソッドを使った複数の同一リクエストがサーバーに与える意図された効果が、単一のリクエストの場合の効果と同じであるときである」となる3。そのうえで、RFC 9110は本仕様で定義するメソッドのうち「PUT、DELETE、および安全なメソッド(safe methods)が冪等である」と明記している3。「安全なメソッド」はRFC 9110 §9.2.1でGET・HEAD・OPTIONS・TRACEの4つと定義されている。これらを表にまとめると次の通りである。

メソッド安全(Safe)冪等(Idempotent)備考
GETRFC 9110 §9.2.1で安全と定義、§9.2.2で冪等
HEADGETと同様
OPTIONSGETと同様
TRACEGETと同様
PUT×リソースを丸ごと置き換えるため、同じ内容を何度送っても結果は同一
DELETE×2回目以降は「既に存在しない」だけで、状態としては変わらない
POST××新規リソース作成など、実行のたびに効果が変わりうる
PATCH××RFC 5789で定義。差分適用の性質上、冪等性は保証されない(実装依存)
CONNECT××トンネル確立のためのメソッドで冪等性は定義されない

RFC 9110は同じ§9.2.2で、冪等なメソッドが特別視される理由も説明している。「冪等なメソッドが区別される理由は、クライアントがサーバーの応答を読み取る前に通信障害が起きた場合、リクエストを自動的に再送できるからである」としたうえで、「非冪等なメソッドについては、リクエストのセマンティクスが実際には冪等であると知る手段がない限り、クライアントは自動的にリトライすべきではない(SHOULD NOT)」と釘を刺している3。つまりPOSTのような非冪等メソッドを安全に自動リトライするには、メソッドの種類とは別に「このリクエストは冪等である」という保証をアプリケーション層で別途用意する必要がある。それがまさに次節で扱うIdempotency-Keyパターンである。なお表中のPATCHはRFC 9110ではなくRFC 5789で定義されており、冪等とは明言されていないため、実装によって冪等になるよう設計することは可能でも、それは実装者の責任にとどまる。

4. Idempotency-Keyパターン: StripeとIETF draftの設計

POSTのような非冪等メソッドを安全にリトライ可能にする、実務で広く使われている解決策が Idempotency-Key パターンである。決済プラットフォームのStripeが最も早くから本番運用しており、事実上の業界標準的な実装として参照されることが多い。Stripeの公式ドキュメントによれば、その仕組みは次のように整理できる4

キーの生成はクライアントの責任である。Stripeは「V4 UUID、あるいはそれに類する十分なエントロピーを持つランダムな文字列」を推奨しており(最大長255文字、メールアドレスなど機微な情報は避ける)、クライアントはリクエストヘッダー Idempotency-Key にこれを載せて送信する。サーバー側は初回のリクエストを通常通り処理し、そのパラメータのフィンガープリントとレスポンス(ステータスコード・ボディ)をキーに紐づけて保存する。この保存は成功・失敗を問わず行われ、「同じキーでの後続リクエストは、500エラーを含めて同じ結果を返す」とドキュメントに明記されている。同じキーで2回目以降のリクエストが届いた場合、パラメータが1回目と一致していれば決済処理を再実行せず保存済みのレスポンスをそのまま返し、パラメータが異なる場合はエラーを返してキーの誤用を防ぐ。1回目がまだ処理中(in-flight)のときに同じキーで2回目が届いた場合は結果を保存せず、クライアントは安全に再試行できるとされている。保存期間は少なくとも24時間で、期限切れのキーが再利用されると新規リクエストとして扱われる。なお、GET/DELETEのように元々HTTPメソッドとして冪等なリクエストにはIdempotency-Keyを送る必要がない(この点はFAQで詳述する)。

Stripe以外にも、Idempotency-KeyをHTTPの標準ヘッダーとして定義しようとする取り組みが進んでいる。IETFのhttpapiワーキンググループが検討している draft-ietf-httpapi-idempotency-key-header はその代表例である5。本記事執筆時点(2026年7月)で参照できる最新版(バージョン07、2025年10月付)はドラフトの有効期限が切れた(Expired)状態だが、標準化の方向性を示す一次資料として参考になる。内容はStripeの実装と概ね一致するが、**処理中(in-flight)の同一キーに別のリクエストが届いた場合は409 Conflictを返すべき(SHOULD)**としている点がより具体的である。またキーの保存期間(有効期限)についてはリソース提供者(サーバー側)が独自にポリシーを定めて公開すべき(SHOULD)とするのみで、IETFのレベルでも「絶対的な正解の期間」は定義されていない。

この2つの一次資料――実運用で長年鍛えられたStripeの実装と、それを標準化しようとするIETF draft――を踏まえると、Idempotency-Keyパターンの本質は「サーバー側に、リクエストの内容とその結果を紐づけて記憶する小さなキャッシュ層を1つ追加すること」だと言える。次節では、この仕組みがある場合とない場合で、実際に二重課金がどの程度発生するのかを定量的に検証する。

5. 実験: at-least-once配信下での二重課金シミュレーション

5.1 モデル設定

第2節で述べた「at-least-once配信+ACK喪失」の状況を、簡易な離散シミュレーションで再現する。モデルは次の通りである。

  • クライアントが決済リクエストを送信すると、サーバーは必ずそのリクエストを受信して処理する(ネットワークの往路は失われない、と仮定する)。
  • 処理結果を返すACK(レスポンス)は、確率 p で失われる。ACKが届かなかったクライアントは「サーバーが処理できたか分からない」ため、同一内容のリクエストを再送する。
  • 再送したリクエストのACKもまた確率 p で失われうるので、ACKが届くまで(理論上は際限なく)リトライが繰り返されうる。シミュレーション上は安全側に最大20回まで許容しているが、p ≤ 10% の範囲では20回連続でACKが失われる確率は天文学的に小さく(0.1^20)、結果にはほぼ無関係な安全マージンにすぎない。
  • 決済リクエストは10,000件、乱数シードは42に固定し、ACK喪失率 p を1%・5%・10%の3通りで比較する。

比較する受信側の実装は2種類である。

  • (a) 冪等性なし: サーバーは受信したリクエストを毎回無条件に新規の課金として処理する。ACK喪失によるリトライは、そのまま二重(以上)の課金になる。
  • (b) Idempotency-Keyあり: サーバーはリクエストに含まれるキーで重複を検出する。同一キーの2回目以降の受信は、保存済みのレスポンスを返すだけで新規の課金は発生しない。

シミュレーションの核となる部分(venvにnumpyを導入したPython, scratchpad/idempotency/sim_idempotency.py)は次の通りである。

def simulate_attempts(n_requests, p_ack_loss, max_retries, seed):
    """各決済リクエストについて、サーバーに実際に届いた試行(配信)回数を返す。"""
    rng = np.random.default_rng(seed)
    attempts = np.ones(n_requests, dtype=np.int64)
    still_retrying = np.ones(n_requests, dtype=bool)
    for _ in range(max_retries):
        draws = rng.random(n_requests)
        lost = still_retrying & (draws < p_ack_loss)
        attempts[lost] += 1
        still_retrying = lost  # ACKが届いた(または打ち切り)ものはもうリトライしない
        if not still_retrying.any():
            break
    return attempts

def summarize(attempts):
    # (a) 冪等性なし: attempts>=2 のリクエストは二重(以上)課金される
    double_charge_incidents = int(np.sum(attempts >= 2))
    total_extra_charges = int(np.sum(attempts - 1))
    # (b) Idempotency-Key: 二重課金は常に0。重複受信数(=防いだ数)は上と同じ
    return double_charge_incidents, total_extra_charges

各決済リクエストについて「ACKが連続して何回失われたか」を幾何分布的にカウントし、attempts >= 2 であれば少なくとも1回のリトライ(=サーバーへの重複配信)が発生したことになる。冪等性なしの実装ではこれがそのまま二重課金の件数になり、Idempotency-Keyありの実装では同じ回数だけ「重複を防いだ」件数として計上される。

5.2 実測結果

N=10,000件、シード42固定での実測結果は次の通りである。

ACK喪失率 p冪等性なし: 二重課金件数Idempotency-Key: 二重課金件数防いだ重複受信件数
1%101件(1.01%)0件101件
5%508件(5.08%)0件532件
10%984件(9.84%)0件1,082件

Idempotency-Keyによる重複排除フローの図。初回リクエストはキーストア未登録のため決済処理を実行して結果をキーに保存し、ACK喪失で再送された重複リクエストはキーストアに登録済みのエントリを発見して、完了済みなら保存済みレスポンスを返却、処理中なら409 Conflictを返すことで、どちらの経路でも二重課金を防ぐ

ACK喪失率別の二重課金件数を示す棒グラフ。冪等性なしの実装ではp=1%で101件、p=5%で508件、p=10%で984件と喪失率にほぼ比例して二重課金が増加するのに対し、Idempotency-Keyによる重複排除を行った実装ではいずれの喪失率でも二重課金は0件であり、防いだ重複受信件数はそれぞれ101件・532件・1,082件だった

結果から分かるのは、まず冪等性なしの実装では、ACK喪失率にほぼ比例して二重課金が発生するということである。1万件中の二重課金件数は、p=1%で101件、p=5%で508件、p=10%で984件と、喪失率がそのまま二重課金の発生率(1.01%・5.08%・9.84%)として現れている。これは決してレアケースではない――移動体通信網やモバイルアプリのようにネットワークが不安定になりやすい環境ではp=5〜10%程度のACK喪失は十分現実的であり、数十件に1件のオーダーで二重課金が発生しうることを意味する。

一方、Idempotency-Keyによる重複排除を行った実装では、3つの喪失率いずれでも二重課金は完全に0件だった。興味深いのは「防いだ重複受信件数」が冪等性なし実装での「余分な課金イベント総数」と一致している点で(p=5%で532件、p=10%で1,082件はattempts=3以上の多重リトライも含むため単純な二重課金件数よりわずかに大きい)、Idempotency-Keyが単発の重複だけでなくリトライが何回発生しても律儀に全て弾き続けていることが確認できた。

6. 実装の勘所

シミュレーションが示す効果を実際のシステムで得るために、実装上気をつけるべき点を整理する。

6.1 キーの保存先とTTL

Idempotency-Keyとレスポンスの組は、決済処理そのものと同じデータベースのテーブルに保存するのが最も安全である。Redisのような別ストアにキーだけを保存する構成も高速だが、「決済は成功したのにキーの保存だけ失敗した」という不整合が起きうるため、決済トランザクションとキーの保存を同一のDBトランザクションでコミットする設計が望ましい。TTLに絶対的な正解値はないが(IETF draftも同旨)、クライアント側のリトライ猶予期間より十分長く取るのが基本方針であり、Stripeの「少なくとも24時間」はその現実的な下限の目安になる。

6.2 処理中の同一キー到着(排他制御)

シミュレーションでは単純化のため考慮していないが、実運用では「1回目の処理がまだ完了していないうちに、同じキーで2回目のリクエストが届く」ケースへの対応が欠かせない。安易に「キーがまだ無いから新規リクエストとして処理する」としてしまうと2つの処理が並行して走り、結局二重課金を防げない。IETF draftが409 Conflictを推奨しているように、キーに対して「処理中」の状態を明示的にロックし、その間に届いた同一キーのリクエストには「後で再試行してほしい」旨のエラーを返す設計が必要になる。RDBであれば、キー列への行ロック(SELECT ... FOR UPDATE)と、後述する一意制約のエラーハンドリングを組み合わせれば実現できる。

6.3 レスポンスの保存

Stripeが「同じキーへの後続リクエストは、500エラーを含めて同じ結果を返す」と明記している点は重要な設計判断である。成功した結果だけでなく失敗した結果も保存してそのまま再生することで、クライアントが前回失敗を踏まえて違う挙動を期待するような、予測不能な状態を避けられる。保存すべき情報は最低限、HTTPステータスコードとレスポンスボディである。

6.4 DB一意制約という最も単純な実装

Idempotency-Keyの実装は複雑に見えるかもしれないが、最も単純な形であれば、決済テーブルの idempotency_key 列に**一意制約(UNIQUE制約)**を張るだけで、基本的な重複排除を実現できる。

CREATE TABLE charges (
    id BIGSERIAL PRIMARY KEY,
    idempotency_key VARCHAR(255) NOT NULL,
    amount INTEGER NOT NULL,
    status VARCHAR(20) NOT NULL,
    response_body JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (idempotency_key)
);

このテーブルに対して INSERT を試み、一意制約違反(23505など)が返ってきたら「既に同じキーで処理済みである」と判断し、既存の行を検索して保存済みの response_body をそのまま返す――という実装は、専用のキャッシュ層やロック機構を新設せずに実現できる、最も手を出しやすい第一歩である。ただし6.2で述べた「処理中の同一キー到着」は一意制約だけでは解決しないため、行ロックと組み合わせる必要がある点には注意したい。

7. 冪等にできない・しにくい操作の扱い

すべての操作を冪等にできるわけではない。冪等性の適用が難しい代表的なケースを整理する。

  • 連番の採番: 請求書番号や伝票番号のように、システム全体で重複してはならない連番を発行する処理は、単純には「実行するたびに次の番号を払い出す」という非冪等な性質を持つ。この操作を安全にリトライ可能にするには、採番処理そのものにもIdempotency-Keyを適用し、「同じキーで再リクエストされたら新しい番号を発行せず前回の番号を返す」というテーブル設計が必要になる。決済処理だけを冪等にして内部の採番ロジックを素通りさせると、決済は重複しなくても採番だけが重複するという中途半端な状態になりかねない。
  • 外部サービスへの副作用: 自社のAPIを冪等にできても、その中で呼び出す先のサードパーティAPI(与信照会、配送業者への出荷指示など)がIdempotency-Keyに対応していなければ、そこで重複が発生する余地が残る。対応していない場合は、呼び出し済みかどうかを自社側で記録してから呼ぶ工夫が必要になる。
  • メール・SMS送信などの通知: 決済完了メールのように直接届く通知は、内部の課金処理が冪等化されていても、送信ロジックが独立して動いていると重複送信が起こりうる。典型的な対策は、決済処理のコミットとメール送信ジョブの登録を同一トランザクション内で行うトランザクショナルアウトボックス(transactional outbox)パターンであり、送信ワーカー側でも送信済みメッセージIDを記録して再送を防ぐ、二段構えの冪等化が現実的な落としどころになる。

これらに共通するのは、冪等性はAPIの入り口1箇所で担保すれば済む話ではなく、その先で連鎖する副作用の1つ1つについて、どこまで冪等にするかを設計判断として決めていく必要があるという点である。

8. まとめ

本記事では、冪等性を f(f(x)) = f(x) という数学的性質のAPI版として定義したうえで、RFC 9110が定めるHTTPメソッドごとの冪等性、StripeとIETF draftが定めるIdempotency-Keyパターンの具体的な仕組みを一次資料から整理した。そのうえで、at-least-once配信+ACK喪失という現実的な状況をシミュレーションし、次の点を定量的に確認した。

  • 冪等性のない実装では、ACK喪失率にほぼ比例して二重課金が発生する(実測: p=1%で101件、p=5%で508件、p=10%で984件/1万件)。
  • Idempotency-Keyによる重複排除を行えば、同じ状況下でも二重課金は完全に0件になる。
  • Idempotency-Keyは単発の重複だけでなく、多重リトライによる重複(p=10%で1,082件)もすべて防ぐ。

「exactly-once配信は原理的に不可能」という分散システムの基本的な制約を前提にする限り、リトライを安全に行うための冪等性設計は避けて通れない土台であり、その最も実践的な実装手段がIdempotency-Keyパターンであることが、一次資料と実測の両面から裏付けられた。

FAQ

Q1. GETリクエストにもIdempotency-Keyは必要か? A. 不要である。RFC 9110の定義上、GETはもともと安全(safe)かつ冪等なメソッドであり、Stripeのドキュメントでも「GET/DELETEにはIdempotency-Keyを送る必要がない」と明記されている。Idempotency-Keyが必要になるのは、POSTのようにメソッド自体が冪等性を保証しない場合に限られる。

Q2. Idempotency-Keyは誰が生成するのか? A. クライアント側が生成する。Stripeは「V4 UUID、あるいは類似の十分なエントロピーを持つランダムな文字列」を推奨しており、サーバー側が代わりにキーを発行するものではない。リクエストの意味的な単位(例: 1回のユーザー操作)ごとにクライアントが一意なキーを払い出し、リトライ時にはそのキーを使い回すことが前提になる。

Q3. キーの有効期限(TTL)はどのくらいに設定すべきか? A. 業界標準として合意された絶対的な値は存在しない。IETF draftも「サーバー側がポリシーを定めて公開すべき」としているのみである。Stripeは実運用として「少なくとも24時間」を採用しており、クライアント側のリトライ猶予期間より十分長く確保するという方針が現実的な出発点になる。

Q4. DBの一意制約を張るだけでは不十分か? A. 二重「作成」を防ぐという最低限の目的には有効な第一歩だが、それだけでは不十分な点が2つある。1つは、一意制約違反(エラー)が発生したときに、それをそのままクライアントにエラーとして返すのではなく、保存済みのレスポンスを検索して返す処理を追加で実装する必要があること。もう1つは、最初の処理がコミットされる前に同じキーで2回目のリクエストが届く「処理中の競合」を、一意制約だけでは検出できないため、行ロックや排他制御と組み合わせる必要があることである。

なお、Idempotency-Keyに使うUUIDの生成は UUID Generator(DevToolBox) で試せる。

関連記事

他の分野の定番書は エンジニアにおすすめの技術書10選 にまとめている。

参考文献


  1. Two Generals’ Problem. Wikipedia. https://en.wikipedia.org/wiki/Two_Generals%27_Problem  ↩︎

  2. Amazon Web Services. Amazon SQS standard queues. AWS SQS Developer Guide. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html  ↩︎

  3. Fielding, R., Ed., Nottingham, M., Ed., and J. Reschke, Ed. (2022). HTTP Semantics. RFC 9110, Section 9.2.1 “Safe Methods” and Section 9.2.2 “Idempotent Methods”. https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2  ↩︎ ↩︎ ↩︎

  4. Stripe. Idempotent Requests. Stripe API Reference. https://docs.stripe.com/api/idempotent_requests  ↩︎

  5. Jena, J. and S. Dalal. (2025). The Idempotency-Key HTTP Header Field. IETF Internet-Draft, draft-ietf-httpapi-idempotency-key-header-07 (Expired). https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07  ↩︎