🏗️ Розділ 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, щоб запити дочекалися звільнення слота» (Заблокує викликаючі потоки сервера і викличе каскадний збій).

Пов’язані теми: