Паттерны отказоустойчивости
Retry, Circuit Breaker, Timeout, Bulkhead, Fallback, Rate Limiter, DLQ — на Java/Resilience4j. Как ограничить радиус поражения при сбоях.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. В распределённой системе отказы — не исключение, а норма. Сеть теряет пакеты, сервисы падают под нагрузкой, базы данных уходят на failover. Вопрос не «что если сервис упадёт?», а «что будет с системой, когда он упадёт?».
Паттерны отказоустойчивости (resilience patterns) не предотвращают сбои — они не дают одному сбою каскадно уронить всё остальное.
Retry + Exponential Backoff
Проблема
Сетевой вызов упал. Причина может быть временной: перезапуск пода, GC-пауза, перегруженный балансировщик. Если повторить через секунду — сработает.
Но если ретраить моментально — 100 клиентов одновременно долбят уже больной сервис. Если не ретраить вообще — теряем запрос из-за случайного сбоя.
Решение
Повторять с экспоненциально растущей задержкой:
Попытка 1: сразу
Попытка 2: через 1 сек
Попытка 3: через 2 сек
Попытка 4: через 4 сек
Попытка 5: через 8 сек (capped at 30 сек)
@Component
public class RetryableServiceCaller {
private final RetryTemplate retryTemplate;
public RetryableServiceCaller() {
this.retryTemplate = RetryTemplate.builder()
.maxAttempts(5)
.exponentialBackoff(
1000, // начальная задержка
2.0, // множитель
30000 // максимальная задержка
)
.retryOn(RetryableException.class)
.build();
}
public <T> T call(Supplier<T> action) {
return retryTemplate.execute(ctx -> action.get());
}
}
Или декларативно через Resilience4j:
@Retry(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResult charge(Long userId, Money amount) {
return paymentClient.charge(userId, amount.toKopecks());
}
private PaymentResult paymentFallback(Long userId, Money amount, Exception e) {
log.error("Payment service unavailable after retries", e);
throw new PaymentUnavailableException();
}
Jitter
100 клиентов получили ошибку одновременно. Все ждут 1 секунду. Все ретраят одновременно. Сервис снова падает. Thundering herd.
Jitter добавляет случайный разброс к задержке:
long delay = Math.min(maxDelay,
baseDelay * (long) Math.pow(multiplier, attempt));
long jitter = ThreadLocalRandom.current().nextLong(0, delay / 2);
Thread.sleep(delay + jitter);
Какие запросы можно ретраить
- Безопасно: идемпотентные операции —
GET,PUTс фиксированным ключом,DELETEпоid. - Опасно: неидемпотентные операции —
POSTбез ключа идемпотентности. Двойной POST может создать два заказа.
Решение для неидемпотентных операций — Idempotency Key. Клиент генерирует UUID, сервер проверяет — если запрос с таким ключом уже обработан, возвращает сохранённый результат. См. REST API: заголовки.
Circuit Breaker
Проблема
Платёжный шлюз лёг. 100 потоков ждут ответа по 30 секунд. Через минуту Thread Pool исчерпан. Ваш сервис перестаёт отвечать на любые запросы — даже на те, которые не связаны с оплатой. Каскадный отказ.
Решение
Circuit Breaker отслеживает процент ошибок и «размыкает цепь» — перестаёт отправлять запросы, возвращая ошибку мгновенно.
- CLOSED — нормальная работа. Запросы проходят. Считаем процент ошибок в скользящем окне.
- OPEN — цепь разомкнута. Запросы не проходят, мгновенный отказ без ожидания. Ждём таймаут.
- HALF_OPEN — пропускаем несколько пробных запросов. Если успешны —
CLOSED. Если нет — обратно вOPEN.
Конфигурация Resilience4j
resilience4j:
circuitbreaker:
instances:
paymentService:
sliding-window-size: 10
failure-rate-threshold: 50 # 50% ошибок → OPEN
wait-duration-in-open-state: 30s # через 30 сек → HALF_OPEN
permitted-number-of-calls-in-half-open-state: 3
record-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignore-exceptions:
- ru.example.orders.exception.OrderException
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentUnavailable")
public PaymentResult charge(Long userId, Money amount) {
return paymentClient.charge(userId, amount.toKopecks());
}
private PaymentResult paymentUnavailable(Long userId, Money amount,
CallNotPermittedException e) {
throw new ServiceTemporarilyUnavailableException("Payment service");
}
Что НЕ считать ошибкой
OrderException.NotFound (404) — это не сбой сервиса, это бизнес-ответ. Если Circuit Breaker будет считать 404 ошибкой — при серии «заказ не найден» он разомкнёт цепь и заблокирует все запросы к платёжному шлюзу. Поэтому ignore-exceptions — обязательная настройка.
Timeout
Проблема
Сервис не отвечает. Без таймаута поток висит бесконечно, занимая ресурсы. 100 таких потоков — и Thread Pool исчерпан.
Решение
Каждый внешний вызов ограничен по времени:
@TimeLimiter(name = "paymentService", fallbackMethod = "paymentTimeout")
public CompletableFuture<PaymentResult> chargeAsync(Long userId, Money amount) {
return CompletableFuture.supplyAsync(() ->
paymentClient.charge(userId, amount.toKopecks()));
}
private CompletableFuture<PaymentResult> paymentTimeout(
Long userId, Money amount, TimeoutException e) {
throw new PaymentTimeoutException();
}
resilience4j:
timelimiter:
instances:
paymentService:
timeout-duration: 5s # максимум 5 секунд на ответ
cancel-running-future: true
Timeout + Retry + Circuit Breaker
Три паттерна работают вместе. Порядок обёртки имеет значение:
- Circuit Breaker — внешний. Если цепь разомкнута — мгновенный отказ, не тратим время на ретраи.
- Retry — средний. Повторяет при временном сбое.
- Timeout — внутренний. Каждая отдельная попытка ограничена по времени.
Правило: таймаут всегда
Любой сетевой вызов без таймаута — потенциальная утечка потоков. Это касается HTTP-клиентов, обращений к БД, Redis, gRPC — всего.
// Retrofit + OkHttp
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build();
Bulkhead
Проблема
Сервис вызывает три внешних системы: платёжный шлюз, складскую систему, страховую. Платёжный шлюз тормозит — все 200 потоков Thread Pool заняты ожиданием ответа. Запросы к складу и страховой тоже не обрабатываются — хотя эти системы работают нормально.
Решение: изоляция ресурсов
Как в корабле переборки (bulkheads) разделяют трюм на отсеки — затопление одного не топит весь корабль.
Два типа Bulkhead
Thread Pool Isolation — каждый внешний вызов в отдельном пуле потоков:
resilience4j:
thread-pool-bulkhead:
instances:
paymentService:
max-thread-pool-size: 20
core-thread-pool-size: 10
queue-capacity: 50
inventoryService:
max-thread-pool-size: 20
core-thread-pool-size: 10
queue-capacity: 50
insuranceService:
max-thread-pool-size: 10
core-thread-pool-size: 5
queue-capacity: 20
@Bulkhead(name = "paymentService",
type = Bulkhead.Type.THREADPOOL,
fallbackMethod = "paymentBulkheadFallback")
public CompletableFuture<PaymentResult> charge(Long userId, Money amount) {
return CompletableFuture.supplyAsync(() ->
paymentClient.charge(userId, amount.toKopecks()));
}
private CompletableFuture<PaymentResult> paymentBulkheadFallback(
Long userId, Money amount, BulkheadFullException e) {
throw new ServiceOverloadedException("Payment service pool exhausted");
}
Semaphore Isolation — ограничение числа одновременных вызовов без отдельного пула потоков. Легче по ресурсам, но выполняется в вызывающем потоке:
resilience4j:
bulkhead:
instances:
paymentService:
max-concurrent-calls: 20
max-wait-duration: 500ms # ждать освобождения слота макс. 500 мс
Thread Pool vs Semaphore
- Thread Pool — полная изоляция. Зависший вызов не блокирует основной Thread Pool. Но overhead на создание и переключение потоков.
- Semaphore — лёгкий. Ограничивает конкурентность, но если вызов завис — поток основного пула тоже завис.
Для HTTP-вызовов с таймаутами обычно достаточно Semaphore. Thread Pool нужен, когда таймауты ненадёжны или не контролируются.
Fallback
Идея
Если основной путь недоступен — используем запасной. Не всегда нужен идеальный ответ, иногда «приемлемый» лучше, чем ошибка.
Варианты fallback
Значение по умолчанию:
@CircuitBreaker(name = "exchangeRateService", fallbackMethod = "defaultRate")
public ExchangeRate getRate(String currency) {
return exchangeRateClient.getRate(currency);
}
private ExchangeRate defaultRate(String currency, Exception e) {
// Возвращаем последний известный курс из кэша
return exchangeRateCache.getLatest(currency)
.orElse(ExchangeRate.fallback(currency));
}
Кэшированный ответ:
@CircuitBreaker(name = "productCatalog", fallbackMethod = "cachedProducts")
public List<Product> getProducts(String category) {
List<Product> products = catalogClient.getProducts(category);
productCache.put(category, products); // обновляем кэш при успехе
return products;
}
private List<Product> cachedProducts(String category, Exception e) {
return productCache.get(category)
.orElse(Collections.emptyList());
}
Деградированный функционал:
@CircuitBreaker(name = "recommendationService", fallbackMethod = "popularProducts")
public List<Product> getRecommendations(Long userId) {
return recommendationClient.getPersonalized(userId);
}
private List<Product> popularProducts(Long userId, Exception e) {
// Вместо персональных рекомендаций — популярные товары
return productRepository.findTopByOrderByPopularityDesc(10);
}
Когда fallback не подходит
Платёж. Если платёжный шлюз недоступен — нельзя «примерно» списать деньги. Здесь правильный fallback — честная ошибка «сервис временно недоступен, попробуйте позже».
Rate Limiter
Проблема
Внешний API ограничивает количество запросов: 100 запросов в секунду. Превышение — 429 Too Many Requests и бан на 5 минут. Или свой сервис получает всплеск трафика — нужно защититься от перегрузки.
Решение
@RateLimiter(name = "paymentApi", fallbackMethod = "rateLimitExceeded")
public PaymentResponse register(PaymentRegisterDto request) {
return paymentClient.register(request);
}
private PaymentResponse rateLimitExceeded(PaymentRegisterDto request,
RequestNotPermitted e) {
throw new TooManyRequestsException(
"Payment API rate limit exceeded, retry later");
}
resilience4j:
ratelimiter:
instances:
paymentApi:
limit-for-period: 50 # 50 запросов
limit-refresh-period: 1s # за 1 секунду
timeout-duration: 2s # ждать слот макс. 2 сек
Алгоритмы
- Fixed Window — счётчик сбрасывается в начале каждого окна. Простой, но на границе окон может пропустить 2x лимита.
- Sliding Window — скользящее окно, более точное ограничение.
- Token Bucket — токены накапливаются с постоянной скоростью. Позволяет кратковременные всплески, если есть накопленные токены.
- Leaky Bucket — запросы обрабатываются с постоянной скоростью. Сглаживает всплески, но добавляет задержку.
Dead Letter Queue (DLQ)
Проблема
Сообщение в Kafka не обрабатывается: битые данные, неизвестный формат, бизнес-валидация не проходит. Бесконечные ретраи бессмысленны — сообщение «отравлено» и блокирует обработку следующих.
Решение
После N попыток — перекладываем в отдельную очередь для разбора.
@Configuration
public class KafkaConsumerConfig {
@Bean
public DefaultErrorHandler errorHandler(
KafkaTemplate<String, String> kafkaTemplate) {
DeadLetterPublishingRecoverer recoverer =
new DeadLetterPublishingRecoverer(kafkaTemplate,
(record, ex) -> new TopicPartition(
record.topic() + ".dlq", record.partition()));
DefaultErrorHandler handler = new DefaultErrorHandler(
recoverer,
new FixedBackOff(1000L, 3L)
);
// Ошибки валидации — сразу в DLQ, ретраить бессмысленно
handler.addNotRetryableExceptions(
ValidationException.class,
JsonParseException.class);
return handler;
}
}
Обработка DLQ
- Мониторинг — алерт, когда в DLQ появляются сообщения. Grafana dashboard с lag по DLQ-топикам.
- Автоматический replay — переотправка в основной топик после фикса. Но осторожно: если причина ошибки не устранена — сообщение снова попадёт в DLQ.
- Ручной разбор — для бизнес-ошибок, которые нельзя автоматизировать.
Как паттерны работают вместе
Порядок обёрток (снаружи → внутрь):
- Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
- Bulkhead — если пул потоков заполнен, не создаём новые вызовы.
- Circuit Breaker — если сервис недоступен, мгновенный отказ (или fallback). Не тратим время на ретраи мёртвого сервиса.
- Retry — если вызов упал, повторяем с backoff. Повторяется только внутри одного слота Bulkhead.
- Timeout — каждая попытка ограничена по времени.
Конфигурация полного стека
resilience4j:
ratelimiter:
instances:
paymentService:
limit-for-period: 100
limit-refresh-period: 1s
bulkhead:
instances:
paymentService:
max-concurrent-calls: 25
circuitbreaker:
instances:
paymentService:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
retry:
instances:
paymentService:
max-attempts: 3
wait-duration: 1s
exponential-backoff-multiplier: 2
timelimiter:
instances:
paymentService:
timeout-duration: 5s
Чек-лист: выбор паттерна
| Проблема | Паттерн |
|---|---|
| Внешний вызов может временно упасть | Retry + Exponential Backoff с Jitter |
| Сервис может лечь надолго | Circuit Breaker — иначе каскадный отказ |
| Нужно ограничить время ожидания | Timeout — на каждый сетевой вызов |
| Один упавший сервис блокирует остальные | Bulkhead (Thread Pool / Semaphore) |
| Допустим деградированный ответ | Fallback: кэш, default, упрощённая логика |
| Защитить внешний API от перегрузки | Rate Limiter (Token Bucket для всплесков) |
| Сообщения не обрабатываются после ретраев | Dead Letter Queue + мониторинг |
Итого
Паттерны отказоустойчивости не предотвращают сбои. Они ограничивают радиус поражения:
- Retry + Backoff — переживаем кратковременные сбои
- Circuit Breaker — не тратим ресурсы на мёртвый сервис
- Timeout — не висим вечно в ожидании ответа
- Bulkhead — изолируем потоки, один сервис не блокирует остальные
- Fallback — деградируем, но не падаем
- Rate Limiter — защищаем от перегрузки себя и внешние API
- DLQ — не теряем и не зацикливаемся на «отравленных» сообщениях
В production эти паттерны всегда работают вместе. Resilience4j позволяет комбинировать их декларативно — каждый внешний вызов оборачивается в нужную комбинацию через аннотации и YAML-конфиг.
Ссылки
- Распределённые паттерны — Saga и Outbox дополняют resilience-паттерны для согласованности данных.
- Apache Kafka — DLQ, idempotent consumer, gun manager.
- Кейс: маркетплейс — где применяются эти паттерны на практике.