Опирается на правила:
R-RES-WHERE-1…R-RES-WHERE-4иR-RES-WHERE-X1из Resilience Style Guide → раздел 1. Где какая защита.
Важно знать
- Outbound HTTP к внешним системам — полный набор:
timeout + @CircuitBreaker + @Bulkhead + опционально @Retry. Без CB первый «slow burn» внешней системы выедает thread pool сервиса.- Internal service-to-service (между нашими микросервисами) —
timeout + @CircuitBreaker. Bulkhead — по необходимости (если вызов тяжёлый или критичный).- Schedulers и outbox-relay — task-queue retry через таблицу БД, не Resilience4j. R4J ловит транзиенты <5s; task-queue — долгие отказы (>30s) и переживает рестарт сервиса.
- Inbound REST (наш API) — RateLimiter на edge (API Gateway).
@RateLimiterв коде только если gateway недоступен.- Локальный код (репозиторий, JOOQ, in-memory вычисления) — без Resilience4j. Нет транзиентов «иногда работает, иногда нет».
- Resilience4j защищает от отказов среды. Бизнес-ошибки (404, 4xx) защищать не нужно — они контрактные.
Resilience4j — это не «навесить везде на всякий случай». У каждой группы вызовов своя категория защиты, и навешивание не туда вредит больше, чем помогает. Раскрытие раздела 1 гайда.
Outbound HTTP к внешним системам — полный набор
R-RES-WHERE-1: любой вызов к внешней системе (платежи, фискализация, страхование, SMS, любой сторонний API) защищён полным набором.
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final SberOrderServicesApi sberApi;
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
@Bulkhead(name = "sber")
@Retry(name = "sber")
@Override
public RegisterResult register(RegisterCommand cmd) {
return executeCall(sberApi.register(toApiRequest(cmd), null));
}
}
Что даёт каждый слой:
- Timeouts (
connectTimeout,readTimeout,callTimeoutнаOkHttpClient) — гарантия, что один call не висит вечно. @CircuitBreaker— fast-fail когда система явно лежит: после N подряд ошибок CB открывается и следующие call'ы падают мгновенно, не нагружая внешнюю систему.@Bulkhead— ограничение concurrent invocations: даже если CB ещё закрыт, не более N одновременных вызовов к этой системе.@Retry— повтор только при условии идемпотентности (см. Retry). Не везде.
Без CB первый медленный API выедает thread pool: 20 запросов висят по 30s, новые тоже висят, весь сервис лежит. С CB на 11-м call'е (после 10 failures) поток уходит в fast-fail.
Internal service-to-service — timeout + CB
R-RES-WHERE-2: вызовы между нашими собственными микросервисами тоже защищаются, но с меньшим набором. Bulkhead — опционально.
@CircuitBreaker(name = "customer-service")
public CustomerView fetchCustomer(Long id) {
return customerServiceClient.getById(id);
}
Почему меньше:
- Внутренние сервисы под нашим контролем — SLA лучше, retry-семантика прозрачна.
- При деградации одного нашего сервиса CB просто остановит каскад. Bulkhead нужен, только если этот call идёт из горячего пути или вызовы могут затопить пул.
- Retry между нашими сервисами обычно опасен (двойные write); тщательнее, чем на внешний API.
Schedulers и outbox-relay — task-queue, не Resilience4j
R-RES-WHERE-3: для scheduled работ (cron-job, outbox-relay, polling-task) Resilience4j не подходит. Используется паттерн task-queue в БД.
Почему R4J не подходит:
- R4J живёт в памяти процесса. Restart сервиса = потеря CB-state и Retry-attempt counter'ов.
- R4J ретраит в рамках одного вызова: in-memory backoff до 5 секунд. Для отказа на минуты этого мало.
- Сделать
Retry(maxAttempts = 100, waitDuration = 60s)нельзя — это100×60s = 100 минутблока worker'а.
Task-queue решает это через персистентную таблицу с next_attempt_at:
CREATE TABLE order_confirmation_task (
task_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
status TEXT NOT NULL, -- PENDING / IN_PROGRESS / COMPLETED / FAILED
retry_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_oct_due ON order_confirmation_task (status, next_attempt_at)
WHERE status IN ('PENDING', 'IN_PROGRESS');
@Scheduled(fixedDelay = 5_000)
public void processPending() {
List<Task> due = taskRepository.findDueForRetry(50); // FOR UPDATE SKIP LOCKED
for (Task t : due) {
try {
adapter.confirm(t);
taskRepository.markCompleted(t.id());
} catch (Exception e) {
taskRepository.scheduleRetry(t.id(), e.getMessage(), nextBackoff(t.retryCount()));
}
}
}
Подробно — в Async и polling и PG Runtime → task-queue.
Inbound REST — RateLimiter на edge
R-RES-WHERE-4: защита нашего REST API от перегрузки клиентами — это RateLimiter, и он живёт на API Gateway (Spring Cloud Gateway, Kong, Istio), не в каждом сервисе.
Почему на gateway:
- Единая точка контроля для всех сервисов.
- Защита до того, как запрос дошёл до приложения — экономия CPU и connection slots.
- Per-client лимиты по API-key / IP — gateway это умеет, сервисы — нет.
@RateLimiter Resilience4j в коде сервиса допустим только в legacy-сценариях, когда gateway недоступен. Пример из real-world: внутренняя инсталляция без API Gateway, и нужно защитить сервис от плохо себя ведущего клиента. Дёрнуть @RateLimiter на контроллере — приемлемый workaround.
Что запрещено
Resilience4j вокруг локальных операций
R-RES-WHERE-X1: никакого @CircuitBreaker / @Retry / @Bulkhead на репозитории, JOOQ-вызове, in-memory вычислении.
// ПЛОХО — CB на репозитории
@Repository
public class JooqOrderRepository implements OrderRepository {
@CircuitBreaker(name = "orderRepo") // ← зачем?
public Optional<Order> findById(OrderId id, SelectMode mode) { ... }
}
Что не так:
- Транзиентов нет. PostgreSQL внутри одного процесса либо доступен, либо нет. Если нет — это серьёзный failure (соединение оборвалось, БД лежит), и CB здесь не помогает: восстановит состояние тот же reconnect-loop HikariCP.
- CB-state бесполезен. «Открыт» CB на репозитории = сервис не работает. Тогда лучше пусть упадёт и k8s его рестартанёт.
- Метрики мусорят.
resilience4j_circuitbreaker_state{name=orderRepo}ничего не значит — она всегда либо closed, либо «пиздец».
Корректно: HikariCP обеспечивает connection retry, jOOQ кидает осмысленные исключения, edge-handler возвращает 500. Локально нечего «защищать» от транзиентов.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@CircuitBreaker на репозитории / JOOQ-вызове | R-RES-WHERE-X1 | Не нужно — нет транзиентов |
Resilience4j вокруг @Service-метода без outbound | R-RES-WHERE-X1 | Не нужно |
| In-memory R4J retry для долгих отказов (>30s) | R-RES-WHERE-3 | Task-queue с БД |
@RateLimiter на каждом контроллере вместо API Gateway | R-RES-WHERE-4 | Centralized rate-limit на edge |
| Outbound без CB и Bulkhead | R-RES-WHERE-1 | Полный набор обязателен |
Куда дальше
- Resilience → раздел 1. Где какая защита — нормативные
R-RES-WHERE-*. - Per-system isolation — отдельный pool и CB на каждую систему.
- Timeouts — иерархия connect / read / call.
- Circuit Breaker — как настроить sliding window и threshold.
- Retry — когда повторять (и когда нельзя).
- Async и polling — task-queue для долгих отказов.