Конкретный сценарий отказа. Платёжный шлюз начал отвечать за 8 секунд вместо привычных 150 мс — не упал полностью, а именно деградировал, что на практике опаснее падения: сервис по-прежнему принимает соединения, поэтому клиент не получает мгновенный connection refused. Без circuit breaker, но с настроенным retry (тема 01) картина усугубляется: каждый входящий запрос делает 4 попытки по 8 секунд — 32 секунды суммарного ожидания на один запрос пользователя, и все эти секунды поток (или соединение из пула HTTP-клиента) занят. При сотне запросов в секунду за первые же секунды исчерпывается весь пул соединений вызывающего сервиса — он сам становится недоступен, хотя изначально деградировал только шлюз. Ретраи не просто не помогают — они умножают нагрузку на и без того страдающую зависимость.
Circuit Breaker разрывает эту цепочку: после накопления достаточной доли отказов он на время полностью прекращает попытки связаться с зависимостью, отклоняя вызовы локально и мгновенно (fail-fast), не тратя ни одного соединения на заведомо обречённый round-trip. Тема 01 уже показывала Circuit Breaker верхнеуровнево — как один из декораторов резилентности, в связке с Retry/Bulkhead/RateLimiter, с базовым конфигом и таблицей CLOSED/OPEN/HALF_OPEN. Здесь — отдельное глубокое погружение именно в Circuit Breaker: как он устроен внутри, как использовать его программно (не только через аннотацию), как настраивать предикаты отказов осознанно и что происходит при горизонтальном масштабировании.
failure-rate-threshold, порядок декораторов — ещё не знакомы, стоит сначала прочитать тему 01: этот материал не повторяет тот фундамент, а строится на нём.
Декларативный @CircuitBreaker (см. тему 01) работает через Spring AOP-прокси и покрывает большинство случаев, но у него есть жёсткое ограничение: он оборачивает весь метод целиком, вызываемый только через бин. Если защитить нужно не весь метод, а конкретный вызов внутри — например, внутри фильтра WebClient, внутри статического утилитного кода или там, где self-invocation в принципе исключает AOP-подход — используется программный API: CircuitBreaker достаётся напрямую из CircuitBreakerRegistry и оборачивает конкретный Supplier:
@Component
class PaymentGatewayHttpClient {
private final RestClient restClient;
private final CircuitBreaker circuitBreaker;
PaymentGatewayHttpClient(RestClient restClient, CircuitBreakerRegistry registry) {
this.restClient = restClient;
// registry.circuitBreaker(name) создаёт инстанс ЛЕНИВО при первом
// обращении и дальше переиспользует тот же объект для того же имени —
// не нужен отдельный @Bean на каждый named-инстанс
this.circuitBreaker = registry.circuitBreaker("paymentGateway");
}
PaymentResult charge(ChargeRequest request) {
Supplier<PaymentResult> call = () -> restClient.post()
.uri("/charges")
.body(request)
.retrieve()
.body(PaymentResult.class);
// decorateSupplier НЕ вызывает call немедленно — возвращает новый
// Supplier, который при вызове .get() сначала проверит permission
return circuitBreaker.decorateSupplier(call).get();
}
}
Имя инстанса ("paymentGateway") — единственная связь между программным кодом и конфигом в application.yml: CircuitBreakerRegistry при первом запросе имени, для которого нет явной конфигурации, использует секцию resilience4j.circuitbreaker.configs.default, а если есть — именованную секцию instances.paymentGateway, ровно как и для аннотации @CircuitBreaker(name = "paymentGateway"). Это значит, что декларативный и программный способы для одного и того же имени используют один и тот же инстанс с общей статистикой и общим текущим состоянием — не два независимых breaker'а.
Тема 01 описывала три состояния как модель для понимания поведения. Внутри Resilience4j их формально пять: к CLOSED/OPEN/HALF_OPEN добавляются DISABLED (breaker выключен, все вызовы проходят без учёта в статистике — удобно для локальной отладки) и FORCED_OPEN (принудительно и постоянно разомкнут, не переходит в HALF_OPEN по таймеру — ручной «рубильник», например, на время известного планового отказа зависимости) и METRICS_ONLY (собирает статистику, но никогда не блокирует вызовы — полезно, чтобы сначала понаблюдать за реальными показателями отказов перед тем, как включать реальную защиту).
Каждое состояние реализует общий внутренний интерфейс с методом проверки разрешения на вызов, а сам breaker хранит текущее состояние в AtomicReference — переход между состояниями является атомарной операцией без блокировок:
// Упрощённо: CircuitBreakerStateMachine изнутри
class CircuitBreakerStateMachine {
private final AtomicReference<CircuitBreakerState> stateReference;
boolean tryAcquirePermission() {
// Делегирует конкретной реализации: у OpenState — сразу false
// (пока не истёк wait-duration), у ClosedState — всегда true,
// у HalfOpenState — true, пока не исчерпан лимит пробных вызовов
return stateReference.get().tryAcquirePermission();
}
void onResult(long durationMs, Outcome outcome) {
stateReference.get().onResult(durationMs, outcome); // пишет в sliding window, может инициировать переход состояния
}
}
Собственно статистика живёт в скользящем окне, и именно тип окна определяет, как быстро breaker «забывает» старые результаты:
// COUNT_BASED — кольцевой буфер фиксированного размера N (sliding-window-size).
// Каждый новый вызов перезаписывает самый старый слот буфера и мгновенно
// пересчитывает агрегаты (долю отказов, долю медленных вызовов) — O(1) на вызов.
// При низком трафике это окно может "зависать" на результатах многочасовой
// давности, если новых вызовов физически не было.
// TIME_BASED — N бакетов, каждый шириной в 1 секунду (всего N = sliding-window-size
// секунд). Новый вызов попадает в текущий бакет; при переходе на новую секунду
// самый старый бакет полностью вытесняется и вычитается из агрегатов.
// Точнее отражает "последние N секунд" независимо от объёма трафика,
// но требует немного больше внутренней бухгалтерии на сдвиг окна.
failure-rate-threshold не проверяется, пока в окне не накопится хотя бы minimum-number-of-calls результатов — иначе 2 отказа из 2 вызовов (100%!) размыкали бы цепь на пустом месте при старте приложения или на редко вызываемом эндпоинте. Это тот же принцип, что minimum-number-of-calls в теме 01 — здесь важно понимать: до накопления минимума breaker физически не может перейти в OPEN, сколько бы отказов подряд ни случилось.
По умолчанию breaker считает отказом любое исключение, вылетевшее из защищённого вызова — включая доменные ошибки, никак не связанные со здоровьем самого шлюза. Это опасно по умолчанию: если платёжный шлюз возвращает бизнес-отказ «недостаточно средств» как HTTP 402, а клиентский код транслирует его в исключение — валидные, ожидаемые бизнес-отказы будут засчитаны как признак деградации инфраструктуры и разомкнут цепь для всех последующих (в том числе абсолютно здоровых) платежей.
# application.yml — явно разделяем "зависимость нездорова" и "клиент неправ"
resilience4j:
circuitbreaker:
instances:
paymentGateway:
record-exceptions:
- java.net.ConnectException
- java.util.concurrent.TimeoutException
- com.example.payment.GatewayServerErrorException # HTTP 5xx от шлюза
ignore-exceptions:
- com.example.payment.InvalidCardException # HTTP 4xx, доменная ошибка
- com.example.payment.InsufficientFundsException
Для логики отказов сложнее списка классов исключений — произвольный предикат через программный конфиг:
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowSize(20)
.failureRateThreshold(50)
// true = засчитать как отказ. HTTP-статусы 5xx и таймауты — да,
// всё остальное (в т.ч. бизнес-исключения 4xx) — нет
.recordException(ex -> ex instanceof GatewayServerErrorException
|| ex instanceof TimeoutException)
.build();
Помимо агрегированных метрик (тема 01), у каждого CircuitBreaker есть EventPublisher — точечные колбэки на конкретные происходящие события, полезные для алертинга сверх стандартного дашборда или для точечного логирования именно моментов деградации, а не постоянного потока метрик:
@Component
class CircuitBreakerAlerting {
CircuitBreakerAlerting(CircuitBreakerRegistry registry, SlackNotifier slackNotifier) {
registry.circuitBreaker("paymentGateway").getEventPublisher()
.onStateTransition(event -> {
String message = "CircuitBreaker %s: %s -> %s".formatted(
event.getCircuitBreakerName(),
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
slackNotifier.send(message); // команда узнаёт о размыкании раньше жалоб клиентов
})
.onCallNotPermitted(event -> log.debug("Rejected while circuit is open: {}", event));
}
}
CircuitBreakerRegistry.circuitBreaker(name) возвращает один и тот же объект при повторных вызовах с тем же именем — подписка на события в конструкторе компонента безопасна и не приведёт к дублированию колбэков на каждый вызов защищённого метода.
Когда нужно вручную скомбинировать несколько паттернов резилентности вокруг одного вызова (а не полагаться на порядок Spring-аспектов из темы 01) — Resilience4j даёт билдер Decorators, который последовательно оборачивает один Supplier нужными декораторами в явном, читаемом порядке:
Supplier<PaymentResult> call = () -> gatewayClient.charge(request);
Supplier<PaymentResult> decorated = Decorators.ofSupplier(call)
.withCircuitBreaker(circuitBreaker)
.withRetry(retry)
.withRateLimiter(rateLimiter)
.decorate();
PaymentResult result = Try.ofSupplier(decorated)
.recover(throwable -> PaymentResult.pendingReview(request))
.get();
Порядок вызовов .withX(...) в билдере читается как порядок оборачивания снаружи внутрь — последний применённый декоратор оказывается ближе всего к реальному вызову. Это тот же принцип вложенности, что и у Spring-аспектов (тема 01), только явный в коде, а не выведенный из свойств *-aspect-order в YAML — предпочтительно там, где порядок критичен и должен быть виден с первого взгляда на код, без чтения конфигурации.
Отдельный модуль spring-cloud-starter-circuitbreaker-resilience4j добавляет ещё один слой — интерфейс CircuitBreakerFactory, не привязанный синтаксически к конкретной реализации (исторически абстракция создавалась, чтобы один и тот же прикладной код работал и с Resilience4j, и с (ныне устаревшим) Hystrix):
@Service
class PaymentService {
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
PaymentResult charge(ChargeRequest request) {
return circuitBreakerFactory.create("paymentGateway")
.run(() -> gatewayClient.charge(request),
throwable -> PaymentResult.pendingReview(request));
}
}
Тонкая настройка конкретных порогов под капотом всё равно происходит через Resilience4j-специфичный Customizer<Resilience4JCircuitBreakerFactory> — абстракция снимает синтаксическую зависимость от конкретного API вызова, но не снимает зависимость от конкретной реализации в конфигурации:
@Bean
Customizer<Resilience4JCircuitBreakerFactory> paymentGatewayCustomizer() {
return factory -> factory.configure(builder -> builder
.circuitBreakerConfig(CircuitBreakerConfig.custom().slidingWindowSize(20).build()),
"paymentGateway");
}
CircuitBreakerFactory для JVM-экосистемы по сути нет — обещание «легко сменить вендора» для Circuit Breaker сегодня скорее теоретическое. Прямое использование Resilience4j API (аннотация или программный CircuitBreakerRegistry) даёт доступ ко всем возможностям библиотеки без промежуточного слоя и обычно проще в отладке. Абстракцию Spring Cloud стоит выбирать осознанно — например, если она уже стандарт в организации для нескольких языковых экосистем — а не по умолчанию «на всякий случай».
Состояние Circuit Breaker — CLOSED/OPEN/HALF_OPEN и вся статистика скользящего окна — живёт в памяти конкретного JVM-процесса. При трёх репликах сервиса — три независимых breaker'а с тремя независимыми окнами статистики, ничего не знающих друг о друге. Это осознанный, а не случайный дизайн:
resilience4j_circuitbreaker_state (тема 05) должен показывать состояние по каждому инстансу отдельно (тег pod/instance), а не агрегировать — «в среднем полуоткрыт» не значит ничего осмысленного для состояния конечного автомата.| Критерий | Аннотация @CircuitBreaker | Программный CircuitBreakerRegistry | Spring Cloud CircuitBreakerFactory |
|---|---|---|---|
| Требует Spring AOP-прокси | Да | Нет — вызов явный в коде | Нет |
| Гранулярность защиты | Весь метод целиком | Любой произвольный кусок кода | Любой произвольный кусок кода |
| Self-invocation | Ломает защиту (см. тему 01) | Не актуально — нет прокси | Не актуально |
| Доступ к событиям/API Resilience4j | Через CircuitBreakerRegistry отдельно | Полный, напрямую | Частичный, через Customizer |
| Когда выбирать | Обычный сервисный метод, вызывается только через бин | Нужна точечная защита части метода, статический контекст, WebClient-фильтр | Организационный стандарт мульти-фреймворковой абстракции (редко) |
Применять стоит, когда:
minimum-number-of-calls) успевала накапливаться в разумное время — иначе breaker физически не сможет вовремя сработать;Избегать или относиться с осторожностью стоит, когда:
minimum-number-of-calls в разумный срок, breaker фактически бесполезен;Кроме точечной проверки «fallback сработал при принудительно открытой цепи» (пример из темы 01), полезно проверять сам факт и последовательность переходов состояний — именно они, а не просто «упал/не упал», являются наблюдаемым поведением breaker'а:
@Test
void opensAfterFailureRateThresholdExceeded() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowSize(10)
.minimumNumberOfCalls(10)
.failureRateThreshold(50)
.build();
CircuitBreaker circuitBreaker = CircuitBreaker.of("test", config);
List<CircuitBreaker.State> observedTransitions = new ArrayList<>();
circuitBreaker.getEventPublisher()
.onStateTransition(event -> observedTransitions.add(event.getStateTransition().getToState()));
// 6 отказов из 10 вызовов = 60% > порога 50%
simulateCalls(circuitBreaker, 6, true); // отказы
simulateCalls(circuitBreaker, 4, false); // успехи
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
assertThat(observedTransitions).contains(CircuitBreaker.State.OPEN);
}
private void simulateCalls(CircuitBreaker cb, int count, boolean failing) {
for (int i = 0; i < count; i++) {
if (failing) {
cb.onError(0, TimeUnit.MILLISECONDS, new RuntimeException("boom"));
} else {
cb.onSuccess(0, TimeUnit.MILLISECONDS);
}
}
}
Такой тест создаёт CircuitBreaker напрямую через CircuitBreaker.of(name, config), без поднятия Spring-контекста — быстрый unit-тест самой конфигурации порогов, не зависящий от того, как breaker подключён к бину (аннотацией или программно). Переход OPEN → HALF_OPEN по истечении wait-duration-in-open-state в реальном времени тестировать таймером неудобно — для интеграционных тестов, где нужен именно этот переход, разумнее либо принудительно вызвать transitionToHalfOpenState() напрямую (как transitionToOpenState() в теме 01), либо использовать Awaitility.await() с искусственно маленьким wait-duration-in-open-state, выставленным только в тестовом профиле.
CircuitBreakerRegistry, поднятый один раз на весь Spring-контекст интеграционных тестов, хранит статистику breaker'а между тестовыми методами — состояние, накопленное в одном тесте, «утекает» в следующий и делает тест недетерминированным. Либо сбрасывайте состояние явно (circuitBreaker.reset()) в @BeforeEach, либо создавайте breaker локально через CircuitBreaker.of(...) в тестах, где важна чистота статистики между прогонами.