リトライと冪等性 — 再送してよい失敗をどう見分けるか
「失敗したらリトライする」は正しい方針ですが、それだけでは足りません。再送してはいけない失敗があり、再送の回数と間隔を誤ると障害を悪化させます。
判断に必要なのは 2 つの問いです。この失敗は再送してよいか。よいなら何回・どの間隔で送るか。
この章で学ぶこと
- 失敗を確定・不確定・過負荷の 3 つに分け、再送してよいものを見分ける
- 再送が安全になる条件を 冪等性 から導く
- 指数バックオフにジッタが要る理由
- 上限を試行回数と総経過時間の2 つで決める理由
失敗を 3 つに分ける
再送の判断は、まず失敗の種類を決めることから始まります。
| 種類 | 何が起きたか | 再送 |
|---|---|---|
| 確定失敗 | サーバーが処理して「できない」と答えた (4xx) | しない |
| 不確定 | 応答が得られなかった (タイムアウト・接続断) | 条件付きでする |
| 過負荷 | 相手が「今は無理」と答えた (429・503) | 待ってからする |
確定失敗を再送しても結果は変わりません。リクエストの内容が不正なら、何回送っても不正です。それどころか無駄な負荷をかけ、ログを汚し、本当の障害を見えなくします。
過負荷は再送してよいのですが、すぐに送ると悪化させます。相手は処理しきれないと言っているので、間隔を空ける必要があります。
過負荷は、送る側だけの問題ではありません。受ける側でこの状態にどう備えるか (台数を増やす、落ちた先へ配らない、一部の機能を諦める) は 可用性と冗長化 が扱います。
不確定がいちばん厄介
タイムアウトしたとき、処理されたかどうかが分かりません。
「タイムアウトしたので処理されていない」と考えるのは誤りです。応答が返る途中で切れた場合、処理は完了しています。ここで再送すると注文が 2 件になります。
再送してよい条件
安全に再送できるのは 2 つの場合だけです。
| 条件 | 根拠 |
|---|---|
| 冪等なメソッドである | 何回送っても最終状態が同じ (HTTP の定義) |
| 冪等キーが効いている | サーバーが重複を検出して 1 回しか処理しない |
GET / PUT / DELETE は冪等なので、不確定な失敗でそのまま再送できます。POST は冪等でないので、冪等キーがなければ再送してはいけません。
冪等キーが保証すること
クライアントがリクエストごとに一意な値を作り、ヘッダーで送ります。再送のときは同じ値を送ります。
POST /api/orders HTTP/1.1
Idempotency-Key: 9f2b1c4e-6a3d-4f8b-9c1e-2d5a7b0f3e4c
サーバーは、この値を見て再送かどうかを判断します。設計上の要点が 3 つあります。
**1. キーを作るのはクライアント。**サーバーが発行する形にすると、キーを取りに行くリクエスト自体が不確定になり、問題が 1 段ずれるだけです。
**2. 2 回目には 1 回目の結果を返す。**エラーではありません。再送してきたクライアントが欲しいのは「作れたのかどうか」であって、409 を返されると処理されたのか分からないままになります。
**3. 重複の判定はデータベースの制約に寄せる。**コードで「既に処理済みか」を確認してから保存すると、確認と保存の間に別のリクエストが割り込みます。これは ロックと分離レベル で見た競合と同じ構造です。
具体的なテーブル設計と実装は Laravel API 開発ガイド — トランザクションと並行性 が扱います。
間隔の設計
固定間隔では詰まる
3 秒ごとに 5 回送る、という設計は単純ですが、相手が落ちているときに効きません。15 秒で諦めることになり、復旧を待てません。
指数バックオフ
待ち時間を倍々にしていきます。
| 試行 | 待ち時間 |
|---|---|
| 1 回目の失敗後 | 1 秒 |
| 2 回目 | 2 秒 |
| 3 回目 | 4 秒 |
| 4 回目 | 8 秒 |
序盤は素早く再試行して一時的な瞬断を吸収し、続くようなら間隔を伸ばして相手に回復の時間を与えます。相手のためでもあるという点が重要です。復旧しかけたサーバーに再送が殺到すれば、また落ちます。
ジッタがないと波になる
指数バックオフだけでは足りません。同時に失敗したクライアントは、同時に再送します。
100 台のクライアントが同じ瞬間に失敗
→ 全台が 1 秒後に再送 (100 リクエストの波)
→ 全台が失敗
→ 全台が 2 秒後に再送 (また 100 の波)
間隔を倍にしても波の形は変わりません。山が来る間隔が広がるだけです。この現象には thundering herd という名前が付いています。
これを崩すのがジッタ — 待ち時間に乱数を混ぜることです。「1 秒後」ではなく「0 秒から 1 秒のあいだのどこか」にすると、再送が時間軸に散らばります。復旧しかけたサーバーが受けるのは、まとまった山ではなく細く続く流れになります。
分散システムの再送ではバックオフとジッタは 1 組として扱います。バックオフだけでは同時に失敗したクライアントの足並みが揃ったままなので、間隔を伸ばしても波は消えません。
相手が待ち時間を指定してくる場合
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Retry-After があれば自分の計算より優先します。相手が「30 秒後なら受けられる」と言っているので、それより早く送っても拒否されます。
上限は 2 つ決める
回数だけでは足りません。
| 上限 | 何を防ぐか |
|---|---|
| 試行回数 | 無限に再送し続けること |
| 総経過時間 | 呼び出し側を待たせ続けること |
指数バックオフは待ち時間が急速に伸びるので、回数だけを決めると総時間が読めません。1 秒から始めて 8 回試すと、待ち時間の合計は 4 分を超えます。
タイムアウト予算から逆算する
上位のタイムアウトを超えるリトライは意味がありません。
ブラウザ → API サーバー (タイムアウト 10 秒)
↓
API サーバー → 決済サービス
ブラウザが 10 秒で諦めるなら、API サーバーが決済サービスへ 30 秒かけてリトライしても無駄です。ブラウザはすでに接続を切っており、結果を受け取る相手がいません。
だから内側の総経過時間は、外側のタイムアウトより短くします。呼び出しの階層が深いほど、内側に配れる時間は少なくなります。
この考え方は エラーハンドリング の「どの層で捕まえるか」と対になります。時間の予算も層をまたいで配分するものです。
データベースの競合に当てる
ロックと分離レベル では、直列化失敗やデッドロックはリトライで受け止めるのが実務的だと書きました。ここには注意が要ります。
競合が集中している行では、リトライの回数を増やすほど悲惨になります。
| 状況 | 回数を増やすと |
|---|---|
| たまに衝突する | 復帰する。回数を増やすのは有効 |
| 同じ行に常に集中している | 待ち行列が伸び、全体の待ち時間が悪化する |
人気商品の在庫のように、1 行に更新が集中する場合、リトライは根本の解決になりません。「何度やっても取れない」という状態になり、そのあいだサーバーの資源を消費し続けます。
こういう場合に必要なのは、リトライの調整ではなく競合そのものを減らす設計です。在庫を複数行に分ける、更新を非同期のキューに寄せて直列化する、といった方向です。
リトライは一時的な失敗の吸収に効きます。構造的な競合の解決には効きません。
判断の手順
冪等キーがない POST がタイムアウトしたとき、再送は選べません。できるのは状態の問い合わせです。「この注文は作られたか」を GET で確認してから判断します。設計の段階で冪等キーを入れておけば、この分岐が要らなくなります。
二重処理を止める層
決済のような取り消せない操作では、防御を重ねます。
| 層 | 何を止めるか | 単独で足りるか |
|---|---|---|
| UI の二度押し防止 | ユーザーの操作ミス | 足りない (通信断で無効) |
| 冪等キー | クライアントの再送 | ほぼ足りる |
| データベースの一意制約 | 上をすり抜けたすべて | 最後の砦 |
上の 2 つは善意のクライアントにしか効きません。最後に頼れるのはデータベースの制約だけです。制約は競合状態の影響を受けないので、確認と書き込みの隙間を作りません。
よくある誤解
「タイムアウトしたので処理されていない」 — 処理は完了している可能性があります。分からないというのが正しい状態の認識です。
「リトライは多いほど親切」 — 相手の負荷を増やし、呼び出し側を待たせます。上限は必ず決めます。
「指数バックオフを入れたので十分」 — ジッタがないと再送が同期して波になります。
「冪等キーはサーバーが発行する」 — クライアントが作ります。サーバー発行にすると、キーを取得するリクエストが不確定になるだけです。
「冪等キーの重複はエラーで返す」 — 1 回目の結果を返します。エラーを返すと再送側は結果を知れません。
「リトライすれば競合は解決する」 — 一時的な失敗には効きますが、同じ行への集中には効きません。回数を増やすと悪化します。
確認問題
問 1. 決済 API を呼んでタイムアウトしました。冪等キーは実装していません。どうしますか。
答え: 再送せず、決済の状態を問い合わせます。
POST は冪等でないので、そのまま再送すると二重課金の危険があります。タイムアウトは「処理されなかった」ではなく「結果が分からない」なので、まず結果を確定させます。
手順は次のとおりです。
- 決済サービスの照会 API で、その注文に対する決済が成立しているかを
GETで確認する - 成立していれば、自分側の記録をそれに合わせる
- 成立していなければ、改めて実行する
照会 API が無い場合、安全に自動復旧する方法はありません。人が確認する運用に落とすことになります。
**この状況を作らないために冪等キーを入れます。**キーがあれば、同じキーで再送するだけで「1 回目の結果」が返り、照会も人手も要りません。取り消せない操作では、設計の時点で入れておくものです。
問 2. 外部 API への呼び出しに「5 回・指数バックオフ (1 秒から)」を設定しました。障害時にユーザーからのタイムアウトが増えました。何が起きていますか。
答え: リトライの総経過時間が、呼び出し元のタイムアウトを超えています。
1 秒から倍にすると、待ち時間は 1 + 2 + 4 + 8 = 15 秒です。これに各試行の所要時間が加わります。外部 API がタイムアウトするまで 10 秒かかるなら、5 回で 50 秒。合計 65 秒です。
ブラウザや上位のサーバーが 30 秒で諦めるなら、その時点でリトライは無駄になっています。それでも自分側の処理は続くので、接続とスレッドを占有し続けます。障害時にこれが積み上がると、リトライしていない他のリクエストまで捌けなくなります。
決めるべきものは 2 つです。
| 上限 | 決め方 |
|---|---|
| 総経過時間 | 呼び出し元のタイムアウトより短くする |
| 試行回数 | 総経過時間から逆算する |
回数を先に決めるのではなく、時間の予算から回数を導きます。予算が 5 秒なら、1 秒 + 2 秒で 2 回が上限です。
問 3. 在庫の更新でデッドロックが出るのでリトライを 3 回から 10 回に増やしました。エラーは減りましたが、応答時間が全体的に悪化しました。
答え: 競合そのものは減っておらず、待ち行列が伸びただけです。
リトライは失敗を成功に変えたのではなく、失敗を待ち時間に変換しています。
| 変化 | 内容 |
|---|---|
| エラー率 | 下がる (最後には成功するので) |
| 応答時間 | 上がる (何度も待つので) |
| サーバーの資源 | 占有が長引く (接続・トランザクションを掴み続ける) |
しかも影響は在庫の更新だけに留まりません。トランザクションとロックを長く保持するので、同じテーブルを触る他の処理も待たされます。
同じ行に更新が集中しているなら、必要なのは競合を減らす設計です。
- ロックを取る順序を固定してデッドロック自体を減らす (ロックと分離レベル)
- 在庫を複数行に分けて、集中する 1 行をなくす
- 更新をキューに寄せて直列に処理する
リトライの回数は、これらを済ませたうえで一時的な失敗を吸収する分だけ設定します。
まとめ
- 失敗は確定 (4xx) ・不確定 (応答なし) ・過負荷 (429/503) に分ける。再送の可否が変わる
- タイムアウトは「処理されていない」ではなく「分からない」
- 再送してよいのは、冪等なメソッドか、冪等キーが効いているときだけ
- 冪等キーはクライアントが作り、2 回目には 1 回目の結果を返す。判定はデータベースの制約に寄せる
- バックオフとジッタは 1 組。ジッタがないと再送が同期して波になる
- 上限は試行回数と総経過時間の 2 つ。総経過時間は上位のタイムアウトより短くする
Retry-Afterがあれば自分の計算より優先する- リトライは一時的な失敗に効き、構造的な競合には効かない。回数を増やすと悪化する
- Laravel API 開発ガイド — トランザクションと並行性 —
Idempotency-Keyのテーブル設計と、一意制約による重複検出の実装 - Laravel API 開発ガイド — キューの運用 — ジョブの試行回数と待ち時間の設定、失敗したジョブの扱い
- RFC 9110 — HTTP Semantics — 冪等なメソッドの定義と
Retry-After - RFC 6585 — Additional HTTP Status Codes —
429 Too Many Requests - AWS Architecture Blog — Exponential Backoff And Jitter — バックオフだけでは再送が同期する (thundering herd) こと、ジッタが足並みを崩すことの検証
次に読む
- オリジンと CORS — ここからブラウザが持つ境界の話に移ります
- ロックと分離レベル — 直列化失敗とデッドロックが起きる仕組み
- HTTP — 冪等性がメソッドごとにどう決まるか