Новаком
SPRING-BOOT

Resilience4j Circuit Breaker в Spring Boot: гайд от 0 до production

Полный практический гайд по Circuit Breaker на Resilience4j: настройка sliding window, slow calls, Bulkhead, Retry с jitter, Fallback. С метриками, тестами и реальными грабли из банковских проектов.

Н
Новаком
2026-05-23 · 14 минут чтения

Когда нужен Circuit Breaker

Сценарий из жизни. Ваш Spring Boot сервис ходит к внешнему KYC-провайдеру для верификации клиента. KYC-провайдер падает на 30 минут. У вас в коде:

@Service
class CustomerService(private val kycClient: KycClient) {
    fun verifyCustomer(id: Long): VerificationResult =
        kycClient.verify(id)  // обычный sync REST вызов с timeout=30s
}

Что происходит за эти 30 минут:

  • Каждый incoming request стучится в KYC.
  • 30 секунд ждёт timeout.
  • Tomcat thread pool из 200 потоков забивается за 60 секунд.
  • Все следующие запросы возвращают 503 Service Unavailable — даже те, которым KYC вообще не нужен (например, чтение каталога).
  • Метрики k8s liveness/readiness падают → pod рестартует → новый pod тут же забивается заново.

Это называется каскадный отказ. Один downstream сервис уронил весь ваш бэкенд.

Решение — Circuit Breaker: мы перестаём звонить в KYC после N подряд неудач. Открываем «выключатель», и следующие запросы получают сразу fail-fast (или fallback) — без 30-секундного timeout.

В этом гайде:

  • как настроить Resilience4j Circuit Breaker правильно (не «из туториала Baeldung»);
  • какие параметры что значат и как их подобрать под свою нагрузку;
  • как добавить Bulkhead, Retry, Fallback к Circuit Breaker;
  • какие грабли мы находим на каждом втором аудите;
  • реальные метрики из банковских проектов.

Что такое Circuit Breaker — в трёх состояниях

        ┌──── CLOSED ─────┐
        │ всё работает   │
        │ запросы идут   │
        └────────┬────────┘
                 │
        N% запросов упали
                 │
                 ▼
        ┌──── OPEN ───────┐
        │ блокируем всё   │
        │ fail-fast       │
        └────────┬────────┘
                 │
        прошло waitDuration
                 │
                 ▼
        ┌──── HALF_OPEN ──┐
        │ пробуем N       │
        │ запросов        │
        └────┬──────────┬─┘
             │          │
       N% упали    < N% упали
             │          │
             ▼          ▼
          OPEN       CLOSED
  • CLOSED — нормальная работа, запросы идут downstream.
  • OPEN — downstream сервис «нездоров», блокируем все запросы. Возвращаем сразу либо исключение, либо результат fallback-метода.
  • HALF_OPEN — после waitDurationInOpenState пускаем пробные запросы. Если они проходят — возвращаемся в CLOSED. Если падают — снова OPEN.

Resilience4j использует sliding window (скользящее окно) для подсчёта failure rate. Окно бывает двух типов:

  • Count-based — последние N запросов. Простое, но не учитывает время.
  • Time-based — запросы за последние N секунд. Точнее для нестабильной нагрузки.

Зависимости и базовая конфигурация

build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-aop")
    implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
    implementation("io.github.resilience4j:resilience4j-kotlin:2.2.0")
    implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
}

application.yml — production-grade конфиг для KYC-клиента:

resilience4j:
  circuitbreaker:
    instances:
      kyc:
        sliding-window-type: TIME_BASED
        sliding-window-size: 60                # 60 секунд скользящего окна
        minimum-number-of-calls: 20            # игнорировать пока вызовов < 20
        failure-rate-threshold: 50             # OPEN при 50% упавших
        slow-call-rate-threshold: 80           # OPEN при 80% медленных
        slow-call-duration-threshold: 2s       # «медленный» = > 2s
        wait-duration-in-open-state: 30s       # 30s в OPEN перед HALF_OPEN
        permitted-number-of-calls-in-half-open-state: 5
        automatic-transition-from-open-to-half-open-enabled: true
        record-exceptions:
          - java.io.IOException
          - java.net.SocketTimeoutException
          - org.springframework.web.client.ResourceAccessException
          - feign.RetryableException
        ignore-exceptions:
          - com.example.kyc.KycValidationException  # бизнес-ошибки не считаем

Разберём важные параметры:

slidingWindowType: TIME_BASED

Для backend под нагрузкой почти всегда правильный выбор. Count-based проблематичен — если у вас 5 RPS и sliding-window-size: 100, то окно покрывает 20 секунд, и при долгом простое внешнего сервиса вы накопите 100 неудач и переключитесь в OPEN только через 20 секунд. Time-based реагирует быстрее.

minimumNumberOfCalls: 20

Без этого порога Circuit Breaker может «выстрелить» от случайного 1-из-3 fail-а при низком трафике. 20 — разумный минимум для production.

failureRateThreshold: 50 + slowCallRateThreshold: 80

Очень важная пара. Часто инженеры ставят только failureRateThreshold, забывая про slow calls. А «медленный» downstream опаснее, чем «упавший» — тред пула, забитого медленными вызовами, нет рычага освободить.

Логика: если 50% вызовов падают с исключением ИЛИ 80% выполняются дольше 2 секунд — открыть выключатель.

recordExceptions vs ignoreExceptions

Бизнес-исключения (например, CustomerNotFoundException) — это нормальная работа downstream, не повод размыкать. Бросаются они с downstream, но Circuit Breaker их игнорирует через ignoreExceptions.

Технические (timeout, connection refused, 5xx) — записывать через recordExceptions.

Если оставить дефолт «всё считаем как failure» — Circuit Breaker размыкается от валидных бизнес-ошибок. Видел это на 4 проектах подряд.


Применение к коду

Способ 1 — аннотации (декларативно):

@Service
class CustomerService(private val kycClient: KycClient) {

    @CircuitBreaker(name = "kyc", fallbackMethod = "kycFallback")
    fun verifyCustomer(id: Long): VerificationResult =
        kycClient.verify(id)

    @Suppress("unused", "UNUSED_PARAMETER")
    fun kycFallback(id: Long, ex: Throwable): VerificationResult =
        VerificationResult.pending(reason = "kyc-temporarily-unavailable")
}

Способ 2 — программный, удобнее для динамической логики:

@Service
class CustomerService(
    private val kycClient: KycClient,
    cbRegistry: CircuitBreakerRegistry,
) {
    private val cb = cbRegistry.circuitBreaker("kyc")

    fun verifyCustomer(id: Long): VerificationResult =
        runCatching {
            cb.executeSupplier { kycClient.verify(id) }
        }.getOrElse { ex ->
            log.warn("KYC fallback for customer=$id: ${ex.javaClass.simpleName}")
            VerificationResult.pending(reason = "kyc-temporarily-unavailable")
        }
}

Программный путь даёт больше контроля и явный fallback без AOP-магии. Мы предпочитаем его в банковских проектах.


TimeLimiter — обязательная пара к Circuit Breaker

Circuit Breaker сам по себе не отменяет долгий запрос. Он только считает, что вызов в течение slow-call-duration-threshold — это «медленно».

Если ваш kycClient.verify() зависнет на 60 секунд — Circuit Breaker зафиксирует это как «slow call», но тред будет занят 60 секунд. Чтобы реально оборвать долгий вызов — нужен TimeLimiter:

resilience4j:
  timelimiter:
    instances:
      kyc:
        timeout-duration: 3s
        cancel-running-future: true
@CircuitBreaker(name = "kyc", fallbackMethod = "kycFallback")
@TimeLimiter(name = "kyc")
fun verifyCustomer(id: Long): CompletableFuture<VerificationResult> =
    CompletableFuture.supplyAsync { kycClient.verify(id) }

Обратите внимание на CompletableFuture в сигнатуре — @TimeLimiter работает только с async-возвратом. Под капотом он отменяет future, если она не завершилась за timeout-duration.


Bulkhead — изоляция thread pool

Даже с Circuit Breaker один проблемный downstream может съесть весь Tomcat-пул через async-задачи. Решение — изоляция через Bulkhead:

resilience4j:
  bulkhead:
    instances:
      kyc:
        max-concurrent-calls: 20
        max-wait-duration: 100ms
  thread-pool-bulkhead:
    instances:
      kyc:
        max-thread-pool-size: 10
        core-thread-pool-size: 5
        queue-capacity: 20

Два режима:

  • Semaphore Bulkhead (bulkhead) — лимит параллельных вызовов через семафор. Лёгкий, без отдельного пула.
  • ThreadPool Bulkhead (thread-pool-bulkhead) — отдельный пул потоков для конкретного downstream. Тяжелее, но изолирует полностью.

Для I/O-bound вызовов (HTTP, БД) обычно semaphore достаточно. Для blocking-вызовов с возможным зависанием — thread-pool, чтобы они не блокировали Tomcat-потоки.

@CircuitBreaker(name = "kyc", fallbackMethod = "kycFallback")
@Bulkhead(name = "kyc")
@TimeLimiter(name = "kyc")
fun verifyCustomer(id: Long): CompletableFuture<VerificationResult> = ...

Порядок аннотаций важен. Resilience4j применяет их в порядке (от внутреннего к внешнему): Bulkhead → TimeLimiter → CircuitBreaker → Retry → RateLimiter → Fallback. Это логично: сначала ограничиваем параллелизм, потом таймаут, потом проверяем CB-статус.


Retry с exponential backoff + jitter

resilience4j:
  retry:
    instances:
      kyc:
        max-attempts: 3
        wait-duration: 200ms
        exponential-backoff-multiplier: 2     # 200, 400, 800
        randomized-wait-factor: 0.5           # ± 50% jitter
        retry-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignore-exceptions:
          - com.example.kyc.KycValidationException
@Retry(name = "kyc")
@CircuitBreaker(name = "kyc", fallbackMethod = "kycFallback")
fun verifyCustomer(id: Long): VerificationResult = ...

Главное — jitter. Без него все клиенты после падения downstream начинают ретраить одновременно — это синхронизированный thundering herd, который догоняет уже восстанавливающийся сервис и опять его кладёт. Jitter рандомизирует время между ретраями, размазывает нагрузку.

💡 Никогда не ставьте retry без jitter. Это правило с 0 исключениями.


Метрики и Grafana

Resilience4j автоматически публикует метрики через Micrometer:

resilience4j.circuitbreaker.calls{name="kyc",kind="successful"} 12450
resilience4j.circuitbreaker.calls{name="kyc",kind="failed"} 42
resilience4j.circuitbreaker.calls{name="kyc",kind="slow"} 18
resilience4j.circuitbreaker.calls{name="kyc",kind="not_permitted"} 0  # эти OPEN заблокировал
resilience4j.circuitbreaker.state{name="kyc",state="closed"} 1
resilience4j.circuitbreaker.state{name="kyc",state="open"} 0
resilience4j.circuitbreaker.state{name="kyc",state="half_open"} 0
resilience4j.circuitbreaker.failure.rate{name="kyc"} 0.34
resilience4j.circuitbreaker.slow.call.rate{name="kyc"} 0.15

Что обязательно в Grafana:

  1. Текущее состояние CBclosed / open / half_open как stat-панель с цветом (зелёный/красный/жёлтый).
  2. Failure rate % — линия с порогом failureRateThreshold на графике.
  3. Slow call rate % — то же с slowCallRateThreshold.
  4. Calls not permitted — сколько запросов отклонил CB в OPEN. Если > 0 — алерт.

И обязательно алерт: «Circuit Breaker kyc в OPEN дольше 1 минуты». Это сигнал, что downstream упал по-настоящему, а не moment of weakness.


Микро-CTA

Если у вас в проде есть Spring Boot микросервисы и вы не уверены, выживет ли система при падении любого downstream — мы это проверяем за 3 дня в Backend Health-Check. Senior находит каскадные риски, отдаёт чек-лист какие CB/Bulkhead настроить и где какие пороги.


Грабли, на которые мы наступаем

Грабля 1: CB размыкается от валидных бизнес-ошибок

Самая частая ошибка. Дефолтная конфигурация считает любое исключение как failure. У вас CustomerNotFoundException для случая «клиент не найден» — это нормальный business case, а CB его считает failure-ом. После 50% таких в окне — CB открывается, реальные validation-кейсы начинают падать.

Фикс: обязательно укажите recordExceptions или ignoreExceptions. Лучше явно указать что записывать (whitelist), чем что игнорировать (blacklist) — меньше сюрпризов.

Грабля 2: Circuit Breaker без TimeLimiter

CB фиксирует медленный вызов как slow call, но не отменяет его. Если у вас downstream висит 30 секунд — поток заблокирован 30 секунд независимо от CB. Без TimeLimiter Circuit Breaker даёт меньше пользы, чем кажется.

Фикс: TimeLimiter + async (CompletableFuture).

Грабля 3: одна CB-инстанция на все вызовы downstream

// плохо
@CircuitBreaker(name = "downstream")
fun callA() = clientA.fetch()

@CircuitBreaker(name = "downstream")  // тот же name
fun callB() = clientB.fetch()

Если downstream A упал, CB размыкается — и блокирует вызовы к B тоже, потому что они под тем же name.

Фикс: одна CB-инстанция = один downstream. name = "kyc", name = "payment-gateway", name = "fraud-detection" — отдельно для каждого.

Грабля 4: Retry без jitter

См. выше. Просто никогда не делайте.

Грабля 5: permittedNumberOfCallsInHalfOpenState слишком большой

Если ставите 100 — после waitDuration система впустит сразу 100 запросов на восстанавливающийся downstream, который ещё может его свалить.

Фикс: 3-10 — разумно. Хватает для надёжного определения состояния, не убивает downstream при возврате.

Грабля 6: Fallback бросает исключение

Метод fallback должен гарантированно возвращать значение или null. Если он сам бросает исключение — оно прокидывается выше как от оригинального метода, и Circuit Breaker считает это как failure. Logged in OPEN-CLOSED-OPEN cycle.

Фикс: fallback должен ловить всё и возвращать sane default.

Грабля 7: тестирование без @AutoConfigureWireMock

«Я написал Circuit Breaker, как проверить?» — отвечаю: WireMock + integration test.

@SpringBootTest
@AutoConfigureWireMock(port = 0)
class CircuitBreakerIntegrationTest {

    @Test
    fun `opens after 5 consecutive failures`() {
        WireMock.stubFor(
            get(urlEqualTo("/kyc/verify"))
                .willReturn(serverError())
        )

        // 5 вызовов → должен открыться
        repeat(5) {
            assertThat { customerService.verifyCustomer(1L) }
                .isFailure()
        }

        assertThat(circuitBreaker.state).isEqualTo(State.OPEN)
    }
}

Без integration-тестов CB-настройки уйдут в прод и сюрприз ловить будете там.


Финальный чек-лист production

  • Sliding window TIME_BASED, окно 30-60 секунд
  • minimumNumberOfCalls ≥ 20 (для среднего трафика)
  • Оба порога: failureRateThreshold и slowCallRateThreshold
  • slowCallDurationThreshold ниже типичной p95 downstream вызова в 2-3 раза
  • recordExceptions / ignoreExceptions явно указаны (не дефолт)
  • TimeLimiter настроен в пару к Circuit Breaker
  • Bulkhead настроен для I/O-heavy вызовов
  • Retry — только с jitter (randomized-wait-factor > 0)
  • Метрики Resilience4j в Grafana, алерт «CB open > 1 min»
  • Один CB = один downstream
  • Integration test для проверки порогов
  • Fallback гарантированно не бросает

Resilience4j — стандарт для Spring Boot, если у вас есть зависимости на внешние сервисы (а они есть). Большинство граблей выше — типовые, мы находим их в каждом втором проекте.

Если хотите внешнее ревью текущей CB-конфигурации с проверкой на каскадные сценарии — это входит в Backend Health-Check за 3 дня. Senior смотрит ваши настройки, моделирует падение downstream через chaos engineering и отдаёт отчёт с рекомендациями. Договор остаётся у вас в любом случае.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект