Один из сервисов в цепочке начинает отвечать с задержкой в 10 секунд. Вышестоящий сервис честно ждёт, потом повторяет запрос, потом ещё раз. Пул потоков заполняется ожидающими вызовами. Через минуту уже не отвечает и он сам. Следом падает тот, кто звал его. Классический каскадный сбой в микросервисной архитектуре разворачивается именно так - не взрывом, а медленным удушением.
Проблема не в том, что downstream упал. Сервисы падают, это нормально. Проблема в том, что upstream об этом не знает и продолжает слать запросы, усугубляя ситуацию. Именно здесь и нужен circuit breaker паттерн - механизм, который замечает, что что-то пошло не так, и временно прекращает попытки достучаться до недоступного соседа.
В этой статье разберём конечный автомат с тремя состояниями, конкретные настройки порогов, реализацию через Resilience4j и типичные ошибки, из-за которых защита либо не срабатывает, либо срабатывает слишком агрессивно.
Коротко:
- Паттерн работает как автоматический выключатель: при превышении порога ошибок он перестаёт пропускать запросы к упавшему сервису.
- Три состояния:
CLOSED(всё работает),OPEN(запросы блокируются),HALF_OPEN(пробный режим). - Главное отличие от Retry: повтор пытается добиться успеха, выключатель - защитить систему от лавины повторов.
- В Resilience4j пороги задаются по проценту ошибок или по медленным вызовам - оба критерия можно комбинировать.
- Ложные срабатывания чаще всего возникают из-за слишком маленького скользящего окна или неправильной классификации исключений.
- Без fallback-логики открытый выключатель просто возвращает ошибку - это лучше, чем таймаут, но не всегда достаточно.
Что происходит без защиты: анатомия каскадного сбоя
Представим типичный сценарий: сервис заказов вызывает сервис инвентаря, тот - сервис склада. Сервис склада начинает отвечать медленно из-за проблем с базой данных. Каждый вызов занимает 8-10 секунд вместо обычных 50 миллисекунд.
Сервис инвентаря держит соединение открытым и ждёт. Параллельных запросов много, пул потоков заканчивается. Новые запросы от сервиса заказов встают в очередь. Очередь растёт. Через несколько минут сервис инвентаря перестаёт отвечать на любые запросы - не только те, что идут на склад. Сервис заказов начинает получать таймауты. Пользователи видят ошибки.
Это и есть каскадные сбои в микросервисах: один медленный узел блокирует ресурсы всей цепочки. Ключевое слово здесь - медленный, а не упавший. Упавший сервис быстро возвращает ошибку соединения. Медленный держит поток занятым.
Конечный автомат: три состояния и переходы между ними
Механизм защиты строится на конечном автомате с тремя состояниями. Понять их важно до того, как смотреть на код.
CLOSED - нормальная работа
Запросы проходят к downstream-сервису без ограничений. Выключатель считает вызовы и отслеживает долю ошибок в скользящем окне. Пока она ниже порога - ничего не происходит. Как только порог превышен, автомат переходит в следующее состояние.
OPEN - блокировка
Все запросы к защищаемому сервису немедленно отклоняются без реальной попытки соединения. Вместо ожидания таймаута вызывающий код сразу получает CallNotPermittedException. Это быстро и предсказуемо. Выключатель остаётся в этом состоянии заданное время (например, 30 секунд), после чего автоматически переходит в пробный режим.
HALF_OPEN - проверка восстановления
Пропускается ограниченное число пробных запросов. Если они проходят успешно - выключатель возвращается в CLOSED. Если снова появляются ошибки - снова уходит в OPEN. Это защищает от ситуации, когда downstream восстановился лишь частично и не готов принять полную нагрузку.
Переходы состояний:
CLOSED - при превышении порога ошибок -> OPENOPEN - по истечении waitDurationInOpenState -> HALF_OPENHALF_OPEN - успешные пробные вызовы -> CLOSEDHALF_OPEN - ошибки в пробных вызовах -> OPEN
Реализация через Resilience4j: конкретный пример
Resilience4j - стандартный выбор для Java-проектов. Библиотека легковесная, не требует отдельного агента и хорошо интегрируется со Spring Boot.
Зависимость
io.github.resilience4j
resilience4j-spring-boot3
2.2.0
Конфигурация через application.yml
resilience4j:
circuitbreaker:
instances:
inventoryService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 80
slowCallDurationThreshold: 2000ms
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: trueРазберём ключевые параметры:
- slidingWindowSize: 20 - выключатель смотрит на последние 20 вызовов.
- minimumNumberOfCalls: 10 - не принимает решение, пока не накопит хотя бы 10 вызовов. Без этого параметра первая же ошибка при старте откроет выключатель.
- failureRateThreshold: 50 - открывается, если 50% и более вызовов завершились ошибкой.
- slowCallDurationThreshold: 2000ms - вызов считается медленным, если занял больше 2 секунд.
- waitDurationInOpenState: 30s - через 30 секунд автоматически переходит в
HALF_OPEN.
Применение аннотацией
@Service
public class OrderService {
private final InventoryClient inventoryClient;
@CircuitBreaker(name = "inventoryService", fallbackMethod = "getInventoryFallback")
public InventoryResponse checkInventory(String productId) {
return inventoryClient.getStock(productId);
}
private InventoryResponse getInventoryFallback(String productId, Exception ex) {
log.warn("Inventory service unavailable for product {}: {}", productId, ex.getMessage());
return InventoryResponse.unavailable();
}
}Fallback-метод получает те же аргументы плюс исключение. Сигнатура должна совпадать, иначе Resilience4j не найдёт метод и выбросит ошибку конфигурации.
Программная конфигурация
Если нужно создавать выключатели динамически или конфигурировать их из базы данных, используют CircuitBreakerRegistry:
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowSize(20)
.minimumNumberOfCalls(10)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.recordExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessValidationException.class)
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker breaker = registry.circuitBreaker("inventoryService");
CheckedFunction0 decorated =
CircuitBreaker.decorateCheckedSupplier(breaker,
() -> inventoryClient.getStock(productId));
Обратите внимание на recordExceptions и ignoreExceptions. Это критически важные параметры: бизнес-исключения (например, товар не найден) не должны учитываться как сбои инфраструктуры.
Circuit Breaker vs Retry: когда что применять
Эти два механизма часто путают или используют вместе без понимания, что каждый из них делает.
| Критерий | Retry | Circuit Breaker |
|---|---|---|
| Цель | Преодолеть временный сбой | Прекратить давить на упавший сервис |
| Поведение при сбое | Повторяет вызов N раз | Блокирует вызовы на период восстановления |
| Подходит для | Кратковременные сетевые ошибки | Длительная недоступность downstream |
| Риск | Умножает нагрузку на и без того нагруженный сервис | Ложные срабатывания при нормальных пиках ошибок |
Retry без выключателя при длительном сбое downstream - это генератор нагрузки. Если 100 клиентов делают по 3 попытки, downstream получает 300 запросов вместо 100. Именно поэтому их обычно комбинируют: сначала Retry пробует несколько раз с экспоненциальной задержкой, а выключатель открывается, если ошибки продолжаются.
Важный порядок: при комбинировании Retry должен быть внешним декоратором, а выключатель - внутренним. Тогда Retry не будет повторять вызовы, когда автомат уже открыт. Если порядок перепутать, каждая попытка Retry будет немедленно отклоняться выключателем, и вы получите N быстрых ошибок вместо одной.
В Resilience4j порядок задаётся через @Retry и @CircuitBreaker на одном методе - внешний декоратор указывается первым в цепочке аннотаций, или используется Decorators builder для явного задания порядка.
Настройка порогов: как не промахнуться
Неправильные пороги - главная причина, по которой защита либо не работает, либо мешает нормальной работе.
Слишком маленькое скользящее окно
При slidingWindowSize: 5 и failureRateThreshold: 50 достаточно трёх ошибок подряд, чтобы выключатель открылся. В реальной системе три ошибки за несколько секунд - это нормальный фоновый шум. Разумный минимум для продакшена - 20-50 вызовов в окне.
Отсутствие minimumNumberOfCalls
Без этого параметра (или при значении 1) первый же вызов, завершившийся ошибкой при старте сервиса, откроет выключатель. Сервис будет недоступен ещё до того, как успеет нормально запуститься.
Вакансии для разработчиков
Неразличение типов ошибок
HTTP 404 - это не инфраструктурный сбой, это бизнес-ответ. Если не добавить его в ignoreExceptions, каждый запрос несуществующего ресурса будет засчитываться как отказ. В системе с активным поиском это может открыть выключатель при абсолютно здоровом downstream.
Слишком короткий waitDurationInOpenState
Если выключатель переходит в HALF_OPEN через 5 секунд, а downstream восстанавливается за 30-60 секунд, пробные запросы будут снова уходить в ошибку. Выключатель будет постоянно открываться и закрываться, создавая нестабильность. Реалистичное значение - от 30 секунд до нескольких минут, в зависимости от типичного времени восстановления зависимости.
TIME_BASED vs COUNT_BASED скользящее окно
Resilience4j поддерживает два типа окна, и выбор между ними влияет на поведение при разной интенсивности трафика.
COUNT_BASED считает последние N вызовов независимо от времени. При низком трафике (10 запросов в минуту) окно из 20 вызовов покрывает 2 минуты. При высоком (1000 в секунду) - 20 миллисекунд. Это значит, что чувствительность к сбоям зависит от нагрузки.
TIME_BASED смотрит на вызовы за последние N секунд. При низком трафике в окне может оказаться всего 2-3 вызова, и minimumNumberOfCalls становится критически важным. При высоком трафике окно будет содержать тысячи вызовов, что делает реакцию более стабильной.
Для высоконагруженных сервисов TIME_BASED обычно даёт более предсказуемое поведение. Для сервисов с редкими вызовами лучше подходит COUNT_BASED с увеличенным minimumNumberOfCalls.
Мониторинг и наблюдаемость
Выключатель без метрик - это чёрный ящик. Нужно знать, как часто он открывается, сколько запросов блокируется и как быстро восстанавливается.
Resilience4j автоматически публикует метрики через Micrometer. При наличии Spring Boot Actuator они доступны без дополнительной настройки:
resilience4j.circuitbreaker.calls- счётчик вызовов с тегамиkind(successful, failed, not_permitted, ignored)resilience4j.circuitbreaker.state- текущее состояние (0 = CLOSED, 1 = OPEN, 2 = HALF_OPEN)resilience4j.circuitbreaker.failure.rate- текущий процент ошибокresilience4j.circuitbreaker.slow.call.rate- процент медленных вызовов
В Grafana стоит настроить алерт на переход в состояние OPEN - это сигнал, что downstream испытывает проблемы, даже если пользователи пока не замечают деградации благодаря fallback-логике.
Также полезно логировать события через CircuitBreakerEventListener:
circuitBreaker.getEventPublisher()
.onStateTransition(event ->
log.info("Circuit breaker '{}' state: {} -> {}",
event.getCircuitBreakerName(),
event.getStateTransition().getFromState(),
event.getStateTransition().getToState()));
Типичные ошибки при внедрении
Один выключатель на все методы клиента
Если сервис инвентаря имеет методы getStock и reserveItem, и оба защищены одним экземпляром - медленный getStock откроет выключатель и заблокирует reserveItem, который мог работать нормально. Для разных операций с разными характеристиками нужны отдельные экземпляры.
Fallback скрывает реальные проблемы
Fallback должен логировать факт срабатывания и инкрементировать счётчик. Если он просто тихо возвращает заглушку, команда может не заметить, что выключатель открыт уже несколько часов и пользователи получают деградированный опыт.
Применение к внутренним вызовам
Выключатель нужен для вызовов через сеть к внешним зависимостям. Оборачивать им обращения к локальному кэшу или синхронные вызовы внутри одного процесса - избыточно и создаёт ненужный overhead.
Игнорирование HALF_OPEN
Некоторые команды устанавливают permittedNumberOfCallsInHalfOpenState: 1. Один успешный вызов не доказывает, что сервис восстановился - он мог просто случайно ответить. Разумное значение - 5-10 пробных запросов.
Когда паттерн не нужен
Не каждый внешний вызов требует выключателя. Есть ситуации, где он создаёт сложность без реальной пользы.
Если downstream - это база данных в той же инфраструктуре с SLA 99.99%, и вы обращаетесь к ней напрямую через connection pool, пул уже управляет доступностью соединений. Добавление выключателя поверх него усложняет диагностику без существенного выигрыша.
Для синхронных вызовов к сервисам, которые должны быть доступны для выполнения основной бизнес-логики (например, платёжный шлюз при оформлении заказа), fallback часто невозможен по бизнес-требованиям. В таком случае выключатель всё равно полезен для быстрого отказа вместо таймаута, но нужно честно продумать, что возвращать пользователю.
Для очень редких вызовов (несколько раз в час) выключатель будет почти всегда в состоянии CLOSED с пустым окном и не даст статистически значимой защиты.
Чеклист внедрения
- Определены все внешние HTTP/gRPC-зависимости, которые нужно защитить
- Для каждой зависимости создан отдельный именованный экземпляр
- Установлен
minimumNumberOfCallsне менее 10 - Скользящее окно содержит не менее 20 вызовов (или 30-60 секунд для TIME_BASED)
- Бизнес-исключения добавлены в
ignoreExceptions - Написан fallback-метод с логированием и метрикой
- Порядок декораторов проверен: Retry снаружи, выключатель внутри
- Метрики подключены к Grafana/Prometheus
- Настроен алерт на переход в состояние OPEN
- Протестировано поведение при искусственно вызванном сбое downstream (например, через Chaos Monkey или WireMock с задержками)
FAQ
Чем circuit breaker отличается от timeout?
Таймаут ограничивает время ожидания одного вызова. Выключатель - это надстройка над таймаутом: он замечает, что таймауты происходят систематически, и перестаёт делать вызовы вообще. Таймаут без выключателя всё равно занимает поток на время ожидания; выключатель в открытом состоянии возвращает ошибку мгновенно.
Как тестировать поведение выключателя?
Лучший подход - WireMock с настроенными задержками и кодами ответов. Можно также использовать Resilience4j Test utilities для unit-тестов. В интеграционных тестах стоит проверить все три перехода: CLOSED -> OPEN, OPEN -> HALF_OPEN, HALF_OPEN -> CLOSED и HALF_OPEN -> OPEN.
Что делать, если выключатель открывается слишком часто?
Сначала проверьте классификацию исключений - скорее всего, бизнес-ошибки засчитываются как сбои. Затем посмотрите на реальный процент ошибок в метриках: если он действительно высокий, проблема в downstream, а не в настройках. Увеличение порога - не решение, а маскировка проблемы.
Resilience4j или Hystrix?
Hystrix от Netflix находится в режиме обслуживания с 2018 года и не получает новых функций. Resilience4j - активно развиваемая альтернатива, нативно поддерживает Java 8+, реактивное программирование и Micrometer. Для новых проектов выбор очевиден.
Работает ли это с реактивным стеком (WebFlux)?
Да. Resilience4j предоставляет resilience4j-reactor с операторами для Mono и Flux. Аннотация @CircuitBreaker также работает с реактивными методами при наличии соответствующего стартера.
Нужен ли выключатель, если используется service mesh (Istio, Linkerd)?
Service mesh реализует выключатель на уровне прокси, без изменения кода приложения. Это удобно для полиглотных систем. Но у него нет доступа к бизнес-логике: он не может различить HTTP 404 как бизнес-ответ и HTTP 503 как сбой. Для точной настройки на уровне приложения библиотечный подход даёт больше контроля. Оба варианта можно комбинировать.
Итог
Выключатель - это не серебряная пуля и не обязательный элемент любой архитектуры. Это конкретный инструмент для конкретной проблемы: предотвратить распространение сбоя по цепочке зависимостей и дать упавшему сервису время восстановиться без лавины повторных запросов.
Главное в настройке - не пороги сами по себе, а правильная классификация ошибок и достаточный объём выборки для принятия решений. Выключатель, который открывается от бизнес-исключений или от трёх вызовов в маленьком окне, создаёт больше проблем, чем решает.
Начните с одной критической зависимости, настройте метрики, проверьте поведение под нагрузкой с искусственными сбоями - и только потом распространяйте подход на остальные сервисы. Это надёжнее, чем добавить аннотацию везде и надеяться, что настройки по умолчанию подойдут.