リトライとバックオフ
実装:
resilience/retry// 実行:go test ./resilience/retry/
一時的な失敗は少し待って再送すれば通ることが多い。だが即座に再送すると過負荷の相手に追い打ちをかけ、多数のクライアントが同じ間隔で再送すれば全員が衝突する。リトライ嵐だ。指数バックオフは待ち時間を試行ごとに伸ばして追い打ちを避け、ジッターは待ちに乱数の揺らぎを足して再送時刻を散らす。この章では指数バックオフ・3種のジッター・恒久的失敗の除外を実装し、なぜジッターが衝突を防ぐかを確かめる。
この章で作るもの
サーキットブレーカーは失敗が続いたら遮断する仕組みだった。だが遮断する前の、個々の失敗にどう対処するか。ネットワークのゆらぎ、瞬間的な過負荷、リーダー選挙中の一時的なエラー。こうした一時的失敗(transient failure)は、少し待って再送すれば通ることが多い。リトライはその再送を自動化する。
問題は「どう待つか」だ。素朴に即座に再送すると、過負荷で失敗した相手に、さらにリクエストを浴びせることになる。しかも失敗しているのは自分だけではない。多数のクライアントが同時に失敗し、全員が一定間隔で再送すれば、再送のたびに負荷のスパイクが立ち、相手は回復できない。これがリトライ嵐(retry storm / thundering herd)だ。この章は、指数バックオフとジッターでこれを避ける。
即再送 ▮▮▮▮▮▮ 失敗のたびすぐ再送 → 追い打ち
指数backoff ▮ ▮ ▮ ▮ 待ちを 1→2→4→8 と伸ばす
(それでも全クライアントが同時刻に再送すると衝突)
+ジッター ▮ ▮ ▮ ▮ ▮ 各クライアントの時刻を乱数で散らす順に作る。
- 指数バックオフ: 待ち時間を
base·mult^nで伸ばし、max で頭打ち。再送の間隔を空けて相手を緩める - ジッター: 待ちに乱数の揺らぎを足す。同時失敗した多数のクライアントの再送時刻を散らし、衝突を防ぐ
- 恒久的失敗は再送しない: 400 番台のような待っても直らない失敗は、即諦めて無駄な再送をしない
① 指数バックオフ: 待ちを伸ばす
まず待ち時間を試行ごとに指数的に伸ばす。倍率をかけていき、上限で頭打ちにする:
// 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…」の間隔で再送する。待ちを伸ばしても、全員が同じ時刻に再送するので、負荷のスパイクは消えない。そこで待ちに乱数の揺らぎ(ジッター)を足す:
// 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: 失敗。次の再送まで 100 待つ(前回の 2 倍、上限 1600)。間隔を空けて相手を追い打ちしない
指数バックオフは待ちを試行ごとに伸ばし、過負荷の相手を追い打ちしない。だが全クライアントが 同じ間隔で再送すると同時刻に集中する(リトライ嵐)。ジッターで待ちに乱数を足すと再送時刻が散り、 負荷スパイクが消える。恒久的失敗(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 なし: 全体の締め切りによる打ち切りは扱わない
参考資料
- AWS: Timeouts, Retries, and Backoff with Jitter — この章の中心資料
- Exponential Backoff And Jitter (AWS) — full/equal jitter の実測
- Google, Site Reliability Engineering 22 章 — リトライ予算とカスケード障害
- 実装: resilience/retry