Паттерны отказоустойчивости

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 отслеживает процент ошибок и «размыкает цепь» — перестаёт отправлять запросы, возвращая ошибку мгновенно.

diagram
  • 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

Три паттерна работают вместе. Порядок обёртки имеет значение:

diagram
  • 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) разделяют трюм на отсеки — затопление одного не топит весь корабль.

diagram

Два типа 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 попыток — перекладываем в отдельную очередь для разбора.

diagram
@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.
  • Ручной разбор — для бизнес-ошибок, которые нельзя автоматизировать.

Как паттерны работают вместе

diagram

Порядок обёрток (снаружи → внутрь):

  1. Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
  2. Bulkhead — если пул потоков заполнен, не создаём новые вызовы.
  3. Circuit Breaker — если сервис недоступен, мгновенный отказ (или fallback). Не тратим время на ретраи мёртвого сервиса.
  4. Retry — если вызов упал, повторяем с backoff. Повторяется только внутри одного слота Bulkhead.
  5. 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-конфиг.

Ссылки