Bulkhead pattern: как не дать одному соседу утопить весь сервис
Сценарий, который я разбирал в трёх компаниях: сервис A зовёт сервис B и сервис C. B начинает тормозить, отвечает по 5 секунд. Запросы к B забивают весь пул потоков сервиса A. Запросы к C, которые нормально отвечали бы за 50 мс, не имеют свободного потока — они стоят в очереди. Через минуту сервис A не отвечает ни на что, хотя C работает прекрасно.
Bulkhead pattern — это про изоляцию ресурсов. Корабельная аналогия: корпус разделён на отсеки, и пробоина в одном не топит весь корабль. В коде — пул потоков (или connections, или семафоров) на каждого зависимого соседа отдельный. Тормозит один — у других своя квота, они работают.
Звучит просто, в реализации много нюансов. Разберу, как это делается, какие виды bulkhead'ов бывают, и где они помогают, а где избыточны.
Зачем bulkhead, если есть circuit breaker
Часто путают с circuit breaker'ом. Это разные паттерны.
Circuit breaker реагирует на превышение порога ошибок и отключает вызовы. Это работает после того, как ошибки накопились. Между моментом, когда сосед начал тормозить, и моментом, когда breaker открылся, проходит время — и за это время пул потоков успевает забиться.
Bulkhead работает до: он лимитирует количество одновременных вызовов к каждому соседу заранее. Даже если сосед тормозит, он может занять не больше своего лимита. Остальные ресурсы остаются для других зависимостей.
Их используют вместе: bulkhead ограничивает урон, circuit breaker отрезает плохого соседа полностью на время.
Виды изоляции
Технически bulkhead реализуется по-разному в зависимости от стека.
Thread pool isolation
Каждой группе вызовов выделяется свой thread pool. Самый «честный» bulkhead: вызовы реально выполняются в разных потоках, не делят процессорное время с другими.
val catalogExecutor = ThreadPoolBulkhead.of(
"catalog",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(20)
.coreThreadPoolSize(10)
.queueCapacity(100)
.build()
)
fun fetchCatalog(id: String): CompletionStage<CatalogItem> =
catalogExecutor.executeSupplier { catalogClient.getItem(id) }20 потоков для каталога. Если он тормозит, все 20 могут быть заняты — но pricing работает на своём пуле. Очередь (queueCapacity=100) задерживает превышение, потом отказ.
Что хорошо: реальная изоляция, никакая операция не может заблокировать другие.
Что платите: переключения контекста между потоками, дополнительная сериализация результата (CompletionStage), сложность отладки (стек идёт через границу пулов).
Semaphore isolation
Без отдельного пула: вызовы выполняются в потоке вызывающего, но количество одновременных вызовов лимитируется семафором.
val catalogBh = Bulkhead.of(
"catalog",
BulkheadConfig.custom()
.maxConcurrentCalls(20)
.maxWaitDuration(Duration.ofMillis(500))
.build()
)
fun fetchCatalog(id: String): CatalogItem =
catalogBh.executeSupplier { catalogClient.getItem(id) }Когда количество одновременных вызовов превышает 20, новые вызовы либо ждут (до 500 мс), либо сразу падают.
Что хорошо: легче, без дополнительных потоков.
Что хуже: блокирующая операция в потоке вызывающего всё равно держит этот поток занятым. Если у вас Tomcat с пулом 200, и 200 запросов вошло — все 200 потоков ждут семафор, и сервис не отвечает на новые запросы.
Semaphore-bulkhead хорош в реактивных стеках (Project Reactor, Kotlin Coroutines), где «занятый поток» не блокирует событийный цикл. В блокирующем стеке лучше thread pool.
Connection pool isolation
На уровне HTTP-клиентов и пулов соединений. Каждому соседу — свой пул HTTP-коннектов с фиксированным размером.
val catalogClient = WebClient.builder()
.clientConnector(ReactorClientHttpConnector(
HttpClient.create(ConnectionProvider.builder("catalog")
.maxConnections(20)
.pendingAcquireTimeout(Duration.ofMillis(500))
.build())
))
.baseUrl("http://catalog")
.build()20 одновременных соединений к каталогу. Превысили — следующий запрос ждёт или падает. Это естественный bulkhead в HTTP-клиенте: через него проходит ограниченный поток.
На практике это самый часто используемый вариант, потому что он встроен почти во все HTTP-клиенты. Не нужны дополнительные библиотеки.
Как считать размер
Главный вопрос — сколько потоков/коннектов выделить на каждого соседа. Зависит от ожидаемой нагрузки и тайминга.
Эвристика по закону Литтла: concurrent calls = throughput × latency.
Если планируете 100 RPS к соседу, и средняя latency — 100 мс, нужно держать порядка 10 одновременных вызовов. Прибавьте 50% запаса — 15. Прибавьте ещё 50% на пиковые всплески — 20-25.
Слишком маленький пул — будете отказывать в нормальной нагрузке. Слишком большой — bulkhead не работает (никогда не сработает лимит).
Я обычно начинаю с x2 от расчётного, дальше тюню по метрикам.
Что делать при превышении
Когда пул заполнен, есть варианты:
Очередь
Запросы сверх лимита пула становятся в очередь. Когда поток освобождается, берётся следующий. Просто, но опасно: если очередь без лимита, она может вырасти на гигабайты.
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(20)
.queueCapacity(100) // очередь до 100 запросов
.build()queueCapacity должен быть строго ограничен. Без лимита это утечка памяти и латентность, которая копится.
Отказ
Превышен пул — сразу 503 пользователю. Жёстко, но честно: лучше быстрый отказ, чем 30 секунд ожидания, после которых всё равно отказ.
Этот режим хорош для UX-чувствительных операций: пользователь не любит ждать неизвестное время.
Fallback
Если в пуле нет места, идём в fallback (кеш, дефолт, упрощённая логика). Это защищает не только сервис, но и UX.
Эту логику надо явно прописывать. Resilience4j умеет связку bulkhead + fallback:
fun fetchCatalogWithFallback(id: String): CatalogItem =
catalogBh.executeSupplier { catalogClient.getItem(id) }
.recover(BulkheadFullException::class.java) { _ ->
cache.get(id) ?: defaultCatalogItem(id)
}Bulkhead на разные операции одного сервиса
Часто у соседа есть разные ручки с разной критичностью. /items/{id} — быстро, важно. /recommendations — медленно, второстепенно.
Один общий bulkhead на каталог делает их равноправными. Если recommendations забивают пул, items тоже не пройдут.
Решение — отдельные bulkhead'ы на разные эндпоинты:
val catalogItemsBh = Bulkhead.of("catalog-items", ...)
val catalogRecsBh = Bulkhead.of("catalog-recommendations", ...)
fun fetchItem(id: String) = catalogItemsBh.executeSupplier { client.getItem(id) }
fun fetchRecs(id: String) = catalogRecsBh.executeSupplier { client.getRecs(id) }Recommendations забит — items работают. И наоборот.
Bulkhead для БД
То же самое работает для пулов соединений к БД. Если сервис ходит в две БД — каждой свой пул.
Чаще встречающийся сценарий — один пул на одну БД с разными типами запросов. Долгая аналитическая выборка может забить пул, и оперативные запросы зависают.
Лекарство — два пула:
@Bean("oltpDataSource")
fun oltpDataSource(): DataSource = HikariDataSource().apply {
maximumPoolSize = 30
connectionTimeout = 1_000
}
@Bean("reportingDataSource")
fun reportingDataSource(): DataSource = HikariDataSource().apply {
maximumPoolSize = 5
connectionTimeout = 30_000
}OLTP-операции идут через один DataSource (быстрый, большой пул, маленький timeout). Аналитические — через другой (медленный, маленький пул, большой timeout).
Если аналитический пул забит — оперативные запросы не страдают. И наоборот: даже если зависла транзакция в OLTP, пул аналитики свободен.
Подводные камни
Несколько вещей, которые я ловил.
Bulkhead без метрик. Не видно, насколько часто срабатывает лимит. Если bulkhead никогда не достигает потолка — он избыточен. Если достигает каждые 5 минут — мал. Метрика «bulkhead full events per minute» обязательна.
Bulkhead, который слишком велик. Поставили maxConcurrentCalls=1000 «с запасом». Лимит никогда не достигается. Bulkhead не работает: одна операция всё равно может забрать столько потоков, что сервис ляжет.
Async vs sync смешение. ThreadPoolBulkhead с асинхронным результатом (CompletionStage) внутри блокирующего хендлера, который ждёт .get() — пул вы выделили, но результат всё равно ждёте в потоке хендлера. Поток хендлера блокирован, пул хендлера забивается. Решение — либо везде async, либо semaphore-bulkhead в синхронном коде.
Bulkhead без timeout. Запрос ушёл в пул, в коннекте он зависает на минуту. Bulkhead занят минуту, пропускает мало запросов. Timeout на отдельный вызов обязателен — это первая линия защиты, bulkhead — вторая.
Все вызовы в один общий bulkhead. Это убивает паттерн. Один общий пул для всего — это просто пул. Bulkhead — это несколько пулов.
Когда bulkhead не нужен
Несколько случаев, когда я бы пропустил.
- У сервиса один зависимый сосед. Тогда «изолировать» нечего: всё, что есть, это вызовы к нему.
- Все вызовы критичны равнозначно. Если падение одного делает невозможным работу с другими, изолировать бесполезно.
- Очень небольшая нагрузка. Если вы делаете 1 RPS к соседу, bulkhead — это перевес инструмента над задачей.
Дефолт для нагруженных сервисов с несколькими зависимостями — bulkhead есть. Альтернатива — рискнуть: один тормозящий сосед роняет всё.
Что запомнить
Bulkhead — изоляция ресурсов на уровне зависимостей. Каждому соседу — свой пул (потоков, коннектов, семафор). Один тормозит — у других есть своя квота.
Виды: thread pool (честная изоляция, дороже), semaphore (легче, меньше overhead), connection pool (часто бесплатно через HTTP-клиент). Выбирайте по стеку.
Связан с circuit breaker (bulkhead ограничивает урон, breaker отключает соседа полностью), timeout (timeout — первая линия, bulkhead — вторая), fallback (что делать при заполнении пула).
Без bulkhead'а один тормозящий сосед может уронить ваш сервис целиком. С bulkhead'ом — занять только свою квоту. Это страховка, и она дешёвая, если поставить её до инцидента, а не после.