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

Посмотрим на код, который часто пишут начинающие разработчики:

Go
// Антипаттерн: примитивный фиксированный 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

Промышленный механизм повторов обязан удовлетворять трем строгим критериям:

  1. Поддерживать отмену через context.Context (если пользователь закрыл вкладку или сработал общий дедлайн, повторы должны немедленно прекратиться).
  2. Исключать утечки памяти в таймерах рантайма.
  3. Различать транзитные ошибки от перманентных.
Go
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 повтора, то:

  1. Сервис B попытается вызвать C 3 раза.
  2. Потерпев неудачу, B вернет ошибку сервису A.
  3. Сервис A посчитает ошибку от B временной и повторит запрос к B еще 2 раза.
  4. При каждом таком повторе 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). Бесполезно раз за разом прогонять экспоненциальный таймер, когда рубильник питания удаленного сервиса выключен на аппаратном уровне.


Итог

  1. Забудьте про фиксированный Sleep: Синхронные паузы неизбежно порождают разрушительную проблему Thundering Herd.
  2. Экспоненциальный Backoff + Full Jitter: Единственный научно обоснованный способ сгладить всплески нагрузки при восстановлении упавших сервисов.
  3. Дисциплина памяти в Go: Используйте time.NewTimer с явным вызовом timer.Stop(), чтобы исключить утечки ресурсов при отмене контекста ctx.Done().
  4. Идемпотентность свята: Повторяйте запросы только при наличии гарантий идемпотентности (Idempotency-Key).
  5. Контролируйте каскады: Избегайте Retry Amplification в цепочках сервисов, настраивая повторы исключительно на внешнем периметре системы.

Мы научились ждать и предпринимать повторные попытки при временных сбоях. Но как долго должен длиться каждый отдельный сетевой вызов до того, как мы признаем его провалившимся? В следующей статье мы разберем критически важный защитный рубеж распределенных систем: 3. Timeout, и выясним, почему отсутствие таймаутов остается главной причиной ночных аварий в продакшене.