Опирается на правила:
R-RES-BH-1…R-RES-BH-3иR-RES-BH-X1из Resilience Style Guide → раздел 6. Bulkhead.
Важно знать
@Bulkhead(name = "<system>")— обязательный слой отдельно от connection pool. Pool ограничивает TCP-соединения; bulkhead — Java concurrent calls.- Тип SEMAPHORE, не THREADPOOL. THREADPOOL создаёт второй пул и теряет MDC/SecurityContext без явного wrapping.
- Sizing:
maxConcurrentCalls = pool maxConcurrent × 0.8. Запас 20% — bulkhead срабатывает раньше исчерпания pool'а.maxWaitDuration: 100ms— короткое ожидание; иначе теряется fail-fast смысл.- При переполнении бросает
BulkheadFullException— адаптер маппит её как иCallNotPermittedException.- Bulkhead защищает thread-pool сервиса от исчерпания, когда внешняя система отвечает медленно, но ещё не лежит (CB не открыт).
Если timeout — это «один зависший вызов не висит вечно», а Circuit Breaker — «после N подряд ошибок не пускаем новые», то Bulkhead — это «не более N одновременных в принципе». Три слоя работают в связке: timeout ограничивает каждый вызов, bulkhead ограничивает их одновременное количество, CB останавливает поток при явной деградации. Раскрытие раздела 6 гайда.
Bulkhead и connection pool — два разных слоя
R-RES-BH-1: Bulkhead — это не дубль connection pool. Это другой слой защиты.
| Слой | Что ограничивает | Когда срабатывает |
|---|---|---|
| ConnectionPool / Dispatcher | TCP-соединения и HTTP-запросы | Когда дёрнули OkHttpClient.newCall().execute() |
| Bulkhead | Java-потоки, входящие в защищённый метод | Когда вызвали @Bulkhead-аннотированный метод |
Почему отдельно:
- При залипании одного call'а pool забит долго (ждёт TCP-timeout, ждёт
callTimeout). Это секунды или десятки секунд. - Bulkhead отказывает новым вызовам сразу, как только лимит достигнут. Это микросекунды.
- Bulkhead срабатывает раньше, чем pool исчерпан — даёт fail-fast, не давая забивать thread-pool сервиса.
Пример: pool maxConcurrent = 20, bulkhead maxConcurrentCalls = 16. При 16 одновременных вызовах 17-й получает BulkheadFullException мгновенно, не доходя до pool. 4 «слота» остаются в pool как буфер на переходные моменты (соединение возвращается в idle, новое стартует).
Тип SEMAPHORE — обязательно
R-RES-BH-2: Resilience4j предлагает два типа Bulkhead: SEMAPHORE и THREADPOOL. Для outbound — только SEMAPHORE.
resilience4j.bulkhead.instances.sber:
max-concurrent-calls: 16
max-wait-duration: 100ms
Что делает semaphore-вариант:
- Java-семафор с
permits = maxConcurrentCalls. Вход в метод —acquire(), выход —release(). - Работает в текущем thread. MDC, SecurityContext, transactional context — всё сохраняется.
- Не создаёт дополнительных потоков. Дёшево.
Что делает threadpool-вариант:
- Создаёт отдельный thread pool под защищённый метод. Вызовы submitятся туда, основной thread ждёт future.
- MDC и SecurityContext теряются без явного wrapping (
MDCContext.wrap,DelegatingSecurityContextExecutor). Логи без traceId, авторизация ломается. - Создаёт «pool на pool» — память и thread switching без явной выгоды.
Для outbound HTTP threadpool-вариант избыточен: вызов уже асинхронен на уровне OkHttp Dispatcher.
Sizing — pool × 0.8
R-RES-BH-3: maxConcurrentCalls = pool maxConcurrent × 0.8. Запас 20% — bulkhead должен срабатывать раньше, чем кончатся коннекты.
client.sber.max-concurrent: 20
resilience4j.bulkhead.instances.sber:
max-concurrent-calls: 16 # = 20 × 0.8
max-wait-duration: 100ms
Логика:
- При 20 calls = bulkhead full (16 уже выполняются + 4 ждут в pool). Из 4 ждущих 17-й, 18-й, 19-й, 20-й точно получат
BulkheadFullException. - При 16 calls = bulkhead full, новые получают fail-fast. Pool ещё с buffer'ом — соединения возвращаются в idle, готовы к следующим вызовам.
- При 21 calls без bulkhead — все 21 ждут в pool по callTimeout. Это секунды простоя worker-thread сервиса.
maxWaitDuration: 100ms — короткое. Это компромисс между «жёсткий fail-fast» (0ms) и «попробовать подождать» (1s+). 100ms покрывает быстрое освобождение слотa при коротких вызовах, но не превращает Bulkhead в очередь.
Маппинг BulkheadFullException
При переполнении бросается io.github.resilience4j.bulkhead.BulkheadFullException. Адаптер маппит её точно так же, как CallNotPermittedException от CB — система временно недоступна.
@Component
public class SberClientAdapter implements PaymentPort {
@CircuitBreaker(name = "sber")
@Bulkhead(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
try {
return doRegister(cmd);
} catch (CallNotPermittedException | BulkheadFullException e) {
throw new PaymentPortException.SystemUnavailable("sber", e);
}
}
}
Семантика для клиента одинакова: «система перегружена / временно недоступна», 503 ответ или fallback в task-queue. Подробно про маппинг — в Circuit Breaker.
Что запрещено
Thread-pool bulkhead для outbound
R-RES-BH-X1: type: THREADPOOL создаёт второй pool и теряет MDC / SecurityContext.
# ПЛОХО — thread-pool bulkhead для outbound
resilience4j.bulkhead.instances.sber:
type: THREADPOOL
core-thread-pool-size: 10
max-thread-pool-size: 20
queue-capacity: 100
Что не так:
@Bulkhead(name = "sber", type = Bulkhead.Type.THREADPOOL)
public RegisterResult register(RegisterCommand cmd) {
String userId = MDC.get("userId"); // ← null! другой thread, MDC пуст
log.info("processing for user {}", userId);
return doRegister(cmd);
}
MDC.get("userId")возвращаетnull. Логи без traceId — невозможно проследить запрос.SecurityContextHolder.getContext().getAuthentication()возвращаетnull. Если адаптер дёргает что-то auth-aware — упадёт.- Решается через
DelegatingSecurityContextExecutor+MDCContext.wrap— но это сложно и легко забыть.
Корректно: SEMAPHORE — работает в том же thread, всё доступно.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
type: THREADPOOL для outbound | R-RES-BH-X1 | SEMAPHORE |
| Bulkhead отсутствует на out-adapter | R-RES-BH-1 | @Bulkhead(name = "<system>") обязателен |
maxConcurrentCalls = pool maxConcurrent (без запаса) | R-RES-BH-3 | × 0.8 буфер |
maxWaitDuration > 1s | R-RES-BH-3 | 100ms — короткое, fail-fast |
Без mapping BulkheadFullException в port-exception | R-RES-CB-6 | Mapping в SystemUnavailable |
Куда дальше
- Resilience → раздел 6. Bulkhead — нормативные
R-RES-BH-*. - Per-system isolation — connection pool и dispatcher.
- Circuit Breaker — другой слой защиты, работает вместе с Bulkhead.
- Fallback — что делать при
BulkheadFullException. - Конфигурация — declarative config.
- Observability —
resilience4j_bulkhead_available_concurrent_callsметрика.