Когда приложение обращается к одному-единственному сервису или базе данных, сбои редки. Но в распределённых системах вызовы к другим сервисам — норма. А значит, норма и то, что что-то упадёт: сеть потеряет пакеты, сервис перезапустится, база данных уйдёт на переключение.
Вопрос не «упадёт ли?», а «что произойдёт с остальной системой, когда это случится?».
Паттерны отказоустойчивости не предотвращают сбои — они не дают одному сбою каскадно уронить всё вокруг.
Retry — повторить при временной ошибке
Сетевой вызов упал. Причина может быть случайной: перезапуск пода, кратковременная перегрузка, пауза при сборке мусора. Если попробовать ещё раз через секунду — сработает.
Но если ретраить моментально, сто клиентов одновременно обрушиваются на уже больной сервис. Если не ретраить совсем — теряем запрос из-за случайного сбоя.
Решение — повторять с нарастающей задержкой (Exponential Backoff):
Попытка 1: сразу
Попытка 2: через 1 сек
Попытка 3: через 2 сек
Попытка 4: через 4 сек
Попытка 5: через 8 сек (не больше 30 сек)
RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(5)
.exponentialBackoff(1000, 2.0, 30_000)
.retryOn(RetryableException.class)
.build();
Jitter — чтобы не долбить сервис одновременно
Все сто клиентов получили ошибку в один момент. Все ждут одну секунду. Все ретраят одновременно. Сервис снова падает. Это называется «эффект толпы» (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по идентификатору. - Опасно:
POSTбез ключа идемпотентности. Двойная отправка может создать два заказа.
Для неидемпотентных операций используют Idempotency Key — клиент генерирует UUID, сервер проверяет: если запрос с таким ключом уже обработан, возвращает сохранённый результат вместо повторного выполнения.
Circuit Breaker — «автомат» на случай падения сервиса
Платёжный шлюз лёг. Сто потоков ждут ответа по тридцать секунд. Через минуту все потоки заняты. Ваш сервис перестаёт отвечать на любые запросы — даже те, что с оплатой не связаны. Это каскадный отказ.
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 — не ждать вечно
Сервис не отвечает. Без таймаута поток висит бесконечно, занимая память и ресурсы. Сто таких потоков — и всё останавливается.
Любой сетевой вызов должен ограничиваться по времени:
@TimeLimiter(name = "paymentService", fallbackMethod = "paymentTimeout")
public CompletableFuture<PaymentResult> chargeAsync(Long userId, Money amount) {
return CompletableFuture.supplyAsync(() ->
paymentClient.charge(userId, amount.toKopecks()));
}
resilience4j:
timelimiter:
instances:
paymentService:
timeout-duration: 5s
cancel-running-future: true
Это касается не только HTTP. Любой вызов к базе данных, Redis, gRPC, очереди сообщений — везде должен быть таймаут.
Как сочетаются Retry, Circuit Breaker и Timeout
Три паттерна работают вместе, и порядок важен:
Circuit Breaker → Retry → Timeout → Внешний сервис
- Circuit Breaker — снаружи. Если цепь разомкнута, не тратим время на ретраи.
- Retry — в середине. Повторяет при временном сбое.
- Timeout — внутри. Каждая отдельная попытка ограничена по времени.
Bulkhead — изоляция ресурсов
Сервис вызывает три внешних системы: платёжный шлюз, склад, страховую. Платёжный шлюз тормозит — все двести потоков заняты ожиданием. Запросы к складу и страховой тоже встают в очередь, хотя эти системы работают нормально.
Bulkhead — по аналогии с переборками в корабле. Каждый отсек герметичен: затопление одного не топит остальные.
Два варианта реализации:
Thread Pool Isolation — каждый внешний вызов идёт в отдельном пуле потоков. Зависший вызов не занимает основной пул. Overhead на переключение потоков.
Semaphore Isolation — ограничение числа одновременных вызовов без отдельного пула. Легче по ресурсам, но если вызов завис — поток основного пула тоже занят.
resilience4j:
thread-pool-bulkhead:
instances:
paymentService:
max-thread-pool-size: 20
core-thread-pool-size: 10
queue-capacity: 50
bulkhead:
instances:
paymentService:
max-concurrent-calls: 20
max-wait-duration: 500ms
Для HTTP-вызовов с таймаутами обычно достаточно Semaphore. Thread Pool нужен, когда таймауты ненадёжны или не контролируются.
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 не подходит: если платёжный шлюз недоступен, нельзя «примерно» списать деньги. Здесь правильный ответ — честная ошибка «сервис временно недоступен, попробуйте позже».
Rate Limiter — защита от перегрузки
Внешний API ограничивает вас: 100 запросов в секунду. Превышение — бан на пять минут. Или ваш собственный сервис получает всплеск трафика — нужно защититься.
@RateLimiter(name = "paymentApi", fallbackMethod = "rateLimitExceeded")
public PaymentResponse register(PaymentRegisterDto request) {
return paymentClient.register(request);
}
resilience4j:
ratelimiter:
instances:
paymentApi:
limit-for-period: 50
limit-refresh-period: 1s
timeout-duration: 2s
Основные алгоритмы:
- Fixed Window — счётчик сбрасывается в начале каждого окна. Простой, но на границе окон может пропустить вдвое больше лимита.
- Sliding Window — скользящее окно, более точное ограничение.
- Token Bucket — токены накапливаются с постоянной скоростью. Позволяет кратковременные всплески, если есть накопленные токены.
- Leaky Bucket — запросы обрабатываются с постоянной скоростью. Сглаживает всплески, но добавляет задержку.
Dead Letter Queue — что делать с необрабатываемыми сообщениями
Сообщение в Kafka не обрабатывается: битые данные, неизвестный формат, бизнес-валидация не проходит. Бесконечные повторы бессмысленны — сообщение «отравлено» и блокирует обработку следующих.
Dead Letter Queue (DLQ, очередь мёртвых писем) — после нескольких неудачных попыток сообщение перекладывается в отдельную очередь для разбора вручную.
@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)
);
handler.addNotRetryableExceptions(
ValidationException.class,
JsonParseException.class);
return handler;
}
Что делать с DLQ:
- Мониторинг — настроить алерт, когда в DLQ появляются сообщения.
- Повторная отправка — после устранения причины переотправить в основной топик. Осторожно: если причина не устранена, сообщение снова попадёт в DLQ.
- Ручной разбор — для бизнес-ошибок, которые нельзя автоматизировать.
Как паттерны работают вместе
На практике паттерны комбинируются. Полный стек для одного внешнего вызова выглядит так (порядок снаружи → внутрь):
- Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
- Bulkhead — если пул заполнен, не создаём новые вызовы.
- Circuit Breaker — если сервис недоступен, мгновенный отказ или fallback.
- Retry — если вызов упал, повторяем с задержкой.
- 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 — не тратим ресурсы на недоступный сервис; три состояния: CLOSED / OPEN / HALF_OPEN.
- Timeout — любой сетевой вызов без таймаута — потенциальная утечка потоков.
- Bulkhead — изолируем ресурсы; один проблемный сервис не блокирует остальные.
- Fallback — деградируем, но не падаем; не всегда применим (платёж — честная ошибка, не «примерный» ответ).
- Rate Limiter — защищаем от перегрузки себя и внешние API.
- DLQ — не теряем и не зацикливаемся на «отравленных» сообщениях.
- Порядок обёртки: Rate Limiter → Bulkhead → Circuit Breaker → Retry → Timeout.
Что почитать дальше
- Распределённые паттерны — Saga и Outbox для согласованности данных между сервисами.
- Apache Kafka — DLQ и идемпотентный потребитель в деталях.