В монолите вызов метода — это прямой переход по указателю: он либо мгновенно успешен, либо мгновенно бросает исключение. В распределённой системе тот же логический «вызов» становится сетевым запросом, а значит появляется целый новый класс отказов, которых не было раньше: сеть может быть медленной, партнёр — временно перегружен, DNS — не резолвиться, TCP-соединение — зависнуть на десятки секунд вместо мгновенной ошибки. Хуже того, без защитных механизмов один медленный сервис способен вызвать каскадный отказ (cascading failure): сервис A ждёт ответа от медленного сервиса B, у A заканчиваются потоки/соединения в пуле, вызовы к A начинают отваливаться по таймауту, а сервис C, который зависит от A, повторяет тот же сценарий — авария распространяется вверх по графу зависимостей, хотя изначально «сломался» только один компонент.
Резилентность (resilience, отказоустойчивость) — это набор паттернов, которые ограничивают радиус поражения одного отказа и не дают ему превратиться в отказ всей системы:
Долгие годы декларативный 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» и не ударили по восстанавливающемуся сервису одновременным залпом.
InvalidCardException или любой другой доменной, «постоянной» ошибке — не просто бесполезно, а вредно: вы впустую тратите попытки и задерживаете ответ пользователю на 4 неудачных round-trip'а вместо одного мгновенного отказа.
Retry, как и @Async, @Cacheable, @Transactional до него — это не «магия компилятора», а классический для Spring паттерн BeanPostProcessor + AOP-прокси. Понимание этого механизма снимает добрую половину вопросов «почему retry не сработал» на собеседовании и в проде.
@EnableResilientMethods регистрирует BeanPostProcessor (в семействе тех же механизмов, что и AsyncAnnotationBeanPostProcessor для @Async), который на фазе postProcessAfterInitialization сканирует методы каждого создаваемого бина в поисках @Retryable/@ConcurrencyLimit. Если хотя бы один метод размечен — бин не возвращается «как есть», а оборачивается в прокси.
Spring строит Advisor (пара «pointcut + advice»): pointcut отбирает методы с аннотацией, advice — это MethodInterceptor, который умеет применять retry-политику. Дальше — стандартный выбор Spring AOP: если бин реализует интерфейс — создаётся JDK dynamic proxy (java.lang.reflect.Proxy), если нет — CGLIB-прокси (рантайм-сабкласс реального класса). В обоих случаях снаружи бин выглядит как обычный Java-объект — вся магия происходит на уровне байткода/рефлексии, без изменения исходного класса.
Когда вызывающий код обращается к 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());
}
}
}
charge() с @Retryable вызывается изнутри того же класса (this.charge(...)), вызов идёт напрямую на реальный объект, минуя прокси — retry-логика молча не сработает, и никакого исключения об этом брошено не будет. Это ровно та же проблема, что у @Transactional и @Async — причина в том, что self-invocation — это обычный Java-вызов метода на this, а не вызов через внешнюю ссылку на прокси. Решение: выносить размеченный метод в отдельный бин и вызывать его через внедрённую зависимость, а не через this.
Вторая нативная аннотация 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 одновременно» без сложной аналитики отказов.
Нативные @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);
}
}
CircuitBreakerAspect, BulkheadAspect, RateLimiterAspect, RetryAspect, TimeLimiterAspect) через тот же Spring AOP, что и нативные @Retryable/@Transactional. Это значит: та же проблема self-invocation, тот же выбор JDK proxy/CGLIB, та же необходимость публичного (или как минимум перехватываемого) метода.
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 → реальный вызов.
В 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
);
}
И нативный @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 молча скрывает критичную ошибку без алертинга — превращается в баг, который никто не заметит |
| Критерий | Spring Framework 7 @Retryable | Resilience4j (Boot 4 starter) | Legacy spring-retry |
|---|---|---|---|
| Что покрывает | Retry, ConcurrencyLimit (лёгкий bulkhead) | Retry, CircuitBreaker, Bulkhead, RateLimiter, TimeLimiter | Только Retry (+ RetryTemplate императивно) |
| Статус | Часть core-фреймворка, актуальный путь для нового кода | Отдельная зависимость, активно поддерживается | Отдельная зависимость, historically первый декларативный retry в Spring; новые проекты предпочитают нативный вариант или Resilience4j |
| Механизм | AOP-прокси через @EnableResilientMethods | AOP-прокси через собственные 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-цикл.