Skip to content
Published on

冪等性とリトライ:信頼できるAPIの作り方

Authors

はじめに — 応答が届かなかった決済

一つの場面から始めましょう。ユーザーが決済ボタンを押します。あなたのサーバーはリクエストを受け取り、カード会社に請求し、注文を作成し、応答を返します。ところがその応答がユーザーに届く直前にネットワークが切れます。ユーザーの画面には「リクエスト失敗」と出ます。ユーザーは当然、もう一度ボタンを押します。

ここで危険な問いが生まれます。決済は二度行われるのでしょうか。

この問いこそがこの記事のすべてです。分散システムではネットワークは信頼できず、信頼できないネットワークの上で私たちは必ずリトライし、リトライは必然的に「すでに処理されたのに応答だけ失われた」状況を生みます。この状況でもシステムを正しく動かす核心の概念が**冪等性(idempotency)**です。この記事は冪等性とは何かから始め、安全なメソッドとそうでないメソッド、POSTを安全にする冪等キー、「ちょうど一度」というよくある誤解、そして正しいリトライの仕方までを押さえます。

冪等性とは何か

冪等性は数学から来た言葉です。ある操作を複数回適用しても一度適用したのと結果が同じなら、その操作は冪等だといいます。絶対値関数を思い浮かべると簡単です。ある数に絶対値を一度取っても十回取っても、結果は同じです。

APIの文脈では冪等性はこう翻訳されます。同じリクエストを一度送っても複数回送っても、サーバーの最終状態が同じなら、そのリクエストは冪等です。ここで重要なのは「応答が毎回まったく同じであるべき」ではなく、「副作用(side effect)が一度だけ起きるべき」という点です。

例を挙げましょう。

  • 「ユーザー42のメールをa@b.com設定せよ」は冪等です。何度送っても、結果はメールがa@b.comである状態が一つだけです。
  • 「ユーザー42の残高を100増やせ」は冪等ではありません。二度送れば残高は200増えます。

この区別が決済の例の核心です。「決済せよ」は本質的に増加操作に近いので、何の仕掛けもなければ冪等ではありません。リトライがそのまま二重請求につながります。私たちの目標は、この非冪等の操作を冪等にすることです。

安全なメソッドと冪等なメソッド

HTTPはこの概念をメソッドのレベルですでに定めています。二つの性質を区別しなければなりません。

安全(safe)なメソッドはサーバーの状態を変えない、つまり読み取り専用のメソッドです。GET、HEAD、OPTIONSがここに属します。安全なメソッドはいくら呼んでもデータを変えないので、心置きなくリトライできます。

冪等(idempotent)なメソッドは複数回呼んでもサーバーの状態が一度呼んだのと同じメソッドです。安全なメソッドは当然冪等ですが、冪等だからといって安全とは限りません。

HTTP仕様が定める各メソッドの性質を表にまとめると次のとおりです。

メソッド安全冪等典型的な意味
GETはいはいリソースの取得
HEADはいはいヘッダーのみ取得
OPTIONSはいはい通信オプションの取得
PUTいいえはいリソースを丸ごと置換(設定)
DELETEいいえはいリソースの削除
POSTいいえいいえ新しいリソースの作成など
PATCHいいえ場合による部分修正

ここでPUTとDELETEがなぜ冪等なのかを見ておく価値があります。PUTは「このリソースをこの値にせよ」という設定操作です。同じPUTを何度送ってもリソースはその値一つに保たれます。DELETEも同じです。すでに消えたものをまた消せと言っても最終状態は「なし」で同じです(応答コードは404などで異なるかもしれませんが、状態は同じです。冪等性は状態に関するものであって応答コードに関するものではありません)。

問題はいつもPOSTです。POSTは「新しいものを作れ」という意味でよく使われ、これは本質的に冪等ではありません。リトライのたびに新しい注文、新しい決済、新しいコメントが生まれます。HTTPステータスコードがそれぞれ何を意味するのか迷ったら、このサイトのHTTPステータスコードツールでコードごとの意味を整理できます。

POSTのための冪等キー

POSTを冪等にする標準的な方法が**冪等キー(idempotency key)**です。考え方は単純です。クライアントがリクエストごとに一意のキー(たいていUUID)を一つ作り、ヘッダーに載せて送ります。サーバーはこのキーを覚えておき、同じキーでリクエストがまた来たら新たに処理せず、保存しておいた最初の応答をそのまま返します。

  1回目のリクエスト
  クライアント --(Idempotency-Key: abc-123, 決済要求)--> サーバー
                                                          |
                                                     初めて見るキー
                                                     -> 実際に決済処理
                                                     -> 結果をabc-123に保存
  クライアント <----------(200 OK, 注文 #500)------------ サーバー

  (応答が失われ、クライアントがリトライ)

  2回目のリクエスト (同じキー)
  クライアント --(Idempotency-Key: abc-123, 決済要求)--> サーバー
                                                          |
                                                     既に見たキー
                                                     -> 決済を再実行しない
                                                     -> 保存した結果を返す
  クライアント <----------(200 OK, 注文 #500)------------ サーバー

核心は二度目のリクエストで実際の決済が再び起きないことです。ユーザーは二度押しましたが、請求は一度だけです。Stripeや PayPalのような決済APIがまさにこの方式を使います。

実装するときに気をつける細部がいくつかあります。

  • キーの保存と失効。 キーと応答の対応をどこか(たとえばRedisやDB)に保存しなければなりません。永遠に保管はできないので、普通は24時間などの失効を設けます。
  • 並行性の処理。 同じキーのリクエスト二つがほぼ同時に届くと問題が起きます。最初のリクエストがまだ処理中なのに二つ目が入ると、両方が「初めて見るキー」と勘違いして二重処理しかねません。ですからキーを受け取った瞬間にロックをかけるか、データベースの一意制約で「このキーはすでに進行中」であることを原子的に印さねばなりません。
  • キーとリクエスト内容の一致検証。 同じキーなのにリクエスト本文が違うなら、それはクライアントの誤りか攻撃です。サーバーはこれを検知して拒否するほうが安全です。
  • 応答の保存。 成功応答だけでなく、失敗の性質も慎重に扱うべきです。一時的な失敗(たとえばDBタイムアウト)はリトライが本当に再試行されるべきですが、確定的な失敗(たとえば残高不足)はキャッシュして同じ答えを返すほうがよいです。

「ちょうど一度」という神話

ここで分散システムのもっともよくある誤解を正面から扱わねばなりません。多くの人が「ちょうど一度(exactly-once)配信」を目標にしたり、あるシステムがそれを提供すると信じたりします。結論から言えば、ネットワークを越える配信で純粋な意味の「ちょうど一度」はほぼ不可能です。

なぜでしょうか。メッセージを送る側には二つの戦略しかありません。

  • 最大一度(at-most-once):送って忘れる。確認応答を待たない。失われることはあるが重複はない。
  • 少なくとも一度(at-least-once):確認応答を受け取るまでリトライする。失われはないが重複が起こりうる。

問題の根源はさきほどの決済の場面と同じです。送った側が「確認応答を受け取れなかったとき」、それが「メッセージが届かなかった」のか「メッセージは届いたが確認応答だけ失われた」のかを区別する方法がありません。ですから失われを防ぐにはリトライせねばならず(少なくとも一度)、リトライは重複を生みます。

では私たちがよく「ちょうど一度のように動く」と言うシステムは何をしているのでしょうか。答えはこれです。

ちょうど一度 = 少なくとも一度の配信 + 受信側の重複排除(dedup)

つまり配信そのものは依然として「少なくとも一度」です。そのかわり受け取る側がすでに処理したメッセージを識別し、二度目からは無視します。この重複排除を可能にするのがまさに、さきほど見た冪等性です。メッセージに一意のIDを付け、処理したIDを記録しておけば、同じメッセージがまた来ても結果は変わりません。

核心の教訓はこれです。「ちょうど一度」をインフラが魔法のように保証してくれるのを待たず、あなたのコンシューマーを冪等にしてください。 そうすれば配信が何度になろうと結果は一度処理したのと同じになります。この原理はAPIだけでなくメッセージキュー全般に当てはまります。キューシステムがこの「少なくとも一度」と重複をどう扱うのか目で見たいなら、このサイトのメッセージキュー・プレイグラウンドで各方式の配信保証を可視化できます。

正しいリトライの仕方 — 指数バックオフ

さてリトライそのものをうまくやる方法に移ります。「失敗したらまた送る」は単純に見えますが、素朴に実装するとかえってシステムを崩壊させます。

もっとも悪い方法は即座に、固定間隔で、無限にリトライすることです。サーバーが一瞬過負荷で遅くなったとき、すべてのクライアントが失敗を検知してすぐにまた、さらにまた叩けば、かろうじて持ちこたえていたサーバーは完全に倒れます。リトライが障害を治すどころか悪化させます。

最初の改善は**指数バックオフ(exponential backoff)**です。リトライ間隔を毎回二倍に増やします。1秒、2秒、4秒、8秒……こうすれば失敗が続くほどリトライの圧力が指数的に減り、あえいでいるサーバーに息をつく間を与えます。

  試行1 失敗 --> 1秒待つ
  試行2 失敗 --> 2秒待つ
  試行3 失敗 --> 4秒待つ
  試行4 失敗 --> 8秒待つ
  ...上限(たとえば30秒)で止め、最大リトライ回数に達したら諦める

ここに必ず二つを加えねばなりません。

  • 上限(cap):間隔が無限に大きくならないよう最大待ち時間を設けます(たとえば30秒)。
  • 最大リトライ回数と諦め:永遠にリトライしてはいけません。一定回数のあとは諦め、失敗をデッドレターキュー(dead-letter queue)へ送るかユーザーに知らせます。

そして、どんな失敗でもリトライしてよいわけではありません。リトライが意味を持つのは一時的(transient)なエラーだけです。ネットワークタイムアウト、503 Service Unavailable、429 Too Many Requestsのようなものは再試行すれば成功しうります。一方、400 Bad Request、401 Unauthorized、404 Not Foundのような確定的なエラーは何度送り直しても結果が同じなので、リトライは無駄です。

サンダリングハード — なぜジッターが必要か

指数バックオフだけでは不十分です。微妙ですが致命的な問題が一つ残っています。それが**サンダリングハード(thundering herd、殺到する群れ)**です。

状況を描いてみましょう。あるサーバーが一瞬ダウンします。その瞬間、1万個のクライアントが同時に失敗を検知します。全員が同じ指数バックオフの規則に従います。1秒後にリトライ、失敗すれば2秒後、その次は4秒後。問題は全員がまったく同じ瞬間にリトライすることです。サーバーがまさに回復しようとする刹那に1万個のリクエストが同時に押し寄せ、サーバーは再び倒れます。そしてこの波は2秒、4秒、8秒後にもまったく同じく繰り返されます。同期したリトライがサーバーを周期的に打ちのめすのです。

解決策はジッター(jitter)、つまり無作為性を加えることです。各クライアントが計算した待ち時間にわずかな無作為値を混ぜると、リトライの時点が時間軸に均等に散らばります。1万個のリクエストが一点に集まる代わりに広く散り、サーバーが回復する余裕を得ます。

  ジッターなし: 全員が同じ瞬間にリトライ
     |||||||||                    |||||||||
  ---+---------+---------+------  (サーバーが波に打たれ倒れ続ける)

  ジッターあり: リトライが散らばる
     | | ||  |  | ||   |  |  |
  ---+---------+---------+------  (負荷が均等に広がる)

ジッターの入れ方にはいくつかあります。もっとも広く推奨されるのは**フルジッター(full jitter)**で、「0から計算されたバックオフ値のあいだの無作為な時間」を待つ方式です。擬似コードで見るとこうです。

import random

def backoff_with_jitter(attempt, base=1.0, cap=30.0):
    # 指数的に大きくするが上限を超えない
    exp = min(cap, base * (2 ** attempt))
    # 0 ~ exp のあいだの無作為 (フルジッター)
    return random.uniform(0, exp)

# 例: 失敗した試行番号ごとに待ち時間が変わる
for attempt in range(5):
    wait = backoff_with_jitter(attempt)
    print(f"attempt {attempt}: wait {wait:.2f}s")

この単純な無作為化が大規模システムの安定性に与える効果は驚くほど大きいものです。AWS Architecture Blogの有名な記事がこの「指数バックオフ + ジッター」の組み合わせを標準の処方として示して以来、事実上すべての信頼できるクライアントの既定パターンになりました。

サーバー側でやること — リトライに耐える設計

ここまでは主にクライアントの視点でした。信頼できるAPIを作るには、サーバーもリトライに耐えるよう設計されねばなりません。

すべての書き込みエンドポイントを冪等に。 さきほど見た冪等キーを決済だけでなく、状態を変える重要なPOST全般に適用することを検討してください。そうすればクライアントが安心してリトライできます。

Retry-Afterでリトライの時点を知らせる。 サーバーが過負荷だったりリクエストを制限(429)したりするとき、Retry-Afterヘッダーで「いつまた来い」を知らせられます。気の利いたクライアントはこの信号を尊重し、不要な早すぎるリトライを控えます。

レート制限(rate limiting)と負荷遮断(load shedding)。 リトライの嵐が押し寄せてもサーバーが自らを守るには、さばける分だけ受け、残りを素早く429で断るほうがよいです。すべてのリクエストを抱えて遅く処理し全員で死ぬより、一部を素早く断り残りを生かすほうが全体の可用性に有益です。

サーキットブレーカー(circuit breaker)。 依存する下流サービスが失敗し続けるなら、リクエストのたびにそのサービスを叩いて状況を悪化させる代わりに、しばらく「遮断」状態に切り替えて素早く失敗を返します。一定時間ののち慎重に再試行し、回復したかを確かめます。これは障害がシステム全体に広がるのを防ぐ仕掛けです。

よくある落とし穴

最後に、実務でよく引っかかる点を圧縮します。

  • 非冪等の操作を何の保護もなくリトライ — 二重決済、重複注文の典型的な原因です。冪等キーで包んでください。
  • すべてのエラーをリトライ — 400、401、404のような確定的なエラーはリトライしても無駄で、資源だけ浪費します。リトライすべきエラーを区別してください。
  • ジッターのない固定バックオフ — サンダリングハードを招きます。無作為性を必ず入れてください。
  • 無限リトライ — 上限と最大回数なしにリトライすると、失敗したリクエストがシステムに永遠に積み上がります。諦める地点とデッドレターキューを設けてください。
  • 冪等キーの並行性を無視 — ほぼ同時に届いた同じキーを原子的に扱わないと、二重処理が抜けます。
  • 「ちょうど一度」への盲信 — インフラがそれを保証してくれると期待せず、コンシューマーを冪等にして自ら保証してください。

おわりに

信頼できるAPIの秘訣は「失敗しないこと」ではありません。ネットワークはいつか必ず失敗し、そうすれば私たちは必ずリトライします。本当の秘訣はリトライが起きても正しい結果が出るようにすることです。その中心に冪等性があります。書き込み操作を冪等にすれば、あの厄介な「すでに処理されたのに応答だけ失われた」状況は、もはや惨事ではなく、ただもう一度届いた無害なリクエストになります。

そこへ指数バックオフとジッターでリトライのリズムを御し、サーバー側でレート制限とサーキットブレーカーで嵐に耐えれば、あなたのシステムは不安定なネットワークの上でも揺らがず立ち続けます。「ちょうど一度」という神話を追うのではなく、「少なくとも一度 + 冪等な処理」という堅固な現実を選ぶこと、それが信頼できるAPIの本当の土台です。

参考資料