1. 現場の阿鼻叫喚:なぜリトライが「自作自演のDoS攻撃」と化すのか?
「ネットワークは信頼できない。だから失敗したらリトライを入れるのが耐障害性の基本だ」——この言説は、分散システムやWebアプリケーションの開発において半ば常識として語られています。そして現場のコードベースには、次のような素朴なリトライ処理が無数に散らばることになります。
// 現場で無数に量産される「ナイーブな即時リトライ」のアンチパターン
func FetchUserData(ctx context.Context, userID string) (*UserData, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
data, err := apiClient.Get(ctx, "/users/"+userID)
if err == nil {
return data, nil
}
lastErr = err
time.Sleep(100 * time.Millisecond) // 固定時間だけ待って再試行
}
return nil, fmt.Errorf("3回試行しましたが失敗しました: %w", lastErr)
}
ローカル環境の単体テストや少人数でのステージング環境であれば、このコードは何の問題もなく動作します。一時的なネットワークの瞬断が発生しても、100ミリ秒後に自動でリトライが成功し、「耐障害性の高い堅牢な実装」であるかのように見えます。
しかし、本番環境で真の負荷試験が行われた瞬間、この無邪気な実装は牙を剥きます。ある金曜日の夜、データベースに一時的なスロークエリが走り、APIのレスポンスタイムが普段の 20ms から 1.5秒 へと悪化したとしましょう。クライアント側のタイムアウトが発火した瞬間、次の惨劇が幕を開けます。
「平常時 2,000 req/sec を捌いていたシステムで、バックエンドが一時的に詰まった。すると 2,000 req/sec の全クライアントが 100ms 後に一斉にリトライを開始し、システムに突如 6,000 req/sec の負荷が襲いかかる。本来なら数秒息を潜めれば回復できたはずのDBは過剰な接続要求で完全に窒息死し、コネクションプール枯渇とOOM KillerでWebサーバー群が次々に道連れ死していった」
これこそが分散システムにおける古典的かつ致命的な破滅パターン、「Retry Storm(リトライの嵐)」によるカスケード障害(連鎖倒壊・Cascading Failure)です。クライアントが良かれと思って行った「粘り強い再試行」が、自らバックエンドに激烈なDoS攻撃を浴びせ、障害を不可逆な全系ダウンへと押し上げるのです。
2. なぜ「Exponential Backoff(指数バックオフ)」だけでは防げないのか?
「即時リトライや短い固定インターバルが危険なのは知っている。だから試行ごとに待機時間を倍々に延ばす『Exponential Backoff(指数バックオフ)』を導入している」と答えるエンジニアも多いでしょう。待機時間を $T = \text{base} \times 2^{\text{attempt}}$(例: 1秒、2秒、4秒、8秒…)と伸ばしていく手法です。
確かにこれによって、リトライの間隔は広がり、瞬間的な負荷の密度は減少します。しかし、高トラフィック環境においては、指数バックオフ単体では致命的な欠陥が残ります。それが「位相同期(Phase Locking / 共鳴現象)」です。
一斉に押し寄せる「周期的リトライ津波」の恐怖
障害発生の瞬間を考えてみてください。上流のマイクロサービスやDBが時刻 $t_0$ でダウンしたとき、その瞬間にリクエストを送っていた数百〜数千のクライアントがほぼ同時に最初のエラーを受け取ります。
指数バックオフに「ランダムな揺らぎ」が含まれていない場合、何が起きるでしょうか? 全クライアントの待機タイマーが同一の計算式で動くため、次のように全員が示し合わせたかのように同期して再試行を放ちます。
- $t_0 + 1.0$ 秒後: 全クライアントが一斉に第1回リトライを集中砲火(第1波)
- $t_0 + 3.0$ 秒後(1s + 2s): 全員が再び一斉に第2回リトライを集中砲火(第2波)
- $t_0 + 7.0$ 秒後(1s + 2s + 4s): 全員が再び一斉に第3回リトライを集中砲火(第3波)
サーバー側のメトリクスを監視していると、トラフィックが平滑化されるどころか、数秒おきに急峻なスパイクが周期的に突き刺さるノコギリ状のグラフが観測されます。サーバーが再起動してメモリやコネクションを初期化し、「さあリクエストを受け付けよう」と顔を上げたジャストのタイミングで、同期したリトライの津波が直撃し、再び瞬殺されるのです。
3. 数理で解き明かす「Jitter(揺らぎ)」の3つのアプローチ
この同期現象(共鳴)を粉砕するために必須となるのが、待機時間にランダム性を注入する「Jitter(ジッター / 揺らぎ)」です。2015年にAWSアーキテクチャチームの Marc Brooker 氏が発表した記念碑的論文『Exponential Backoff And Jitter』では、バックオフに対する主要なアプローチが数学的・シミュレーション的に比較検証されました。
1. No Jitter(ジッターなし)
待機時間を一切ブレさせず、厳密に指数計算するアプローチです。
sleep = base * (2 ^ attempt)
前述の通り、全クライアントが同調してリクエストを叩き込むため、競合(Contention)が最大化し、最もサーバー復旧を妨げます。本番環境での採用は論外です。
2. Equal Jitter(イコール・ジッター)
計算された待機時間の「半分を確定枠(最小保証)」とし、残り半分にランダム性を加えるアプローチです。
temp = base * (2 ^ attempt)
sleep = (temp / 2) + rand(0, temp / 2)
常に一定のバックオフ時間が保証される安心感はありますが、依然として「前半の固定時間」によってクライアント群の行動タイミングが狭い時間枠に偏る弱点があります。
3. Full Jitter(フル・ジッター: 推奨解)
指数関数的に増加する待機時間の上限値(Cap)を計算し、0 からその上限値までの区間全体から一様ランダムに待機時間を選択するアプローチです。
temp = min(cap, base * (2 ^ attempt))
sleep = rand(0, temp)
一見すると「試行回数が増えているのに、運悪く 0 に近いスリープ時間が選ばれてすぐにリトライしてしまうのではないか?」と直感に反するように思えます。しかし、大規模分散環境でのシミュレーションにおいて、全クライアントのトータル競合回数が最も低く、サーバーの負荷が完全に時間軸上へ均一分散され、システム全体の処理完了時間が最短になったのはこの「Full Jitter」でした。
試行が進むにつれて「選ばれ得る区間の幅」が指数関数的に広がるため、リクエストが時間軸上に美しく拡散し、共鳴現象が完全に消失するのです。
4. Go言語による堅牢なリトライエンジンのクリーンルーム実装
理論が理解できたら、実際の現場で即座に使える自己完結なコードへ落とし込みましょう。外部サードパーティライブラリに一切依存せず、Go標準ライブラリ(`math/rand/v2`, `context`, `time`)のみを用いてゼロから構築した、クリーンルーム設計のリトライ機構です。
package resilience
import (
"context"
"errors"
"math/rand/v2"
"net/http"
"time"
)
// RetryConfig はリトライ戦略のパラメータを規定する
type RetryConfig struct {
MaxRetries int // 最大リトライ回数
BaseDelay time.Duration // 初回バックオフ基本時間(例: 100ms)
MaxDelay time.Duration // バックオフ上限キャップ(例: 5s)
}
// DefaultRetryConfig は標準的な本番向け設定
var DefaultRetryConfig = RetryConfig{
MaxRetries: 3,
BaseDelay: 100 * time.Millisecond,
MaxDelay: 3 * time.Second,
}
// IsRetryableFunc は発生したエラーがリトライ可能かを判定する述語関数
type IsRetryableFunc func(err error) bool
// RetryWithFullJitter はFull Jitterアルゴリズムを用いた堅牢なリトライ実行関数
func RetryWithFullJitter[T any](
ctx context.Context,
cfg RetryConfig,
isRetryable IsRetryableFunc,
operation func(ctx context.Context) (T, error),
) (T, error) {
var zero T
var lastErr error
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
// 1. 処理の実行
res, err := operation(ctx)
if err == nil {
return res, nil // 成功
}
lastErr = err
// 2. 最後の試行だった場合はリトライせずに脱出
if attempt == cfg.MaxRetries {
break
}
// 3. そもそもリトライすべきエラーかどうかを冷徹に判定
if !isRetryable(err) {
return zero, err // リトライ不可能なエラーは即座にFail Fast
}
// 4. コンテキストが既にキャンセル/タイムアウトしていないかチェック
if ctx.Err() != nil {
return zero, errors.Join(lastErr, ctx.Err())
}
// 5. Full Jitter の計算: sleep = rand(0, min(MaxDelay, BaseDelay * 2^attempt))
backoffLimit := float64(cfg.BaseDelay) * float64(uint(1)<<attempt)
if backoffLimit > float64(cfg.MaxDelay) {
backoffLimit = float64(cfg.MaxDelay)
}
// 0 から backoffLimit までの区間から一様ランダムに選択
jitteredDuration := time.Duration(rand.Float64() * backoffLimit)
// 6. 次の試行まで待機(コンテキストのキャンセルに即時追従)
select {
case <-ctx.Done():
return zero, errors.Join(lastErr, ctx.Err())
case <-time.After(jitteredDuration):
// 次の試行へ
}
}
return zero, lastErr
}
HTTPステータスコードに応じたエラー選別フィルター
このリトライエンジンで最も重要なのが第3ステップの isRetryable(err) 述語関数です。多くの現場が犯す最大の過ちは「何でもかんでもリトライすること」です。
// HTTPクライアント向けのリトライ判定フィルター
type HTTPStatusError struct {
StatusCode int
Err error
}
func (e *HTTPStatusError) Error() string {
return e.Err.Error()
}
func DefaultHTTPRetryPolicy(err error) bool {
var httpErr *HTTPStatusError
if errors.As(err, &httpErr) {
// 4xx クライアントエラー(429以外)は絶対にリトライしてはならない
if httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 {
// 429 Too Many Requests のみリトライ対象(できればRetry-Afterヘッダーに従う)
return httpErr.StatusCode == http.StatusTooManyRequests
}
// 5xx サーバーエラーのうち、一時的とみなせるものだけをリトライ
switch httpErr.StatusCode {
case http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
default:
// 500 Internal Server Error や 501 Not Implemented は
// アプリケーションのバグである可能性が高いため原則リトライ不可
return false
}
}
// ネットワーク瞬断・コネクションリセット等のI/Oエラーはリトライ可
return true
}
5. クライアント単体では防げない「全系防衛」の2大ルール
どれほど完璧なFull Jitterを実装しても、クライアント個別の視点だけでは解決できない構造的課題があります。システム全体を破滅から守るために、アーキテクトが絶対に敷くべき2つの防衛ラインを提示します。
ルール1: Retry Budget(リトライバジェット)の導入
GoogleのSRE(Site Reliability Engineering)プラクティスにおいて提唱され、LinkerdやEnvoyなどのモダンなサービスメッシュで標準搭載されている概念が「Retry Budget(リトライ予算)」です。
「通常のリクエストを含めた総トラフィックのうち、リトライ処理が占めてよい割合は最大10%(0.1)までに制限する。それを超えたリトライ要求は即座に拒否(Fail Fast)しなければならない」
もしバックエンドが深刻な障害に見舞われ、全リクエストの80%がエラーになったとします。このとき全クライアントが3回ずつリトライを実行すると、システム全体のトラフィックは一瞬で平常時の2.4倍以上に膨れ上がります。
Retry Budgetを設けておけば、クライアント(またはプロキシ)はトークンバケット等を用いて「直近10秒間の成功・失敗リクエスト数」を追跡し、リトライ率が10%を超えた段階でリトライの実行を自動的に遮断し、呼び出し元に即座にエラーを返します。これにより、障害時であってもシステム全体のトラフィック上限が厳密に縛られ、破滅的な雪崩を物理的に阻止できるのです。
ルール2: 「リトライしてはいけないエラー(Non-retryable)」の冷徹な排除
コードレビューで即座に弾くべきアンチパターンが、「リクエストの中身に起因するエラー」のリトライです。
- 400 Bad Request / 422 Unprocessable Entity: パラメータ不正。何万回再試行しようが100%同じエラーになります。ネットワークとCPUリソースの完全な浪費です。
- 401 Unauthorized / 403 Forbidden: 認証切れ・権限不足。トークンを再取得しない限り、リトライしても無駄です。
- Context Canceled(クライアント離脱): ユーザーがブラウザのタブを閉じた、あるいは上流のタイムアウトが切れた場合、コンテキストは
Canceledになります。裏側で孤児となったリトライ処理を回し続けるのは、幽霊にサーバーを占拠させる行為です。
6. まとめ — リトライは「特効薬」であり、用法を誤れば「猛毒」である
リトライ処理は、分散システムにおいて最も手軽に導入できる耐障害性パターンであると同時に、最も簡単に本番システムを粉砕できる両刃の剣です。安易に `for` ループで括る前に、必ず次のセルフチェックを自分たちのコードに課してください。
- 即時リトライ・固定間隔リトライを撲滅したか? → 必ず指数バックオフを採用する。
- Full Jitter を導入して共鳴を粉砕したか? → バックオフ値の上限まで一様ランダムに分散させる。
- Non-retryable なエラーを即座に除外しているか? → 4xx系やContextキャンセルをリトライの輪廻に巻き込まない。
- システム全体に Retry Budget があるか? → 障害時のリトライ増幅率に厳格な天井を設ける。
- その処理は「冪等(Idempotent)」か? → リトライによって二重決済や二重送信が起きないステートマシンまたはIdempotency-Keyが担保されているか。
「エラーが起きたらとりあえずリトライ」という思考停止を脱ぎ捨て、数理に基づいたJitterと冷徹なエラー境界を設計すること。それこそが、本番環境の混沌(カオス)に耐えうる真のシニアエンジニアの流儀です。