Когда нужен 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:
- Текущее состояние CB —
closed / open / half_openкак stat-панель с цветом (зелёный/красный/жёлтый). - Failure rate % — линия с порогом
failureRateThresholdна графике. - Slow call rate % — то же с
slowCallRateThreshold. - 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 и отдаёт отчёт с рекомендациями. Договор остаётся у вас в любом случае.