lenec ru

← все посты

Retry, backoff и jitter: как делать повторы, которые не убьют сервис

17K

Любой код, который ходит в сеть, рано или поздно встречается с сетевой ошибкой. Сервис прислал 503, БД таймаутнула, gRPC отвалился по дедлайну. Логичный рефлекс — повторить запрос. Логичный, но опасный: неправильный retry может усугубить проблему вместо того, чтобы её решить.

За двенадцать лет я видел три инцидента, в которых главным виновником был именно ретрай. Сервис лёг под нагрузкой, тысячи клиентов одновременно начали ретраить, нагрузка выросла на порядок, сервис не вставал даже когда первопричина прошла. Это называется retry storm, и каждый раз корень один: ретраи без backoff и без jitter.

Разберу, как делать ретраи правильно: какие ошибки ретраить, как считать задержку, зачем нужен jitter и где останавливаться.

Что ретраить, что не ретраить

Главное правило — ретраить только идемпотентные операции. GET, PUT, DELETE по HTTP-спеке идемпотентны. POST — нет. Ретрай POST на платёж может вылиться в двойное списание.

Решение для не-идемпотентных операций — добавить идемпотентность через Idempotency-Key на сервер-стороне. Тогда POST с одним ключом, повторённый дважды, отработает один раз.

Какие ошибки имеет смысл ретраить:

  • Сетевые таймауты, ConnectException.
  • HTTP 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.
  • HTTP 429 Too Many Requests (но с уважением к Retry-After).
  • gRPC UNAVAILABLE, DEADLINE_EXCEEDED.

Какие НЕ имеет смысла:

  • HTTP 4xx (кроме 429): запрос неправильный, ретрай не починит.
  • HTTP 500 в общем случае: серверная ошибка, может быть и постоянной. Аккуратнее.
  • Бизнес-ошибки: «недостаточно средств», «товар не доступен». Это не сбой, это ответ.

Constant retry — антипаттерн

Самый простой ретрай: «упало — повторим через секунду, ещё раз через секунду, ещё». Это retry storm в миниатюре.

// ТАК НЕ ДЕЛАЙТЕ
repeat(5) {
    try {
        return client.call()
    } catch (e: Exception) {
        Thread.sleep(1000)
    }
}

Что плохо. Если сервис упал из-за перегрузки, тысяча клиентов одновременно повторят запрос ровно через секунду. Сервис, начавший вставать, получает удар на порядок выше нагрузки. И снова падает. И через секунду снова — снова удар.

Это называется thundering herd. Решение — backoff.

Exponential backoff

Каждая следующая попытка делается через интервал, увеличивающийся экспоненциально: 1с, 2с, 4с, 8с, 16с. Это даёт сервису время восстановиться и распределяет нагрузку.

fun <T> retry(maxAttempts: Int, baseDelay: Duration, block: () -> T): T {
    var attempt = 0
    while (true) {
        try {
            return block()
        } catch (e: Exception) {
            attempt++
            if (attempt >= maxAttempts || !isRetriable(e)) throw e
            val delay = baseDelay.multipliedBy(1L shl (attempt - 1))
            Thread.sleep(delay.toMillis())
        }
    }
}

Этот вариант лучше constant retry, но всё ещё имеет проблему: все клиенты повторяют запрос в одно и то же время. Если 1000 клиентов одновременно увидели падение, через 1 секунду все 1000 повторят, через 2 секунды — все 1000.

Jitter

Jitter — это случайная добавка к интервалу backoff. Каждый клиент задерживается чуть по-разному, и нагрузка размазывается во времени.

Есть несколько вариантов jitter, и они дают разный результат:

Full jitter

Каждая попытка делается через случайный интервал в пределах [0, exponential_delay].

val capped = min(baseDelay.toMillis() * (1L shl attempt), maxDelay.toMillis())
val delay = Random.nextLong(0, capped)

Это самый «расслабленный» вариант. Часто запрос идёт почти сразу (если jitter попал в нижнюю часть диапазона), что не даёт сервису полностью восстановиться. Но нагрузка отлично распределена.

Equal jitter

Половина интервала фиксирована, половина — случайна.

val capped = min(baseDelay.toMillis() * (1L shl attempt), maxDelay.toMillis())
val delay = capped / 2 + Random.nextLong(0, capped / 2)

Гарантирует минимальную задержку, при этом размазывает нагрузку. Хороший компромисс.

Decorrelated jitter

Каждая следующая задержка считается на основе предыдущей с фактором случайности:

var sleep = baseDelay.toMillis()
repeat(maxAttempts) {
    sleep = min(maxDelay.toMillis(), Random.nextLong(baseDelay.toMillis(), sleep * 3))
    Thread.sleep(sleep)
}

Этот алгоритм AWS рекомендует в своих туториалах. Он лучше всего размазывает нагрузку при множестве клиентов, но менее интуитивен.

На большинстве проектов я использую equal jitter — простой, понятный, работает.

Cap на максимальную задержку

Без ограничения экспонента быстро уходит в космос. После 10 попыток с базой 1с интервал — 1024 секунды, или 17 минут. Никто столько ждать не будет.

Всегда ставьте cap: max_delay = 30 секунд или 1 минута, в зависимости от чувствительности. После cap'а интервалы перестают расти.

val cap = Duration.ofSeconds(30)
val rawDelay = baseDelay.multipliedBy(1L shl attempt)
val cappedDelay = if (rawDelay > cap) cap else rawDelay

Сколько попыток

Нет универсального ответа. Зависит от сценария.

Внутренний межсервисный вызов: 2-3 попытки с быстрым backoff. Если сервис лёг, скорее всего, и 5 попыток не помогут — лучше упасть быстро и дать circuit breaker'у сработать.

Внешний API в фоновой задаче: 5-10 попыток с экспоненциальным backoff и cap. Не страшно ждать 30 секунд, главное — выполнить.

Запрос от пользователя: 1-2 попытки с маленьким интервалом. Пользователь не будет ждать 10 секунд из-за ретраев — он перезагрузит страницу.

Эвристика: бюджет общего времени. Если у вас SLA «ответить за 5 секунд», бюджет ретраев — около половины этого времени, не больше.

Retry-After header

Сервер может вернуть HTTP 429 или 503 с заголовком Retry-After. Это явное указание, через сколько повторять.

Уважайте этот заголовок. Если сервер просит подождать 10 секунд — ждите 10, не «1 секунда из вашего backoff». Иначе вы продолжаете давить, когда вас явно попросили остановиться.

if (response.status == 429 || response.status == 503) {
    val retryAfter = response.headers["Retry-After"]?.toLongOrNull()
    if (retryAfter != null) {
        Thread.sleep(retryAfter * 1000)
        return
    }
}

Идемпотентные ретраи

Если ретрай делается на не-идемпотентной операции, обязателен Idempotency-Key. Клиент генерирует UUID при первой попытке, держит его весь цикл ретраев. Сервер дедуплицирует.

val idempotencyKey = UUID.randomUUID().toString()
retry(maxAttempts = 5, baseDelay = Duration.ofSeconds(1)) {
    httpClient.post("/payments") {
        header("Idempotency-Key", idempotencyKey)
        body = paymentRequest
    }
}

Без этой ключа повторный успешный платёж может пройти дважды: первый раз дошёл до сервера и обработался, ack потерялся, клиент ретраит, сервер обрабатывает снова.

Где ретраить — на клиенте или в gateway

Ретрай можно делать на разных уровнях:

  • В коде клиента (резильентная библиотека).
  • В service mesh sidecar (Istio retry policy).
  • В API gateway.
  • В load balancer.

Все они умеют ретраить, и это становится проблемой: ретрай на каждом уровне умножает попытки. Клиент попробовал 3 раза, mesh sidecar — ещё 3, gateway — ещё 3. Итого 27 попыток для одного запроса.

Решение — выбрать один уровень и отключить остальные. Я обычно держу retry на ближайшем к коду уровне (приложение или sidecar) и явно отключаю на gateway/load balancer.

Связь с circuit breaker

Ретрай и circuit breaker — два инструмента, которые работают вместе. Ретрай повторяет запрос. Circuit breaker запоминает, что сервис плохой, и временно вообще не пускает запросы.

Без circuit breaker'а: сервис лежит, клиент ретраит, ретраит, ретраит. Каждый ретрай — попытка к мёртвому сервису. Бюджет времени тратится впустую.

С circuit breaker'ом: после N подряд неудач он размыкается, ретраи мгновенно отвечают «нет, сервис плохой». Клиент видит ошибку быстро, может пойти в fallback или вернуть ошибку пользователю.

Подводные камли

Несколько ситуаций, на которые я наступал.

Synchronized retry waves. Все клиенты в одном куске инфраструктуры используют одинаковый backoff без jitter. Получаем те же thundering herd, только с экспоненциальным шагом.

Retry на длинном запросе. Запрос таймаутится через 30 секунд, ретрай делает то же самое, ещё 30 секунд. Бюджет времени съедается. Решение — короткий timeout на отдельную попытку и прикладной общий timeout на весь цикл ретраев.

Тихие ретраи. Логирование без указания, что был retry, маскирует проблемы. На дашборде «всё ок», в реальности — каждый запрос делает 3 попытки. Метрика retry_rate должна быть.

Ретраи бизнес-ошибок. Сервис вернул «недостаточно средств», клиент ретраит. Через 30 секунд — снова «недостаточно средств». Логирует это как «временная ошибка». Различайте бизнес-ответы и сбои.

Один общий ретрай для разных операций. Все операции используют один и тот же retry config (5 попыток, экспонента до 30с). Для критичной операции это норма, для UX-чувствительной — слишком долго. Конфиг ретрая — на операцию, не на сервис целиком.

Что запомнить

Правильный ретрай — это: только идемпотентные операции (или с Idempotency-Key), exponential backoff, jitter, cap на максимальную задержку, разумный лимит попыток, уважение к Retry-After, выбор одного уровня для retry в стеке, связка с circuit breaker'ом.

Без этого набора ретрай работает в счастливом случае и ломает систему в плохом. И ломает он именно тогда, когда система уже без того нездорова — то есть в момент, когда пользоваться ему особенно нужно. Платите налог сразу: правильно настроенный retry дешевле, чем разбор инцидента, в котором ваш собственный код добил ваш собственный сервис.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.