Що таке патерн 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або тимчасовий deadlock).
Головне застереження:
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 |
НІ ❌ | Неправильний синтаксис чи ідентифікатор. Повторний запит поверне ту саму помилку. |
| Помилки безпеки | 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() повністю ігноруються.
Рішення: Винести метод в окремий Spring-сервіс або інжектувати залежність на самого себе (self-injection) через 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 / Envoy) може тимчасово заблокувати 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 вказує на відсутність ресурсу; повтори безглузді).
- ❌ «Повторювати запит треба миттєво, без жодних пауз» (Миттєвий повтор доб’є збійний сервіс лавиною запитів).