Retry, backoff и jitter: как делать повторы, которые не убьют сервис
Любой код, который ходит в сеть, рано или поздно встречается с сетевой ошибкой. Сервис прислал 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 дешевле, чем разбор инцидента, в котором ваш собственный код добил ваш собственный сервис.