Skip to content

リトライとバックオフ

実装: resilience/retry/ / 実行: go test ./resilience/retry/

一時的な失敗は少し待って再送すれば通ることが多い。だが即座に再送すると過負荷の相手に追い打ちをかけ、多数のクライアントが同じ間隔で再送すれば全員が衝突する。リトライ嵐だ。指数バックオフは待ち時間を試行ごとに伸ばして追い打ちを避け、ジッターは待ちに乱数の揺らぎを足して再送時刻を散らす。この章では指数バックオフ・3種のジッター・恒久的失敗の除外を実装し、なぜジッターが衝突を防ぐかを確かめる。

この章で作るもの

サーキットブレーカーは失敗が続いたら遮断する仕組みだった。だが遮断する前の、個々の失敗にどう対処するか。ネットワークのゆらぎ、瞬間的な過負荷、リーダー選挙中の一時的なエラー。こうした一時的失敗(transient failure)は、少し待って再送すれば通ることが多い。リトライはその再送を自動化する。

問題は「どう待つか」だ。素朴に即座に再送すると、過負荷で失敗した相手に、さらにリクエストを浴びせることになる。しかも失敗しているのは自分だけではない。多数のクライアントが同時に失敗し、全員が一定間隔で再送すれば、再送のたびに負荷のスパイクが立ち、相手は回復できない。これがリトライ嵐(retry storm / thundering herd)だ。この章は、指数バックオフとジッターでこれを避ける。

即再送        ▮▮▮▮▮▮  失敗のたびすぐ再送 → 追い打ち
指数backoff   ▮ ▮  ▮    ▮        待ちを 1→2→4→8 と伸ばす
              (それでも全クライアントが同時刻に再送すると衝突)
+ジッター     ▮  ▮ ▮   ▮  ▮      各クライアントの時刻を乱数で散らす
待ち方の違い。即再送は追い打ち。指数バックオフは待ちを伸ばして緩める。ジッターは複数クライアントの再送時刻を散らして衝突を防ぐ

順に作る。

  1. 指数バックオフ: 待ち時間を base·mult^n で伸ばし、max で頭打ち。再送の間隔を空けて相手を緩める
  2. ジッター: 待ちに乱数の揺らぎを足す。同時失敗した多数のクライアントの再送時刻を散らし、衝突を防ぐ
  3. 恒久的失敗は再送しない: 400 番台のような待っても直らない失敗は、即諦めて無駄な再送をしない

① 指数バックオフ: 待ちを伸ばす

まず待ち時間を試行ごとに指数的に伸ばす。倍率をかけていき、上限で頭打ちにする:

go

// Policy はリトライの方針。
type Policy struct {
	MaxAttempts int // 最大試行回数(0 は 1 に補正)
	BaseDelay   int // 初回の基本遅延(論理時間)
	MaxDelay    int // 遅延の上限
	Multiplier  int // 試行ごとの倍率(0/1 未満は 2 に補正)
	Jitter      JitterKind
}

// rawDelay は attempt 回目の素の遅延 base·mult^attempt(max で頭打ち)。
func (p Policy) rawDelay(attempt int) int {
	base := p.BaseDelay
	mult := p.Multiplier
	if mult < 2 {
		mult = 2
	}
	d := base
	for i := 0; i < attempt; i++ {
		d *= mult
		if p.MaxDelay > 0 && d >= p.MaxDelay {
			return p.MaxDelay
		}
	}
	if p.MaxDelay > 0 && d > p.MaxDelay {
		return p.MaxDelay
	}
	return d
}

// Backoff は attempt 回目の待ち時間を、ジッター種別に応じて返す。
// r はジッター用の乱数源(JitterNone なら nil でよい)。
func (p Policy) Backoff(attempt int, r *Rand) int {
	raw := p.rawDelay(attempt)
	switch p.Jitter {
	case JitterFull:
		if r == nil || raw <= 0 {
			return raw
		}
		return r.intn(raw + 1) // [0, raw]
	case JitterEqual:
		if r == nil || raw <= 0 {
			return raw
		}
		half := raw / 2
		return half + r.intn(raw-half+1) // [half, raw]
	default:
		return raw
	}
}

テストでは base=10, mult=2, max=100 で、待ちが 10, 20, 40, 80, 100, 100 と伸びて頭打ちになることを固定した。伸ばす理由は、失敗の原因が過負荷なら、間隔を空けるほど相手が処理を捌く余裕ができるからだ。頭打ちの理由は、伸ばし続けると待ちが非現実的に長くなる(2^10 = 1024 倍)ので、上限で止める。

② ジッター: 再送時刻を散らす

指数バックオフだけでは足りない。1000 台のクライアントが同じ瞬間に同じ相手の失敗を受けたら、全員が同じ「10, 20, 40…」の間隔で再送する。待ちを伸ばしても、全員が同じ時刻に再送するので、負荷のスパイクは消えない。そこで待ちに乱数の揺らぎ(ジッター)を足す:

go

// permanentError は「再試行しても無駄」なエラーをラップする。
type permanentError struct{ err error }

func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }

// Permanent はエラーを恒久的(再試行しない)としてマークする。
// 400 Bad Request のような、待っても直らない失敗に使う。
func Permanent(err error) error { return &permanentError{err: err} }

// Result はリトライの結果。
type Result struct {
	Err        error // 最終エラー(成功なら nil)
	Attempts   int   // 実際に試した回数
	TotalDelay int   // 試行の間に待った論理時間の合計
}

// Do は fn を Policy に従ってリトライする。成功するか、最大試行に達するか、
// 恒久的エラーが返るまで。試行の「間」だけ待ち、最後の失敗後は待たない。
func (p Policy) Do(fn func() error, r *Rand) Result {
	max := p.MaxAttempts
	if max < 1 {
		max = 1
	}
	var res Result
	for attempt := 0; attempt < max; attempt++ {
		res.Attempts++
		err := fn()
		if err == nil {
			res.Err = nil
			return res
		}
		var perm *permanentError
		if errors.As(err, &perm) {
			res.Err = err // 恒久的失敗は即諦める
			return res
		}
		res.Err = err
		if attempt < max-1 {
			// 次の試行の前に待つ(最後の試行の後は待たない)。
			res.TotalDelay += p.Backoff(attempt, r)
		}
	}
	return res
}

実装では、揺らぎなし・full jitter・equal jitter の3種を切り替えられる。full jitter は [0, raw] の一様乱数で、最も散る代わりに待ちが極端に短くなることもある。equal jitter は raw/2 + [0, raw/2] で、下限を確保しつつ散らす。テストで固定したのが効果の核心で、full jitter を使うと 100 クライアントの再送時刻が 50 以上の異なる値に散る。ジッターなしなら全員が同じ 800 に集中する。AWS の解析では full jitter がサーバ負荷とクライアント完了時間の両方で最良だった。

③ 恒久的失敗は再送しない

すべての失敗を再送してよいわけではない。400 Bad Request は、何度送っても Bad Request のままだ。存在しない ID へのリクエストも、権限のない操作も、待っても直らない。こうした恒久的失敗(permanent failure)を再送するのは、無駄な負荷でしかない。

Permanent でエラーをマークすると、Do はそれを再送せず即座に諦める。テストでは、Permanent なエラーが 1 回しか呼ばれない(再送されない)ことを固定した。リトライすべきは一時的失敗(5xx、タイムアウト、接続リセット)だけで、この区別が「リトライしてよいか」の設計判断になる。冪等性も関わる。GET は何度送っても安全だが、「送金」を再送すると二重送金になりかねない。再送前提の操作には冪等キー(メッセージキューの実質1回)が要る。

動かす

下のデモは 2 つの見方を用意した。「バックオフ」は 1 クライアントのリトライを追い、待ちが指数的に伸びて成功するまで、あるいは諦めるまでを見る。「リトライ嵐」は多数のクライアントが同時失敗したとき、ジッターの有無で再送時刻の分布がどう変わるかをヒストグラムで比べる。

デモリトライとバックオフ試行 1
バックオフリトライ嵐
試行1失敗wait 100
試行2失敗wait 200
試行3失敗wait 400
試行4成功

試行 1: 失敗。次の再送まで 100 待つ(前回の 2 倍、上限 1600)。間隔を空けて相手を追い打ちしない

1 / 4

指数バックオフは待ちを試行ごとに伸ばし、過負荷の相手を追い打ちしない。だが全クライアントが 同じ間隔で再送すると同時刻に集中する(リトライ嵐)。ジッターで待ちに乱数を足すと再送時刻が散り、 負荷スパイクが消える。恒久的失敗(4xx)は待っても直らないので再送しない。

設計の観点

  • ジッターの選択: full jitter が最も散るが待ちのばらつきが大きい。equal jitter は最小待ちを保証しつつ散らす。多くの実装は full を既定にする(AWS の推奨)
  • 何を再送してよいか: 一時的(5xx/タイムアウト)は再送、恒久的(4xx)は再送しない。冪等でない操作は冪等キーとセットでないと再送で副作用が重複する
  • リトライ予算: 個々のリトライは正しくても、全体のリトライが増えるとシステム全体の負荷が跳ねる。全体のリトライ率に上限(budget)をかけ、上限超過時は再送を抑えるのが大規模での定石
  • サーキットブレーカーとの組み合わせ: リトライは個別の失敗に、ブレーカーは連続失敗の遮断に効く。リトライを尽くしてなお失敗が続けばブレーカーが開く、という多層防御
  • タイムアウトとの関係: 各試行にタイムアウトを設け、全体にも締め切り(deadline)を設ける。バックオフで待つうちに全体の締め切りを超えたら、それ以上リトライしない

対照と実例

待ち方追い打ち回避衝突回避用途
即再送(固定間隔)なしなし単発・低頻度のみ
指数バックオフ(ジッターなし)ありなし単一クライアント
指数バックオフ + full jitterあり最良多数クライアント(推奨)
指数バックオフ + equal jitterあり良(最小待ち保証)待ちの下限が要る場合

裏どり:

  • AWS: Exponential Backoff And Jitter: full/equal jitter を実測比較した 公式ブログ。full jitter が最良という結論の一次資料
  • Google SRE Book: リトライ予算と、リトライがシステム全体に与える増幅効果の議論
  • gRPC / AWS SDK: 指数バックオフ + ジッターを標準装備。リトライ可能なステータスコードも定義
  • thundering herd: 同時再送で負荷が跳ねる現象の一般名。キャッシュ失効やリーダー選挙でも同型の問題が起きる

簡略化したこと

  • 論理時間: 実際の sleep でなく待ち時間を数えるだけ。決定性のため
  • リトライ判定は Permanent マークのみ: 実物はステータスコードやエラー型で分岐
  • リトライ予算なし: 全体のリトライ率制限は設計の観点で述べるに留めた
  • deadline なし: 全体の締め切りによる打ち切りは扱わない

参考資料