2. Retry и backoff
Искусство не сдаваться (но делать это умно)
В предыдущей статье мы исследовали 1. Circuit breaker — архитектурный предохранитель, который бескомпромиссно заявляет: «Сервис перегружен, цепь разорвана, не бей лежачего». Но в реальной жизни распределенных систем далеко не каждый сетевой сбой означает катастрофический отказ инфраструктуры.
Пакет в виртуальной сети облака может потеряться из-за кратковременной перестройки маршрутов в SDN, база данных PostgreSQL может на 10 миллисекунд поймать взаимную блокировку транзакций (Deadlock), а под в Kubernetes — перезапускаться при плановом накате релиза. Если при первой же мимолетной заминке сразу возвращать пользователю красный экран с ошибкой — это откровенно плохой инженерный дизайн и разрушенный пользовательский опыт (UX).
Мы обязаны попытаться еще раз. Паттерн Retry (Повторные попытки) предназначен для прозрачного сглаживания временных, транзитных (transient) сетевых аномалий.
Однако реализация повторов «в лоб» превращает ваше приложение в мощнейшее оружие массового поражения: вместо спасения системы вы собственноручно организуете сокрушительную DDoS-атаку на собственную базу данных. В этой статье мы разберем физику таймингов, математику случайного рассеивания нагрузки, внутреннее устройство таймеров в рантайме Go и ключевые ловушки проектирования.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph NaivePattern["Синхронный Retry (Thundering Herd)"]
direction TB
N1["10 000 клиентов"]:::error -->|"Сбой в БД (Failover)"| N2["Одновременный Sleep(1s)"]
N2 -->|"Синхронный залп через 1.00s"| N3["Повторный сокрушительный удар в БД"]
end
subgraph JitterPattern["Exponential Backoff + Full Jitter"]
direction TB
J1["10 000 клиентов"]:::data -->|"Сбой в БД"| J2["Экспонента + Рандомизация"]
J2 -->|"Равномерное размазывание во времени"| J3["Плавное восстановление БД"]
end
Наивный Retry и Thundering Herd Problem
Посмотрим на код, который часто пишут начинающие разработчики:
// Антипаттерн: примитивный фиксированный Retry
func BadRetry(do func() error) error {
var err error
for i := 0; i < 3; i++ {
if err = do(); err == nil {
return nil
}
time.Sleep(1 * time.Second) // Задержка ровно в 1 секунду
}
return err
}
В чем кроется скрытая угроза этого простого цикла?
Представьте, что кластер СУБД или балансировщик кратковременно «моргнул» (произошел Failover мастера на 2–3 секунды). В этот момент у вас работало 10 000 параллельных запросов. Все 10 000 горутин одновременно получают сетевую ошибку, синхронно вызывают time.Sleep ровно на одну секунду, одновременно просыпаются через 1000 миллисекунд и единым плотным фронтом обрушиваются на только что поднявшуюся базу данных!
База данных, едва начавшая прогревать кэши и открывать пулы соединений, мгновенно захлебывается и падает снова. Этот кошмар архитекторов называется Thundering Herd Problem (Проблема стада бизонов): фиксированные, регулярные интервалы повторов синхронизируют независимых клиентов в мощные резонансные волны нагрузки.
Математика спасения: Exponential Backoff и Jitter
Чтобы разорвать фазовую синхронизацию клиентов и дать деградирующему сервису время на плавное «дыхание», в распределенных системах применяют комбинацию двух математических инструментов:
1. Exponential Backoff (Экспоненциальная задержка)
Вместо константного интервала время ожидания перед каждым следующим повтором увеличивается экспоненциально в зависимости от номера попытки.
Каноническая формула: Wait = Base * 2^Attempt (или в математической записи $\text{Wait} = \text{Base} \times 2^{\text{Attempt}}$).
Где:
- Попытка 0: $\text{Base} \times 2^0 = 100\text{ мс}$
- Попытка 1: $\text{Base} \times 2^1 = 200\text{ мс}$
- Попытка 2: $\text{Base} \times 2^2 = 400\text{ мс}$
- Попытка 3: $\text{Base} \times 2^3 = 800\text{ мс}$
Экспонента быстро раздвигает временные рамки: если downstream-сервис испытывает затяжные трудности, мы не забиваем его канал, а кратно снижаем частоту запросов.
2. Jitter (Джиттер / Случайный разброс)
Одной лишь экспоненциальной задержки недостаточно. Если 10 000 запросов споткнулись в один момент времени $t_0$, то через 100 мс они вновь придут вместе, затем через 200 мс снова ударят вместе. Резонанс сохраняется.
Нам необходим контролируемый хаос. Jitter добавляет случайную энтропию в расчетную формулу ожидания.
Исследования распределенных систем (популяризированные инженерами Amazon Web Services) доказали максимальную эффективность алгоритма Full Jitter:
$$\text{Wait} = \text{random}(0, \, \min(\text{MaxDelay}, \, \text{Base} \times 2^{\text{Attempt}}))$$
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Req["Входящий запрос"]:::entry --> Call["1. Сетевой вызов сервиса"]
Call --> IsOK{"2. Успех?"}
IsOK -->|"Да"| ExitOK["Возврат результата"]
IsOK -->|"Нет"| IsTransient{"3. Ошибка транзитная?"}
IsTransient -->|"Нет (4xx / Fatal)"| Fail["Fast Fail: Отказ без повторов"]
IsTransient -->|"Да (5xx / Timeout)"| Limit{"4. Лимит попыток?"}
Limit -->|"Исчерпан"| FailMax["Возврат финальной ошибки"]
Limit -->|"Есть запас"| Backoff["5. Расчет Full Jitter:<br/>rand(0, Base * 2^attempt)"]
Backoff --> Sleep["6. Ожидание через time.NewTimer<br/>с проверкой ctx.Done()"]
Sleep --> Call
Случайный разброс по диапазону от нуля до текущей экспоненциальной границы превращает узкий смертоносный пик нагрузки в ровный, легко перевариваемый распределенный поток.
Production-Ready реализация на Go
Промышленный механизм повторов обязан удовлетворять трем строгим критериям:
- Поддерживать отмену через
context.Context(если пользователь закрыл вкладку или сработал общий дедлайн, повторы должны немедленно прекратиться). - Исключать утечки памяти в таймерах рантайма.
- Различать транзитные ошибки от перманентных.
package retry
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net"
"time"
)
// Config задает параметры политики повторов
type Config struct {
MaxRetries int // Максимальное число попыток (например, 3)
BaseDelay time.Duration // Базовый интервал (например, 100ms)
MaxDelay time.Duration // Верхний потолок ожидания (например, 3s)
}
// IsTransient проверяет, является ли сбой временным
func IsTransient(err error) bool {
if err == nil {
return false
}
// Ошибки сети и временные таймауты подлежат повтору
var netErr net.Error
if errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) {
return true
}
// Дополнительно: здесь проверяются HTTP 502 Bad Gateway, 503, 504
return true
}
// Do выполняет переданную функцию с защитой Exponential Backoff + Full Jitter
func Do(ctx context.Context, cfg Config, fn func(ctx context.Context) error) error {
var lastErr error
for attempt := 0; attempt < cfg.MaxRetries; attempt++ {
// Проверяем контекст перед каждой попыткой
if err := ctx.Err(); err != nil {
return err
}
lastErr = fn(ctx)
if lastErr == nil {
return nil // Операция успешно завершилась
}
// Если ошибка фатальная (не транзитная), прерываемся без повторов
if !IsTransient(lastErr) {
return lastErr
}
// Если попытки исчерпаны, возвращаем последнюю ошибку
if attempt == cfg.MaxRetries-1 {
break
}
// Вычисляем экспоненциальную задержку с джиттером
delay := calculateBackoff(cfg.BaseDelay, cfg.MaxDelay, attempt)
// Используем time.NewTimer для строгого контроля над аллокациями памяти
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
// Обязательно останавливаем таймер, чтобы освободить внутренние структуры
timer.Stop()
return ctx.Err()
case <-timer.C:
// Время паузы истекло, переходим к следующей итерации
}
}
return fmt.Errorf("исчерпан лимит повторных попыток (%d): %w", cfg.MaxRetries, lastErr)
}
func calculateBackoff(base, max time.Duration, attempt int) time.Duration {
// Расчет экспоненты: base * 2^attempt со сдвигом битов
multiplier := 1 << attempt
exp := float64(base) * float64(multiplier)
if exp > float64(max) {
exp = float64(max)
}
// Идиоматичный Full Jitter на современном Go math/rand/v2
// random в диапазоне [0, exp)
jitter := rand.Float64() * exp
return time.Duration(jitter)
}
Архитектурные ловушки (Gotchas)
1. Умножение повторов (Retry Amplification)
Представим распределенную цепочку синхронных вызовов между микросервисами: A -> B -> C.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Сервис A<br/>(3 retries)"]:::entry -->|"Запрос"| B["Сервис B<br/>(3 retries)"]
B -->|"Запрос"| C["Сервис C<br/>(Сбоит)"]
Если сервис C испытывает затруднения, а в каждом звене бездумно настроено по 3 повтора, то:
- Сервис
Bпопытается вызватьC3 раза. - Потерпев неудачу,
Bвернет ошибку сервисуA. - Сервис
Aпосчитает ошибку отBвременной и повторит запрос кBеще 2 раза. - При каждом таком повторе
Bснова 3 раза ударит поC!
В итоге сервис C получит $3 \times 3 = 9$ запросов на один клиентский клик. В цепочке из четырех микросервисов глубина умножения достигнет $3^3 = 27$ запросов. Система устраивает самострел (Retry Storm), гарантированно сжигая процессорные ядра и сетевые очереди downstream-сервиса.
Архитектурное решение:
- Повторные попытки должны выполняться строго на одном уровне абстракции (обычно на входном API Gateway или внешнем BFF). Внутренние межсервисные вызовы должны сразу возвращать ошибку.
- Использование сквозных «бюджетов повторов» (Retry Budget) через заголовки распределенного контекста, ограничивающих глобальную долю ретраев до 5–10% от общего RPS.
2. Идемпотентность — Строгое требование
Повторять можно исключительно идемпотентные операции.
Если вы отправляете запрос POST /charge (списание денежных средств с банковской карты), запрос успешно дошел до шлюза банка, деньги списались со счета клиента, но на обратном пути произошел сетевой таймаут сокета — ваш клиент получит ошибку соединения. Если в коде настроен автоматический Retry — со счета спишется двойная сумма! Подробнее этот сценарий разобран в материале 4. Partial failure.
3. Circuit Breaker + Retry
В надежных распределенных архитектурах Retry всегда выступает клиентом для паттерна 1. Circuit breaker.
Если удаленный сервис лежит безнадежно, Circuit Breaker переходит в состояние OPEN. Очередной вызов целевой функции мгновенно возвращает ошибку ErrCircuitBreakerOpen.
Обработчик IsTransient в коде Retry обязан распознавать эту ошибку как терминальную (не подлежащую повторам) и немедленно прерывать цикл (Fast Fail). Бесполезно раз за разом прогонять экспоненциальный таймер, когда рубильник питания удаленного сервиса выключен на аппаратном уровне.
Итог
- Забудьте про фиксированный Sleep: Синхронные паузы неизбежно порождают разрушительную проблему Thundering Herd.
- Экспоненциальный Backoff + Full Jitter: Единственный научно обоснованный способ сгладить всплески нагрузки при восстановлении упавших сервисов.
- Дисциплина памяти в Go: Используйте
time.NewTimerс явным вызовомtimer.Stop(), чтобы исключить утечки ресурсов при отмене контекстаctx.Done(). - Идемпотентность свята: Повторяйте запросы только при наличии гарантий идемпотентности (
Idempotency-Key). - Контролируйте каскады: Избегайте Retry Amplification в цепочках сервисов, настраивая повторы исключительно на внешнем периметре системы.
Мы научились ждать и предпринимать повторные попытки при временных сбоях. Но как долго должен длиться каждый отдельный сетевой вызов до того, как мы признаем его провалившимся? В следующей статье мы разберем критически важный защитный рубеж распределенных систем: 3. Timeout, и выясним, почему отсутствие таймаутов остается главной причиной ночных аварий в продакшене.