Суть Читай реальный retry-код — backoff с jitter, retry budget, вложенный fan-out, half-open breaker — и выбирай фикс с наибольшим рычагом, который senior делает первым.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги ретраев прячутся в коде, который выглядит корректным в юнит-тесте и детонирует под реальным 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 } // exponential, but no jitter sleep := base * time.Duration(1<<attempt) // 100, 200, 400, 800, 1600 ms time.Sleep(sleep) } return err}
Викторина
Completed
10 000 клиентов одновременно вызывают это против зависимости, которая только что блипнула. Что идёт не так и какой однострочный фикс?
Heads-up Exponential backoff разносит попытки по времени, но не расцепляет клиентов: толпа, стартовавшая вместе, топает вместе на каждом одинаковом интервале. Без jitter всплески просто переезжают на 100 мс, потом 200 мс, потом 400 мс.
Heads-up 1<<4 равно 16; длительности (от 100 мс до 1600 мс) в норме. Реальный дефект — отсутствие случайности, из-за которого все клиенты синхронизированы.
Heads-up Больше попыток умножают толпу, а не укрощают. Фикс — jitter для расцепления плюс budget для ограничения объёма, но никогда не больше синхронных попыток.
Сниппет 2 — retry budget
// token-bucket retry budget: retries may consume at most ~10% of request volumetype RetryBudget struct { mu sync.Mutex tokens float64}func (b *RetryBudget) OnRequest() { b.mu.Lock(); b.tokens += 0.1; b.mu.Unlock() } // +0.1 per requestfunc (b *RetryBudget) TryRetry() bool { b.mu.Lock(); defer b.mu.Unlock() if b.tokens >= 1 { b.tokens -= 1 // each retry costs 1 token return true } return false // budget exhausted: fail fast, do not retry}
Викторина
Completed
Зависимость полностью лежит: каждый запрос сбоит. Какую установившуюся частоту ретраев допускает этот budget и что это даёт?
Heads-up Каждый запрос добавляет лишь 0.1 токена, а ретрай стоит 1, поэтому 9 из 10 попыток ретрая находят пустое ведро и быстро проваливаются. Budget — это ровно то, что останавливает неограниченный случай.
Heads-up Токены накапливаются по 0.1 на запрос, поэтому после ~10 запросов в ведре 1 и разрешён один ретрай. Частота устанавливается около 10%, а не нуля.
Heads-up 0.1 — это токены, добавляемые на запрос; ретрай стоит полный токен. Соотношение — один ретрай на десять запросов, то есть ~10% сверх нагрузки, а не десять ретраев на каждый.
Сниппет 3 — вложенные ретраи
// data layerfunc (d *DataLayer) Read(ctx context.Context, k string) (V, error) { return retry(3, func() (V, error) { return d.pool.Read(ctx, k) }) // retries 3x}// service layerfunc (s *Service) Get(ctx context.Context, k string) (V, error) { return retry(3, func() (V, error) { return s.data.Read(ctx, k) }) // retries 3x, calling the above}// gatewayfunc (g *Gateway) Handle(ctx context.Context, k string) (V, error) { return retry(3, func() (V, error) { return g.svc.Get(ctx, k) }) // retries 3x, calling the above}
Викторина
Completed
На один запрос, сбоящий на пуле, сколько вызовов придёт к connection pool и какой правильный структурный фикс?
Heads-up Все три слоя ретраят 3 раза, и они вложены: каждая попытка шлюза гонит полный service-retry, каждый из которых гонит полный data-retry. Произведение — 3³ = 27, а не 9.
Heads-up Внутренний ретрай покрывает лишь свои 3 попытки; внешние слои не знают, что он ретраил, и добавляют свои множители сверху. К пулу приходит 27 вызовов.
Heads-up Jitter разносит 27 по времени, но не уменьшает их. Структурный фикс — перестать ретраить на нескольких слоях, а не переформировать тайминг 27 вызовов.
В состоянии HalfOpen этот код пропускает всех конкурентных вызывающих сразу. Под нагруженным сервисом почему это опасно и какой фикс?
Heads-up Half-open существует, чтобы тестировать восстановление минимальной нагрузкой: одной пробой. Допуск полного конкурентного трафика рушит этот смысл и может снова перегрузить бэкенд, который только начал дышать.
Heads-up Fallthrough намеренный — после cooldown Open переходит в HalfOpen и запускает пробу. Реальный дефект в том, что HalfOpen не ограничен одной пробой.
Heads-up Автоматическое half-open-восстановление корректно и желательно. Фикс — ограничить конкурентность в HalfOpen, а не отключать автоматическое восстановление.
Итог
Каждый retry-инцидент читается в коде: backoff без jitter ресинхронизирует толпу (full jitter — однострочный фикс); token-bucket retry budget превращает неограниченную амплификацию в потолок ~10%; вложенные ретраи на N слоях множатся до retries^N (ретрай на одном слое, проброс на остальных); а half-open breaker должен пускать одну пробу, а не поток. Предскажи fan-out при сбое, чини структуру, потом перетестируй под той же синхронной нагрузкой.