29. Retry и backoff

Повторный вызов упавшей операции подобен стуку в закрытую дверь. Если хозяин дома ненадолго отлучился на кухню, вежливый повторный стук через несколько секунд вполне уместен и приведет к успеху. Но если дом пуст, или в нем бушует пожар, отчаянная барабанная дробь в дверь сотни раз в секунду не откроет ее — вы лишь сломаете замок и покалечите собственные пальцы.

В распределенных системах большинство катастрофических аварий происходит не из-за первичных сбоев, а из-за так называемых «штормов повторов» (retry storms). Когда база данных или зависимый микросервис испытывает секундное затруднение под пиковой нагрузкой, тысячи клиентов начинают мгновенно повторять неудачные запросы. Входящий трафик удваивается, утраивается, и кратковременная деградация превращается в полный каскадный коллапс всей инфраструктуры.

Философия Retry и Backoff в Go

В распределенных системах временные отказы — не исключение, а норма. Сетевые пакеты теряются, DNS временно не резолвит, пулы соединений БД исчерпаны, внешние API возвращают 503 или 429. Retry — это не попытка пробить стену силой, а стратегическое отступление с паузой, позволяющее инфраструктуре восстановиться. В Go нет магических аннотаций @Retryable. Повторы реализуются явно через циклы, контекст отмены и математически просчитанные задержки, что дает полный контроль над трафиком, потреблением ресурсов и поведением при деградации.

1. Классификация отказов: когда повторять, а когда отступать

Слепое повторение любых ошибок превращает временный сбой в каскадный отказ. Все ошибки делятся на две фундаментальные категории:

  • Временные (Transient): сетевой таймаут, connection refused, 502/503/504, context.DeadlineExceeded, rate limit exceeded, временная недоступность БД. Могут исчезнуть сами. Требуют повторной попытки.
  • Постоянные (Permanent): 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, ошибки валидации, синтаксический парсинг. Повтор не поможет, только зря потратит ресурсы. Требуют немедленного возврата клиенту.

В Go классификация реализуется через типизированные ошибки и errors.Is. Сервис должен явно маркировать, какие ошибки подлежат ретраю.

Go
package retry

import (
    "context"
    "errors"
    "net"
    "net/http"
)

type HTTPError struct {
    StatusCode int
    Message    string
}

func (e HTTPError) Error() string {
    return e.Message
}

func isRetryable(err error) bool {
    if err == nil {
        return false
    }
    if errors.Is(err, context.Canceled) {
        return false // Пользователь или сервер отменил запрос
    }
    if errors.Is(err, context.DeadlineExceeded) {
        return true // Таймаут часто временный
    }

    var httpErr HTTPError
    if errors.As(err, &httpErr) {
        switch httpErr.StatusCode {
        case http.StatusServiceUnavailable, http.StatusTooManyRequests, http.StatusBadGateway:
            return true
        default:
            return false
        }
    }

    // Сетевые ошибки ОС часто временные
    var opErr *net.OpError
    if errors.As(err, &opErr) {
        return opErr.Timeout() || opErr.Temporary()
    }

    return false
}

2. Математика задержек: Exponential Backoff и Jitter

Простой цикл for { retry() } бесполезен и деструктивен. Нужна стратегия плавного роста задержки. Exponential Backoff удваивает паузу между попытками: 100ms → 200ms → 400ms → 800ms. Но без Jitter (случайной добавки) все клиенты, упавшие в одну миллисекунду, синхронизируют свои повторы, создавая периодический волнообразный всплеск нагрузки (thundering herd).

Формула Full Jitter (рекомендована исследователями AWS и Netflix):
sleep = random(0, min(cap, base * 2^attempt))

flowchart TD
    A["Попытка 1"] -->|"Ошибка"| B["Расчет задержки с Full Jitter"]
    B --> C["Ожидание заданное время"]
    C -->|"Контекст активен"| D["Попытка 2"]
    D -->|"Ошибка"| B
    D -->|"Успех"| E["Возврат результата"]
    C -->|"Отмена контекста"| F["Прерывание цикла"]
    F --> G["Возврат ошибки отмены"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef success fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef cancel fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A,B,D normal;
    class C decision;
    class E success;
    class F,G cancel;
  

3. Идиоматичная реализация цикла повторов

Продакшн-реализация должна строго проверять контекст, ограничивать максимальное число попыток и использовать современный пакет math/rand/v2 (Go 1.22+) для быстрого генератора случайных чисел без глобального мьютекса.

Go
package retry

import (
    "context"
    "fmt"
    "math/rand/v2"
    "time"
)

type Config struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
}

func Do(ctx context.Context, cfg Config, fn func(ctx context.Context) error) error {
    var attempt int
    delay := cfg.BaseDelay

    for {
        attempt++
        err := fn(ctx)
        if err == nil {
            return nil // Успех
        }

        if !isRetryable(err) || attempt >= cfg.MaxAttempts {
            return fmt.Errorf("retry failed after %d attempts: %w", attempt, err)
        }

        // Проверка отмены до начала сна
        if err := ctx.Err(); err != nil {
            return err
        }

        // Расчет задержки с Full Jitter: random(0, min(MaxDelay, delay))
        maxSleep := delay
        if maxSleep > cfg.MaxDelay {
            maxSleep = cfg.MaxDelay
        }
        sleepTime := maxSleep
        if maxSleep > 0 {
            sleepTime = rand.N(maxSleep)
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(sleepTime):
            // Увеличиваем базовую задержку для следующей итерации
            delay *= 2
        }
    }
}

4. Под капотом. Таймеры, планировщик и Mechanical Sympathy

Вызов time.Sleep(d) в Go не блокирует тред ОС. Он вызывает низкоуровневую функцию рантайма runtime·timeSleep, которая:

  1. Упаковывает текущую горутину runtime.g в структуру runtime.timer.
  2. Вставляет её в минимальную кучу таймеров (min-heap) процессора P, отсортированную по времени пробуждения.
  3. Переводит горутину в состояние ожидания _Gwaiting, освобождая тред M для выполнения других горутин P.
  4. Планировщик периодически проверяет кучу, извлекает истекшие таймеры и переводит горутины обратно в статус _Grunnable.

При отмене context (или вызове timer.Stop()), рантайм извлекает таймер из кучи за O(log n) и не выполняет пробуждение. Это критично: при Graceful Shutdown тысячи горутин в ретрае немедленно прерываются, не дожидаясь конца сна, что обеспечивает детерминированное завершение процесса.

5. Интеграция с идемпотентностью и Graceful Shutdown

Retry без идемпотентности (28. Idempotency) опасен. Если операция создает платеж, а сеть обрывается после успешного выполнения на стороне сервиса, но до отправки ответа, клиент повторит запрос. Без ключа идемпотентности будет двойное списание.

При интеграции с 10. Graceful shutdown контекст main должен передаваться во все функции ретрая. Это гарантирует, что при получении SIGTERM все активные повторы немедленно прервутся, ресурсы освободятся, и сервис корректно завершит работу без зависших горутин.

6. Собеседование и типичные вопросы

7. Итог

  1. Повторяйте только временные ошибки. Явно классифицируйте их через errors.Is и коды статусов.
  2. Используйте Exponential Backoff с Full Jitter для предотвращения синхронных всплесков нагрузки.
  3. Всегда проверяйте ctx.Done() перед сном и внутри цикла. Ретрай должен уважать отмену.
  4. time.Sleep предпочтительнее time.After в циклах для минимизации аллокаций каналов.
  5. Связывайте ретрай с 28. Idempotency для безопасной повторной отправки запросов.
  6. При 10. Graceful shutdown контекст отмены должен немедленно прерывать все активные циклы повторов.
  7. Не выполняйте долгие ретраи синхронно в HTTP-обработчиках. Используйте асинхронные очереди для фонового восстановления.

Следующая статья: 30. Circuit breaker