Опирается на правила:
R-RES-ISO-1…R-RES-ISO-3иR-RES-ISO-X1…R-RES-ISO-X2из Resilience Style Guide → раздел 2. Per-system isolation.
Важно знать
- На каждую внешнюю систему — отдельный
OkHttpClient(илиRestClient/WebClient) с собственнымиConnectionPool,Dispatcher,CircuitBreaker,Bulkhead.- Имя bean'а и R4J-инстансов одинаковое:
sber,odnakassa,insurance. Совпадение позволяет адаптеру использовать одно имя в@CircuitBreaker(name = "sber"),@Bulkhead(name = "sber"),@Retry(name = "sber").- Connection pool sizing:
pool = maxConcurrent × 1.2(запас на keep-alive). Total всех систем ≤ HikariCP-пул / 2 — внешние клиенты не должны съесть БД-соединения.- Изоляция нужна, потому что зависание одной системы не должно блокировать другие. Shared pool — главный антипаттерн.
- Дефолтные настройки
OkHttpClient.Builder()дают global defaults (200 idle, shared dispatcher). Это всегда настраивается явно.
Если на один и тот же OkHttpClient повесить вызовы к Sber и к OdnaKassa, то 30-секундное зависание Sber съест 200 идл-соединений в общем dispatcher — и OdnaKassa тоже встанет, потому что ей не осталось ресурсов. Изоляция по системам — первое, что делается, когда у сервиса больше одной внешней зависимости. Раскрытие раздела 2 гайда.
Отдельный OkHttpClient на каждую систему
R-RES-ISO-1: для каждой внешней системы — отдельный @Bean OkHttpClient со своими ConnectionPool и Dispatcher.
@Configuration
@RequiredArgsConstructor
public class SberClientConfig {
@Bean("sberOkHttpClient")
OkHttpClient sberOkHttpClient(SberClientSettings settings) {
var dispatcher = new Dispatcher();
dispatcher.setMaxRequests(settings.maxConcurrent() * 2);
dispatcher.setMaxRequestsPerHost(settings.maxConcurrent());
var pool = new ConnectionPool(
settings.maxIdleConnections(),
5, TimeUnit.MINUTES
);
return new OkHttpClient.Builder()
.connectTimeout(settings.connectTimeout())
.readTimeout(settings.readTimeout())
.callTimeout(settings.callTimeout())
.dispatcher(dispatcher)
.connectionPool(pool)
.addInterceptor(new RequestsInterceptor(parser))
.build();
}
}
Аналогично для OdnaKassa — отдельный класс OdnaKassaClientConfig со своим бином odnakassaOkHttpClient. Никаких shared компонентов.
Что важно:
Dispatcherуправляет concurrent HTTP-запросами в OkHttp.maxRequests— общий лимит,maxRequestsPerHost— на хост (актуально, когда одна система имеет несколько endpoint'ов).ConnectionPoolуправляет keep-alive TCP-соединениями. Размер —maxIdleConnections(типовое:maxConcurrent).- Interceptor для логирования / tracing — per-system, чтобы видеть в логах, какая именно система пошла наружу.
Для нового сервиса лучше RestClient (Spring 6.1+) вместо OkHttp напрямую — см. OpenAPI generator binding, но принцип per-system isolation тот же.
Sizing connection pool
R-RES-ISO-2: формула простая, но связывает два слоя — внешний клиент и БД.
- Per-system:
pool size = maxConcurrent × 1.2. Запас 20% покрывает keep-alive idle, при которых соединение ещё в пуле, но Bulkhead уже не считает его «занятым». - Total:
sum(all systems pool sizes) ≤ HikariCP max-pool-size / 2. Это критично: если внешние клиенты съели 100 file-descriptor'ов, столько же остаётся на БД-соединения. При нормальной нагрузке HTTP : DB ≈ 1:1, поэтому делим пополам.
Пример для сервиса с тремя внешними:
spring.datasource.hikari.maximum-pool-size: 40
client.sber.max-concurrent: 20 # pool ≈ 24
client.odnakassa.max-concurrent: 30 # pool ≈ 36
client.insurance.max-concurrent: 10 # pool ≈ 12
# total ≈ 72
72 > 40/2 = 20 — нарушение R-RES-ISO-2. Решения: либо уменьшить maxConcurrent (если бизнес-нагрузка позволяет), либо увеличить HikariCP до 144+. Без коррекции есть риск, что внешние HTTP отъедают сетевые ресурсы у БД-пула на пике.
Единое имя для bean и R4J-инстансов
R-RES-ISO-3: bean sberOkHttpClient, CB-instance sber, Bulkhead-instance sber, Retry-instance sber — одно имя на всю «единицу изоляции».
resilience4j.circuitbreaker.instances.sber:
base-config: default
failure-rate-threshold: 30
resilience4j.bulkhead.instances.sber:
max-concurrent-calls: 16
max-wait-duration: 100ms
resilience4j.retry.instances.sber:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
@Bulkhead(name = "sber")
@Retry(name = "sber")
public RegisterResult register(RegisterCommand cmd) { ... }
Зачем единое имя:
- Глазами видно «sber-единица». В метриках, логах, конфиге одно имя — легко искать и алёртить.
- Меньше копи-паст багов. Не получится случайно нацелить CB на
sber, а Bulkhead наsbr— typo в имени = silent misconfiguration на проде. - Удобно при добавлении новой системы. Скопировал блок конфига, поменял имя — готов новый адаптер.
Что запрещено
Shared OkHttpClient / Dispatcher
R-RES-ISO-X1: один @Bean OkHttpClient на несколько внешних систем — главный антипаттерн.
// ПЛОХО — один client на всех
@Bean
OkHttpClient sharedOkHttpClient() {
return new OkHttpClient.Builder()
.connectTimeout(5, SECONDS)
.readTimeout(30, SECONDS)
.build();
}
@Component
class SberClientAdapter {
private final OkHttpClient client; // shared!
public RegisterResult register(...) { ... }
}
@Component
class OdnaKassaAdapter {
private final OkHttpClient client; // тот же!
public CashboxResponse send(...) { ... }
}
Что не так: при зависании Sber 200 идл-коннектов в shared dispatcher заняты ожиданием его response'а. OdnaKassa получает BulkheadFullException или ждёт coннект-слот. Cascading failure: одна система ломает всё.
Корректно: bean sberOkHttpClient, bean odnakassaOkHttpClient, у каждого свой Dispatcher и ConnectionPool.
Дефолтный OkHttpClient.Builder() без явных pool/dispatcher
R-RES-ISO-X2: new OkHttpClient() без явной настройки берёт global defaults: Dispatcher с maxRequests=64, ConnectionPool с 5 idle и keep-alive 5 минут. Эти defaults shared между всеми инстансами по умолчанию (если не передавать в Builder).
// ПЛОХО — defaults, неявная изоляция
OkHttpClient sber = new OkHttpClient.Builder()
.connectTimeout(5, SECONDS)
.build();
Корректно: всегда явно создавать Dispatcher и ConnectionPool per-system (см. выше).
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Shared OkHttpClient / Dispatcher на несколько систем | R-RES-ISO-X1 | Отдельный bean на каждую систему |
Дефолтный OkHttpClient.Builder() без явных pool/dispatcher | R-RES-ISO-X2 | Явные Dispatcher и ConnectionPool |
| Разные имена для CB / Bulkhead / Retry одной системы | R-RES-ISO-3 | Единое имя sber для всех R4J-инстансов |
| Total pool size всех систем > HikariCP-пул | R-RES-ISO-2 | sum(pools) ≤ hikari / 2 |
| Pool size < maxConcurrent (без запаса на keep-alive) | R-RES-ISO-2 | pool = maxConcurrent × 1.2 |
Куда дальше
- Resilience → раздел 2. Per-system isolation — нормативные
R-RES-ISO-*. - Timeouts —
connectTimeout/readTimeout/callTimeoutна OkHttpClient. - Circuit Breaker — отдельный CB на каждую систему.
- Bulkhead — semaphore-based ограничение concurrency.
- OpenAPI generator binding — где размещать R4J-аннотации с generated client.
- Конфигурация — declarative config через application.yml.