← Назад к списку тем

01. Резилентность

Retry, Circuit Breaker, Bulkhead, Rate Limiter, Timeout. Нативные @Retryable/@ConcurrencyLimit из Spring Framework 7 и Resilience4j в Spring Boot 4 — механика AOP-прокси под капотом, когда применять и когда избегать.

Зачем нужна резилентность: проблема каскадных отказов

В монолите вызов метода — это прямой переход по указателю: он либо мгновенно успешен, либо мгновенно бросает исключение. В распределённой системе тот же логический «вызов» становится сетевым запросом, а значит появляется целый новый класс отказов, которых не было раньше: сеть может быть медленной, партнёр — временно перегружен, DNS — не резолвиться, TCP-соединение — зависнуть на десятки секунд вместо мгновенной ошибки. Хуже того, без защитных механизмов один медленный сервис способен вызвать каскадный отказ (cascading failure): сервис A ждёт ответа от медленного сервиса B, у A заканчиваются потоки/соединения в пуле, вызовы к A начинают отваливаться по таймауту, а сервис C, который зависит от A, повторяет тот же сценарий — авария распространяется вверх по графу зависимостей, хотя изначально «сломался» только один компонент.

Резилентность (resilience, отказоустойчивость) — это набор паттернов, которые ограничивают радиус поражения одного отказа и не дают ему превратиться в отказ всей системы:

  • Timeout — не ждать ответа бесконечно, освобождать ресурс (поток, соединение) по истечении разумного времени
  • Retry — повторить операцию при временном сбое (сетевой blip, кратковременная перегрузка)
  • Circuit Breaker — перестать долбить заведомо неработающий сервис, дав ему время восстановиться, и быстро fail-fast вместо накопления зависших запросов
  • Bulkhead — изолировать ресурсы (потоки, пул соединений) на конкретную зависимость, чтобы её отказ не съел ресурсы, нужные для остальной системы
  • Rate Limiter — ограничить частоту вызовов, чтобы не перегрузить ни себя, ни зависимость
  • Fallback — деградировать осмысленно (кэш, дефолтное значение, урезанная функциональность) вместо полного отказа
⚠️ Timeout — обязательная предпосылка для остальных паттернов. Без timeout Circuit Breaker не сможет вовремя понять, что вызов «висит», Bulkhead исчерпает изолированный пул потоков и будет держать их занятыми бесконечно, а Retry рискует повторять операцию поверх ещё не завершившегося первого вызова. Прежде чем добавлять Retry или Circuit Breaker — всегда сначала выставьте разумный timeout.

Retry «из коробки»: @Retryable в Spring Framework 7

Долгие годы декларативный retry в Spring существовал только как отдельная библиотека spring-retry (пакет org.springframework.retry, аннотация @Retryable + @EnableRetry) — не часть core-фреймворка. Начиная со Spring Framework 7 retry и лимитирование конкурентности стали нативной частью ядра: пакет org.springframework.resilience.annotation с аннотациями @Retryable и @ConcurrencyLimit, включаемыми через @EnableResilientMethods на конфигурационном классе (аналог того, как @EnableAsync включает обработку @Async).

@Configuration
@EnableResilientMethods
class ResilienceConfig {
}

@Service
class PaymentGatewayClient {

    private final RestClient restClient;

    PaymentGatewayClient(RestClient restClient) {
        this.restClient = restClient;
    }

    @Retryable(
        includes = { TransientApiException.class, ConnectException.class },
        excludes = { InvalidCardException.class },
        maxAttempts = 4,
        delay = 200,
        jitter = 100,
        multiplier = 2.0,
        maxDelay = 3000
    )
    PaymentResult charge(ChargeRequest request) {
        return restClient.post()
            .uri("/charges")
            .body(request)
            .retrieve()
            .body(PaymentResult.class);
    }
}

Ключевые атрибуты: includes/excludes — по каким исключениям повторять (по умолчанию — по любому Exception, но список всегда стоит сузить осознанно), maxAttempts — общее число попыток (включая первую), delay/multiplier/maxDelay — экспоненциальный backoff между попытками, jitter — случайный разброс задержки, чтобы синхронные ретраи множества клиентов не превратились в «thundering herd» и не ударили по восстанавливающемуся сервису одновременным залпом.

🔑 includes/excludes — не декорация, а главная точка риска. Retry имеет смысл только для временных, транзиентных сбоев (сетевой таймаут, HTTP 503, connection reset). Повторять вызов при InvalidCardException или любой другой доменной, «постоянной» ошибке — не просто бесполезно, а вредно: вы впустую тратите попытки и задерживаете ответ пользователю на 4 неудачных round-trip'а вместо одного мгновенного отказа.

Механика внутри: как @Retryable устроен в коде Spring

Retry, как и @Async, @Cacheable, @Transactional до него — это не «магия компилятора», а классический для Spring паттерн BeanPostProcessor + AOP-прокси. Понимание этого механизма снимает добрую половину вопросов «почему retry не сработал» на собеседовании и в проде.

Шаг 1 — обнаружение аннотации на этапе создания бина

@EnableResilientMethods регистрирует BeanPostProcessor (в семействе тех же механизмов, что и AsyncAnnotationBeanPostProcessor для @Async), который на фазе postProcessAfterInitialization сканирует методы каждого создаваемого бина в поисках @Retryable/@ConcurrencyLimit. Если хотя бы один метод размечен — бин не возвращается «как есть», а оборачивается в прокси.

Шаг 2 — создание AOP-прокси

Spring строит Advisor (пара «pointcut + advice»): pointcut отбирает методы с аннотацией, advice — это MethodInterceptor, который умеет применять retry-политику. Дальше — стандартный выбор Spring AOP: если бин реализует интерфейс — создаётся JDK dynamic proxy (java.lang.reflect.Proxy), если нет — CGLIB-прокси (рантайм-сабкласс реального класса). В обоих случаях снаружи бин выглядит как обычный Java-объект — вся магия происходит на уровне байткода/рефлексии, без изменения исходного класса.

Шаг 3 — перехват вызова

Когда вызывающий код обращается к paymentGatewayClient.charge(...), он на самом деле вызывает метод прокси-объекта. Interceptor ловит вызов, читает атрибуты @Retryable с метода, строит политику backoff (по сути — обёртку в духе org.springframework.util.backoff.ExponentialBackOff, давно существующего в spring-core утилитного класса для тех же целей в HTTP-клиентах и планировщиках) и в цикле вызывает реальный метод целевого объекта через рефлексию, перехватывая исключения и решая — повторить, пробросить дальше или (при исчерпании попыток) бросить финальное исключение.

// Псевдокод того, что делает MethodInterceptor внутри прокси
// (сильно упрощённо, для понимания механики):
Object invoke(MethodInvocation invocation) {
    BackOffExecution backOff = policy.start();
    int attempt = 0;
    while (true) {
        try {
            return invocation.proceed(); // реальный вызов через рефлексию
        } catch (Throwable ex) {
            attempt++;
            if (!isRetryable(ex) || attempt >= maxAttempts) {
                throw ex;
            }
            Thread.sleep(backOff.nextBackOff());
        }
    }
}
🚨 Классическая ловушка Spring AOP — self-invocation. Если метод charge() с @Retryable вызывается изнутри того же класса (this.charge(...)), вызов идёт напрямую на реальный объект, минуя прокси — retry-логика молча не сработает, и никакого исключения об этом брошено не будет. Это ровно та же проблема, что у @Transactional и @Async — причина в том, что self-invocation — это обычный Java-вызов метода на this, а не вызов через внешнюю ссылку на прокси. Решение: выносить размеченный метод в отдельный бин и вызывать его через внедрённую зависимость, а не через this.

@ConcurrencyLimit — лёгкий bulkhead без внешней библиотеки

Вторая нативная аннотация Spring Framework 7 — @ConcurrencyLimit, которая ограничивает число одновременных вызовов метода. Механизм устроен по тому же принципу прокси + interceptor, но внутри держит Semaphore: interceptor захватывает разрешение перед вызовом и освобождает после, а превышение лимита либо блокирует вызывающий поток, либо (в зависимости от конфигурации) немедленно отклоняет запрос.

@Service
class ReportGenerator {

    // Не больше 3 одновременных построений тяжёлого отчёта —
    // защита от исчерпания памяти/CPU при всплеске запросов
    @ConcurrencyLimit(3)
    byte[] generateHeavyReport(ReportRequest request) {
        // CPU/memory-intensive работа
        return renderer.render(request);
    }
}

@ConcurrencyLimit — это упрощённый, встроенный аналог Bulkhead из Resilience4j (semaphore-based режим). Он не даёт статистики, скользящих окон и HALF_OPEN-состояний, зато не требует внешней зависимости и достаточен там, где нужно просто «не больше N одновременно» без сложной аналитики отказов.

Resilience4j: полноценный Circuit Breaker, Bulkhead, Rate Limiter в Spring Boot 4

Нативные @Retryable/@ConcurrencyLimit закрывают retry и простую конкурентную защиту, но Circuit Breaker остаётся вне ядра Spring — для него по-прежнему используется библиотека Resilience4j, интегрированная в Spring Boot 4 через стартер io.github.resilience4j:resilience4j-spring-boot3. Resilience4j — идейный преемник Netflix Hystrix (тот в состоянии maintenance mode уже несколько лет), построенный на тех же принципах, но легче и без обязательной привязки к RxJava.

// build.gradle.kts
implementation("io.github.resilience4j:resilience4j-spring-boot3")
implementation("org.springframework.boot:spring-boot-starter-aop") // обязателен: аннотации Resilience4j — тоже AOP-прокси
# application.yml
resilience4j:
  circuitbreaker:
    instances:
      paymentGateway:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20
        minimum-number-of-calls: 10
        failure-rate-threshold: 50       # % отказов, после которых цепь размыкается
        slow-call-rate-threshold: 80
        slow-call-duration-threshold: 2s
        wait-duration-in-open-state: 10s  # сколько ждать перед HALF_OPEN
        permitted-number-of-calls-in-half-open-state: 3
  bulkhead:
    instances:
      paymentGateway:
        max-concurrent-calls: 15
  ratelimiter:
    instances:
      paymentGateway:
        limit-for-period: 50
        limit-refresh-period: 1s
        timeout-duration: 0
@Service
class PaymentService {

    @CircuitBreaker(name = "paymentGateway", fallbackMethod = "chargeFallback")
    @Bulkhead(name = "paymentGateway")
    @RateLimiter(name = "paymentGateway")
    PaymentResult charge(ChargeRequest request) {
        return gatewayClient.charge(request);
    }

    // Fallback-метод: та же сигнатура + дополнительный параметр Throwable
    private PaymentResult chargeFallback(ChargeRequest request, Throwable ex) {
        log.warn("Payment gateway unavailable, degrading: {}", ex.getMessage());
        return PaymentResult.pendingReview(request);
    }
}
🔑 Механизм тот же самый — AOP-прокси. Resilience4j-spring регистрирует свои собственные аспекты (CircuitBreakerAspect, BulkheadAspect, RateLimiterAspect, RetryAspect, TimeLimiterAspect) через тот же Spring AOP, что и нативные @Retryable/@Transactional. Это значит: та же проблема self-invocation, тот же выбор JDK proxy/CGLIB, та же необходимость публичного (или как минимум перехватываемого) метода.

Circuit Breaker: механика состояний

Circuit Breaker — конечный автомат с тремя состояниями, реализованный в Resilience4j как lock-free структура на основе атомарного счётчика и скользящего окна результатов вызовов:

СостояниеПоведениеПереход дальше
CLOSEDВсе вызовы проходят к реальному сервису; результаты (успех/отказ/медленный вызов) пишутся в скользящее окноЕсли доля отказов ≥ failure-rate-threshold или доля медленных вызовов ≥ slow-call-rate-threshold (при накоплении минимум minimum-number-of-calls измерений) → OPEN
OPENВсе вызовы отклоняются немедленно, без обращения к реальному сервису — бросается CallNotPermittedException (или вызывается fallback)По истечении wait-duration-in-open-state → HALF_OPEN
HALF_OPENПропускается ограниченное число «пробных» вызовов (permitted-number-of-calls-in-half-open-state) к реальному сервисуЕсли пробные вызовы успешны → CLOSED (счётчики сбрасываются); если снова отказы → обратно OPEN

Ключевая идея OPEN-состояния — это не «ретрай с задержкой», а fail-fast: пока цепь разомкнута, вызовы не тратят время на реальный (заведомо неработающий) сетевой round-trip, а отклоняются локально, за микросекунды. Это именно то, что защищает вызывающий сервис от исчерпания собственных потоков/соединений, пока зависимость лежит.

Sliding window бывает двух видов: COUNT_BASED (окно из последних N вызовов — предсказуемо по объёму данных, но при низком трафике может «зависать» на старых результатах) и TIME_BASED (окно из вызовов за последние N секунд — точнее реагирует на реальное текущее состояние сервиса при неравномерном трафике, ценой чуть большей внутренней бухгалтерии).

Порядок декораторов: почему это важно

Когда на одном методе одновременно висят @Retry, @CircuitBreaker, @RateLimiter, @Bulkhead, @TimeLimiter — это не независимые проверки, а цепочка вложенных декораторов, и порядок их применения определяет семантику. Resilience4j-spring позволяет настроить порядок явно через свойства resilience4j.*.retry-aspect-order и т.д. — но важно понимать логику, а не просто копировать конфиг.

Рекомендуемая логика вложенности (снаружи внутрь): Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → реальный вызов.

  • Retry снаружи CircuitBreaker — чтобы каждая повторная попытка проходила через проверку состояния цепи: если цепь уже OPEN, retry не должен вслепую долбить в закрытую дверь, а обязан быстро завершиться отказом (или fallback) после первой же попытки
  • CircuitBreaker снаружи RateLimiter/Bulkhead — отказ от ограничителя (превышен rate limit или bulkhead) — это тоже сигнал нагрузки, который стоит учитывать в статистике цепи
  • Bulkhead ближе всего к реальному вызову — он физически ограничивает число потоков/семафоров, занятых непосредственно сетевым I/O
⚠️ Опасная комбинация без настройки — Retry поверх Bulkhead с блокирующим ожиданием. Если Bulkhead настроен в режиме ожидания свободного слота (а не немедленного отказа), а Retry сверху повторяет вызов при таймауте — при перегрузке легко получить лавинообразный рост числа заблокированных потоков: каждая повторная попытка тоже встаёт в очередь на bulkhead. Для высоконагруженных путей предпочитайте bulkhead с немедленным отказом (fail-fast) поверх которого работает retry с ограниченным числом попыток и jitter.

Резилентность и виртуальные потоки / асинхронность

В Spring Boot 4 с включёнными виртуальными потоками (spring.threads.virtual.enabled=true) многие рассуждения про Bulkhead как «пул потоков» меняются: виртуальные потоки дешёвы, и thread-based изоляция (отдельный ExecutorService на зависимость) становится куда доступнее, чем раньше, когда каждый лишний поток стоил ~1 МБ стека. Тем не менее Rate Limiter и Circuit Breaker остаются актуальны — они защищают не поток как ресурс, а сам внешний сервис (и соединения к нему) от перегрузки, что не решается одной лишь дешевизной виртуальных потоков.

Для асинхронных сценариев (CompletableFuture) Resilience4j даёт декораторы вида CircuitBreaker.decorateCompletionStage(...)/Retry.decorateCompletionStage(...) либо аннотацию @TimeLimiter, которая явно рассчитана на методы, возвращающие CompletableFuture<T>: она ограничивает суммарное время ожидания результата асинхронной операции, не блокируя вызывающий поток на время всего таймаута.

@TimeLimiter(name = "paymentGateway")
@CircuitBreaker(name = "paymentGateway", fallbackMethod = "chargeAsyncFallback")
CompletableFuture<PaymentResult> chargeAsync(ChargeRequest request) {
    return CompletableFuture.supplyAsync(
        () -> gatewayClient.charge(request),
        virtualThreadExecutor
    );
}

Наблюдаемость: метрики и health-индикаторы

И нативный @Retryable, и Resilience4j публикуют метрики через Micrometer «из коробки» при наличии spring-boot-starter-actuator. Для Resilience4j это, среди прочего: resilience4j_circuitbreaker_calls_total (с тегами kind=successful/failed/not_permitted), resilience4j_circuitbreaker_state (гейдж текущего состояния), resilience4j_bulkhead_available_concurrent_calls, resilience4j_ratelimiter_available_permissions.

# application.yml — health group для circuit breaker'ов
management:
  endpoint:
    health:
      show-details: always
  health:
    circuitbreakers:
      enabled: true

Без этих метрик Circuit Breaker в проде — чёрный ящик: без дашборда, показывающего текущее состояние (CLOSED/OPEN/HALF_OPEN) и историю переходов, невозможно отличить «сервис реально лежит» от «неверно настроен порог срабатывания» — оба случая внешне выглядят как «клиенты получают ошибку», но чинятся по-разному.

Когда применять, а когда — избегать

ПаттернПрименять, когдаИзбегать / осторожно, когда
RetryОперация идемпотентна, сбой — вероятно временный (сеть, 503, connection reset)Операция не идемпотентна без доп. защиты (idempotency key); ошибка — доменная/постоянная (4xx кроме 429/408); операция дорогая по времени/деньгам
Circuit BreakerВызов внешней системы, у которой реально бывают деградации; хочется fail-fast вместо накопления зависших запросовЛокальный/внутрипроцессный вызов без сети; сервис критичен и деградации быть не должно в принципе (тогда важнее алертинг, а не «терпимость» к отказам)
BulkheadНесколько внешних зависимостей делят общий пул потоков/соединений и одна медленная не должна забрать все ресурсыЕдинственная зависимость в сервисе — изоляция избыточна, лишняя сложность конфигурации
Rate LimiterЕсть SLA/квота у партнёра (X req/sec), нужно самим себя ограничить, чтобы не словить бан или 429Внутренние вызовы без внешних ограничений — обычно не нужен
FallbackЕсть осмысленная деградация (кэш, дефолт, «повторите позже») лучше, чем голая ошибка пользователюFallback молча скрывает критичную ошибку без алертинга — превращается в баг, который никто не заметит
🚨 Главная ошибка senior-уровня — резилентность без наблюдаемости. Каждый добавленный слой resilience — это ещё один способ скрыть проблему от глаз (retry прячет единичные сбои, fallback прячет полный отказ зависимости, circuit breaker прячет деградацию за «быстрым отказом»). Без метрик и алертов по каждому паттерну команда узнаёт о реальной проблеме зависимости не из мониторинга, а из жалоб пользователей на «странное поведение», хотя вся телеметрия давно показывала бы open circuit breaker или растущий retry rate.

Native @Retryable vs Resilience4j vs legacy spring-retry

КритерийSpring Framework 7 @RetryableResilience4j (Boot 4 starter)Legacy spring-retry
Что покрываетRetry, ConcurrencyLimit (лёгкий bulkhead)Retry, CircuitBreaker, Bulkhead, RateLimiter, TimeLimiterТолько Retry (+ RetryTemplate императивно)
СтатусЧасть core-фреймворка, актуальный путь для нового кодаОтдельная зависимость, активно поддерживаетсяОтдельная зависимость, historically первый декларативный retry в Spring; новые проекты предпочитают нативный вариант или Resilience4j
МеханизмAOP-прокси через @EnableResilientMethodsAOP-прокси через собственные Aspect'ыAOP-прокси через @EnableRetry
Circuit BreakerНетДа, полноценный state machineНет
Когда выбратьНужен только retry/лимит конкурентности, минимум зависимостейНужна полная защита внешней интеграции (retry + breaker + rate limit)Поддержка существующего legacy-кода; для нового кода обычно не первый выбор

Тестирование резилентности

Резилентность нельзя проверить одним unit-тестом happy path — нужно явно эмулировать отказы зависимости. Для Resilience4j состояние Circuit Breaker можно проверять и даже принудительно переключать в тестах напрямую через реестр:

@Test
void fallbackIsUsedWhenCircuitIsOpen(CircuitBreakerRegistry registry) {
    CircuitBreaker cb = registry.circuitBreaker("paymentGateway");
    cb.transitionToOpenState(); // принудительно размыкаем цепь

    PaymentResult result = paymentService.charge(request);

    assertThat(result.status()).isEqualTo(Status.PENDING_REVIEW); // сработал fallback
}

Для интеграционных тестов сетевой слой обычно эмулируется через WireMock с задержками/500-ми ответами (withFixedDelay, aResponse().withStatus(503)), чтобы проверить не только «код компилируется», а реальное поведение под задержкой и частичным отказом — включая корректное срабатывание timeout раньше, чем истечёт retry-цикл.

← Предыдущая