Архитектура бэкенда
Bulkheads: изоляция доменов отказа
Твой сервис зовёт три даунстрима — платежи, рекомендации и поиск — из одного общего пула воркеров на 50 потоков. Рекомендации, наименее важные из трёх, замедляются до 5 с. За секунды все 50 потоков припаркованы на вызовах рекомендаций, и теперь платежи и поиск тоже падают, потому что не осталось потоков их выполнить. Брейкер на рекомендациях сработает — но лишь после накопления достаточного числа отказов, и к тому времени урон нанесён: некритичная зависимость уронила критичную, потому что они черпали из одного ведра. Брейкер ограничивает, как долго ты зовёшь больную зависимость; bulkhead ограничивает, сколько твоей ёмкости любая одна зависимость может вообще удерживать.
Проблема общего бюджета
Брейкер реактивен — он срабатывает после накопления доли отказов по окну. Но каскад может случиться до его реакции, потому что пока брейкер ещё считает, каждый вызов в полёте к медленной зависимости держит поток из пула, общего со всеми остальными. Если этот пул общий на все зависимости, одна медленная зависимость может занять его весь, и теперь вызовы к совершенно здоровым зависимостям падают из-за нехватки потока. Брейкер в итоге срабатывает, но радиус поражения уже накрыл весь сервис.
Имя из судостроения: корпус делится на водонепроницаемые bulkhead-отсеки, чтобы пробоина затопила один отсек, а не весь корабль. Программный паттерн идентичен — раздели свои ресурсы, чтобы отказ был ограничен одним отсеком вместо потопления всего.
Раздели бюджет на зависимость
Bulkhead ограничивает конкурентность на зависимость. Вместо одного пула на 50 потоков, общего на платежи, рекомендации и поиск, ты даёшь каждой свою ограниченную долю — скажем 20 / 10 / 20. Теперь если рекомендации замедляются, они могут припереть максимум свои 10; остальные 40 остаются доступны платежам и поиску. Вызовы рекомендаций сверх 10 отклоняются немедленно (или коротко ставятся в очередь), что само кормит сигнал отказа брейкера — но критически, отказ заперт в рекомендациях. Одна зависимость больше не может потратить ёмкость всего сервиса.
Это та же идея ограниченной конкурентности, что и размер пула, применённая для изоляции, а не пропускной способности: задача лимита не максимизировать работу, а ограничить радиус поражения любой одной зависимости.
Два способа изолировать: потоки против семафоров
Есть две реализации, и выбор — настоящий компромисс:
- Thread-pool изоляция. Каждая зависимость получает свой выделенный пул потоков (модель Hystrix по умолчанию,
coreSize = 10). Вызывающий передаёт работу этому пулу и ждёт со своим таймаутом. Большое преимущество: поскольку вызов выполняется на отдельном потоке, вызывающий может уйти от зависшего вызова — если зависимость блокируется навсегда, таймаут вызывающего всё равно срабатывает и освобождает вызывающего. Цена — накладные расходы: каждый вызов платит передачу потока и переключение контекста, и ты содержишь много пулов. - Semaphore изоляция. Простой счётчик ограничивает, сколько вызовов выполняется конкурентно (
SemaphoreBulkheadу resilience4j,maxConcurrentCalls = 25,maxWaitDuration = 0). Вызов выполняется на собственном потоке вызывающего — без передачи, почти без накладных. Загвоздка: семафор умеет только считать; он не может прервать вызов, который уже блокируется. Если зависимость зависает, вызывающий поток зависает с ней, и семафор лишь останавливает новые вызовы — он не может спасти уже застрявшие.
Правило большого пальца: используй semaphore изоляцию для быстрых, внутрипроцессных или неблокирующих вызовов, где важны накладные и зависания невозможны; используй thread-pool изоляцию для сетевых вызовов, которые могут зависнуть, потому что только отдельный поток даёт реально бросить застрявший вызов.
Почему это работает
Почему семафор не может защитить от зависающей зависимости, хотя он явно ограничивает конкурентность? Потому что семафор — это только счётчик: он выдаёт пропуск перед вызовом и освобождает после. У него нет своего потока и нет способа дотянуться до уже начавшегося вызова и остановить его. Если зависимость приняла твой запрос и потом никогда не отвечает, поток, взявший пропуск, сидит заблокированным в сетевом чтении бесконечно, всё ещё держа пропуск. Семафор честно предотвращает старт нового вызова, как только все пропуски взяты, так что он ограничивает, сколько потоков может застрять разом — но он не может отлепить уже застрявшие. Thread-pool bulkhead может, потому что вызов выполняется на потоке пула, пока вызывающий ждёт отдельно со своим таймаутом; когда таймаут срабатывает, вызывающий перестаёт ждать и забирает свой поток обратно, хотя поток пула всё ещё застрял на мёртвой зависимости. Цена этой силы реальна — передача потока и переключение контекста на каждом вызове, плюс память и накладные планирования многих пулов — так что ты не платишь её везде. Ты платишь её ровно там, где вызовы могут зависнуть, то есть сетевой I/O, и используешь дешёвый семафор везде, где зависание невозможно. Различие то же, что в уроке про таймауты: только независимый ожидающий может навязать дедлайн вызову, который он не контролирует.
| Thread-pool изоляция | Semaphore изоляция | |
|---|---|---|
| Механизм | Выделенный пул на зависимость | Счётчик конкурентности |
| Выполняется на | Потоке пула (передача) | Собственном потоке вызывающего |
| Накладные | Выше (переключение контекста, много пулов) | Около нуля |
| Бросить зависший вызов? | Да — вызывающий таймаутит независимо | Нет — вызывающий блокируется с вызовом |
| Лучше для | Сетевых вызовов, что могут зависнуть | Быстрых, внутрипроцессных, неблокирующих |
| Пример по умолчанию | Hystrix coreSize 10 | resilience4j maxConcurrentCalls 25 |
Три даунстрима делят один пул на 50 потоков. Наименее важный замедляется до 5 с, и весь сервис падает, хотя его брейкер в итоге срабатывает. Что bulkhead добавляет, чего брейкер сам по себе не даёт?
Почему thread-pool изоляция защищает от зависающего сетевого вызова, а semaphore изоляция нет?
- 01Почему circuit breaker недостаточно сам по себе, и что добавляет bulkhead?
- 02Каков компромисс между thread-pool и semaphore изоляцией?
Circuit breaker ограничивает, как долго ты зовёшь больную зависимость, но он реактивен — срабатывает лишь после накопления отказов, а тем временем общий пул потоков может быть полностью осушён одним медленным даунстримом, роняя вызовы к здоровым зависимостям из-за нехватки потока. Bulkhead закрывает этот разрыв, ограничивая конкурентность на зависимость, как водонепроницаемые отсеки корабля запирают пробоину: дай платежам, рекомендациям и поиску свои ограниченные доли, и медленная может припереть максимум свою часть, заперев отказ в одном отсеке. Это ограниченная конкурентность, применённая для изоляции, а не пропускной способности. Реализация — реальный компромисс: thread-pool изоляция выполняет каждый вызов на выделенном потоке пула, чтобы вызывающий мог бросить зависший вызов через свой таймаут, платя передачу и переключение контекста на вызов; semaphore изоляция — счётчик с почти нулевыми накладными на собственном потоке вызывающего, что ограничивает конкурентность, но не может прервать уже блокирующийся вызов. Используй семафоры для быстрой неблокирующей работы и пулы потоков для сетевых вызовов, что могут зависнуть. Брейкер и bulkhead вместе ограничивают как долго и сколько — но ни один не отвечает, что вызывающий должен вернуть, когда вызов отклонён. Следующий урок покрывает таймауты как триггер и фолбэки как ответ.