🏗️ Розділ 17 · Питання #20

Що таке exponential backoff

Базова формула обчислення інтервалу очікування для спроби $a$ (починаючи з 0): $$\text{Delay} = \min\left(\text{Cap}, \text{Base} \times \text{Multiplier}^a\right)$$


🟢 Junior Level

Exponential Backoff (експоненційне уповільнення / експоненційна затримка) — це алгоритм керування повторними спробами (Retry), за якого час очікування між спробами експоненційно збільшується після кожного невдалого виклику.

Без Exponential Backoff (Фіксована затримка 1 сек):
Спроба 1 ──(1с)──► Спроба 2 ──(1с)──► Спроба 3 ──(1с)──► Спроба 4
(Сервіс не встигає відновитися під невпинними ударами)

З Exponential Backoff (База 1 сек, множник 2.0):
Спроба 1 ──(1с)──► Спроба 2 ──(2с)────► Спроба 3 ──(4с)────────► Спроба 4 ──(8с)──► Успіх! ✅

Навіщо це потрібно:

  1. Час на відновлення: Якщо віддалена база даних або мікросервіс перевантажені, повторний виклик через 100 мс гарантовано завершиться черговою помилкою. Експоненційна затримка дає компоненту, що зазнав збою, дедалі більше часу на перезапуск або очищення внутрішніх черг.
  2. Запобігання повторним перевантаженням: Знижує частоту звернень від кожного клієнта в геометричній прогресії.

🟡 Middle Level

1. Математична формула та параметри

Базова формула обчислення інтервалу очікування для спроби $a$ (починаючи з 0): \(\text{Delay} = \min\left(\text{Cap}, \text{Base} \times \text{Multiplier}^a\right)\)

  • Base (Базова затримка): Початковий час очікування після першого збою (наприклад, $500\text{ мс}$).
  • Multiplier (Множник / Експонента): Коефіцієнт зростання (зазвичай $2.0$).
  • Cap (Максимальна межа / «стеля»): Верхня межа затримки (наприклад, $30\text{ секунд}$). Обов’язковий параметр, інакше на 10-й спробі затримка становитиме $500 \times 2^{10} \approx 8.5\text{ хвилин}$, а на 20-й — понад 5 діб!
  • Max Attempts: Ліміт кількості спроб (зазвичай $3\text{–}5$), після вичерпання якого виклик вважається остаточно проваленим.

2. Реалізація у Resilience4j

@Configuration
public class RetryConfiguration {

    @Bean
    public RetryRegistry retryRegistry() {
        RetryConfig config = RetryConfig.custom()
            .maxAttempts(5)
            // Базовий інтервал 500мс, множник 2.0 (500мс -> 1000мс -> 2000мс -> 4000мс)
            .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
                Duration.ofMillis(500), // initialInterval
                2.0,                    // multiplier
                0.5                     // randomizationFactor (Jitter)
            ))
            .retryExceptions(TimeoutException.class, IOException.class)
            .ignoreExceptions(IllegalArgumentException.class)
            .build();

        return RetryRegistry.of(config);
    }
}

🔴 Senior Level

1. Проблема синхронного резонансу: Thundering Herd

Якщо 10 000 клієнтів одночасно отримали помилку через падіння бекенду о $12:00:00$:

  • Без випадковості (чистий Exponential Backoff) усі 10 000 клієнтів зачекають рівно 1 секунду і синхронно, єдиним фронтом вдарять по бекенду о $12:00:01$.
  • Отримавши відмову, вони зачекають рівно 2 секунди і знову синхронно вдарять о $12:00:03$.
  • Виникає небезпечний хвильовий резонанс, який вбиває сервіс під час кожної спроби піднятися (Thundering Herd Problem).
Синхронні піки без Jitter:
RPS ^
    |   |||               |||               |||
    |   |||               |||               |||
    +────┴─────────────────┴─────────────────┴──────► Time
       T=1s              T=2s              T=4s

Згладжений трафік із Full Jitter:
RPS ^
    |   . . . . . . . . . . . . . . . . . . . . . .
    +───────────────────────────────────────────────► Time (Плавний розподілений потік)

2. Класифікація алгоритмів Jitter (Дослідження AWS Architecture)

Інженери AWS (Marc Brooker) виділили три фундаментальні варіанти додавання випадковості (Jitter):

  1. Full Jitter (Рекомендований AWS за замовчуванням): \(t = \text{random}\left(0, \min(\text{Cap}, \text{Base} \times 2^a)\right)\) Інтервал сну обирається випадково від 0 до розрахункового максимуму. Забезпечує найкраще руйнування синхронних хвиль і мінімальний сумарний час очікування.
  2. Equal Jitter: \(\text{temp} = \min(\text{Cap}, \text{Base} \times 2^a); \quad t = \frac{\text{temp}}{2} + \text{random}\left(0, \frac{\text{temp}}{2}\right)\) Половина інтервалу суворо детермінована, друга половина є випадковою.
  3. Decorrelated Jitter: \(t = \min\left(\text{Cap}, \text{random}(\text{Base}, \text{PreviousSleep} \times 3)\right)\) Затримка обчислюється на основі попереднього сну без прив’язки до номера спроби. Запобігає групуванню черг навіть під час тривалих аварій.

3. Смертельна пастка: Backoff у Kafka @KafkaListener

Груба помилка розробників у Spring Kafka:

// АНТИПАТЕРН: Призводить до аварії кластера!
@KafkaListener(topics = "orders")
public void onMessage(OrderEvent event) throws InterruptedException {
    while (attempts < 5) {
        try {
            paymentClient.charge(event);
            return;
        } catch (Exception e) {
            Thread.sleep(delay); // ЗАГИБЕЛЬ КОНСЬЮМЕРА!
            delay *= 2;
        }
    }
}

Чому це вбиває систему: Потік консьюмера Kafka зобов’язаний регулярно викликати poll() у межах інтервалу max.poll.interval.ms (типово 5 хвилин). Якщо сумарний Thread.sleep() перевищить цей таймаут, координатор Kafka вирішить, що консьюмер помер, виключить його з групи та запустить каскадне ребалансування (Rebalance). Усі партиції почнуть нескінченно стрибати між живими подами, паралізуючи вичитування повідомлень у всьому кластері. Правильне рішення: Non-blocking Retries через Retry Topics (orders-retry-1s, orders-retry-5s, orders-retry-30s, orders-dlt) з використанням Spring Kafka @RetryableTopic.


4 Tricky Questions

1. Чому блокуючий Thread.sleep() у ретраях категорично непридатний для неблокуючих серверів (WebFlux / Netty)?

Відповідь: Netty та Spring WebFlux використовують модель Event Loop, де на кожне ядро процесора виділяється рівно один потік виконання. Цей потік обслуговує тисячі паралельних мережевих з’єднань. Якщо викликати Thread.sleep(5000) у реактивному ланцюжку, єдиний потік Event Loop заблокується на 5 секунд. Це призведе до того, що тисячі інших непов’язаних запитів, призначених на цей Event Loop, миттєво застрягнуть і впадуть за таймаутом. У реактивному коді затримки реалізуються виключно через неблокуючі планувальники: Mono.delay(Duration.ofSeconds(delay)) або таймери Netty HashedWheelTimer.

2. Чим відрізняється Full Jitter від простого додавання 10% шуму (Noise)?

Відповідь: Додавання 10% шуму (наприклад, $2000\text{ мс} \pm 200\text{ мс}$) зберігає вузький часовий коридор повторів (усі запити надійдуть в інтервалі від 1800 до 2200 мс). Це лише злегка розмиває межі, проте залишає амплітуду сплеску руйнівною для перевантаженого бекенду. Full Jitter обирає випадкове число в повному діапазоні від $0$ до максимального значення (від 0 до 2000 мс). У результаті повторні запити розподіляються абсолютно рівномірним тонким шаром по всій часовій шкалі, повністю ліквідуючи пікові навантаження на залежний сервіс.

3. Чому параметр Cap (максимальна затримка) є критично важливим для запобігання витокам пам’яті (OOM)?

Відповідь: Кожен відкладений запит, який очікує повторного відправлення, утримує в оперативній пам’яті об’єкт контексту завдання (DTO, заголовки, стек виклику). Без параметра Cap час очікування на високих спробах зростає до годин. У разі тривалої недоступності залежності сотні тисяч накопичених у черзі завдань переповнять купу (Heap) JVM, спричинивши аварію java.lang.OutOfMemoryError. Cap обмежує максимальний час життя завдання в буфері повторів.

4. У яких випадках використання Exponential Backoff протипоказане і погіршує користувацький досвід?

Відповідь:

  1. Синхронний UI користувача: Якщо клієнт очікує на відповідь у мобільному застосунку, тримаючи палець на екрані, затримка понад 1–2 секунди сприймається як зависання програми. Тут доцільно застосувати щонайбільше один швидкий ретрай (Fast Retry) через 100 мс, після чого керування передається у Fallback або користувачеві відображається зрозуміла помилка.
  2. Системи жорсткого реального часу (Hard Real-Time): Де затримка суворо регламентована SLA (наприклад, обробка біржових котирувань чи телеметрія сенсорів). Застарілі дані втрачають цінність, вигідніше пропустити такт, ніж наздоганяти систему через backoff.

🎯 Шпаргалка для інтерв’ю

Головне:

  • Exponential Backoff — збільшення затримки в геометричній прогресії: $T = \text{base} \times \text{multiplier}^{\text{attempt}}$.
  • Вирішує проблему відновлення перевантаженого сервісу, надаючи йому більше часу на розвантаження.
  • Обов’язковий параметр Cap (верхня межа затримки, наприклад 30 сек), інакше час очікування полетить у години.
  • Обов’язковий Jitter (Full Jitter) — додавання випадкового розкиду від 0 до максимуму для руйнування синхронного шторму повторів (Thundering Herd).
  • Ніколи не використовувати Thread.sleep() у реактивних Event Loop (Netty/WebFlux) та у @KafkaListener (спричинить каскадне ребалансування).
  • У Kafka для backoff використовуються Retry Topics (@RetryableTopic).

Формула Full Jitter для відповіді:

\(\text{Sleep} = \text{random}\left(0, \, \min(\text{Cap}, \, \text{Base} \times 2^{\text{attempt}})\right)\)

Червоні прапорці (Чого говорити НЕ МОЖНА):

  • ❌ «Ми робимо ретраї з експоненційною затримкою через Thread.sleep у слухачі Kafka» (Призведе до спрацьовування max.poll.interval.ms і нескінченного ребалансування партицій).
  • ❌ «Jitter — це просто додати 10% часу» (Це слабкий шум, який не рятує від резонансних сплесків; потрібен Full Jitter від нуля).
  • ❌ «Cap не потрібен, що довше чекаємо — то краще» (Заб’є пам’ять JVM мільйонами завислих завдань і викличе OOM).

Пов’язані теми: