🏗️ Раздел 17 · Вопрос #19

Что такое паттерн Retry и как его правильно использовать

Retry — это «обоюдоострый меч». Если удалённый сервис упал под высокой нагрузкой, миллионы автоматических повторных попыток мгновенно утроят трафик и вызовут Retry Storm (лавину...


🟢 Junior Level

Retry (Паттерн повторных попыток) — это механизм устойчивости, который автоматически повторяет выполнение неудачной сетевой операции, рассчитывая на то, что сбой носит кратковременный характер (Transient Fault) и устранится сам собой через небольшой промежуток времени.

Сетевой сбой (Network Blip):
[Client] ──1. Request──► [Payment Service] ──(Моргание сети: Connection Timeout ❌)
[Client] ──(Пауза 500мс)
[Client] ──2. Retry────► [Payment Service] ──(Сеть восстановилась: 200 OK ✅)

Когда Retry помогает:

  • Кратковременные сетевые флуктуации (потеря пакета TCP, ребалансировка роутера).
  • Перезагрузка одного пода за балансировщиком (следующий запрос балансировщик перенаправит на живой под).
  • Кратковременная блокировка строки в базе данных (OptimisticLockException).

Главное предостережение:

Retry — это «обоюдоострый меч». Если удалённый сервис упал под высокой нагрузкой, миллионы автоматических повторных попыток мгновенно утроят трафик и вызовут Retry Storm (лавину повторов), окончательно добив умирающую систему.


🟡 Middle Level

1. Золотое правило: Идемпотентность

КАТЕГОРИЧЕСКИЙ ЗАПРЕТ: Паттерн Retry НЕЛЬЗЯ использовать для неидемпотентных операций без механизма гарантии уникальности!

  • Если запрос GET /orders/1 упал по таймауту — его безопасно повторить 3 раза (чтение идемпотентно).
  • Если запрос POST /api/v1/payments/charge упал по таймауту — неизвестно, выполнился ли платёж:
    1. Либо пакет упал по дороге к банку $\to$ списания не было.
    2. Либо банк списал деньги, но сетевой пакет ответа потерялся на обратном пути. Если тупо повторить вызов без Idempotency-Key, банк спишет деньги второй раз.
// Безопасный вызов с Retry и Idempotency-Key
public PaymentResponse chargeWithRetry(PaymentRequest request) {
    String idempotencyKey = UUID.randomUUID().toString(); // Уникальный ключ транзакции
    
    return retryRegistry.retry("paymentRetry").executeSupplier(() -> 
        restClient.post()
            .uri("/charge")
            .header("X-Idempotency-Key", idempotencyKey)
            .body(request)
            .retrieve()
            .body(PaymentResponse.class)
    );
}

2. Классификация ошибок: Retryable vs Non-Retryable

Тип ошибки HTTP-код / Исключение Делать ли Retry? Почему
Сетевые сбои ConnectException, SocketTimeoutException ДА Временная недоступность маршрута или кратковременный лаг.
Перегрузка сервиса HTTP 503 Service Unavailable, HTTP 429 Too Many Requests ДА (с Backoff) Сервис восстановится, особенно если указан заголовок Retry-After.
Ошибки клиента HTTP 400 Bad Request, HTTP 404 Not Found, HTTP 422 НЕТ ❌ Неверный синтаксис или ID. Повторный запрос вернет ту же ошибку.
Ошибки безопасности HTTP 401 Unauthorized, HTTP 403 Forbidden НЕТ ❌ Истекший токен или отсутствие прав не починятся от повтора.
Бизнес-ошибки InsufficientFundsException (Нет денег на счете) НЕТ ❌ Деньги на счете не появятся через 500 миллисекунд.

🔴 Senior Level

1. Retry Storm (Шторм повторов) и Retry Budget

При аварии одного сервиса в цепочке поведение тысяч клиентов с наивным Retry порождает лавинообразную перегрузку:

Нормальный режим: 1 000 RPS ──► [Service B] (Работает)
Авария:          1 000 RPS ──(Fail)──► [Каждый клиент делает 3 повтора!]
Результат:       4 000 RPS ──► [Service B] (Окончательная смерть от самострела 💥)

Защитные механизмы:

  1. Exponential Backoff с Jitter (Джиттер): Каждая следующая попытка выполняется с увеличенным интервалом ($200\text{мс} \to 400\text{мс} \to 800\text{мс}$) плюс случайный разброс времени, размазывающий толпу клиентов во времени.
  2. Retry Budget (Бюджет повторов): Клиентский балансировщик (например, в Envoy / Finagle) отслеживает статистику: ретраи разрешены только в том случае, если их доля составляет не более 10% от общего числа запросов. Если сбоит более 10% вызовов, ретраи отключаются полностью, превращаясь в чистый Fail-Fast.
  3. Связка с Circuit Breaker: Если Circuit Breaker переходит в OPEN, вызовы обрываются до выполнения первой попытки, предотвращая саму возможность запуска цикла Retry.

2. Deadline Propagation (Сквозной контроль дедлайна)

Классическая скрытая ошибка: «Бессмысленные повторы» (Useless Retries):

  • Пользователь в браузере отправил запрос. Общий таймаут шлюза API Gateway на весь флоу равен 3 секунды.
  • Внутренний микросервис $C$ на 3-й попытке ретрая ждёт экспоненциальную паузу в 4 секунды.
  • Пока микросервис $C$ спал и повторял запрос, шлюз на периметре уже закрыл TCP-соединение с мобильным клиентом и вернул пользователю ошибку 504 Gateway Timeout.
  • Вся дальнейшая работа сервиса $C$ и его ретраев — это впустую сожжённые ресурсы CPU и базы данных. Решение: Deadline Propagation (gRPC Deadlines / HTTP заголовки X-Request-Deadline). Сервис проверяет оставшееся до дедлайна время. Если $T_{remaining} < \text{backoffDuration}$, попытка немедленно отменяется.

4 Tricky Questions

1. Почему в Spring Retry аннотация @Retryable на методе не срабатывает при вызове из того же класса (this.method())?

Ответ: Spring Retry реализован через стандартный механизм Spring AOP Dynamic Proxy. Когда внешний компонент внедряет бин через @Autowired, он вызывает метод у сгенерированного прокси-объекта, который перехватывает вызов и применяет логику ретраев. Если метод a() вызывает метод b() внутри одного и того же класса (this.b()), обращение происходит напрямую к целевому объекту в памяти в обход Spring-прокси. В результате аннотации @Retryable и @Recover на методе b() полностью игнорируются. Решение: Вынести метод в отдельный сервис или инжектировать зависимость на самого себя через ApplicationContext.

2. Что произойдет, если совместить Spring @Transactional и @Retryable в неправильном порядке?

Ответ: Если аннотация @Transactional находится снаружи @Retryable: При ошибке во время первой попытки транзакция в Hibernate/JPA помечается как Rollback-Only, а EntityManager переходит в поврежденное состояние. Все последующие попытки ретрая будут падать с исключением Transaction silently rolled back или Transaction rolled back because it has been marked as rollback-only, даже если внешняя причина сбоя исчезла. Правило: @Retryable ОБЯЗАН быть снаружи @Transactional. Каждый новый ретрай должен открывать абсолютно новую, свежую транзакцию БД:

@Retryable(retryFor = {TransientDataAccessException.class})
@Transactional // Каждая попытка открывает чистый BEGIN / COMMIT
public void processPayment() { ... }

3. Как правильно обрабатывать HTTP-ответ 429 Too Many Requests в клиенте с Retry?

Ответ: Стандартные клиенты при ошибке 429 не должны использовать слепой экспоненциальный backoff. Сервер, защищающийся от перегрузки, отправляет точное время ожидания в заголовке ответа: Retry-After: 120 (секунды) или в формате HTTP-Date. Клиент обязан извлечь заголовок Retry-After и заснуть ровно на указанный интервал. Если повторять запросы раньше времени, указанного сервером, шлюз безопасности (Cloudflare / WAF) заблокирует IP клиента за нарушение политик API.

4. В чем разница между стратегиями Full Jitter и Equal Jitter (по исследованию AWS Architecture)?

Ответ:

  • Без Jitter: sleep = base * 2^attempt (клиенты бьют сервер синхронными волнами).
  • Equal Jitter: half = sleep / 2; sleep = half + random(0, half) (половина времени фиксирована, вторая половина случайна; уменьшает амплитуду волн, но сохраняет периодичность).
  • Full Jitter: sleep = random(0, base * 2^attempt) (интервал полностью случаен от 0 до вычисленного экспоненциального максимума). Исследования AWS показали, что Full Jitter дает наилучшую защиту от Thundering Herd, полностью разрушая резонанс повторных запросов и обеспечивая минимальное суммарное время восстановления серверов.

🎯 Шпаргалка для интервью

Главное:

  • Retry — автоматический повтор при кратковременных технических сбоях (Timeout, 503, ConnectException).
  • Железное правило: Retry применяется ТОЛЬКО к идемпотентным операциям (или с обязательным Idempotency-Key).
  • Никогда не ретраить: 4xx ошибки, ошибки авторизации, бизнес-валидации.
  • Обязательно использовать Exponential Backoff с Full Jitter, чтобы не устроить Retry Storm.
  • Retry Budget: Ограничение доли повторов до 10% от общего объема вызовов.
  • В Spring: @Retryable должен быть снаружи @Transactional!

Красные флаги (Чего говорить НЕЛЬЗЯ):

  • ❌ «Мы настроили Retry на метод списания денег с карты без Idempotency Key» (Приведет к двойным списаниям при сетевых таймаутах).
  • ❌ «Если упало с 404 Not Found, мы пробуем еще 3 раза» (404 означает отсутствие ресурса; повторы бессмысленны).
  • ❌ «Повторять запрос нужно сразу, без всяких пауз» (Мгновенный повтор добьет упавший сервис лавиной запросов).

Связанные темы: