Повторы могут усугубить сбой: проектируем бюджет повторных попыток¶
Три попытки на каждом переходе цепочки из пяти сервисов — усилитель нагрузки, который сильнее всего действует именно при сбое нижнего сервиса, когда тот меньше всего способен принять лишний трафик. Все понимают это в теории и всё равно ставят max_attempts=3: три кажется небольшим числом. Я построил цепочку с тремя попытками на каждом уровне, поместил неисправный origin внизу, отправил десять запросов наверху и посчитал дошедшие вниз. Восемьсот десять. Разберём измерение, два механизма ограничения и случай, когда правильнее вернуть клиенту ошибку.
Числа получены скриптом статьи с origin внутри процесса, который можно заставить отказывать. Версии: clientwright 0.2.2, httpx 0.28.1, Python 3.13.
Арифметика¶
Повтор — ставка на то, что следующая попытка будет успешнее. Если зависимость медленная из-за перегрузки, ставка усугубляет причину: каждый повтор добавляет запрос перегруженному сервису от клиента, только что узнавшего о проблеме. Три попытки одного клиента утраивают его долю нагрузки. Три попытки пятидесяти клиентов утраивают всю нагрузку. Пока это один переход.
В цепочке попытки перемножаются. Наверху запрос получает три попытки; каждая вызывает следующий сервис с его тремя попытками и так далее. Четыре слоя дают 3⁴ = 81 запрос внизу на один наверху, если их ничего не останавливает. По умолчанию — ничего.
При трёх попытках на каждом из трёх переходов один запрос может породить до 27 вызовов самой глубокой зависимости. Это верхняя граница, а не измеренная частота.
Измерение: один переход¶
Пятьдесят конкурентных клиентов, один origin, на всё отвечает 503:
no retries origin saw 50 requests (1.00x) callers ok=0
max_attempts=3, no budget origin saw 150 requests (3.00x) callers ok=0
max_attempts=3, budget_ratio=0.1 (default) origin saw 60 requests (1.20x) callers ok=0 retries refused by budget=50
Первые две строки подтверждают арифметику. Успехов нет в обоих случаях; вторая конфигурация потратила втрое больше запросов, чтобы это узнать. Третья добавляет бюджет: по-прежнему разрешены три попытки, но повторы расходуют общий для origin запас, пополняемый примерно на десять процентов частоты обычных запросов. Пятьдесят первых попыток, десять повторов, сорок отказов в повторе с отдельным счётчиком. С учётом начального запаса origin получил на двадцать процентов больше трафика вместо утроения.
Бюджет ограничивает отношение повторов к запросам для origin и пополняется трафиком. Он определяет не максимальное число попыток одного вызова, а долю повторов в общей нагрузке — именно это важно серверу.
Когда бюджет незаметен, а когда мешает¶
Бюджет полезен, если не мешает сценарию, ради которого нужны повторы. Пятьдесят клиентов, пять запросов ошибаются один раз и затем проходят:
max_attempts=3, no budget origin saw 55 requests (1.10x) callers ok=50
max_attempts=3, budget_ratio=0.1 origin saw 55 requests (1.10x) callers ok=50
Одинаковый результат. Пять повторов укладываются в запас, все пять клиентов получают ответ со второй попытки, ни одного запрета. В нормальной системе с редкими сбоями бюджет незаметен.
Теперь случай, где он проявляется:
--- every one of the fifty callers fails twice, then recovers ---
max_attempts=3, no budget origin saw 150 requests (3.00x) callers ok=50
max_attempts=3, budget_ratio=0.1 origin saw 60 requests (1.20x) callers ok=0 retries refused by budget=50
Посмотрите на последний столбец второй строки. С бюджетом все клиенты получили ошибку. Без него все добились успеха ценой тройного трафика. Это осознанный компромисс: если каждому запросу нужны два повтора, зависимость серьёзно неисправна, а спасающие отдельного клиента попытки вместе могут поддерживать перегрузку. Бюджет выбирает возможность восстановления origin ценой текущего успеха клиента. Клиент получает ошибку примерно за сто миллисекунд вместо результата после трёх попыток; origin — существенно меньшую нагрузку, при которой может восстановиться. Приемлемость зависит от обработки ошибки. Пользовательский интерфейс с возможностью повторить позже — подходящий получатель такого отказа.
Измерение: цепочка из трёх сервисов¶
Десять клиентов в начале цепочки из трёх сервисов, у каждого собственный клиент с тремя попытками, origin внизу отвечает 503:
3 attempts per hop, no budget, no breaker origin saw 810 requests (81.00x) 2.82s
3 attempts per hop, no budget, breaker on origin saw 30 requests ( 3.00x) 0.31s
3 attempts per hop, budget on, breaker on origin saw 20 requests ( 2.00x) 0.13s
Первая строка — реальное 3⁴ на клиента: восемьсот десять запросов origin от десяти входящих и почти три секунды непрерывных ударов по уже недоступной зависимости через четыре слоя повторов.
Вторая строка добавляет circuit breaker со стандартными настройками на каждом переходе. Он наблюдает окончательные результаты вызовов origin и после достаточного числа последовательных сбоев временно отклоняет следующие локально. После первых пяти сбоев на переходе усиление упало с восьмидесяти одного до трёх. Автомат действует после накопления свидетельств; пока считает, повторы ещё проходят.
Третья строка добавляет бюджет. Автомат по-прежнему размыкает цепь, но повторы до этого момента уже ограничены запасом; усиление падает до двух. Механизмы дополняют друг друга: бюджет с самого начала ограничивает долю повторов, автомат полностью останавливает обращения после подтверждённого отказа.
Где размещать повторы¶
Измерение подтверждает арифметику: чем меньше повторяющих слоёв, тем меньше усиление. Обычная рекомендация — повторять на одном уровне: у входной границы, знающей о пользователе и ценности всей операции, либо на последнем переходе, ближайшем к ошибке и способном её классифицировать. Каждый промежуточный слой добавляет множитель.
Если повторять обязаны несколько уровней — из-за разных команд или разных типов сбоев, — каждому нужны собственные бюджет и автомат, а всем вместе общий дедлайн вместо независимых таймаутов. Это тема статьи о дедлайнах. Повторять всё равно можно только безопасно повторяемые вызовы: статья о gRPC применима и к HTTP.
Политика¶
from clientwright import CircuitBreakerConfig, ClientConfig, RetryConfig, TimeoutConfig, build
config = ClientConfig(
service_name="orders",
timeout=TimeoutConfig(total=5.0), # the deadline the attempts share
retry=RetryConfig(
max_attempts=3, # per call
budget_ratio=0.1, # per origin: retries may be ~10% of requests
),
circuit_breaker=CircuitBreakerConfig(fail_threshold=5, recovery_timeout=30.0),
)
client = build("httpx", config)
Три числа: попытки на вызов, которые задают все; допустимая доля повторов, ограничивающая лавину с начала; число сбоев до размыкания, останавливающее её после подтверждения недоступности. По умолчанию бюджет включён на десять процентов. Его отключение через budget_ratio=None использовано для первых строк таблиц.
Это clientwright. Политика хранит token bucket на каждый origin в runtime приложения, отклоняет повтор без доступного токена и считает его в http_client_retry_skipped_total{reason="budget"}. На графике видна нагрузка, которую удалось предотвратить.
Суть — в первой строке последней таблицы: восемьсот десять запросов вместо десяти.