Что такое паттерн 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упал по таймауту — неизвестно, выполнился ли платёж:- Либо пакет упал по дороге к банку $\to$ списания не было.
- Либо банк списал деньги, но сетевой пакет ответа потерялся на обратном пути.
Если тупо повторить вызов без
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] (Окончательная смерть от самострела 💥)
Защитные механизмы:
- Exponential Backoff с Jitter (Джиттер): Каждая следующая попытка выполняется с увеличенным интервалом ($200\text{мс} \to 400\text{мс} \to 800\text{мс}$) плюс случайный разброс времени, размазывающий толпу клиентов во времени.
- Retry Budget (Бюджет повторов): Клиентский балансировщик (например, в Envoy / Finagle) отслеживает статистику: ретраи разрешены только в том случае, если их доля составляет не более 10% от общего числа запросов. Если сбоит более 10% вызовов, ретраи отключаются полностью, превращаясь в чистый Fail-Fast.
- Связка с 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 означает отсутствие ресурса; повторы бессмысленны).
- ❌ «Повторять запрос нужно сразу, без всяких пауз» (Мгновенный повтор добьет упавший сервис лавиной запросов).