リトライと指数バックオフ
Retry and Exponential Backoff ・ リトライとしすうバックオフ
失敗した処理を間隔を広げながら再試行する定石。無邪気なリトライは障害を増幅する。
概要
リトライは、失敗した処理をもう一度試すことです。ネットワークの一瞬の途切れ、依存先の瞬間的な過負荷、デプロイ直後の揺らぎ — 分散システムの失敗の多くは「もう一度やれば成功する」一過性のものなので、リトライは可用性を上げる最も手軽で効果的な手段です。
ただし、リトライには裏の顔があります。失敗の原因が「相手の過負荷」だった場合、リトライは弱っている相手にさらにリクエストを浴びせる行為になり、障害を長引かせ、増幅します。そこで「間隔をどんどん広げながら再試行する」のが指数バックオフ(exponential backoff)、その間隔に乱数のゆらぎを加えるのがジッター(jitter)です。「リトライ + 指数バックオフ + ジッター」の三点セットは、タイムアウトと並んで、外部呼び出しを堅牢にする定石として広く共有されています。
なぜ生まれたか
無邪気なリトライ — 失敗したら即座に、あるいは固定の短い間隔でやり直す実装 — が引き起こす典型的な事故が「リトライストーム」です。依存先が一瞬詰まると、大量のクライアントが一斉に失敗し、一斉にリトライします。相手が受ける負荷は元の2倍、3倍に膨れ上がり、回復しかけたサーバは再び押し潰されます。クライアントから見れば「まだ失敗する」のでさらにリトライが重なり、呼び出し階層が深いと各層のリトライが掛け算で効いて、末端には元の何十倍ものリクエストが殺到します。ちょっとした瞬断が、リトライによって本格的な障害へと育ってしまうのです。
指数バックオフは、この「善意の一斉攻撃」を抑えるために生まれました。ルーツは古く、初期のイーサネットが通信の衝突時に「衝突するたびに待ち時間の上限を倍にして再送する」方式を採ったことに遡ります。回線でもサーバでも本質は同じで、混んでいるなら引く、それでも駄目ならもっと引く — 競合する参加者たちが自律的に負荷を散らすための、時間方向の譲り合いのアルゴリズムです。
詳細
指数バックオフとジッターの仕組み
基本形は単純です。1回目の失敗後は例えば1秒待つ、2回目は2秒、3回目は4秒、4回目は8秒 — と待ち時間を指数的に(通常2倍ずつ)伸ばし、上限(例えば60秒)で頭打ちにします。失敗が続くほど「相手は本格的に調子が悪い」可能性が高いので、試行の頻度を急速に下げて相手の回復時間を確保する、という理屈です。
しかし指数バックオフだけでは足りません。同時に失敗した1000台のクライアントは、全員が同じ計算式で待つため、1秒後・2秒後・4秒後に「一斉に」再試行します。負荷の波は小さくなっても、波が同期したままなのです。そこで待ち時間に乱数を混ぜるのがジッターです。代表的な「フルジッター」方式では、待ち時間を「0〜計算値の間の乱数」にしてしまいます。これで再試行の瞬間がばらけ、スパイクがなだらかな平均負荷へとならされます。AWS の検証で「ジッターなしの指数バックオフより、大胆に乱数化したほうが総所要時間も総リクエスト数も少ない」ことが示されて以来、ジッターは省略してはいけない要素として定着しました。
同じ障害・同じクライアント数でも、待ち方の違いだけで回復までの時間と総負荷が大きく変わることを、下のシミュレーションで確かめられます。
「実行」を押すと、冒頭5ティックの瞬断(赤帯)のあと、3つの戦略がどう回復するかを同時にシミュレートします。
リトライしてよい失敗、してはいけない失敗
すべての失敗をリトライしてよいわけではありません。判断の軸は「もう一度やれば結果が変わりうるか」です。HTTP で言えば、503(過負荷)や 429(レート制限超過)、そしてタイムアウトは一過性の可能性が高く、リトライの候補です。一方、400(リクエスト不正)や 403(権限なし)のような 4xx 系は、こちらの送る内容が変わらない限り何度やっても同じ結果なので、リトライは無駄弾どころか有害です。429 のように相手が Retry-After ヘッダで「いつ来てよいか」を指示してくる場合は、自前のバックオフ計算よりその指示を優先します。
もうひとつの大前提が冪等性です。タイムアウトした呼び出しは「失敗した」のではなく「結果が分からない」だけで、実は相手側で処理が完了しているかもしれません。そこにリトライを重ねると、決済が二重に走る、注文が二件入る、といった事故になります。リトライを安全にするには、同じリクエストを何度受けても結果が一度分にしかならないよう、冪等キーなどで受け側を設計しておくことが欠かせません。
やりすぎを防ぐ — 回数上限とリトライバジェット
リトライには必ず打ち切りを設けます。試行回数の上限(3回程度が一般的)に加え、近年は「リトライバジェット」という考え方が広まっています。これは「リトライは通常トラフィックの10%まで」のように、リトライが全体に占める割合へ上限を課すものです。回数上限だけだと、全リクエストが失敗する大規模障害時には「全員が3回ずつ」で負荷が一気に3倍になりますが、バジェット方式なら障害が広がるほどリトライが自動的に絞られ、増幅が頭打ちになります。サービスメッシュの実装などがこの方式を採用しています。
また、リトライは階層のどこか1か所に集約するのが原則です。各層が善意でリトライすると掛け算で増幅されるためで、「末端の呼び出し層でだけリトライし、上位層はしない」といった役割分担を決めておきます。ユーザーの応答を待たせられない処理なら、その場で粘るのではなくメッセージキューに積んで非同期に再試行し、規定回数失敗したものはデッドレターキューに退避して人が調べる、という形に逃がすのも定石です。
系譜の次へ — 諦める勇気
バックオフしながらのリトライは「そのうち直る」障害には有効ですが、長引く障害に対しては、失敗するとわかっている試行を延々と続けることになります。「この依存先は今は駄目だ」と判断して呼び出し自体を止め、回復の兆しが見えたら再開する — その判断を自動化したのがサーキットブレーカーです。タイムアウトで速く失敗し、バックオフ付きリトライで粘り、それでも駄目ならブレーカーで諦める。この3段構えが、外部呼び出しの信頼性設計の基本形になっています。
