開発をしていると、外部 API がほんの一瞬だけ応答を返さない場面に出くわします。
多くは「もう一度叩けば成功する」たぐいの、ごく一時的なつまずき。
ところが、その「もう一度」を雑に書くと、かえってシステム全体を巻き込む事故につながります。
「リトライって、ただ繰り返せばいいんじゃないの?」と感じたことはありませんか。
この素朴な疑問をほどくほど、頑丈なリトライの設計図が見えてくる。
単純なリトライが、かえって危ない理由
まず、なぜ素朴な再送が危ないのか、その理屈から押さえます。
鍵になるのは 失敗するときは多数のリクエストが同時に失敗する という事実です。
失敗が重なると何が起きるか
サーバーが一時的に弱ったとき、多くのクライアントが同じ瞬間に失敗を受け取ります。
そして全員が申し合わせたように、すぐ再送する。
弱ったサーバーへ回復する間も与えず、波状攻撃を仕掛ける形になってしまうのです。
これこそ 「リトライ嵐」と呼ばれる、自分で自分の首を絞める状態。
復旧しかけたサーバーが、集中する再送でまた倒れる悪循環に陥ります。
しかも利用者に見える症状は「たまに遅い」程度で、本当の原因にたどり着きにくい。
だからこそ、再送のしかたそのものを設計対象として捉える必要があります。
指数バックオフという考え方
そこで登場するのが、待ち時間を賢く伸ばす指数バックオフです。
待ち時間を倍々にしていく
仕組み自体は驚くほどシンプル。
1 回目の失敗で 1 秒、2 回目で 2 秒、3 回目で 4 秒と、待ち時間を指数関数的に倍々で伸ばしていきます。
再送が重なるほど間隔が開くため、弱ったサーバーに息つく暇が生まれる。
結果として、全員で一斉に叩く状態を時間軸でばらけさせられる のが、この方式の肝です。
ジッターで足並みを崩す
ただし、バックオフだけでは抜け切らない穴が残ります。
全クライアントが「1 秒、2 秒、4 秒」と同じ数列で待つと、結局また同じ瞬間に再送が揃ってしまう。
そこで効くのが、待ち時間に乱数のゆらぎを足す ジッター(jitter) という工夫です。
各クライアントの再送が時間軸でばらけ、山だった負荷をなだらかな裾野へ均せます。
擬似コードにすると、勘どころはこの数行に収まる。
# 擬似コード(考え方のみ)
wait = base * (2 ** attempt) # 倍々に伸ばす
wait = wait + random.uniform(0, wait) # ジッターを足す
sleep(min(wait, cap)) # 上限で頭打ち
# ... 略
とくに多数のクライアントを抱えるサービスほど、この一手間が安定性を大きく左右する。
派手さはなくても、安定運用への効き目は大きい。
よくある落とし穴
考え方が分かったところで、実装で足をすくわれがちな点も並べておきます。
- 待ち時間に上限を設けない:失敗が続くと待ちが分単位に膨張し、復旧後の反応が致命的に遅れる
- 何でも再送する:認証エラーや 400 系など、再送しても結果が変わらないエラーまで繰り返すのは無駄
- ジッターを省く:足並みが揃ったままで、せっかくのバックオフが台無し
どれも、知ってさえいれば避けられるものばかり。
逆に言えば、上限・対象の見極め・ジッターの 3 点を押さえるだけで、設計はぐっと安定します。
一歩進んだ運用のコツ
最後に、もう一歩進めるための視点を 2 つ。
リトライ総量に予算を持たせる
個々のリクエスト単位だけでなく、システム全体での再送回数に上限を設ける考え方があります。
いわゆる リトライバジェット で、暴走を全体最適の観点から止める仕組み。
全体で何回まで再送を許すかを決めておけば、一部の不調が全系へ連鎖する展開を防げる。
「冪等性」とセットで考える
再送して安全かどうかは、その操作が 冪等(何度行っても結果が変わらない性質) かどうかに大きく左右されます。
残高を増やすような操作を無邪気に再送すると、二重実行という別の事故を招く。
結局のところ、リトライ設計の核心は「待ち方の工夫」と「再送してよい操作かの見極め」の両輪にある。
より厳密な設計指針はコチラが参考になります。
最後に
リトライは、ともすると「とりあえず繰り返す」で済ませてしまいがちな処理です。
けれど一歩引いて眺めると、相手をいたわりながら粘る、思いやりの設計だと気づきます。
次に小さな再送処理を書くときは、まず「待ち方」と「再送してよいか」を思い出したいですね。
以上です。







コメントを残す