Kotlin
February 27

Использование resilience4j со Spring Boot

resilience4j - библиотека, предоставляющая набор инструментов для повышения надежности и отказоустойчивости java приложений прежде всего в микросервисной архитектуре

Список инструментов библиотеки:

  • circuitBreaker
  • bulkhead
  • rateLimiter
  • timeLimiter
  • retry

Наиболее популярным аналогом данной библиотеки является Hystrix, но он уже давно не поддерживается, последний релиз состоялся несколько лет назад, и даже в GitHub разработчики Hystrix приводят ссылку на resilience4j как более современное решение (в частности Hystrix не поддерживает работу с webflux)

Есть также и другие похожие библиотеки, но resilience4j на их фоне выделяется наличием интеграции со spring boot на базе аннотаций, при этом её инструменты можно использовать как при стандартном подходе разработки с webmvc, так и вместе с неблокирующим подходом webflux

Документацию resilience4j можно найти здесь, но кое-где она устарела и в основном показывает как работать с инструментами в функциональном стиле, мы будем рассматривать работу только через аннотации

Для использования со Spring Boot необходимо добавить стартер resilience4j-spring-boot2 (для Spring Boot 3 название стартера отличается последней цифрой resilience4j-spring-boot3) и spring-boot-starter-aop для корректной работы аннотаций (но обычно его добавлять нет необходимости, т.к. другие ваши стартеры транзитивно используют aop)

dependencies { 	implementation "org.springframework.boot:spring-boot-starter-aop"      implementation "io.github.resilience4j:resilience4j-spring-boot2:${resilience4jVersion}"     implementation "io.github.resilience4j:resilience4j-reactor:${resilience4jVersion}"}

При работе с webflux также необходимо добавить библиотеку resilience4j-reactor

Circuit Breaker

Наиболее популярный инструмент данной библиотеки, в большинстве случаев используют только его

Думаю многие знакомы с данным паттерном, опишу его кратко

В ситуациях когда приложение-сервер начинает отвечать на запросы ошибками (или отвечает слишком медленно) несколько раз подряд, circuit breaker на стороне приложения-клиента это понимает и на некоторое время прерывает отправку запросов (размыкается), и вместо отправки запросов в это время возвращает типовой ответ (fallback) или просто выбрасывает исключение

Таким образом мы даем приложению-серверу немного времени на передышку и не спамим его запросами. Это особенно актуально в высоконагруженных системах, где за секунду могут отправляться тысячи запросов

Circuit Breaker имеет 3 состояния:

  • CLOSED (замкнут) - поведение приложения не меняется, все работает в штатном режиме
  • OPEN (разомкнут) - цепочка вызовов разорвана
  • HALF_OPEN (частично разомкнут) - переходит в данное состояние из OPEN после таймаута, половина запросов отправляется на конечный сервис, для проверки работоспособности, если все хорошо переходит в CLOSED, иначе в OPEN

Также есть 2 специальных состояния:

  • DISABLED - отключен
  • FORCED_OPEN - принудительно OPEN

Пример использования:

@CircuitBreaker(name = "myCircuitBreaker", fallbackMethod = "recoverMethod")public MyResponse sendRequest() {    return ...;}private MyResponse recoverMethod(Exception ex) {    log.warn(ex.getMessage(), ex);    return ...;}

В параметре name является обязательным, в нем указывается имя circuitBreaker'а, по которому задаются настройки

Параметр fallbackMethod необязательный, в него мы передаем имя метода, который будет исполнен вместо текущего метода если circuitBreaker находится в разомкнутом состоянии OPEN. Если же данный параметр не задан, то будет выброшено исключение CallNotPermittedException

fallback метод должен соответствовать нескольким критериям:
- возвращать тот же тип данных, что и исходный метод
- принимать исключение в качестве параметра, а также может принимать те же параметры что и основной метод (не обязательно), но не больше параметров чем у исходного метода

Обязательно тестируйте работоспособность fallback методов, поскольку если он написан неправильно, то ошибка будет выброшена только в момент вызова этого метода уже во время работы приложения

resilience4j использует скользящее окно (sliding window) по которому делает вывод об изменении состояния circuitBreaker'а

Скользящее окно может быть двух типов:

  • COUNT_BASED - собирает последние N запросов
  • TIME_BASED - собирает запросы за последние N секунд

Это довольно важный момент, по умолчанию тип COUNT_BASED и если ваш сервис редко отправляет запросы, то есть вероятность что circuitBreaker может сработать когда этого не ожидаешь, например за последние несколько дней периодически появлялись ошибки, но запросов было так мало, что процент ошибок превысил пороговое значение и circuitBreaker откроется с появлением очередной ошибки, в таких случаях (количество отправляемых запросов небольшое) лучше пользоваться TIME_BASED окном

Настройки circuitBreaker'а:

  • failureRateThreshold - порог невалидных запросов, по ум. 50
  • slowCallRateThreshold - порог медленных запросов (запрос считается медленным, если выполняется дольше, чем указано в настройке slowCallDurationThreshold), по ум. 100
  • slowCallDurationThreshold - время, по которому определяется что запрос является медленным, по ум. 60000 мс
  • permittedNumberOfCallsInHalfOpenState - количество запросов, которые необходимо выполнить в HALF_OPEN состоянии, по ум. 10
  • maxWaitDurationInHalfOpenState - время в течение которого CircuitBreaker остается в HALF_OPEN состоянии, перед тем как переключится в OPEN, по ум. значение 0, значит время не ограничено
  • slidingWindowType - два возможных значения COUNT_BASED, TIME_BASED, по ум. COUNT_BASED
  • slidingWindowSize - размер sliding window, при COUNT_BASED - обозначает количество запросов, при TIME_BASED - количество секунд, по ум. 100
  • minimumNumberOfCalls - минимальное количество вызовов, по которым принимается решение о переключении состояния. Если тип скользящего окна TIME_BASED, то данное минимальное количество вызовов должно быть выполнено за указанный период в slidingWindowSize, по ум. 100
  • waitDurationInOpenState - время, в течение которого состояние остается OPEN, после чего переходит в HALF_OPEN, по ум. 60000 мс
  • automaticTransitionFromOpenToHalfOpenEnabled - в значении true автоматически переводит в HALF_OPEN состояние, не ожидая следующего вызова, но для этого запускается отдельный поток, который выполняет перевод состояния, по ум. false
  • recordExceptions - список исключений, при выбросе которых выполнение метода будет считаться невалидным, по умолчанию при выбросе любых исключений считается невалидным, по ум. пустой
  • ignoreExceptions - список исключений, которые будут явно проигнорированы, при определении невалидных запросов. При выбросе данных исключений они не будут расценены ни как валидные, ни как невалидные, по ум. пустой
  • recordFailurePredicate - условие при котором исключение будет расценено как невалидный ответ. по ум. - throwable -> true
  • ignoreExceptionPredicate - условие при котором исключение будет проигнорировано, по ум. - throwable -> false

Данные настройки можно задать как для всех circuitBreaker'ов сразу, так и для каждого по отдельности:

resilience4j:  circuitbreaker:    configs:      default:        slidingWindowSize: 100        permittedNumberOfCallsInHalfOpenState: 10        slowCallDurationThreshold: 4s        slowCallRateThreshold: 90        failureRateThreshold: 50        waitDurationInOpenState: 10s        minimumNumberOfCalls: 10    instances:      myCircuitBreaker:        baseConfig: default        waitDurationInOpenState: 20s      myCircuitBreaker2:        baseConfig: default        waitDurationInOpenState: 30s

myCircuitBreaker - это тот самый name, который мы задаем в аннотации

Также circuitBreaker отдает метрики микрометра, здесь можно скачать дашборд grafana для мониторинга

Retry

Позволяет выполнять повторные попытки в случае возникновения ошибок

Настройки retry:

  • maxAttempts - максимальное количество попыток, включая первый вызов, по ум. 3
  • waitDuration - фиксированное время между попытками, по ум. 500 мс
  • intervalFunction - функция определения интервала, по ум. numOfAttempts -> waitDuration
  • intervalBiFunction - также функция определения интервала, но в качестве аргументов принимает количество попыток и результат выполнения метода, по ум. (numOfAttempts, Either<throwable, result>) -> waitDuration
  • retryOnResultPredicate - условие по которому должен быть выполнен retry, при определенном результате, по ум. result -> false
  • retryExceptionPredicate - условие по которому должен быть выполнен retry, при ошибке, по ум. throwable -> true (при всех ошибках)
  • retryExceptions - список ошибок, по которым должен быть выполнены retry, по ум. пустой
  • ignoreExceptions - список игнорируемых ошибок, по ум. пустой
  • failAfterMaxAttempts - следует ли выбрасывать MaxRetriesExceededException, в случае превышения количества попыток, по ум. false

При использовании с webflux подставляет в реактивную цепочку оператор .retry()

Пример использования:

@Retry(name = "myRetry")public Mono<MyResponse> sendRequest() {    ...}
resilience4j.retry:  configs:    default:      maxAttempts: 3      waitDuration: 100ms      retryExceptions:        - org.springframework.web.client.HttpServerErrorException        - java.util.concurrent.TimeoutException        - java.io.IOException      ignoreExceptions:        - io.github.robwin.exception.BusinessException

Обратите внимание, что в настройках не обязательно прописывать name конкретного инструмента, если вам достаточно default конфигурации

Данный инструмент удобен, если в вашем коде в нескольких местах нужно выполнять повторные попытки с одинаковыми условиями, поскольку можно один раз задать все нужные настройки и использовать аннотацию в нескольких местах

Если же вы выберете spring retry или оператор .retry() в webflux, то вам каждый раз придется задавать все условия выполнения повторных попыток в месте использования этих

Также как и circuitBreaker отдает метрики, поэтому можно настроить мониторинг

Rate Limiter

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

Например мы хотим чтобы какой-то внешний сервис нельзя было вызывать более 100 раз в секунду, и если мы вдруг превысили данную нагрузку, то потоки будут ждать, либо вернуть заданный ответ (fallback), либо выбросят ошибку

Настройки rateLimiter:

  • timeoutDuration - время в течение которого поток ждет разрешения, по ум. 5 сек
  • limitRefreshPeriod - период за который ограничивается число вызовов, по ум. 500 наносекунд
  • limitForPeriod - предельное число вызовов за время указанное в limitRefreshPeriod, по ум. 50

Пример использования:

@RateLimiter(name = "myRateLimiter", fallbackMethod = "myFallbackMethod")public Mono<SomeResult> doSomething() {    ...}private Mono<SomeResult> myFallbackMethod(Exception ex) {    log.warn(ex.getMessage(), ex);    return Mono.error(...);}
resilience4j.rateLimiter:  configs:    default:      timeoutDuration: 5s      limitForPeriod: 10      limit-refresh-period: 1s

В данном примере если количество вызовов метода превысит 10 за секунду, то вызывающие потоки будут ждать 5 секунд, перед тем как попытаться снова вызвать метод

Если количество вызовов метода превысит limitForPeriod и потоки не дождутся своей очереди, то будет выброшено исключение RequestNotPermitted или вызван fallback метод, если мы его задали

Использует два возможных механизма, на основе атомиков и на основе семафора, первый используется по умолчанию и переключиться на второй нет никакой возможности

Во внутренней реализации с использованием webflux добавляет .delaySubscription() - т.е. откладывает именно подписку на издателя (Mono/Flux), а не откладывает пропускание самих элементов

Поэтому если ваш метод не создает Mono/Flux, а например принимает его в качестве параметра, добавляет в цепочку операторы и возвращает в ответе, то работать RateLimiter будет не так как вы ожидаете, здесь подробно закапываться в эту тему не буду, достаточно понимать что в таком подходе его лучше не использовать

TimeLimiter

Ограничивает время выполнения метода

Поддерживает работу только с методами, возвращающими реактивные типы Flux/Mono или CompletableFuture (а точнее наследников CompletionStage)

Настройки timeLimiter:

  • timeoutDuration - таймаут выполнения, по ум. 1 сек
  • cancelRunningFuture - признак вызова cancel сигнала, если ответ оборачивается во future, по ум. false

Пример использования:

@TimeLimiter(name = "myTimeLimiter", fallbackMethod = "myFallbackMethod")public CompletableFuture<SomeResult> doSomething() {    ...}
resilience4j.timelimiter:  configs:    default:      cancelRunningFuture: false      timeoutDuration: 2s

В случае webflux добавляет оператор timeout в возвращаемый Flux/Mono

В случае с CompletableFuture создает задачу на вызов completeExceptionally и передает ее на исполнение ScheduledExecutor'у с заданным таймаутом выполнения

Данный инструмент почти не имеет области применения и полагаю что им пользуются крайне редко

Bulkhead

Данный паттерн позволяет ограничить количество потоков на выполнение метода

Осторожно, нельзя использовать с webflux, далее опишу почему

В отличие от RateLimiter в Bulkhead нет ограничения по времени, только ограничение на одновременное количество вызовов метода

Имеется две имплементации:

  • SemaphoreBulkhead - на основе семафора
  • FixedThreadPoolBulkhead - на основе фиксированного пула потоков

Общие настройки blukhead:

  • maxConcurrentCalls - максимальное количество параллельных вызовов метода, по ум. 25
  • maxWaitDuration - максимальное количество времени, в течение которого ожидается получение свободного потока, по ум. 0

Дополнительные настройки для ThreadPoolBulkhead

  • maxThreadPoolSize - максимальное количество потоков в пуле, по ум. кол-во ядер
  • coreThreadPoolSize - количество кор потоков в пуле, по ум. кол-во ядер - 1
  • queueCapacity - допустимое количество потоков в очереди, по ум. 100
  • keepAliveDuration - когда количество потоков превышает количество кор потоков, данная настройка задает их время жизни, при условии что они простаивают, по ум. 20 мс
  • writableStackTraceEnabled - выводить ли стектрейс bulkhead исключения, по умолчанию true, если значение false то при ошибке будет выведена одна строка в лог, по ум. true

Тип имплементации можно указать в самой аннотации:

@Bulkhead(name = "myBulkhead", fallbackMethod = "myFallbackMethod", type = Bulkhead.Type.THREADPOOL)public SomeResponse someRequest() {    ...}
resilience4j.bulkhead:  configs:    default:      maxConcurrentCalls: 100      maxWaitDuration: 10ms  instances:    myBulkhead:       baseConfig: default

При работе с webflux используется только Semaphore имплементация, которая блокирует вызывающий поток при попытке выполнить подписку на время указанное в maxWaitDuration

Выходит что использовать данный паттерн с webflux не рекомендуется, т.к. это может привести к блокировке потоков и снижению производительности приложения

Либо следует выставлять maxWaitDuration = 0, тогда при превышении одновременного количества вызовов данного метода, будет выброшено исключение или отработает fallback метод

Для корректного ограничения количества потоков выполнения в webflux рекомендую прочитать вот эту статью

Комбинирование аннотаций

Если требуется то вы можете комбинировать несколько аннотаций над одним методом:

@Retry(name = "myRetry", fallbackMethod = "recoverMethod")@RateLimiter(name = "myRateLimiter", fallbackMethod = "recoverMethod")@CircuitBreaker(name = "myCircuitBreaker", fallbackMethod = "recoverMethod")public MyResponse sendRequest() {    return ...;}

В любом случае я бы не рекомендовал комбинировать аннотации, из-за того что не всегда понятно как инструменты работают вместе

Выводы

Таким образом, сегодня мы рассмотрели наиболее удобный способ работы с помощью аннотаций в Spring Boot и их конфигурирование

Взглянули на каждый инструмент по отдельности и можно сказать, что в реальном проекте имеет смысл использовать только инструменты circuitBreaker и rateLimiter, остальные либо плохо применимы, либо легко заменяются аналогами

Источник