Перейти к содержанию

Повторы могут усугубить сбой: проектируем бюджет повторных попыток

Три попытки на каждом переходе цепочки из пяти сервисов — усилитель нагрузки, который сильнее всего действует именно при сбое нижнего сервиса, когда тот меньше всего способен принять лишний трафик. Все понимают это в теории и всё равно ставят max_attempts=3: три кажется небольшим числом. Я построил цепочку с тремя попытками на каждом уровне, поместил неисправный origin внизу, отправил десять запросов наверху и посчитал дошедшие вниз. Восемьсот десять. Разберём измерение, два механизма ограничения и случай, когда правильнее вернуть клиенту ошибку.

Числа получены скриптом статьи с origin внутри процесса, который можно заставить отказывать. Версии: clientwright 0.2.2, httpx 0.28.1, Python 3.13.

Арифметика

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

В цепочке попытки перемножаются. Наверху запрос получает три попытки; каждая вызывает следующий сервис с его тремя попытками и так далее. Четыре слоя дают 3⁴ = 81 запрос внизу на один наверху, если их ничего не останавливает. По умолчанию — ничего.

ИДЕЯ В СХЕМЕЧисло попыток умножается на границах сервисов
---
config:
  theme: default
  look: classic
  flowchart:
    useMaxWidth: false
    wrappingWidth: 150
    padding: 12
    nodeSpacing: 24
    rankSpacing: 32
---
flowchart LR
    accTitle: Число попыток умножается на границах сервисов
    accDescr: При трёх попытках на каждом из трёх переходов один запрос может породить до 27 вызовов самой глубокой зависимости. Это верхняя граница, а не измеренная частота.
 R["1 входящий запрос"] -->|"× 3"| A["До 3 вызовов"] -->|"× 3"| B["До 9 вызовов"] -->|"× 3"| C["До 27 вызовов"]

При трёх попытках на каждом из трёх переходов один запрос может породить до 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"}. На графике видна нагрузка, которую удалось предотвратить.

Суть — в первой строке последней таблицы: восемьсот десять запросов вместо десяти.