Модуль: Распределённые системы · Уровень: 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задержка
0100 ms
1200 ms
2400 ms
3800 ms
41600 ms
710 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#

Сценарий каскада:

  1. Сервис D начинает медленно отвечать (перегрузка / GC).
  2. Вызовы в D таймаутятся, клиенты ретраят.
  3. Нагрузка на D растёт в 2–3 раза от ретраев → D совсем ложится.
  4. Сервис C, зависящий от D, копит зависшие горутины/коннекты, тоже деградирует и тоже начинает ретраить.
  5. Деградация поднимается вверх по графу зависимостей — 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.