Circuit breaker: как защитить сервис от лавины запросов к умирающему соседу
Сценарий, который я разбирал не раз: сервис заказов зовёт сервис каталога. Каталог тормозит, ответы идут по 5 секунд вместо 50 миллисекунд. Пул потоков сервиса заказов забивается ожиданием каталога, начинает не отвечать на свои запросы. Через минуту лежат оба. Через две — лежит вся цепочка, потому что фронт ретраит к заказам, а заказы — к каталогу.
Это cascading failure, и он лечится одной идеей: если сосед плохо себя чувствует, не надо его добивать запросами. Надо подождать, пока ему станет лучше. Эта идея реализована в circuit breaker — паттерне, который разрывает цепь к проблемному сервису и даёт ему восстановиться.
За двенадцать лет я ставил circuit breaker'ы в трёх стеках: Hystrix (RIP), Resilience4j, Polly в .NET. Базовая логика везде одна, но в реализациях есть нюансы, которые в учебниках не описывают.
Как он работает
Circuit breaker оборачивает вызов к внешнему сервису и хранит состояние:
- Closed — нормальная работа. Запросы проходят. Считаются ошибки.
- Open — слишком много ошибок. Запросы не проходят, сразу возвращается fail без вызова. Дайте сервису полежать.
- Half-open — после паузы пропускаем один-два запроса. Если успешны — возвращаемся в Closed. Если падают — снова Open.
Closed --[ошибки превысили порог]--> Open
Open --[прошёл таймаут]--> Half-open
Half-open --[пробный запрос успешен]--> Closed
Half-open --[пробный запрос упал]--> OpenВ Open-состоянии все запросы failуются мгновенно. Это и есть защита: вы не тратите потоки на ожидание мёртвого сервиса.
Минимальная реализация на Resilience4j
val cb = CircuitBreaker.of("catalog", CircuitBreakerConfig.custom()
.failureRateThreshold(50f) // открыть, если 50% запросов с ошибкой
.slidingWindowSize(20) // окно из 20 последних запросов
.minimumNumberOfCalls(10) // не реагировать, пока не было 10 вызовов
.waitDurationInOpenState(Duration.ofSeconds(10))
.permittedNumberOfCallsInHalfOpenState(3)
.build())
fun fetchCatalogItem(id: String): CatalogItem {
return cb.executeSupplier {
catalogClient.getItem(id)
}
}Эта конфигурация говорит: смотрим на последние 20 вызовов, если 50% упали — открываемся на 10 секунд. После 10 секунд пропускаем 3 пробных запроса. Если успешны — возвращаемся в норму.
Что считать «ошибкой»
Кажется, что просто: ошибка — это exception. Но не всё так однозначно.
Бизнес-ошибки (404 «товар не найден», 400 «неправильный запрос») — это не сбой соседа. Если их учитывать в circuit breaker'е, он будет открываться при потоке клиентов, ищущих несуществующие id'шники. Это ложное срабатывание.
CircuitBreakerConfig.custom()
.recordExceptions(
IOException::class.java,
TimeoutException::class.java
)
.ignoreExceptions(
NotFoundException::class.java,
ValidationException::class.java
)
.build()Чёткое разделение: учитываются только сетевые/инфраструктурные сбои. Бизнес-исключения проходят насквозь без влияния на breaker.
Slow call как ошибка
Часто сосед не падает, а тормозит. 502 нет, но запрос идёт 10 секунд вместо 50 мс. Для вашего сервиса это так же плохо, как падение: пул потоков всё равно забивается.
Resilience4j умеет считать медленные вызовы как ошибки:
CircuitBreakerConfig.custom()
.slowCallRateThreshold(50f)
.slowCallDurationThreshold(Duration.ofSeconds(2))
.build()Если 50% запросов идут дольше 2 секунд — открываемся. Это ловит «деградацию», которая опаснее явного падения, потому что её сложнее заметить.
Где ставить
Circuit breaker ставится на каждый внешний вызов к отдельному сервису. На каждый. Не один общий на сервис, не на пакет вызовов.
// ТАК НЕ НАДО
val globalCb = CircuitBreaker.ofDefaults("all-services")
// ТАК ПРАВИЛЬНО
val catalogCb = CircuitBreaker.of("catalog", config)
val pricingCb = CircuitBreaker.of("pricing", config)
val inventoryCb = CircuitBreaker.of("inventory", config)Логика: если упал каталог, я не хочу, чтобы это блокировало вызовы к pricing. Это разные сервисы с разной судьбой. Один общий breaker делает их все мёртвыми, когда упал один.
Ещё тоньше — отдельные breaker'ы на разные эндпоинты одного сервиса, если они независимы. Сервис каталога имеет ручку /items/{id} и /search. Если /search тормозит из-за нагрузки на ElasticSearch внутри каталога, /items может работать нормально. Один breaker обрушит и то, и то.
Fallback
Когда breaker открыт, что возвращать пользователю? Вариантов несколько.
Кешированное значение
Лучший вариант, если применимо. У вас был последний удачный ответ — отдайте его, помечая как stale. Пользователь видит немного устаревшие данные, но сервис не падает.
fun getCatalogItem(id: String): CatalogItem? {
return try {
cb.executeSupplier { catalogClient.getItem(id) }
.also { cache.put(id, it) }
} catch (e: CallNotPermittedException) {
cache.get(id)
}
}Дефолтное значение
Для некритичных полей можно вернуть «безопасный дефолт». Например, в карточке товара поле «рекомендации от других пользователей» — если рекомендательный сервис лежит, можно отдать пустой массив.
Партишинальный ответ
Главный экран собирается из 5 источников. Один лежит — отдаём остальные четыре + пометку «такая-то секция временно недоступна». UX страдает частично, не полностью.
Явная ошибка
Если данные критичные и заменить нечем — честный 503 пользователю. Но не «висим 30 секунд», а сразу «попробуйте позже».
Какой вариант выбрать — зависит от бизнес-семантики. Это не техническое решение, а продуктовое: «что мы готовы показать пользователю при отказе соседа».
Конфигурация порогов
Какие цифры ставить — частый вопрос.
failureRateThreshold. 50% — нормальный дефолт. Меньше (20-30%) — слишком чувствительно, реагирует на временные всплески. Больше (70-80%) — медленно реагирует, breaker открывается, когда уже всё совсем плохо.
slidingWindowSize. 20-50 запросов. Меньше — статистическая шумность, breaker дёргается на каждом всплеске. Больше — медленно реагирует на реальные проблемы.
minimumNumberOfCalls. 10-15. Защита от ложных срабатываний на холодном старте: пока не было N вызовов, breaker не реагирует.
waitDurationInOpenState. 5-30 секунд. Зависит от того, насколько быстро восстанавливается сосед. 5 секунд — для кратковременных всплесков. 30 — для серьёзных проблем, когда нужно дать время рестарту.
Эти цифры — стартовая точка. На своём проекте надо смотреть метрики и подкручивать.
Связь с timeout и retry
Circuit breaker без правильно настроенных timeout'ов — лотерея.
Если timeout на вызов 30 секунд, а вы ставите breaker с failureRateThreshold по медленным вызовам в 2 секунды, то breaker сработает быстрее, чем хоть один запрос упадёт по timeout'у. Это нормально и даже желательно.
Если timeout — 1 секунда, а slowCallDurationThreshold — 5 секунд, breaker никогда не среагирует на медленность: запросы будут падать раньше, чем медленность зафиксируется.
Правило: slowCallDurationThreshold < timeout. Иначе медленность не различается от падения.
Retry — отдельная тема. Внутри breaker'а ретраи противопоказаны: каждый retry — это новый вызов, который breaker считает. Если ретраите 5 раз, и каждая попытка таймаутится, breaker увидит 5 ошибок вместо одной и откроется быстрее, чем нужно.
Правильный порядок: retry над breaker'ом. Breaker оборачивает один вызов. Если breaker отдаёт CallNotPermittedException — retry понимает, что сервис недоступен, и не повторяет (или ждёт дольше).
Подводные камни
Несколько граблей.
Bulkhead не заменяется breaker'ом. Breaker не защищает от заполнения пула. Если сервис тормозит, и у вас лимит пула 100, первые 100 запросов уйдут на этого тормоза, и пул забьётся. Breaker откроется только после того, как часть из них упадёт. К тому моменту вы уже не отвечаете на свои запросы. Bulkhead (отдельный пул на каждый зависимый сервис) решает это, breaker — дополняет.
Глобальный breaker через распределённую систему. Breaker — это процессное состояние. Его не нужно реплицировать в Redis или другую общую БД. Каждый инстанс сервиса имеет свой breaker, и это нормально. Иначе вы получите задержку синхронизации, лагающую логику и непредсказуемое поведение.
Breaker без метрик. Открылся ли breaker, как часто, на каком соседе — это критичная наблюдаемость. Без неё breaker открывается, fallback срабатывает, пользователь видит деградацию, а команда узнаёт об этом из жалоб.
Breaker как замена тестирования. «У нас стоит breaker, нам не страшно если что» — позиция, которая часто прячет недотестированные сценарии. Breaker защищает от инцидента, но не делает обработку ошибок правильной.
Half-open под высокой нагрузкой. При очень большом RPS даже permittedNumberOfCallsInHalfOpenState=1 — это всплеск. Если 1000 инстансов одновременно перешли в half-open, сосед получает 1000 пробных запросов. Решение — медленнее переход в half-open или экспоненциальное восстановление.
Что запомнить
Circuit breaker — это страховка от cascading failure. Открывается на пороге ошибок, держится в Open пару секунд, пробует через half-open, возвращается в норму. На каждый внешний сервис — свой breaker. Учитывает только инфраструктурные ошибки, не бизнес.
Связан с timeout'ами (slow call < timeout), bulkhead (отдельные пулы), retry (retry поверх breaker'а, не наоборот), fallback (что отдавать при открытом breaker'е). Без этих компаньонов он работает только частично.
Главное правило — настраивайте под ваш контекст. Дефолтные пороги Resilience4j или Polly — стартовая точка, не финал. Метрики breaker'а должны быть в дашборде, и если breaker регулярно открывается на каком-то соседе — это сигнал не «у нас работает breaker», а «у соседа проблемы, которые надо разобрать».