lenec ru

← все посты

Circuit breaker: как защитить сервис от лавины запросов к умирающему соседу

12K

Сценарий, который я разбирал не раз: сервис заказов зовёт сервис каталога. Каталог тормозит, ответы идут по 5 секунд вместо 50 миллисекунд. Пул потоков сервиса заказов забивается ожиданием каталога, начинает не отвечать на свои запросы. Через минуту лежат оба. Через две — лежит вся цепочка, потому что фронт ретраит к заказам, а заказы — к каталогу.

Это cascading failure, и он лечится одной идеей: если сосед плохо себя чувствует, не надо его добивать запросами. Надо подождать, пока ему станет лучше. Эта идея реализована в circuit breaker — паттерне, который разрывает цепь к проблемному сервису и даёт ему восстановиться.

За двенадцать лет я ставил circuit breaker'ы в трёх стеках: Hystrix (RIP), Resilience4j, Polly в .NET. Базовая логика везде одна, но в реализациях есть нюансы, которые в учебниках не описывают.

Как он работает

Circuit breaker оборачивает вызов к внешнему сервису и хранит состояние:

  • Closed — нормальная работа. Запросы проходят. Считаются ошибки.
  • Open — слишком много ошибок. Запросы не проходят, сразу возвращается fail без вызова. Дайте сервису полежать.
  • Half-open — после паузы пропускаем один-два запроса. Если успешны — возвращаемся в Closed. Если падают — снова Open.
Closed --[ошибки превысили порог]--> Open
Open --[прошёл таймаут]--> Half-open
Half-open --[пробный запрос успешен]--> Closed
Half-open --[пробный запрос упал]--> Open

В Open-состоянии все запросы failуются мгновенно. Это и есть защита: вы не тратите потоки на ожидание мёртвого сервиса.

Минимальная реализация на Resilience4j

val cb = CircuitBreaker.of("catalog", CircuitBreakerConfig.custom()
    .failureRateThreshold(50f) // открыть, если 50% запросов с ошибкой
    .slidingWindowSize(20)     // окно из 20 последних запросов
    .minimumNumberOfCalls(10)  // не реагировать, пока не было 10 вызовов
    .waitDurationInOpenState(Duration.ofSeconds(10))
    .permittedNumberOfCallsInHalfOpenState(3)
    .build())

fun fetchCatalogItem(id: String): CatalogItem {
    return cb.executeSupplier {
        catalogClient.getItem(id)
    }
}

Эта конфигурация говорит: смотрим на последние 20 вызовов, если 50% упали — открываемся на 10 секунд. После 10 секунд пропускаем 3 пробных запроса. Если успешны — возвращаемся в норму.

Что считать «ошибкой»

Кажется, что просто: ошибка — это exception. Но не всё так однозначно.

Бизнес-ошибки (404 «товар не найден», 400 «неправильный запрос») — это не сбой соседа. Если их учитывать в circuit breaker'е, он будет открываться при потоке клиентов, ищущих несуществующие id'шники. Это ложное срабатывание.

CircuitBreakerConfig.custom()
    .recordExceptions(
        IOException::class.java,
        TimeoutException::class.java
    )
    .ignoreExceptions(
        NotFoundException::class.java,
        ValidationException::class.java
    )
    .build()

Чёткое разделение: учитываются только сетевые/инфраструктурные сбои. Бизнес-исключения проходят насквозь без влияния на breaker.

Slow call как ошибка

Часто сосед не падает, а тормозит. 502 нет, но запрос идёт 10 секунд вместо 50 мс. Для вашего сервиса это так же плохо, как падение: пул потоков всё равно забивается.

Resilience4j умеет считать медленные вызовы как ошибки:

CircuitBreakerConfig.custom()
    .slowCallRateThreshold(50f)
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .build()

Если 50% запросов идут дольше 2 секунд — открываемся. Это ловит «деградацию», которая опаснее явного падения, потому что её сложнее заметить.

Где ставить

Circuit breaker ставится на каждый внешний вызов к отдельному сервису. На каждый. Не один общий на сервис, не на пакет вызовов.

// ТАК НЕ НАДО
val globalCb = CircuitBreaker.ofDefaults("all-services")

// ТАК ПРАВИЛЬНО
val catalogCb = CircuitBreaker.of("catalog", config)
val pricingCb = CircuitBreaker.of("pricing", config)
val inventoryCb = CircuitBreaker.of("inventory", config)

Логика: если упал каталог, я не хочу, чтобы это блокировало вызовы к pricing. Это разные сервисы с разной судьбой. Один общий breaker делает их все мёртвыми, когда упал один.

Ещё тоньше — отдельные breaker'ы на разные эндпоинты одного сервиса, если они независимы. Сервис каталога имеет ручку /items/{id} и /search. Если /search тормозит из-за нагрузки на ElasticSearch внутри каталога, /items может работать нормально. Один breaker обрушит и то, и то.

Fallback

Когда breaker открыт, что возвращать пользователю? Вариантов несколько.

Кешированное значение

Лучший вариант, если применимо. У вас был последний удачный ответ — отдайте его, помечая как stale. Пользователь видит немного устаревшие данные, но сервис не падает.

fun getCatalogItem(id: String): CatalogItem? {
    return try {
        cb.executeSupplier { catalogClient.getItem(id) }
            .also { cache.put(id, it) }
    } catch (e: CallNotPermittedException) {
        cache.get(id)
    }
}

Дефолтное значение

Для некритичных полей можно вернуть «безопасный дефолт». Например, в карточке товара поле «рекомендации от других пользователей» — если рекомендательный сервис лежит, можно отдать пустой массив.

Партишинальный ответ

Главный экран собирается из 5 источников. Один лежит — отдаём остальные четыре + пометку «такая-то секция временно недоступна». UX страдает частично, не полностью.

Явная ошибка

Если данные критичные и заменить нечем — честный 503 пользователю. Но не «висим 30 секунд», а сразу «попробуйте позже».

Какой вариант выбрать — зависит от бизнес-семантики. Это не техническое решение, а продуктовое: «что мы готовы показать пользователю при отказе соседа».

Конфигурация порогов

Какие цифры ставить — частый вопрос.

failureRateThreshold. 50% — нормальный дефолт. Меньше (20-30%) — слишком чувствительно, реагирует на временные всплески. Больше (70-80%) — медленно реагирует, breaker открывается, когда уже всё совсем плохо.

slidingWindowSize. 20-50 запросов. Меньше — статистическая шумность, breaker дёргается на каждом всплеске. Больше — медленно реагирует на реальные проблемы.

minimumNumberOfCalls. 10-15. Защита от ложных срабатываний на холодном старте: пока не было N вызовов, breaker не реагирует.

waitDurationInOpenState. 5-30 секунд. Зависит от того, насколько быстро восстанавливается сосед. 5 секунд — для кратковременных всплесков. 30 — для серьёзных проблем, когда нужно дать время рестарту.

Эти цифры — стартовая точка. На своём проекте надо смотреть метрики и подкручивать.

Связь с timeout и retry

Circuit breaker без правильно настроенных timeout'ов — лотерея.

Если timeout на вызов 30 секунд, а вы ставите breaker с failureRateThreshold по медленным вызовам в 2 секунды, то breaker сработает быстрее, чем хоть один запрос упадёт по timeout'у. Это нормально и даже желательно.

Если timeout — 1 секунда, а slowCallDurationThreshold — 5 секунд, breaker никогда не среагирует на медленность: запросы будут падать раньше, чем медленность зафиксируется.

Правило: slowCallDurationThreshold < timeout. Иначе медленность не различается от падения.

Retry — отдельная тема. Внутри breaker'а ретраи противопоказаны: каждый retry — это новый вызов, который breaker считает. Если ретраите 5 раз, и каждая попытка таймаутится, breaker увидит 5 ошибок вместо одной и откроется быстрее, чем нужно.

Правильный порядок: retry над breaker'ом. Breaker оборачивает один вызов. Если breaker отдаёт CallNotPermittedException — retry понимает, что сервис недоступен, и не повторяет (или ждёт дольше).

Подводные камни

Несколько граблей.

Bulkhead не заменяется breaker'ом. Breaker не защищает от заполнения пула. Если сервис тормозит, и у вас лимит пула 100, первые 100 запросов уйдут на этого тормоза, и пул забьётся. Breaker откроется только после того, как часть из них упадёт. К тому моменту вы уже не отвечаете на свои запросы. Bulkhead (отдельный пул на каждый зависимый сервис) решает это, breaker — дополняет.

Глобальный breaker через распределённую систему. Breaker — это процессное состояние. Его не нужно реплицировать в Redis или другую общую БД. Каждый инстанс сервиса имеет свой breaker, и это нормально. Иначе вы получите задержку синхронизации, лагающую логику и непредсказуемое поведение.

Breaker без метрик. Открылся ли breaker, как часто, на каком соседе — это критичная наблюдаемость. Без неё breaker открывается, fallback срабатывает, пользователь видит деградацию, а команда узнаёт об этом из жалоб.

Breaker как замена тестирования. «У нас стоит breaker, нам не страшно если что» — позиция, которая часто прячет недотестированные сценарии. Breaker защищает от инцидента, но не делает обработку ошибок правильной.

Half-open под высокой нагрузкой. При очень большом RPS даже permittedNumberOfCallsInHalfOpenState=1 — это всплеск. Если 1000 инстансов одновременно перешли в half-open, сосед получает 1000 пробных запросов. Решение — медленнее переход в half-open или экспоненциальное восстановление.

Что запомнить

Circuit breaker — это страховка от cascading failure. Открывается на пороге ошибок, держится в Open пару секунд, пробует через half-open, возвращается в норму. На каждый внешний сервис — свой breaker. Учитывает только инфраструктурные ошибки, не бизнес.

Связан с timeout'ами (slow call < timeout), bulkhead (отдельные пулы), retry (retry поверх breaker'а, не наоборот), fallback (что отдавать при открытом breaker'е). Без этих компаньонов он работает только частично.

Главное правило — настраивайте под ваш контекст. Дефолтные пороги Resilience4j или Polly — стартовая точка, не финал. Метрики breaker'а должны быть в дашборде, и если breaker регулярно открывается на каком-то соседе — это сигнал не «у нас работает breaker», а «у соседа проблемы, которые надо разобрать».

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.