🏗️ Раздел 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).

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