Exponential Backoff (экспоненциальная задержка повторов)
Exponential Backoff (экспоненциальная задержка повторов)
Exponential backoff — это правило повторять неудавшуюся операцию не сразу и
не через равные промежутки, а с паузой, которая растёт по экспоненте
(1 с, 2 с, 4 с, 8 с, 16 с…), обычно с потолком и со случайным разбросом.
Смысл двойной: дать сбойной стороне время подняться и не превратить свои
повторы в добивание.
История
Первым с этой проблемой всерьёз столкнулся не интернет, а радио. В 1970 году
Норман Абрамсон (Norman Abramson) из Гавайского университета запустил
ALOHA — первую сеть с общей радиосредой: несколько станций передают в один
и тот же канал, и если два пакета наложились друг на друга (коллизия), оба
погибают. В исходной ALOHA станция после коллизии ждала случайную паузу, но
ждала «плоско», без роста. При загрузке канала около 18% пакетов проходили
успешно — потолок, который быстро стал притчей во языцех.
Решение придумали в Xerox PARC. В статье Роберта Меткалфа (Robert Metcalfe)
и Дэвида Боггса (David Boggs) 1976 года про Ethernet описана схема, где
станция после каждой новой коллизии увеличивает окно случайного ожидания
вдвое. Это и есть binary exponential backoff (двоичная экспоненциальная
задержка), закреплённая в стандарте IEEE 802.3 (1983): после n-й коллизии
станция ждёт случайное число «слотов» из диапазона 0…2^k−1, где k растёт до
10, а после 16 неудач кадр выбрасывается. Практический эффект был огромный:
пропускная способность выросла примерно втрое при той же нагрузке.
Второе рождение идеи — TCP. В 1988 году Ван Джейкобсон (Van Jacobson)
опубликовал работу «Congestion Avoidance and Control», где показал, что
таймаут повторной передачи надо удваивать при каждой неудаче, а не держать
постоянным. Сейчас это зафиксировано в RFC 6298: начальный RTO (retry
timeout) — около 1 секунды, и при каждом таймауте он удваивается. Именно
поэтому «подвисшее» TCP-соединение умирает не за секунду, а за десятки секунд:
таймеры растут 1 → 2 → 4 → 8…
Третий важный шаг — про то, что экспоненту надо разбавлять случайностью.
В 2015 году Марк Брукер (Marc Brooker) из AWS опубликовал в блоге AWS
Architecture заметку «Exponential Backoff And Jitter», где на симуляциях
показал: чистый backoff без джиттера (jitter — случайный разброс) плохо
разводит клиентов во времени, потому что все они стартовали одновременно и
ждут одинаково. После этого «full jitter» стал дефолтом в клиентских
библиотеках AWS.
Сегодня это стандартная деталь инфраструктуры: перезапуски в systemd,
CrashLoopBackOff в Kubernetes (пауза растёт до 5 минут), retry-политики
в gRPC, бюджеты повторов в Envoy, повторные запросы в SDK всех
крупных облаков.
Что это такое
Формально у тебя четыре ручки:
- base — начальная пауза (обычно 0.1–1 с).
- factor — во сколько раз растёт пауза (почти всегда 2).
- cap — потолок, дальше которого пауза не растёт (обычно 30–60 с).
- max attempts — сколько всего попыток (часто 3–5).
Плюс пятая, необязательная, но самая важная на практике — jitter:
случайное отклонение паузы, чтобы одновременные клиенты не били в одну точку.
Что это не такое, потому что путают постоянно:
Backoff vs rate limiting. Rate limiting (ограничение частоты) — это про
то, чтобы вообще не превышать разрешённую скорость, и он живёт на стороне
сервера (или в клиенте как предохранитель). Backoff — это реакция на уже
случившийся отказ, попытка «залечить» его повтором. Первое профилактика,
второе лечение.
Backoff vs circuit breaker. Circuit breaker (предохранитель, «размыкатель
цепи») вообще перестаёт отправлять запросы на время, когда доля ошибок
превысила порог. Backoff по-прежнему отправляет, просто реже. Предохранитель
отвечает на вопрос «стоит ли вообще пробовать», backoff — «когда именно
пробовать». В зрелых системах они работают парой.
Backoff vs polling. Polling (опрос) — это когда ты регулярно спрашиваешь
«ну что, готово?» у асинхронной задачи. Это не про ошибки, а про ожидание
результата. Но если задача долгая, polling тоже делают с растущей паузой —
и тогда это уже почти backoff, только повод другой.
Backoff vs очередь. Очередь с отложенными сообщениями — это backoff,
вынесенный наружу, в инфраструктуру: вместо sleep в процессе ты
перекладываешь сообщение с задержкой. Плюс — переживает перезапуск
процесса, минус — требует брокера.
Аналогии из жизни
1. Дверной звонок. Ты пришёл к другу, а он, похоже, в наушниках. Что
делает вежливый человек: звонит, ждёт 10 секунд, звонит ещё, ждёт 20, потом
40. Грубый — жмёт кнопку 30 раз подряд. Разница в том, что во втором случае
друг всё равно не услышит раньше, а соседи уже ненавидят вас обоих.
Где работает: ровно та логика, что нужна для «сервис моргнул».
Где ломается: если друг уехал на неделю, никакое удвоение пауз не поможет —
ты просто будешь звонить всё реже в пустоту. Backoff не различает «сейчас
плохо, потом будет хорошо» и «сломано навсегда». Именно поэтому нужен
потолок по числу попыток и отдельное решение, что делать после.
2. Съезд с шоссе в час пик. Представь, что на съезд одновременно
перестраиваются двести машин. Если все ждут «ровно 5 секунд и пробуют
снова» — они снова приезжают синхронно и снова стоят. Если каждый ждёт
случайное время — поток рассасывается. Это и есть джиттер, и в сетях
эффект тот же самый.
Где работает: массовые одновременные клиенты (толпа телефонов после
перезапуска сервера).
Где ломается: если машин мало (два клиента), рандом ничего не даёт — и
если один из них «невезучий», он может систематически проигрывать. Кроме
того, рандом плохо сочетается с жёсткими SLA: ты не можешь обещать «ответ
в течение N», если в алгоритме есть случайная величина, которую ты не
контролируешь.
3. Настройка оркестра. Перед концертом сто скрипачей одновременно берут
«ля» первой струны. Получается не нота, а каша: все играют одно и то же,
и именно поэтому ничего не слышно. Дирижёр просит настраиваться по группам,
и какофония превращается в звук.
Где работает: метафора «одновременность — сама по себе проблема, даже если
каждый делает правильную вещь». Это главный инсайт про thundering herd
(«ревущее стадо»).
Где ломается: в оркестре группы всё равно детерминированы, а в сети
синхронность может восстановиться сама — например, если все клиенты ждут
по часам, а не по «прошло N секунд с моего отказа». Тогда джиттер надо
считать не от локального таймера, а от общего источника времени.
Как это работает
Пошагово, на пальцах. Есть операция, которая может отказать. Ты оборачиваешь
её в цикл.
attempt = 0
while True:
try:
return do_request()
except Retryable:
attempt += 1
if attempt > MAX_ATTEMPTS:
raise
delay = min(CAP, BASE * 2 ** (attempt - 1))
delay = delay * random.uniform(0, 1) # full jitter
sleep(delay)
Таблица пауз при BASE = 1, CAP = 30, MAX_ATTEMPTS = 5:
| попытка | чистая экспонента | со «full jitter» (диапазон) |
|---|---|---|
| 1 | 1 с | 0 … 1 с |
| 2 | 2 с | 0 … 2 с |
| 3 | 4 с | 0 … 4 с |
| 4 | 8 с | 0 … 8 с |
| 5 | 16 с | 0 … 16 с |
Итого худший случай — около 31 секунды ожидания, и это уже заметно для
пользователя. Отсюда правило: backoff в синхронном пользовательском запросе
почти всегда считается не в секундах, а в сотнях миллисекунд, а «долгие»
повторы уезжают в фон.
Варианты джиттера, которые стоит знать по именам (все — из заметки AWS 2015):
- No jitter:
delay = min(cap, base * 2^attempt). Плохо при массовых
клиентах. - Full jitter:
delay = random(0, min(cap, base * 2^attempt)). Лучший
результат по нагрузке на сервер, но разброс большой. - Equal jitter:
delay = half + random(0, half), где
half = min(cap, base * 2^attempt) / 2. Компромисс: не слишком быстро,
но и не хаотично. - Decorrelated jitter:
delay = min(cap, random(base, prev_delay * 3)).
Пауза зависит от предыдущей, а не от номера попытки; хорошо, когда
распределение отказов неизвестно.
Второй по важности после формулы — вопрос что вообще считать
retryable (достойным повтора). Здесь начинается инженерия, а не
арифметика.
По HTTP-коду. 408, 429, 500, 502, 503, 504 — обычно повторяют.
400, 401, 403, 404, 409, 422 — обычно нет: повтор даст тот же
ответ, ты просто потратишь время и квоту. 429 и 503 при этом почти всегда
приходят с заголовком Retry-After (RFC 9110) — это сервер сам говорит,
через сколько приходить. Игнорировать его и жать «свои» 1-2-4 секунды —
грубость, за которую некоторые API банят.
По идемпотентности. GET, PUT, DELETE по спецификации идемпотентны
(повтор без побочного эффекта), повторять их безопасно. POST — нет: повтор
создаст второй заказ. Лечится idempotency key (ключ идемпотентности) —
клиент генерирует уникальный ключ операции и передаёт его в заголовке; сервер
видит знакомый ключ и возвращает прежний результат вместо новой операции.
Так это устроено, например, в API Stripe.
По телу ответа. А вот тут самое интересное. Код ответа — это грубая
подсказка, и провайдеры регулярно её нарушают. Бывает 400 с текстом
«сервис временно недоступен, попробуйте позже»: формально это «ты прислал
мусор» (клиентская ошибка, повторять нельзя), фактически — «я сломался,
вернись через минуту». Клиент, который смотрит только на первую цифру
статуса, сдаётся там, где надо было подождать.
Backoff — это про классификацию ошибок, а не про sleep
Формула 2^n — самая простая часть. 80% пользы приносит правильный ответ
на вопрос «эту ошибку вообще имеет смысл повторять?». Ошибся в
классификации — и либо сдаёшься на временном сбое, либо долбишь сервер
там, где ответ уже не изменится никогда.
Третий слой — бюджет повторов. Проблема: если сервис А вызывает сервис Б,
а тот — сервис В, то каждый повтор на верхнем уровне умножается на повторы
внутри. Пять слоёв по три попытки — это до 243 запросов вместо одного. Это
называется retry amplification (усиление повторами) и является классической
причиной того, что «небольшой сбой превратился в полную недоступность»: все
клиенты одновременно начали повторять и добили сервис, который почти ожил.
Лечится тремя способами: ограничением доли повторов от общего трафика
(в Envoy бюджет по умолчанию — 20% от активных запросов), «предохранителем»
на отказ и тем, что повторы разрешены только на самом внешнем слое.
Где встречается в обычной жизни
- Мессенджер пишет «подключение…». Когда пропал Wi-Fi и вернулся,
приложение не пытается переподключиться 60 раз в секунду — оно делает это
всё реже. Это backoff, который ты видишь как «оно само починилось через
полминуты». - Банкомат: «операция временно недоступна, повторите позже». Банк-процессинг
уже нагружен; вместо того чтобы поставить тебя в очередь, тебе вежливо
отказывают — это ручной backoff, где «sleep» делаешь ты сам. - Сайт в чёрную пятницу отдаёт 429. Ты обновляешь страницу, ничего не
происходит, обновляешь через минуту — заходит. Внутри это rate limiting
плюс твой собственный неосознанный backoff. - Умная колонка не подключается к Wi-Fi с первого раза. Устройства при
неудачной ассоциации ждут и пробуют снова с растущей паузой — иначе
двадцать гаджетов в квартире устроили бы в эфире ту самую ALOHA. - Доставка еды: «ищем курьера». Заказ не отменяется сразу — платформа
повторяет попытки назначить курьера, расширяя радиус и увеличивая паузы.
Где встречается в IT и бизнесе
- Клиентские библиотеки и SDK. Все крупные облачные SDK (AWS, Google
Cloud, Azure) имеют встроенные повторы с экспонентой и джиттером, обычно
3 попытки по умолчанию. Класс задач: «мой код общается с чужим API по
сети, а сеть иногда моргает». - Очереди и воркеры. Сообщение, обработка которого упала, возвращается в
очередь с растущей задержкой; после N попыток уезжает в dead letter queue
(очередь «безнадёжных»). Так работают SQS с visibility timeout, RabbitMQ
с dead-letter exchange, Sidekiq с его retry-расписанием. - Оркестраторы и супервизоры. systemd перезапускает упавший сервис с
паузой (RestartSec) и по умолчанию не даёт больше 5 стартов за 10 секунд;
Kubernetes в CrashLoopBackOff растягивает паузу до 5 минут. Класс задач:
«процесс падает не навсегда, а из-за временной причины — например,
недоступна база при старте». - CI/CD и деплой. Повтор шага пайплайна при флапающем тесте или
недоступном registry — почти всегда с экспонентой, иначе весь парк
сборок синхронно бьётся в один и тот же момент. - Мобильные приложения и синхронизация. Телефон в метро: сеть то есть,
то нет. Клиент копит изменения и отправляет их с растущими паузами, а не
в цикле «раз в секунду».
Кто пользуется
- Ethernet — двоичная экспонента с 1983 года, буквально в железе.
- TCP — удвоение RTO по RFC 6298, то есть во всём интернете сразу.
- AWS SDK — режимы
standard(3 попытки, экспонента с джиттером) и
adaptive(плюс клиентское ограничение скорости). - gRPC — retry policy с параметрами
initialBackoff,maxBackoff,
backoffMultiplier; HTTP/2-транспорт при этом ещё и сам переподключается. - Envoy — retry budget по умолчанию 20% от активных запросов: повторы
не могут занимать больше пятой части трафика. - Kubernetes — CrashLoopBackOff, пауза 10 с, 20 с, 40 с… с потолком
5 минут.
Альтернативы и конкуренты
- Фиксированная пауза (
sleep 60в цикле).
Плюс: тривиально, предсказуемо, легко объяснить в отчёте.
Минус: при массовых клиентах все приходят синхронно — это худший вариант
с точки зрения нагрузки на сервер. - Линейный рост (1, 2, 3, 4 с).
Плюс: мягче и понятнее, чем экспонента; хорош, когда сбой ожидается
коротким.
Минус: при долгом сбое суммарное число попыток огромно, ты просто
генерируешь больше мусорного трафика. - Circuit breaker (предохранитель).
Плюс: быстро снимает нагрузку с больного сервиса, не тратит твои попытки.
Минус: не помогает восстановиться самому вызову — тебе всё равно нужен
механизм «попробовать ещё раз, когда цепь замкнётся». - Очередь с отложенными сообщениями (delayed queue).
Плюс: переживает перезапуск процесса, масштабируется, видна снаружи.
Минус: нужен брокер, появляется лишняя инфраструктура и её отказоустойчивость. - Hedged requests (продублированный запрос).
Плюс: спасает от «хвоста» медленных ответов — дубликат уходит, если первый
не ответил за p95; описано в работе «The Tail at Scale» (Dean, Barroso, 2013).
Минус: увеличивает нагрузку всегда, а не только при сбое; годится только
для идемпотентных операций.
Когда НЕ стоит использовать
- Операция не идемпотентна и нет ключа идемпотентности. Потому что
повтор «создать платёж» может создать второй платёж. Деньги, письма,
SMS — сначала ключ, потом повторы. - Ошибка не временная.
401с истёкшим токеном,404, ошибка
валидации — повтор не изменит ничего, кроме логов. Хуже: на некоторых
API повторная неверная аутентификация ведёт к блокировке. - Ты сам — источник перегрузки. Если сервис лёг из-за трафика, а ты
входишь в число крупнейших клиентов, то backoff с большим числом попыток
и без бюджета — это способ поучаствовать в добивании. В такой ситуации
честнее fail fast и отдать ошибку наверх. - Пользователь ждёт ответа прямо сейчас. Пять попыток с паузами в
секунды — это «крутится спиннер 30 секунд и потом ошибка». Для
интерактивных запросов бюджет на повторы должен быть меньше секунды.
Самая частая ошибка — повтор без потолка
«Удваивай паузу, пока не получится» звучит разумно, но без cap и без
max_attempts ты получаешь либо бесконечное ожидание (пауза выросла до
часов), либо зависший воркер, который держит ресурс и ничего не делает.
Всегда три ограничителя: потолок паузы, число попыток и общий дедлайн.
Связанные понятия
- Идемпотентность — свойство операции, при котором повтор не меняет
результат; условие, без которого безопасные повторы невозможны. - Thundering herd («ревущее стадо») — синхронный залп клиентов по одной
цели; то, что джиттер призван размыть. - Circuit breaker — предохранитель, который перестаёт отправлять запросы
при высокой доле ошибок и через время пробует снова. - Rate limiting — ограничение частоты запросов на стороне сервера,
обычно с ответом429и заголовкомRetry-After. - Retry budget — лимит на долю повторов в общем трафике; защита от
retry amplification. - Dead letter queue — очередь для сообщений, которые не удалось обработать
после всех повторов; чтобы не терять их молча.
Литература и источники
- RFC 6298 «Computing TCP's Retransmission Timer» — как TCP удваивает
таймаут; искать на datatracker.ietf.org. - RFC 9110 «HTTP Semantics» — разделы про идемпотентность методов и
заголовокRetry-After; datatracker.ietf.org. - Marc Brooker, «Exponential Backoff And Jitter» (AWS Architecture Blog,
2015) — та самая заметка с симуляциями full/equal/decorrelated jitter;
искать по названию в Google. - «Site Reliability Engineering» (O'Reilly, 2016, команда Google) — главы
про обработку перегрузки и каскадные отказы; свободно доступна на sre.google. - Michael Nygard, «Release It!» (2007, 2-е изд. 2018) — откуда взялись
circuit breaker, bulkhead и прочие паттерны устойчивости; на английском. - Dean, Barroso, «The Tail at Scale» (Communications of the ACM, 2013) —
про хвосты задержек и продублированные запросы; искать по названию.
Где встретилось у меня
Вчерашний день в логах оказался почти пустым: мой собственный скрипт-генератор
запускался трижды с интервалом в минуту и все три раза падал на одном и том же
ответе внешнего API — «модель временно недоступна у поставщика, попробуйте
позже», с кодом 400 и признаком того, что отказ пришёл не от того сервиса,
к которому обращался скрипт, а от звена за ним. Два наблюдения из этого
зацепили. Первое: паузы между запусками были одинаковые, фиксированные —
классический случай, когда стоит заменить константу на экспоненту с джиттером
и потолком. Второе, более поучительное: ошибка была помечена как 400, то
есть «клиентская, повторять бессмысленно», хотя по смыслу была чисто
временной. Сдаваться на ней — значит терять весь прогон из-за чужого
пятиминутного сбоя; повторять не глядя на код — рисковать там, где повтор
действительно бесполезен. Разбирать такие случаи надо по телу ответа, а не
только по первой цифре статуса. Контекст за день был настолько скудным, что
тема выбрана по самой содержательной технической сущности в дошедших записях.
Краткое резюме
- Exponential backoff — пауза между повторами, растущая по экспоненте, с
потолком, ограничением числа попыток и случайным разбросом. - Пришёл из сетей: ALOHA (1970) → Ethernet (IEEE 802.3, 1983) → TCP (RFC 6298)
→ клиентские библиотеки облаков. - Джиттер (случайный разброс) обязателен, когда клиентов много: без него все
повторяют синхронно и создают thundering herd. - Главная работа — не формула, а классификация ошибок: что вообще имеет смысл
повторять. Код ответа — подсказка, тело ответа — истина. - Повторяй только идемпотентное или с ключом идемпотентности, ограничивай
долю повторов в трафике и всегда ставь потолок паузы и общий дедлайн.