Опирается на правила: R-RES-BH-1R-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 / DispatcherTCP-соединения и HTTP-запросыКогда дёрнули OkHttpClient.newCall().execute()
BulkheadJava-потоки, входящие в защищённый методКогда вызвали @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 для outboundR-RES-BH-X1SEMAPHORE
Bulkhead отсутствует на out-adapterR-RES-BH-1@Bulkhead(name = "<system>") обязателен
maxConcurrentCalls = pool maxConcurrent (без запаса)R-RES-BH-3× 0.8 буфер
maxWaitDuration > 1sR-RES-BH-3100ms — короткое, fail-fast
Без mapping BulkheadFullException в port-exceptionR-RES-CB-6Mapping в 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 метрика.