Практическая ситуация: сервис повторяет вызов временно недоступной зависимости, из-за чего отказ может усилиться. Какой механизм должен временно прекратить такие вызовы?
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
Нужен предохранитель (circuit breaker): после заданного числа отказов он переводит зависимость в состояние открыто и сразу отклоняет новые вызовы без сетевого обращения. Через паузу выполняются ограниченные пробные вызовы; при восстановлении зависимость возвращается в рабочее состояние.
Одних повторных попыток недостаточно: при массовом отказе они увеличивают нагрузку на уже перегруженную зависимость.
Подход появился как реакция на каскадные отказы в системах с синхронными вызовами. Когда один компонент замедляется или становится недоступным, вызывающие компоненты продолжают ждать и повторять запросы, расходуя рабочие потоки, соединения и память.
Circuit breaker переносит решение о временном прекращении вызовов на границу зависимости. Это позволяет локализовать отказ и дать зависимому сервису время на восстановление.
В примере каждый запрос может породить до трёх вызовов payment_service. Если зависимость отвечает тайм-аутом, количество запросов к ней возрастает именно в момент, когда она уже испытывает проблемы. При большом числе клиентов это создаёт retry storm — шторм повторных попыток.
Без дополнительной защиты вызывающий сервис тоже может исчерпать пул потоков или соединений. В результате временный отказ платёжного сервиса превращается в недоступность всей цепочки.
Предохранитель обычно имеет три состояния:
Порог срабатывания лучше считать по скользящему окну: например, по доле ошибок или по числу последовательных отказов. Важно учитывать только ошибки, указывающие на недоступность зависимости: тайм-ауты, сетевые ошибки и ответы вроде 503. Ошибка валидации или корректный бизнес-ответ не должна открывать предохранитель.
Период открытия должен быть ограничен, иначе кратковременный сбой превратится в длительный отказ даже после восстановления зависимости. Пробные вызовы в состоянии half-open нужно ограничивать, иначе восстановление снова будет перегружено.
Повторы и предохранитель решают разные задачи. Повтор допустим для редкой временной ошибки, но должен иметь небольшой лимит, экспоненциальную задержку и случайное смещение (jitter). Предохранитель ограничивает общий поток вызовов при устойчивом отказе. Для операций с побочными эффектами повтор допустим только при гарантии безопасной повторной обработки.
Предохранитель не заменяет тайм-ауты: без тайм-аута один зависший вызов может удерживать ресурс до открытия предохранителя. Также нужны ограничение параллелизма, наблюдаемость по состояниям и метрики быстрых отказов, доли ошибок, задержки и времени восстановления.
Минимальная схема переходов выглядит так:
В реальной реализации переход в open происходит после достижения порога, а is_open() после истечения периода ожидания переводит предохранитель в half-open. Одновременные пробные вызовы должны синхронизироваться или ограничиваться отдельным счётчиком.
Главный компромисс — доступность против свежести результата. При открытом предохранителе система может вернуть ошибку, кэшированный ответ или отложить операцию, зато не усугубляет отказ зависимости. Выбор fallback зависит от бизнес-критичности операции: для платежа безопаснее явно сообщить о временной недоступности, чем молча считать операцию выполненной.
Сервис оформления заказа вызывает сервис доставки для расчёта срока. При проблеме у сервиса доставки запросы начали ждать тайм-аут, затем повторяться; пул соединений сервиса заказов заполнялся, и даже операции, не связанные с доставкой, стали отвечать медленно.
Рассматривались три варианта. Увеличение тайм-аута давало больше времени на восстановление ответа, но дольше занимало ресурсы. Полный отказ от повторов снижал нагрузку, но ухудшал результат при единичных сетевых сбоях. Предохранитель с коротким тайм-аутом, одной повторной попыткой с jitter и ограниченным числом проб в half-open локализовал проблему.
Выбрали третий вариант. Для расчёта доставки добавили fallback с пометкой, что срок будет уточнён позже; критичный вызов подтверждения доставки при открытом предохранителе сразу возвращал контролируемую ошибку. В результате отказ зависимости перестал блокировать несвязанные операции, а восстановление происходило без нового резкого всплеска нагрузки.
Обычно состояние предохранителя привязывают к конкретной зависимости и операции, а не ко всему приложению без разбора. Если один предохранитель объединяет независимые методы, отказ одного метода может заблокировать здоровые. Слишком мелкое разделение, напротив, уменьшает объём статистики и усложняет настройку, поэтому границу выбирают по общему пути отказа и общему ресурсу зависимости.
Порог не учитывает длительность окна и объём трафика. Десять ошибок подряд при десяти запросах в минуту и десять ошибок среди миллиона запросов имеют разный смысл. Поэтому используют сочетание минимального числа наблюдений, доли ошибок, скользящего окна и иногда отдельного порога по задержке; параметры проверяют по метрикам, а не выбирают произвольно.
Каждый экземпляр будет принимать решение по своей локальной статистике. Это снижает требования к общей синхронизации, но при небольшом трафике решение может быть нестабильным: один экземпляр уже открыл предохранитель, а другой продолжает отправлять запросы. Для большинства сценариев локального состояния достаточно, если дополнительно ограничить общий исходящий поток; распределённое состояние применяют только когда локальной изоляции недостаточно и стоимость координации оправдана.