← Назад к списку тем

06. Circuit Breaker

Внутреннее устройство CircuitBreakerStateMachine и скользящего окна Resilience4j, программный API и функциональная композиция декораторов, кастомные предикаты отказов, события, абстракция Spring Cloud CircuitBreaker, распределённые нюансы и тестирование переходов состояний.

Зачем нужен именно Circuit Breaker: retry не спасает от деградирующей зависимости

Конкретный сценарий отказа. Платёжный шлюз начал отвечать за 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: как он устроен внутри, как использовать его программно (не только через аннотацию), как настраивать предикаты отказов осознанно и что происходит при горизонтальном масштабировании.

🔑 Обязательная зависимость: тема 01. Если базовые понятия — состояния, sliding window, failure-rate-threshold, порядок декораторов — ещё не знакомы, стоит сначала прочитать тему 01: этот материал не повторяет тот фундамент, а строится на нём.

Не только аннотация: программный API Resilience4j

Декларативный @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'а.

Механика внутри: CircuitBreakerStateMachine и скользящее окно

Тема 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 секунд" независимо от объёма трафика,
// но требует немного больше внутренней бухгалтерии на сдвиг окна.
🔑 minimum-number-of-calls — защита от статистики на малой выборке. Порог 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();
🚨 Ложное срабатывание из-за бизнес-исключений — самая частая ошибка первичной настройки Circuit Breaker. Всплеск карт с недостаточным балансом (обычный день перед зарплатой, не деградация шлюза) при дефолтном «любое исключение — отказ» способен разомкнуть цепь и заблокировать оплату для клиентов с деньгами на счету — то есть breaker сам создаёт инцидент там, где зависимость абсолютно здорова. Первый шаг при подключении Circuit Breaker к любому новому вызову — явно пройтись по всем исключениям, которые может бросить защищённый код, и осознанно разнести их на «инфраструктурный отказ» и «ожидаемый доменный результат».

События: реакция на переходы состояний в реальном времени

Помимо агрегированных метрик (тема 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));
    }
}
🔑 Регистрировать слушателей нужно один раз, на singleton-инстансе из registry. CircuitBreakerRegistry.circuitBreaker(name) возвращает один и тот же объект при повторных вызовах с тем же именем — подписка на события в конструкторе компонента безопасна и не приведёт к дублированию колбэков на каждый вызов защищённого метода.

Функциональная композиция без Spring AOP: Decorators

Когда нужно вручную скомбинировать несколько паттернов резилентности вокруг одного вызова (а не полагаться на порядок 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 CircuitBreaker: абстракция поверх Resilience4j

Отдельный модуль 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");
}
⚠️ На практике переносимость между реализациями почти никогда не используется. Hystrix в maintenance mode уже несколько лет, альтернативных полноценных реализаций CircuitBreakerFactory для JVM-экосистемы по сути нет — обещание «легко сменить вендора» для Circuit Breaker сегодня скорее теоретическое. Прямое использование Resilience4j API (аннотация или программный CircuitBreakerRegistry) даёт доступ ко всем возможностям библиотеки без промежуточного слоя и обычно проще в отладке. Абстракцию Spring Cloud стоит выбирать осознанно — например, если она уже стандарт в организации для нескольких языковых экосистем — а не по умолчанию «на всякий случай».

Распределённые нюансы: состояние breaker'а не шарится между инстансами

Состояние Circuit Breaker — CLOSED/OPEN/HALF_OPEN и вся статистика скользящего окна — живёт в памяти конкретного JVM-процесса. При трёх репликах сервиса — три независимых breaker'а с тремя независимыми окнами статистики, ничего не знающих друг о друге. Это осознанный, а не случайный дизайн:

  • Плюс: отказ или медленная деградация breaker'а не требует сетевого похода в общее хранилище состояния (Redis и т.п.) на каждый вызов — решение принимается локально, за наносекунды, без дополнительной точки отказа;
  • Минус: при неравномерном распределении трафика по репликам (например, за sticky-балансировщиком) одна реплика может разомкнуть цепь раньше других, которые продолжат слать трафик в уже деградирующую зависимость — общая защита кластера включается с задержкой, пока статистика не накопится на каждой реплике по отдельности;
  • Следствие для наблюдаемости: дашборд с метрикой resilience4j_circuitbreaker_state (тема 05) должен показывать состояние по каждому инстансу отдельно (тег pod/instance), а не агрегировать — «в среднем полуоткрыт» не значит ничего осмысленного для состояния конечного автомата.
🔑 Шарить состояние Circuit Breaker между репликами — редкая и специфичная задача. В отличие от распределённых блокировок или rate limiter'ов (где общее состояние — норма), для Circuit Breaker общий консенсус между репликами почти никогда не нужен: он защищает конкретный процесс от бесполезной траты его собственных ресурсов, а не координирует общий лимит на кластер. Если всё же требуется единая картина «зависимость лежит» на весь кластер — это обычно решают не разделяемым состоянием breaker'а, а внешним health-check зависимости и централизованным feature-флагом/kill-switch поверх него, а не модификацией самого Resilience4j.

Сравнение способов использования Circuit Breaker

КритерийАннотация @CircuitBreakerПрограммный CircuitBreakerRegistrySpring Cloud CircuitBreakerFactory
Требует Spring AOP-проксиДаНет — вызов явный в кодеНет
Гранулярность защитыВесь метод целикомЛюбой произвольный кусок кодаЛюбой произвольный кусок кода
Self-invocationЛомает защиту (см. тему 01)Не актуально — нет проксиНе актуально
Доступ к событиям/API Resilience4jЧерез CircuitBreakerRegistry отдельноПолный, напрямуюЧастичный, через Customizer
Когда выбиратьОбычный сервисный метод, вызывается только через бинНужна точечная защита части метода, статический контекст, WebClient-фильтрОрганизационный стандарт мульти-фреймворковой абстракции (редко)

Когда применять Circuit Breaker, когда — нет

Применять стоит, когда:

  • вызов уходит за пределы процесса к зависимости, у которой реалистичны периоды деградации (внешний API, соседний сервис, БД под нагрузкой) — а не просто «на всякий случай» на каждый метод подряд;
  • эндпоинт вызывается достаточно часто, чтобы статистика (minimum-number-of-calls) успевала накапливаться в разумное время — иначе breaker физически не сможет вовремя сработать;
  • у вызывающей стороны есть реальный ресурс, который стоит защищать при деградации зависимости (пул потоков, пул соединений) — то есть fail-fast действительно экономит что-то ценное, а не просто меняет одну ошибку на другую.

Избегать или относиться с осторожностью стоит, когда:

  • вызов — редкий, низкочастотный фоновый процесс (раз в час) — окно статистики никогда не наберёт minimum-number-of-calls в разумный срок, breaker фактически бесполезен;
  • ошибки зависимости почти всегда доменные/бизнесовые (4xx), а не инфраструктурные — без корректно настроенного предиката (см. раздел выше) breaker будет размыкаться на здоровой зависимости;
  • зависимость критична и не может иметь «деградированного» режима работы в принципе (например, локальная операция без сети) — здесь важнее алертинг на первый же отказ, а не терпимость к продолжающимся;
  • глубокий граф вызовов с breaker'ом на каждом хопе (сервис A → B → C → D, у каждого свой breaker с похожими порогами) усложняет диагностику инцидента: единственный реальный отказ внизу графа порождает каскад из нескольких независимо открывшихся breaker'ов выше — важно смотреть на события/метрики (тема 05) на всех уровнях сразу, а не только на верхний симптом.

Тестирование переходов состояний

Кроме точечной проверки «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, выставленным только в тестовом профиле.

⚠️ Не переиспользуйте один и тот же именованный CircuitBreaker между независимыми тестами. CircuitBreakerRegistry, поднятый один раз на весь Spring-контекст интеграционных тестов, хранит статистику breaker'а между тестовыми методами — состояние, накопленное в одном тесте, «утекает» в следующий и делает тест недетерминированным. Либо сбрасывайте состояние явно (circuitBreaker.reset()) в @BeforeEach, либо создавайте breaker локально через CircuitBreaker.of(...) в тестах, где важна чистота статистики между прогонами.
Следующая →