🏗️ Розділ 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’ом як окрема помилка, що штучно завищить відсоток збоїв утричі та призведе до хибного розмикання кола.


4 Tricky Questions

1. Чому повільні відповіді (Slow Calls) для мікросервісів небезпечніші за миттєві помилки 500?

Відповідь: Миттєва помилка 500 швидко звільняє потік виконання (Thread) і мережеве з’єднання. Повільна відповідь (наприклад, зависання на 10–30 секунд через дедлок у БД) тримає потік заблокованим. У серверах із моделлю 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 дією).

Пов’язані теми: