АрхитектураПроектирование системАрхитектор распределённых систем

Практическая ситуация: сервис повторяет вызов временно недоступной зависимости, из за чего отказ может усил...

Практическая ситуация: сервис повторяет вызов временно недоступной зависимости, из-за чего отказ может усилиться. Какой механизм должен временно прекратить такие вызовы?

for attempt in 1..3:
    response = payment_service.charge(request)
    if response.success:
        return response
    if response.status not in {timeout, 503}:
        return response
    sleep(100ms)
return temporary_error
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Нужен предохранитель (circuit breaker): после заданного числа отказов он переводит зависимость в состояние открыто и сразу отклоняет новые вызовы без сетевого обращения. Через паузу выполняются ограниченные пробные вызовы; при восстановлении зависимость возвращается в рабочее состояние.

Одних повторных попыток недостаточно: при массовом отказе они увеличивают нагрузку на уже перегруженную зависимость.

Исторический контекст

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

Circuit breaker переносит решение о временном прекращении вызовов на границу зависимости. Это позволяет локализовать отказ и дать зависимому сервису время на восстановление.

Постановка проблемы

В примере каждый запрос может породить до трёх вызовов payment_service. Если зависимость отвечает тайм-аутом, количество запросов к ней возрастает именно в момент, когда она уже испытывает проблемы. При большом числе клиентов это создаёт retry storm — шторм повторных попыток.

Без дополнительной защиты вызывающий сервис тоже может исчерпать пул потоков или соединений. В результате временный отказ платёжного сервиса превращается в недоступность всей цепочки.

Подробное решение

Предохранитель обычно имеет три состояния:

  • Closed — вызовы проходят, а система собирает статистику отказов и задержек.
  • Open — вызовы быстро отклоняются локально, без обращения к зависимости.
  • Half-open — после периода ожидания пропускается небольшое число пробных вызовов. Успех закрывает предохранитель, а новый отказ снова открывает его.

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

Период открытия должен быть ограничен, иначе кратковременный сбой превратится в длительный отказ даже после восстановления зависимости. Пробные вызовы в состоянии half-open нужно ограничивать, иначе восстановление снова будет перегружено.

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

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

Минимальная схема переходов выглядит так:

if breaker.is_open(): return local_failure() try: response = call_with_timeout(dependency, timeout=200) breaker.record_success() return response except TransientFailure: breaker.record_failure() return temporary_error()

В реальной реализации переход в open происходит после достижения порога, а is_open() после истечения периода ожидания переводит предохранитель в half-open. Одновременные пробные вызовы должны синхронизироваться или ограничиваться отдельным счётчиком.

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

Ситуация из практики

Сервис оформления заказа вызывает сервис доставки для расчёта срока. При проблеме у сервиса доставки запросы начали ждать тайм-аут, затем повторяться; пул соединений сервиса заказов заполнялся, и даже операции, не связанные с доставкой, стали отвечать медленно.

Рассматривались три варианта. Увеличение тайм-аута давало больше времени на восстановление ответа, но дольше занимало ресурсы. Полный отказ от повторов снижал нагрузку, но ухудшал результат при единичных сетевых сбоях. Предохранитель с коротким тайм-аутом, одной повторной попыткой с jitter и ограниченным числом проб в half-open локализовал проблему.

Выбрали третий вариант. Для расчёта доставки добавили fallback с пометкой, что срок будет уточнён позже; критичный вызов подтверждения доставки при открытом предохранителе сразу возвращал контролируемую ошибку. В результате отказ зависимости перестал блокировать несвязанные операции, а восстановление происходило без нового резкого всплеска нагрузки.

Что кандидаты часто упускают

  1. Должен ли предохранитель быть общим для всех клиентов зависимости?

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

  1. Почему одного порога по числу ошибок недостаточно?

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

  1. Что произойдёт, если несколько экземпляров сервиса имеют независимые предохранители?

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