Модуль: Распределённые системы · Уровень: Senior+
TL;DR#
- Ретрай — это попытка повторить запрос после сбоя. Наивный «повторить сразу N раз» в распределённой системе создаёт retry storm и усиливает деградацию.
- Базовая формула паузы: exponential backoff (
base * 2^attempt) с cap (потолком) и jitter (рандомизацией), чтобы избежать синхронизации клиентов (thundering herd). - Лучший дефолт — decorrelated jitter или full jitter (по статье AWS «Exponential Backoff And Jitter»).
- Ретраить можно только идемпотентные операции (или операции, защищённые idempotency key). Иначе риск двойного списания/дублей.
- Retry budget ограничивает долю ретраев от общего трафика (например, не более 10–20%), предотвращая каскадное усиление нагрузки.
- Deadline propagation через
context.Context: общий дедлайн всего вызова пробрасывается вниз, и каждая попытка (включая backoff) обязана в него укладываться. - Ретраить осмысленно: transient-ошибки (5xx, timeout, connection reset), а не 4xx (кроме 429/503 с Retry-After).
Теория#
Зачем вообще ретраи#
В распределённой системе сбои — норма, а не исключение: сетевые таймауты, кратковременная недоступность пода во время rolling update, GC-пауза на сервере, потеря пакета. Многие из них transient — повторная попытка через короткое время с высокой вероятностью пройдёт. Ретрай повышает наблюдаемую надёжность (availability) без изменения самого сервиса.
Опасность: ретрай — это усилитель нагрузки. Если downstream уже перегружен и отвечает ошибками, наивные ретраи добавляют ещё больше запросов ровно в тот момент, когда сервису плохо. Это превращает локальную деградацию в cascading failure.
Exponential backoff#
Идея: каждая следующая попытка ждёт экспоненциально дольше, давая downstream время восстановиться.
sleep = min(cap, base * 2^attempt)base— стартовая задержка (например, 100 ms).attempt— номер попытки (0, 1, 2, …).cap— потолок, чтобы паузы не росли до минут (например, 10 s).
Пример без jitter (base=100ms, cap=10s):
| attempt | задержка |
|---|---|
| 0 | 100 ms |
| 1 | 200 ms |
| 2 | 400 ms |
| 3 | 800 ms |
| 4 | 1600 ms |
| 7 | 10 s (cap) |
Проблема чистого экспоненциального backoff: если 1000 клиентов получили ошибку одновременно (например, сервис мигнул на 1 секунду), они все будут ждать ровно 100 ms, потом ровно 200 ms — и синхронно бомбардировать downstream волнами. Это thundering herd / retry synchronization.
Jitter — рандомизация для рассинхронизации#
Jitter добавляет случайность в задержку, размазывая волну ретраев во времени. Три классических варианта (терминология из AWS Architecture Blog):
1. Full jitter — равномерно случайно от 0 до экспоненциального значения:
sleep = random(0, min(cap, base * 2^attempt))Максимально размазывает нагрузку. Минус: иногда даёт очень маленькие паузы (близко к 0), то есть слабее «отдыхает».
2. Equal jitter — половина фиксирована, половина случайна:
temp = min(cap, base * 2^attempt)
sleep = temp/2 + random(0, temp/2)Компромисс: гарантирует минимальную паузу и при этом рассинхронизирует. На практике по бенчмаркам AWS почти всегда уступает full/decorrelated.
3. Decorrelated jitter — задержка зависит от предыдущей задержки, а не от номера попытки:
sleep = min(cap, random(base, prev_sleep * 3))Где prev_sleep стартует с base. Даёт хорошее покрытие диапазона и быстрое восстановление при минимуме лишних запросов. По исследованию AWS — лучший общий выбор по соотношению «число вызовов / время завершения».
Вывод из статьи AWS: любой jitter драматически снижает число лишних вызовов по сравнению с backoff без jitter. Full jitter и decorrelated jitter — практически равны и оба хороши. Equal jitter — слабее.
ASCII-иллюстрация thundering herd:
Без jitter (все ждут одинаково): С full jitter (размазано):
t=100ms ████████████ (1000 req) t=20ms ██
t=200ms ████████████ (1000 req) t=50ms ███
t=400ms ████████████ (1000 req) t=90ms ████
волны добивают downstream t=130ms ███ ... равномерноRetry budgets (бюджеты ретраев)#
Ограничение количества ретраев «N попыток на запрос» не контролирует системную нагрузку: при массовом сбое каждый из миллионов запросов делает свои 3 ретрая → 3x трафика по всему флоту.
Retry budget ограничивает ретраи как долю от общего числа запросов за окно времени. Например, политика «ретраи не должны превышать 20% от основного трафика». Когда бюджет исчерпан — ретраи временно отключаются, запрос падает сразу.
Так делают gRPC (retryThrottling с maxTokens/tokenRatio), Envoy, Finagle, Linkerd. Token-bucket подход:
- Каждый исходный запрос добавляет токены.
- Каждый ретрай тратит токены.
- Нет токенов → ретраи запрещены.
Это превращает «жёсткий лимит на запрос» в «адаптивный лимит на сервис», который автоматически глушит ретраи при широком сбое и не мешает им при единичных ошибках.
Что можно ретраить: идемпотентность как предусловие#
Ретрай безопасен, только если повторное выполнение операции не вызывает побочного эффекта дважды. Это идемпотентность.
- Идемпотентны по природе:
GET,PUT(полная замена),DELETE, чтение. - НЕ идемпотентны:
POST /payments, «списать деньги», «отправить email», «инкремент счётчика».
Особо коварен случай: запрос дошёл и выполнился, но ответ потерялся (таймаут на ответе). Клиент думает «не получилось» и ретраит → двойное списание.
Решение для неидемпотентных операций — idempotency key: клиент генерирует уникальный ключ на логическую операцию и шлёт его при каждой попытке. Сервер дедуплицирует по ключу (сохраняет результат первого выполнения и возвращает его на повторах). Тогда операция становится идемпотентной на уровне протокола, и ретрай снова безопасен.
Правило: ретраи и идемпотентность — неразделимы. Прежде чем включать ретраи на write-эндпоинте, ответь: что произойдёт, если запрос выполнится дважды?
Timeout и deadline propagation#
Ретраи бессмысленны без таймаутов: попытка должна иметь верхнюю границу, иначе зависший вызов блокирует всю цепочку.
Два уровня:
- Per-attempt timeout — таймаут на одну попытку.
- Overall deadline — общий дедлайн на весь вызов (включая все ретраи и паузы backoff).
Критично пробрасывать дедлайн вниз по цепочке через context.Context. Если у клиента осталось 200 ms, бессмысленно начинать попытку с таймаутом 1 s или ждать backoff 500 ms. Deadline propagation означает, что каждый downstream-вызов знает оставшийся бюджет времени и не делает работу, которую заведомо не успеет отдать (защита от «зомби»-работы).
Запрос A (deadline=1s)
└─> вызов B (передаём оставшийся deadline)
└─> вызов C (передаём оставшийся deadline)Если у A истёк дедлайн, отмена через context каскадно прерывает B и C.
Retry storms / cascading failures#
Сценарий каскада:
- Сервис D начинает медленно отвечать (перегрузка / GC).
- Вызовы в D таймаутятся, клиенты ретраят.
- Нагрузка на D растёт в 2–3 раза от ретраев → D совсем ложится.
- Сервис C, зависящий от D, копит зависшие горутины/коннекты, тоже деградирует и тоже начинает ретраить.
- Деградация поднимается вверх по графу зависимостей — cascading failure.
Защитный набор (defense in depth):
- backoff + jitter (рассинхронизация),
- retry budget (ограничение доли ретраев),
- circuit breaker (fail fast при устойчивых ошибках — см. соседний материал),
- дедлайны и их проброс,
- не ретраить на нескольких уровнях стека одновременно (если ретраит и клиент, и сервис-посредник, и SDK — эффект множится: 3 уровня по 3 попытки = до 27 запросов).
Пример на Go: backoff + jitter + deadline + ретрай только transient#
package retry
import (
"context"
"errors"
"math"
"math/rand"
"time"
)
type Policy struct {
MaxAttempts int // верхняя граница попыток
Base time.Duration // стартовая задержка
Cap time.Duration // потолок задержки
}
// retryable решает, имеет ли смысл повторять. Только transient-ошибки.
type retryable interface{ Retryable() bool }
func isRetryable(err error) bool {
var r retryable
if errors.As(err, &r) {
return r.Retryable()
}
// по умолчанию — таймауты/отмену контекста не считаем "сетевым transient",
// они означают, что бюджет времени исчерпан.
return false
}
// fullJitter: random(0, min(cap, base*2^attempt))
func fullJitter(p Policy, attempt int) time.Duration {
backoff := float64(p.Base) * math.Pow(2, float64(attempt))
backoff = math.Min(backoff, float64(p.Cap))
return time.Duration(rand.Int63n(int64(backoff) + 1))
}
// Do выполняет op с ретраями. Уважает общий дедлайн из ctx.
func Do(ctx context.Context, p Policy, op func(ctx context.Context) error) error {
var lastErr error
for attempt := 0; attempt < p.MaxAttempts; attempt++ {
// per-attempt контекст наследует общий дедлайн -> deadline propagation
err := op(ctx)
if err == nil {
return nil
}
lastErr = err
if !isRetryable(err) {
return err // неретраиваемая ошибка — выходим сразу
}
if attempt == p.MaxAttempts-1 {
break
}
delay := fullJitter(p, attempt)
// если backoff не укладывается в оставшийся дедлайн — нет смысла ждать
if dl, ok := ctx.Deadline(); ok && time.Now().Add(delay).After(dl) {
return lastErr
}
select {
case <-ctx.Done():
return ctx.Err() // дедлайн/отмена пробрасываются вверх
case <-time.After(delay):
}
}
return lastErr
}Использование с общим дедлайном:
ctx, cancel := context.WithTimeout(parent, 2*time.Second) // overall deadline
defer cancel()
p := retry.Policy{MaxAttempts: 4, Base: 50 * time.Millisecond, Cap: 1 * time.Second}
err := retry.Do(ctx, p, func(ctx context.Context) error {
// per-attempt таймаут, но не больше оставшегося бюджета ctx
attemptCtx, c := context.WithTimeout(ctx, 500*time.Millisecond)
defer c()
return callDownstream(attemptCtx) // должен вернуть ошибку с Retryable()
})Для production предпочтительнее проверенные библиотеки: cenkalti/backoff/v4, avast/retry-go, gRPC service config retry policy, либо встроенные retry-механизмы service mesh (Envoy/Istio/Linkerd), где budget и backoff конфигурируются декларативно.
Подводные камни / gotchas#
- Ретрай неидемпотентной операции без idempotency key — классика двойного списания. Особенно при потере ответа после успешного выполнения.
- Ретраи на каждом уровне стека (клиент + прокси + SDK) перемножаются. Решение: ретраить на одном уровне, остальные — passthrough.
- Backoff без cap — задержки уходят в минуты, попытки висят дольше, чем имеет смысл.
- Backoff без jitter — thundering herd, синхронные волны добивают downstream.
- Игнор дедлайна при backoff — ждём 1s паузу, когда у запроса осталось 200ms.
- Ретрай 4xx-ошибок —
400/401/403/404детерминированно повторятся с тем же результатом, это бесполезная нагрузка. Исключения:429 Too Many Requestsи503с заголовкомRetry-After. - Ретрай по
context.DeadlineExceeded— обычно бессмысленно: дедлайн уже истёк, новая попытка не успеет. math/randбез правильного использования — глобальный источник под мьютексом может стать точкой контеншена при высоком RPS; в Go 1.20+ глобальный источник авто-сидируется, но для горячего пути используйтеrand.New(rand.NewSource(...))на горутину илиmath/rand/v2.- Нет retry budget — единичная политика «3 попытки» при массовом сбое = 3x нагрузка на весь флот.
- Ретрай не-thread-safe тела op — если op мутирует общее состояние/переиспользует body запроса (
io.Readerуже вычитан), повтор сломается. HTTP-тело нужно уметь пересоздать (GetBody).
Вопросы на собеседовании#
В: Зачем нужен jitter, если уже есть exponential backoff? О: Backoff разносит попытки одного клиента во времени, но не рассинхронизирует разных клиентов: если все получили ошибку одновременно, они ждут одинаковые интервалы и бьют downstream синхронными волнами (thundering herd). Jitter добавляет случайность, размазывая ретраи во времени и снижая пиковую нагрузку. По данным AWS, jitter резко уменьшает число лишних вызовов.
В: В чём разница между full, equal и decorrelated jitter?
О: Full jitter — random(0, exp), максимально размазывает, но иногда даёт почти нулевые паузы. Equal jitter — exp/2 + random(0, exp/2), гарантирует минимальную паузу. Decorrelated jitter — random(base, prev*3), зависит от предыдущей паузы, а не от номера попытки, даёт хорошее покрытие диапазона. На практике full и decorrelated близки и лучшие; equal слабее.
В: Какие операции можно ретраить? О: Только идемпотентные — те, где повторное выполнение не даёт дополнительного побочного эффекта (GET, PUT, DELETE). Неидемпотентные (POST-платёж, отправка письма) нельзя ретраить вслепую; их делают безопасными через idempotency key и серверную дедупликацию.
В: Запрос на списание денег ушёл, сервер списал, но ответ потерялся по таймауту. Клиент ретраит. Что произойдёт и как защититься? О: Без защиты — двойное списание, потому что клиент не знает, что первая попытка реально выполнилась. Защита: idempotency key, генерируемый клиентом один раз на логическую операцию; сервер сохраняет результат по ключу и на повторе возвращает тот же результат вместо повторного списания.
В: Что такое retry budget и чем он лучше «N попыток на запрос»? О: «N попыток» — локальный лимит, не контролирующий системную нагрузку: при широком сбое каждый запрос множит трафик в N раз по всему флоту. Retry budget ограничивает ретраи как долю от общего трафика (token bucket): при массовом сбое бюджет исчерпывается и ретраи глушатся, а единичные ошибки ретраятся свободно. Так делают gRPC retryThrottling, Envoy, Linkerd.
В: Что такое cascading failure и как ретраи его провоцируют? О: Каскадный отказ — деградация одного сервиса распространяется вверх по графу зависимостей. Ретраи усиливают нагрузку на уже перегруженный downstream в 2–3 раза, добивая его, после чего деградируют зависящие сервисы. Защита: backoff+jitter, retry budget, circuit breaker (fail fast), дедлайны, отказ от многоуровневых ретраев.
В: Как ретраи связаны с context и дедлайнами? О: Общий дедлайн вызова хранится в context и пробрасывается вниз (deadline propagation). Каждая попытка плюс пауза backoff должны укладываться в оставшийся бюджет; если backoff не влезает — лучше упасть сразу. Отмена context каскадно прерывает все downstream-вызовы, не давая делать «зомби»-работу.
В: Почему не стоит ретраить на каждом уровне стека? О: Ретраи перемножаются: если клиент делает 3 попытки, прокси 3, и SDK 3 — это до 27 запросов на один логический вызов, что многократно усиливает retry storm. Ретрай должен жить на одном уровне (обычно ближе к клиенту или в mesh), остальные — passthrough.
В: Стоит ли ретраить ошибку 429?
О: Да, но с уважением к Retry-After: сервер явно просит притормозить. Ретрай с backoff уместен, но без него можно лишь усугубить rate-limiting. В отличие от 4xx вроде 400/404, которые детерминированы и ретраить их бессмысленно.
На что копают на senior+#
- Понимаешь ли ты, что ретрай — усилитель нагрузки, и умеешь ли проектировать защиту от retry storm (budget + breaker + jitter), а не просто «обернуть в for».
- Связываешь ли ретраи с идемпотентностью и idempotency keys, разбираешь ли кейс «выполнилось, но ответ потерян».
- Знаешь ли конкретику jitter (формулы full/equal/decorrelated) и вывод исследования AWS, а не «добавим рандом».
- Понимаешь ли deadline propagation и взаимодействие per-attempt timeout vs overall deadline.
- Видишь ли проблему многоуровневых ретраев и их мультипликативный эффект.
- Можешь ли обосновать, что именно ретраить (transient 5xx/timeout/connection reset) и что нет (4xx, DeadlineExceeded).
- Знаешь ли промышленные реализации (gRPC service config, Envoy, Linkerd, cenkalti/backoff) и где ретраи лучше держать — в коде или в mesh.