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. Сервис должен явно маркировать, какие ошибки подлежат ретраю.
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+) для быстрого генератора случайных чисел без глобального мьютекса.
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, которая:
- Упаковывает текущую горутину
runtime.gв структуруruntime.timer. - Вставляет её в минимальную кучу таймеров (min-heap) процессора
P, отсортированную по времени пробуждения. - Переводит горутину в состояние ожидания
_Gwaiting, освобождая тредMдля выполнения других горутинP. - Планировщик периодически проверяет кучу, извлекает истекшие таймеры и переводит горутины обратно в статус
_Grunnable.
При отмене context (или вызове timer.Stop()), рантайм извлекает таймер из кучи за O(log n) и не выполняет пробуждение. Это критично: при Graceful Shutdown тысячи горутин в ретрае немедленно прерываются, не дожидаясь конца сна, что обеспечивает детерминированное завершение процесса.
5. Интеграция с идемпотентностью и Graceful Shutdown
Retry без идемпотентности (28. Idempotency) опасен. Если операция создает платеж, а сеть обрывается после успешного выполнения на стороне сервиса, но до отправки ответа, клиент повторит запрос. Без ключа идемпотентности будет двойное списание.
При интеграции с 10. Graceful shutdown контекст main должен передаваться во все функции ретрая. Это гарантирует, что при получении SIGTERM все активные повторы немедленно прервутся, ресурсы освободятся, и сервис корректно завершит работу без зависших горутин.
6. Собеседование и типичные вопросы
7. Итог
- Повторяйте только временные ошибки. Явно классифицируйте их через
errors.Isи коды статусов. - Используйте Exponential Backoff с Full Jitter для предотвращения синхронных всплесков нагрузки.
- Всегда проверяйте
ctx.Done()перед сном и внутри цикла. Ретрай должен уважать отмену. time.Sleepпредпочтительнееtime.Afterв циклах для минимизации аллокаций каналов.- Связывайте ретрай с 28. Idempotency для безопасной повторной отправки запросов.
- При 10. Graceful shutdown контекст отмены должен немедленно прерывать все активные циклы повторов.
- Не выполняйте долгие ретраи синхронно в HTTP-обработчиках. Используйте асинхронные очереди для фонового восстановления.
Следующая статья: 30. Circuit breaker