Алексей Меленчук
вся записка
узел 03 · Обмен
дата
объём 4 мин чтения

Ретрай, который устраивает DDoS вам же

Повтор без джиттера и без бюджета превращает частичный отказ в полный. Разбор механики лавины и трёх вещей, которые её останавливают.

Коротко. Повтор — это умножение нагрузки в тот момент, когда система хуже всего к ней готова. Без джиттера, бюджета и предохранителя он не повышает надёжность, а превращает просадку в отказ.

Повторы добавляют, чтобы система пережила короткий сбой. Логика безупречная: сеть моргнула, попробуем ещё раз, пользователь ничего не заметил.

Проблема появляется, когда моргнула не сеть, а сервис. И когда клиентов у него не один.

Механика лавины

Сервис B тормозит: ответы вместо тридцати миллисекунд идут две секунды. Ещё не отказ, просто плохо.

Клиенты сервиса A упираются в таймаут и делают то, что им велено, — повторяют. Трафик на B удваивается. B тормозит сильнее. Таймаутов становится больше, повторов тоже. Утраивается.

Через несколько итераций B получает кратную нагрузку в состоянии, когда он и обычную-то не тянет. Дальше он ложится совсем — и теперь его добивают уже не запросы, а повторы повторов, потому что у A свои клиенты, и они тоже настроены повторять.

Отдельный неприятный эффект: повторы синхронизируются. Все клиенты упёрлись в один и тот же таймаут примерно одновременно, значит и повторят примерно одновременно. Нагрузка приходит не ровным потоком, а волнами. Между волнами сервис успевает подняться, следующая волна его роняет. Со стороны это выглядит как мигание, и по графику непонятно, что происходит.

Три вещи, которые это лечат

Джиттер. Экспоненциальная задержка без случайности не решает главного: все повторяют синхронно, просто реже. Нужна случайная составляющая.

задержка = random(0, min(потолок, база * 2^попытка))

Это «полный джиттер», и он размазывает волну в поток. Без него экспоненциальная задержка даёт волны с растущим интервалом — чуть лучше, чем ничего, и заметно хуже, чем нужно.

Бюджет повторов. Ограничение не на попытку, а на систему: доля повторов не должна превышать нескольких процентов от основного потока. Когда доля превышена, повторы просто перестают выполняться, пока не восстановится.

Это ключевая мысль, и она не про клиента, а про всех клиентов сразу. «Три попытки на запрос» звучит скромно ровно до момента, когда запросов стало десять тысяч в секунду. Тогда это плюс двадцать тысяч в секунду — на систему, которая уже не справляется.

Предохранитель. После серии отказов клиент перестаёт ходить вовсе и быстро отвечает ошибкой. Через паузу пропускает один пробный запрос. Прошёл — открывается, не прошёл — ждёт дальше.

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

Что повторять нельзя

Повтор уместен только для того, что могло не выполниться. Это простое правило нарушают чаще всего.

  • Таймаут — повторять можно и нужно. Но помните: таймаут не значит «не выполнилось». Он значит «мы не знаем». Если операция не идемпотентна, повтор по таймауту создаёт дубликат.
  • Пятисотая — как правило, можно.
  • Четырёхсотая, кроме 429 — нельзя. Запрос неправильный, повтор вернёт тот же ответ, вы просто потратите ресурс дважды.
  • 429 и 503 с Retry-After — повторять строго по заголовку. Игнорировать Retry-After и повторять по своему расписанию — способ превратить ограничение частоты в блокировку.

Отдельно: не повторять на каждом уровне. Если повторяет клиент библиотеки, поверх — сервисный слой, а поверх — шлюз, три попытки превращаются в двадцать семь. Уровень повтора должен быть один и выбранный осознанно.

Предохранитель: три состояния и одна ошибка

Механизм описывают тремя состояниями, и это не формальность — у каждого своя роль.

Закрыт — запросы идут, считается доля отказов в скользящем окне. Открыт — запросы не идут вовсе, клиент сразу получает ошибку. Полуоткрыт — после паузы пропускается ограниченное число пробных запросов; успех закрывает, отказ открывает снова.

Ошибка, которую делают почти всегда: считают отказы, а не долю. Правило «десять ошибок подряд — открываемся» ломается на сервисе, который держит десять тысяч запросов в секунду: десять ошибок там набегают за миллисекунду при полном здоровье. Считать надо долю в окне и только при достаточном числе запросов в этом окне — иначе на низком трафике два случайных отказа откроют предохранитель.

И отдельно: у предохранителя должно быть поведение на открытом состоянии, отличное от «вернуть 500». Кэшированный ответ, значение по умолчанию, деградированный режим. Иначе вы просто заменили медленный отказ быстрым, а пользователю не легче.

Порядок, в котором я это ставлю

Не все три сразу. Джиттер первым — он стоит одну строчку и убирает худший эффект. Предохранитель вторым, когда есть внешние зависимости, которые действительно падают. Бюджет последним, потому что для него нужна метрика доли повторов, а её обычно ещё нет.

Когда можно ничего не делать. Один клиент, один экземпляр сервиса, десяток запросов в минуту. Лавина требует множества клиентов; там, где их нет, три попытки с фиксированной паузой — совершенно нормальное решение, и усложнять его незачем.