🏗️ Раздел 17 · Вопрос #18

Что такое паттерн Bulkhead

Корпус океанского лайнера разделён водонепроницаемыми переборками (Bulkheads) на изолированные отсеки. Если корабль получает пробоину в носовой части, вода заполняет только один...


🟢 Junior Level

Bulkhead («Судовая переборка» / «Изоляция отсеков») — это паттерн проектирования отказоустойчивости, который разделяет системные ресурсы (пулы потоков, память, семафоры) на изолированные независимые «отсеки», чтобы перегрузка или сбой в одном отсеке не привели к гибели всего приложения.

Без Bulkhead (Общий трюм):
Tomcat Thread Pool (200 потоков):
[ Рекомендации (200 зависших потоков) ] ──► Пул исчерпан!
                                        ──► Заказы и Оплата НЕ МОГУТ выполниться 💥

С Bulkhead (Изолированные отсеки):
Tomcat Thread Pool (200 потоков):
├── Отсек Checkout:       [50 слотов] ──► Заказы оформляются штатно ✅
├── Отсек Payments:       [50 слотов] ──► Оплаты проходят штатно ✅
└── Отсек Recommendation: [20 слотов] ──► Зависло, но блокирует ТОЛЬКО свои 20 слотов 🛡️

Морская аналогия:

Корпус океанского лайнера разделён водонепроницаемыми переборками (Bulkheads) на изолированные отсеки. Если корабль получает пробоину в носовой части, вода заполняет только один изолированный отсек. Корабль накреняется, но остается на плаву. В микросервисах медленный или зависший сторонний сервис (например, сервис рекомендаций) изолируется в собственный отсек и не может отобрать потоки у критически важных процессов (оформление заказа, списание денег).


🟡 Middle Level

1. Два типа реализации в Resilience4j: Semaphore vs ThreadPool

Resilience4j предоставляет два принципиально разных механизма изоляции:

Критерий Semaphore Bulkhead (Семафор) ThreadPool Bulkhead (Пул потоков)
Поток исполнения Выполняется в потоке вызывающего (Tomcat thread). Выполняется в отдельном выделенном пуле потоков.
Накладные расходы Минимальные: атомарный счетчик (AtomicInteger), $0$ переключений контекста. Высокие: создание потоков ОС, переключение контекста, очередь в памяти.
Изоляция потоков Частичная: ограничивает число одновременных вызовов, но поток удерживается. Полная изоляция: отказ пула никак не влияет на вызывающие потоки.
Асинхронность Синхронная работа. Возвращает CompletableFuture<T>.
Рекомендация Стандарт для большинства задач и для Java 21+ Virtual Threads. Для изоляции неблокирующего/асинхронного тяжелого I/O на старых JVM.

2. Реализация Semaphore Bulkhead в Spring Boot

@Service
@RequiredArgsConstructor
public class RecommendationService {

    private final RestClient restClient;

    // Ограничиваем параллельные запросы к рекомендациям 15 одновременными вызовами
    @Bulkhead(name = "recommendationBulkhead", fallbackMethod = "fallbackRecommendations", type = Bulkhead.Type.SEMAPHORE)
    public List<String> getRecommendations(Long userId) {
        return restClient.get()
            .uri("https://recommendations-service/api/v1/{userId}", userId)
            .retrieve()
            .body(new ParameterizedTypeReference<List<String>>() {});
    }

    public List<String> fallbackRecommendations(Long userId, BulkheadFullException ex) {
        // Если все 15 слотов заняты — мгновенно отдаем топ товаров из кэша
        return List.of("Популярный товар 1", "Популярный товар 2");
    }
}

Конфигурация в application.yml:

resilience4j:
  bulkhead:
    instances:
      recommendationBulkhead:
        maxConcurrentCalls: 15          # Максимум 15 параллельных запросов
        maxWaitDuration: 20ms           # Ждать слот не более 20 мс, затем сброс в Fallback

🔴 Senior Level

1. Расчет лимитов по Закону Литтла ($L = \lambda \times W$)

Как архитектору рассчитать значение maxConcurrentCalls не «на глаз», а математически обоснованно? Используется фундаментальный закон теории очередей — Закон Литтла: \(L = \lambda \times W\) где:

  • $L$ (Capacity) — среднее количество одновременно обрабатываемых запросов (искомый лимит Bulkhead).
  • $\lambda$ (Throughput) — ожидаемая частота входящих запросов (RPS).
  • $W$ (Latency) — среднее время обработки запроса целевым сервисом в секундах.

Пример расчета: Если сервис платежей обрабатывает $\lambda = 200 \text{ req/sec}$, а среднее время ответа составляет $W = 100 \text{ ms} (0.1 \text{ c})$: \(L = 200 \times 0.1 = 20 \text{ слотов}\) С запасом на пиковые флуктуации (Safety Factor $\times 1.5$) значение maxConcurrentCalls выставляется равным 30. Если сервис платежей начнет деградировать и отвечать за 2 секунды, потребность вырастет до $200 \times 2 = 400$ слотов. Семафор заблокирует расширение выше 30, отбросив избыток запросов и защитив остальную систему.

2. Ловушка ThreadPool Bulkhead: Потеря контекста (ThreadLocal)

При использовании ThreadPoolBulkhead вызов переключается в поток из другого пула. В Java это приводит к мгновенной потере данных ThreadLocal:

  • Очищается контекст безопасности Spring Security (SecurityContextHolder.getContext() == null).
  • Теряется идентификатор трассировки MDC (Mapped Diagnostic Context) и traceId для логов.
  • Ломаются транзакции @Transactional, привязанные к потоку.
Tomcat Thread (Имеет UserAuth, TraceId) 
       │
       ▼ (Resilience4j ThreadPoolBulkhead переключает задачу)
Worker Thread из другого пула (UserAuth = NULL, TraceId = NULL!) ──► NPE / 401 Unauthorized!

Решение: Использование ContextSnapshot из Micrometer Context Propagation или настройка декораторов пулов потоков (DelegatingSecurityContextAsyncTaskExecutor).

3. Bulkhead в эпоху Virtual Threads (Java 21+)

С появлением виртуальных потоков (Project Loom):

  • Блокировка виртуального потока на сетевом I/O больше не блокирует дорогой поток ОС (Carrier Thread) и потребляет всего несколько килобайт памяти.
  • Паттерн ThreadPoolBulkhead становится устаревшим (anti-pattern), так как виртуальные потоки не требуют очередей и пулов.
  • Паттерн SemaphoreBulkhead становится ЕЩЁ БОЛЕЕ ВАЖНЫМ! Без ограничения виртуальные потоки могут создать 100 000 параллельных соединений к внешней базе данных или стороннему API, вызвав гарантированный отказ удалённой системы. Semaphore Bulkhead ограничивает давление (Backpressure) на зависимый сервис.

4 Tricky Questions

1. Почему появление Virtual Threads (Java 21) делает бессмысленным ThreadPoolBulkhead, но оставляет критически важным SemaphoreBulkhead?

Ответ: ThreadPoolBulkhead создавался для того, чтобы медленный I/O не исчерпал дефицитные платформенные потоки ОС (которых в Tomcat обычно всего 200 штук). В Java 21 виртуальные потоки создаются миллионами с минимальными затратами оперативной памяти (килобайты). Исчерпать пул виртуальных потоков невозможно. Однако, если 50 000 виртуальных потоков одновременно сделают сетевой вызов к legacy-сервису или СУБД, удалённый сервис мгновенно захлебнется и упадет. SemaphoreBulkhead защищает не вызывающий сервис от нехватки потоков, а вызываемый сервис от перегрузки параллелизмом (Concurrency Throttling).

2. В чем разница между отказами: BulkheadFullException vs CallNotPermittedException?

Ответ:

  • BulkheadFullException: Выбрасывается, когда в данный момент времени исчерпана квота параллелизма (maxConcurrentCalls), но сам удаленный сервис может быть абсолютно здоров. Означает локальное насыщение лимита одновременных задач.
  • CallNotPermittedException: Выбрасывается Circuit Breaker, когда цепь находится в состоянии OPEN. Означает, что удаленный сервис сломан, и вызовы к нему запрещены превентивно.

3. Почему в конфигурации Semaphore Bulkhead параметр maxWaitDuration не рекомендуется ставить большим (например, более 50-100 мс)?

Ответ: Если maxWaitDuration равен 5 секундам, то при исчерпании лимита слотов входящие потоки Tomcat будут выстраиваться в очередь и висеть в ожидании слота целых 5 секунд. Это полностью нивелирует смысл изоляции: потоки Tomcat снова окажутся заблокированными, но уже не сетевым вызовом, а ожиданием семафора! maxWaitDuration должен быть минимальным (от 0 до 20 мс) для реализации принципа Fail-Fast: если слотов нет, немедленно отдаем управление в Fallback или возвращаем отказ.

4. На каких уровнях инфраструктуры, помимо кода приложения, реализуется паттерн Bulkhead?

Ответ:

  1. На уровне контейнеров (Kubernetes): Resource Quotas, CPU Limits / Memory Limits (resources.limits), изолирующие поды на уровне cgroups Linux.
  2. На уровне узлов (K8s Nodes): Выделение критических сервисов на отдельные физические ноды с помощью NodeAffinity, Taints & Tolerations (например, платежные поды живут на изолированных серверах от аналитики).
  3. На уровне баз данных: Выделение изолированных пулов соединений (Dedicated Connection Pools в PgBouncer) под разные сервисы.

🎯 Шпаргалка для интервью

Главное:

  • Bulkhead (Переборка) — изоляция ресурсов (пулов потоков, счетчиков семафоров) под разные задачи/сервисы.
  • Предотвращает ситуацию, когда медленный второстепенный сервис забирает 100% потоков сервера (Tomcat starvation).
  • Два типа: Semaphore (легковесный счетчик в потоке вызывающего) и ThreadPool (выделенный пул потоков с очередью).
  • В Java 21+ с Virtual Threads используется Semaphore Bulkhead.
  • При использовании ThreadPool Bulkhead возникает проблема потери контекста ThreadLocal (SecurityContext, MDC TraceId).
  • Размер лимита рассчитывается по Закону Литтла: $L = \text{RPS} \times \text{Latency}$.
  • maxWaitDuration должен быть минимальным ($0\text{–}20$ мс) для мгновенного Fail-Fast.

Красные флаги (Чего говорить НЕЛЬЗЯ):

  • ❌ «Bulkhead и Circuit Breaker — это одно и то же» (Circuit Breaker блокирует вызовы при сбоях; Bulkhead изолирует ресурсы параллелизма независимо от ошибок).
  • ❌ «В Java 21 с виртуальными потоками Bulkhead больше вообще не нужен» (ThreadPool не нужен, но Semaphore Bulkhead критически необходим для защиты зависимостей от лавины вызовов).
  • ❌ «Можно поставить maxWaitDuration: 10s, чтобы запросы дождались освобождения слота» (Заблокирует вызывающие потоки сервера и вызовет каскадный сбой).

Связанные темы: