Retry amplification: чтение кода
Читай реальный retry-код — backoff с jitter, retry budget, вложенный fan-out, half-open breaker — и выбирай фикс с наибольшим рычагом, который senior делает первым.
Баги ретраев прячутся в коде, который выглядит корректным в юнит-тесте и детонирует под реальным outage. Прочитай каждый сниппет, предскажи поведение, когда зависимость лежит, и выбери фикс, который senior делает первым.
Отработай цикл, который ты прогоняешь на каждом retry-конфиге: прочитай backoff, budget и граф вызовов; предскажи fan-out при сбое; и тянись к фиксу с наибольшим рычагом, прежде чем добавлять ещё ретраи.
Сниппет 1 — backoff без jitter
func callWithRetry(ctx context.Context, fn func() error) error {
base := 100 * time.Millisecond
var err error
for attempt := 0; attempt < 5; attempt++ {
if err = fn(); err == nil {
return nil
}
// экспоненциальный, но без jitter
sleep := base * time.Duration(1<<attempt) // 100, 200, 400, 800, 1600 ms
time.Sleep(sleep)
}
return err
}
10 000 клиентов одновременно вызывают это против зависимости, которая только что блипнула. Что идёт не так и какой однострочный фикс?
Сниппет 2 — retry budget
// token-bucket retry budget: ретраи могут потреблять не более ~10% объёма запросов
type RetryBudget struct {
mu sync.Mutex
tokens float64
}
func (b *RetryBudget) OnRequest() { b.mu.Lock(); b.tokens += 0.1; b.mu.Unlock() } // +0.1 на запрос
func (b *RetryBudget) TryRetry() bool {
b.mu.Lock(); defer b.mu.Unlock()
if b.tokens >= 1 {
b.tokens -= 1 // каждый ретрай стоит 1 токен
return true
}
return false // бюджет исчерпан: быстрый отказ, без ретрая
}
Зависимость полностью лежит: каждый запрос сбоит. Какую установившуюся частоту ретраев допускает этот budget и что это даёт?
Сниппет 3 — вложенные ретраи
// слой данных
func (d *DataLayer) Read(ctx context.Context, k string) (V, error) {
return retry(3, func() (V, error) { return d.pool.Read(ctx, k) }) // 3 повтора
}
// сервисный слой
func (s *Service) Get(ctx context.Context, k string) (V, error) {
return retry(3, func() (V, error) { return s.data.Read(ctx, k) }) // 3 повтора, вызывает вышеуказанный
}
// шлюз
func (g *Gateway) Handle(ctx context.Context, k string) (V, error) {
return retry(3, func() (V, error) { return g.svc.Get(ctx, k) }) // 3 повтора, вызывает вышеуказанный
}
На один запрос, сбоящий на пуле, сколько вызовов придёт к connection pool и какой правильный структурный фикс?
Сниппет 4 — half-open breaker
func (b *Breaker) Call(fn func() error) error {
switch b.state {
case Open:
if time.Since(b.openedAt) < b.cooldown {
return ErrOpen // быстрый отказ, без сетевого вызова
}
b.state = HalfOpen // cooldown истёк: допускаем пробы
fallthrough
case HalfOpen:
err := fn()
if err != nil {
b.state = Open; b.openedAt = time.Now() // проба провалилась: снова Open
return err
}
b.state = Closed // проба прошла: возобновляем нормальный трафик
return nil
default: // Closed
return b.trackFailures(fn)
}
}
В состоянии HalfOpen этот код пропускает всех конкурентных вызывающих сразу. Под нагруженным сервисом почему это опасно и какой фикс?
Каждый retry-инцидент читается в коде: backoff без jitter ресинхронизирует толпу (full jitter — однострочный фикс); token-bucket retry budget превращает неограниченную амплификацию в потолок ~10%; вложенные ретраи на N слоях множатся до retries^N (ретрай на одном слое, проброс на остальных); а half-open breaker должен пускать одну пробу, а не поток. Предскажи fan-out при сбое, чини структуру, потом перетестируй под той же синхронной нагрузкой.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.