🏗️ Раздел 17 · Вопрос #5

Что такое паттерн Circuit Breaker

В жилых домах электрический автомат в щитке размыкает цепь при коротком замыкании или перегрузке сети, чтобы проводка не загорелась. В микросервисах Circuit Breaker делает то же...


🟢 Junior Level

Circuit Breaker («Автоматический выключатель» / «Предохранитель») — это архитектурный паттерн стабильности распределённых систем, который предотвращает каскадное падение сервисов при сбое или деградации одной из зависимостей.

Без Circuit Breaker (Каскадный сбой):
[Клиенты] ──► [Order Service] ──(Таймаут 5 сек)──► [Payment Service (ЗАВИС)]
                │ (Все 200 потоков Tomcat заняты ожиданием)
                ▼
            [ПАДЕНИЕ ВСЕЙ СИСТЕМЫ 💥]

С Circuit Breaker (Fail-Fast):
[Клиенты] ──► [Order Service] ──► [Circuit Breaker: OPEN ⚡]
                                       │ (Мгновенный возврат ошибки / Fallback за 1 мс)
                                       ▼
                                 [Быстрый отказ / Graceful Degradation ✅]

Основная аналогия:

В жилых домах электрический автомат в щитке размыкает цепь при коротком замыкании или перегрузке сети, чтобы проводка не загорелась. В микросервисах Circuit Breaker делает то же самое: если зависимый сервис начинает отдавать ошибки или зависать, предохранитель размыкает цепь (переходит в состояние OPEN) и немедленно отклоняет последующие запросы (Fail-Fast), не тратя ресурсы на бессмысленное ожидание тайм-аутов.

Три базовых состояния:

  1. CLOSED (Замкнут): Нормальный режим. Запросы свободно отправляются зависимому сервису. Метрики собираются.
  2. OPEN (Разомкнут): Обнаружена авария. Запросы к удалённому сервису не отправляются вообще. Сразу вызывается запасной сценарий (Fallback) или выбрасывается исключение (CallNotPermittedException).
  3. HALF-OPEN (Полуразомкнут): Время ожидания истекло. Предохранитель пропускает ограниченное число пробных запросов. Если они прошли успешно — сервис восстановился (CLOSED). Если упали — цепь снова размыкается (OPEN).

🟡 Middle Level

1. Реализация на Spring Boot и Resilience4j

Resilience4j — современный легковесный стандарт отказоустойчивости для Java (пришедший на замену устаревшему Netflix Hystrix).

@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentClient {

    private final RestClient restClient;

    @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
    public PaymentResponse processPayment(PaymentRequest request) {
        return restClient.post()
            .uri("https://payment-service/api/v1/charge")
            .body(request)
            .retrieve()
            .body(PaymentResponse.class);
    }

    // Fallback-метод: ОБЯЗАН иметь ту же сигнатуру параметров + Throwable в конце
    public PaymentResponse paymentFallback(PaymentRequest request, Throwable ex) {
        log.warn("Сервис платежей недоступен: {}. Применяется деградация.", ex.getMessage());
        return PaymentResponse.builder()
            .status(PaymentStatus.DEFERRED_PENDING) // Перевод в отложенную обработку
            .orderId(request.orderId())
            .build();
    }
}

2. Конфигурация в application.yml

resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        slidingWindowType: COUNT_BASED       # Учет по количеству вызовов
        slidingWindowSize: 20                # Анализ последних 20 вызовов
        minimumNumberOfCalls: 10             # Минимальное число вызовов для включения расчетов
        failureRateThreshold: 50             # Процент ошибок для размыкания (50%)
        slowCallRateThreshold: 70            # Процент медленных ответов (70%)
        slowCallDurationThreshold: 2000ms    # Вызов считается медленным, если длится > 2 сек
        waitDurationInOpenState: 15s         # Время нахождения в состоянии OPEN
        permittedNumberOfCallsInHalfOpenState: 5 # Пробных вызовов в состоянии HALF-OPEN
        automaticTransitionFromOpenToHalfOpenEnabled: true

3. Разграничение: Circuit Breaker vs Retry vs Rate Limiter vs Bulkhead

Паттерн Основная цель Что делает при сбое
Circuit Breaker Предотвращение каскадных аварий и защита зависшей системы Размыкает цепь и моментально отклоняет новые вызовы (Fail-Fast)
Retry Преодоление кратковременных сетевых флуктуаций Повторяет упавший запрос через интервал времени
Rate Limiter Контроль пропускной способности (RPS) и защита от перегрузки Ограничивает входящую частоту запросов (Leaky Bucket / Token Bucket)
Bulkhead Изоляция ресурсов (пулов потоков, семафоров) Ограничивает количество параллельных потоков, выделенных на один сервис

Опасность: Использование Retry БЕЗ Circuit Breaker во время деградации удалённого сервиса приводит к Retry Storm (лавине повторов), которая окончательно «добивает» умирающий сервис миллионами повторных запросов.


🔴 Senior Level

1. Медленные ответы (Slow Calls) vs Явные ошибки (Failures)

Главный убийца микросервисов в продакшене — это НЕ быстрые ошибки HTTP 500, а медленные ответы (Slow Calls):

  • Если сервис $B$ моментально возвращает 500 Internal Server Error, сервис $A$ освобождает поток Tomcat за 5 миллисекунд.
  • Если сервис $B$ зависает на 15 секунд из-за блокировки в БД, каждый запрос удерживает поток ОС/Tomcat в течение 15 секунд.
  • При пуле в 200 потоков (server.tomcat.threads.max=200) и входящем потоке 50 req/sec, весь пул потоков сервиса A будет исчерпан ровно за 4 секунды. Сервис $A$ перестает отвечать на Health Checks от Kubernetes (Liveness/Readiness probe) и уходит в рестарт.

Поэтому современный Circuit Breaker обязан отслеживать не только failureRateThreshold, но и slowCallRateThreshold.

2. Локальный (In-Memory) vs Распределённый Circuit Breaker

Почему Resilience4j хранит метрики скользящего окна локально в памяти каждого пода (JVM), а не в общем распределённом кэше (например, Redis)?

In-Memory (Рекомендуемый подход):
[Pod 1 (Local CB)] ──► [Ext Service]
[Pod 2 (Local CB)] ──► [Ext Service]
[Pod 3 (Local CB)] ──► [Ext Service]
(Нет зависимости от Redis, нулевые накладные расходы по сети)

Распределённый через Redis (Антипаттерн):
[Pod 1] ───(Каждый запрос пишет в Redis)───► [Redis Cluster]
(Если Redis затормозит — Circuit Breaker сам станет причиной аварии!)
  • Накладные расходы: Проверка состояния предохранителя на каждый вызов по сети в Redis добавит $1\text{–}2$ мс задержки на каждый межсервисный запрос.
  • Single Point of Failure: Падение общего Redis парализует выполнение всех защитных механизмов системы.
  • Особенность локального подхода: При горизонтальном автоскейлинге (HPA в K8s) новый под стартует с состоянием CLOSED (холодный старт). Поэтому важно грамотно выставлять minimumNumberOfCalls, чтобы предотвратить преждевременные ложные срабатывания.

3. Порядок применения аспектов (Aspect Order Pitfall)

В Spring Boot при одновременном использовании @CircuitBreaker, @Retry и @Bulkhead порядок применения Spring AOP аспектов имеет решающее значение:

Правильный порядок (снаружи внутрь):
[Входящий вызов]
       │
       ▼
1. @Bulkhead        ──► Ограничивает параллелизм (не даёт занять все потоки)
       │
       ▼
2. @CircuitBreaker ──► Проверяет состояние цепи (CLOSED/OPEN)
       │
       ▼
3. @Retry           ──► Выполняет повторы ТОЛЬКО если Circuit Breaker замкнут
       │
       ▼
4. [Целевой метод]

Если по ошибке поместить @CircuitBreaker внутри @Retry, то каждый из 3 повторов будет учитываться Circuit Breaker’ом как отдельная ошибка, что искусственно завысит процент сбоев в 3 раза и приведет к ложному размыканию цепи.


4 Tricky Questions

1. Почему медленные ответы (Slow Calls) для микросервисов опаснее, чем мгновенные ошибки 500?

Ответ: Мгновенная ошибка 500 быстро освобождает поток исполнения (Thread) и сетевое соединение. Медленный ответ (например, зависание на 10–30 секунд из-за deadlock в БД) держит поток заблокированным. В серверах с моделью Thread-per-Request (Tomcat) пул потоков (по умолчанию 200) исчерпывается за секунды при умеренном трафике. В результате сервис перестает принимать любые запросы (включая несвязанные и Health Check’и) и падает от starvation (голодания потоков).

2. Что произойдет, если Fallback-метод сам начнет выполнять сетевой запрос и упадет?

Ответ: Это грубое нарушение паттерна отказоустойчивости. Fallback должен обладать гарантированной доступностью:

  1. Если Fallback вызывает сторонний сервис без собственной защиты, возникает вторичная каскадная авария.
  2. Исключение, выброшенное из Fallback-метода, всплывает наружу вызывающему клиенту, нивелируя всю пользу предохранителя. Правило: Fallback должен быть исключительно in-memory операцией (возврат дефолтного значения из статической константы, чтение из локального кэша Caffeine, генерация заглушки или асинхронный сброс сообщения в локальный буфер/диск).

3. Как правильно тестировать Circuit Breaker в интеграционных тестах?

Ответ: Для тестирования переходов состояний используют:

  1. MockServer / WireMock: Имитация возврата HTTP 500 или искусственной задержки (withFixedDelay(3000)).
  2. Прямое управление CircuitBreakerRegistry: В тестах можно программно переключать состояния:
    circuitBreakerRegistry.circuitBreaker("paymentService").transitionToOpenState();
    
  3. Проверка того, что при открытом состоянии метод целевого сервиса вообще не вызывается (WireMock верифицирует 0 requests received), а управление мгновенно передается в Fallback.

4. Почему Circuit Breaker не защищает от исчерпания памяти (OutOfMemoryError) при использовании неблокирующего Reactive-стека (WebFlux)?

Ответ: В WebFlux потоки не блокируются в ожидании I/O, поэтому исчерпание пула потоков Event Loop (Netty) не происходит. Однако, если зависимый сервис зависает, тысячи незавершенных реактивных цепочек (Mono/Flux) с буферами данных накапливаются в Heap памяти JVM. Если входящий поток запросов высок, а тайм-ауты велики, реактивный сервис упадет с OutOfMemoryError. Circuit Breaker в реактивном коде (transformDeferred(CircuitBreakerOperator.of(...))) защищает от накопления ожидающих реактивных подписок, немедленно разрывая стрим по тайм-ауту оператора.


🎯 Шпаргалка для интервью

Главное:

  • Circuit Breaker защищает вызывающий сервис от каскадного падения при зависании или сбоях зависимостей (Fail-Fast).
  • Три состояния: CLOSED (норма), OPEN (цепь разомкнута, запросы не идут, вызывается Fallback), HALF-OPEN (проверка здоровья сервиса тестовыми вызовами).
  • Ключевой триггер: Не только ошибки (failureRateThreshold), но и задержки ответов (slowCallRateThreshold).
  • Стандарт де-факто в Java — Resilience4j (аннотация @CircuitBreaker).
  • Circuit Breaker работает локально в памяти JVM, а не через распределенный Redis.

Архитектурная связка стабильности:

\(\text{Bulkhead (Изоляция)} \to \text{Circuit Breaker (Fail-Fast)} \to \text{Retry (Повторы)} \to \text{Fallback (Деградация)}\)

Красные флаги (Чего говорить НЕЛЬЗЯ):

  • ❌ «Circuit Breaker делает повторные попытки запроса» (Повторы делает Retry; Circuit Breaker, наоборот, запрещает запросы).
  • ❌ «Мы синхронизируем состояние Circuit Breaker между всеми подами через Redis» (Добавляет критический сетевой hop и единую точку отказа).
  • ❌ «В Fallback-методе мы обращаемся к запасной базе данных по сети» (Fallback обязан быть надежным и быстрым in-memory действием).

Связанные темы: