O nome "bulkhead" vem da arquitetura naval. Navios são divididos internamente em compartimentos estanques — as anteparas (bulkheads) — que impedem que água que entrou em um compartimento inunde todo o casco. Se o casco é perfurado na proa, as anteparas garantem que só a proa afunda: o resto do navio permanece flutuante. Michael Nygard, em Release It! (Pragmatic Programmers, 2007, segunda edição 2018), formalizou essa metáfora para sistemas distribuídos: se uma dependência externa começa a travar, o bulkhead garante que só o pool de recursos dedicado àquela dependência é comprometido — o restante do sistema continua operando.
A falha em cascata é o inimigo que o bulkhead combate. Em sistemas sem isolamento de recursos, um serviço dependente lento pode consumir todos os threads ou conexões disponíveis aguardando resposta — e isso priva todas as outras operações do sistema dos mesmos recursos. O resultado é que a degradação de uma dependência secundária (um serviço de recomendações, um provedor de email, um sistema de analytics) derruba funcionalidades críticas (o checkout, a autenticação, o core do produto) que não têm relação direta com a dependência problemática.
O bulkhead opera na camada de alocação de recursos: em vez de um pool compartilhado de threads ou conexões que qualquer dependência pode consumir, cada dependência tem seu próprio pool com tamanho máximo definido. Quando a dependência de email trava e esgota seu pool de 20 threads, o checkout tem seus próprios 50 threads intactos. O email degrada; o checkout não sabe.
Este conceito explora as duas formas principais de bulkhead em aplicações — isolamento por thread pool e isolamento por semáforo — e estende o padrão para o contexto de multi-tenancy: isolamento por tenant como bulkhead contra o problema do "noisy neighbor".
Bulkhead por thread pool
A implementação mais clássica de bulkhead é dedicar um thread pool separado para cada dependência externa. Quando uma chamada a um serviço downstream é feita, ela é executada em um thread do pool daquela dependência — não no thread pool geral da aplicação. Se todos os threads do pool estão ocupados aguardando respostas lentas, novas chamadas à mesma dependência recebem um rejection imediato (que pode ser tratado com fallback ou erro rápido) em vez de bloquear um thread geral.
Netflix Hystrix, desenvolvido internamente e open-sourceado em 2012, popularizou esse padrão. A documentação do Hystrix descrevia o problema que motivou o design: em 2011, uma dependência de serviço de usuário estava com latência elevada. Sem isolamento, todos os threads do serviço de API do Netflix estavam bloqueados aguardando respostas. O sistema todo ficou indisponível por conta de um único serviço lento. Com thread pool isolation por serviço, a degradação do serviço de usuário seria confinada ao pool dedicado a ele.
Bulkhead por thread pool tem custo: cada pool tem overhead de criação e manutenção de threads, e o número de pools se multiplica com o número de dependências. O padrão é justificado quando você tem dependências com latência variável e imprevisível — chamadas a terceiros, serviços de parceiros, APIs públicas. Para dependências internas com latência previsível e baixa, o overhead raramente se justifica.
Calibrando o tamanho do pool
A pergunta mais comum ao implementar bulkhead é: qual tamanho para cada pool? Nygard e a documentação do Hystrix convergem em uma heurística prática: calcule a concorrência máxima esperada para aquela dependência com base no throughput e latência P99.
Fórmula (Lei de Little):
pool_size = throughput × latency_P99
Exemplo:
- Serviço de pagamento: 100 req/s, P99 = 500ms
- pool_size = 100 × 0.5 = 50 threads
- Adicionar 20-30% de margem: pool_size = 65
- Serviço de email: 10 req/s, P99 = 200ms
- pool_size = 10 × 0.2 = 2 threads
- Mínimo razoável: pool_size = 5
O tamanho mínimo razoável de pool é geralmente 5 — pools menores aumentam probabilidade de rejection desnecessário sob variância normal de latência. O tamanho máximo deve ser calibrado pela memória disponível: cada thread consome memória de stack (256KB a 1MB por thread em Java/.NET típico).
Bulkhead por semáforo
Uma alternativa mais leve ao isolamento por thread pool é o isolamento por semáforo. Em vez de executar a chamada em um thread separado, o semáforo controla quantas chamadas concorrentes podem estar em voo para aquela dependência. Se o limite é 20 e já há 20 chamadas em andamento, a 21ª recebe rejection imediato.
Semáforo tem custo menor que thread pool: não cria threads adicionais, funciona no thread atual. A troca é que se a chamada bloqueia (timeout longo), o thread que a fez fica bloqueado mesmo assim — o semáforo apenas limita a concorrência, não isola o thread. Para chamadas síncronas com I/O bloqueante, thread pool é mais seguro. Para código assíncrono (async/await, goroutines, asyncio), semáforo é geralmente suficiente e muito mais leve.
using Polly;
using Polly.Bulkhead;
// Thread pool isolation via Polly v8 ResiliencePipeline
var paymentPipeline = new ResiliencePipelineBuilder()
.AddBulkhead(new BulkheadStrategyOptions
{
MaxConcurrentCalls = 50, // máximo paralelo
MaxQueuedCalls = 10, // fila de espera
OnRejected = args =>
{
logger.LogWarning("Bulkhead payment rejeitou chamada");
return ValueTask.CompletedTask;
}
})
.AddTimeout(TimeSpan.FromMilliseconds(500))
.Build();
var emailPipeline = new ResiliencePipelineBuilder()
.AddBulkhead(new BulkheadStrategyOptions
{
MaxConcurrentCalls = 5,
MaxQueuedCalls = 0, // sem fila: rejeição imediata
})
.AddTimeout(TimeSpan.FromMilliseconds(200))
.Build();
// Registro no DI
services.AddKeyedSingleton("payment", paymentPipeline);
services.AddKeyedSingleton("email", emailPipeline);
// Uso — email não impacta payment
try {
await emailPipeline.ExecuteAsync(ct =>
emailClient.SendAsync(notification, ct), cancellationToken);
} catch (BulkheadRejectedException) {
// fallback: enfileirar para retry assíncrono
await outbox.EnqueueAsync(notification);
}
Polly v8 reescreveu completamente a API em relação ao v7. Use ResiliencePipelineBuilder e BulkheadStrategyOptions. O semáforo é o default — para thread pool verdadeiro, use ExecuteAsync com Task.Run explícito.
import asyncio
import functools
from typing import Callable, TypeVar
T = TypeVar("T")
class Bulkhead:
"""Isolamento por semáforo para dependências externas."""
def __init__(self, name: str, max_concurrent: int):
self.name = name
self._sem = asyncio.Semaphore(max_concurrent)
self._rejected = 0
self._active = 0
async def execute(self, coro_func: Callable, *args, **kwargs):
if self._sem._value == 0:
self._rejected += 1
raise BulkheadRejectedError(
f"Bulkhead {self.name!r} cheio "
f"({self._rejected} rejeições totais)"
)
async with self._sem:
self._active += 1
try:
return await coro_func(*args, **kwargs)
finally:
self._active -= 1
@property
def stats(self) -> dict:
return {"active": self._active, "rejected": self._rejected}
class BulkheadRejectedError(Exception):
pass
# Uso
payment_bh = Bulkhead("payment", max_concurrent=50)
email_bh = Bulkhead("email", max_concurrent=5)
async def process_order(order_id: str):
# payment: crítico — deixa propagar o erro
result = await payment_bh.execute(
payment_client.charge, order_id
)
# email: opcional — trata rejeição como degradação
try:
await email_bh.execute(email_client.send_confirmation, order_id)
except BulkheadRejectedError:
await outbox.enqueue(order_id) # retry async
Em Python assíncrono, asyncio.Semaphore é leve e eficiente. A checagem _sem._value == 0 antes de async with implementa rejeição imediata sem entrar na fila de espera do semáforo.
import "golang.org/x/sync/semaphore"
type Bulkhead struct {
name string
sem *semaphore.Weighted
rejected atomic.Int64
}
func NewBulkhead(name string, maxConcurrent int64) *Bulkhead {
return &Bulkhead{
name: name,
sem: semaphore.NewWeighted(maxConcurrent),
}
}
// Execute tenta adquirir semáforo; rejeita imediatamente se cheio.
func (b *Bulkhead) Execute(ctx context.Context, fn func(context.Context) error) error {
if !b.sem.TryAcquire(1) {
b.rejected.Add(1)
return &BulkheadRejectedError{Bulkhead: b.name}
}
defer b.sem.Release(1)
return fn(ctx)
}
type BulkheadRejectedError struct{ Bulkhead string }
func (e *BulkheadRejectedError) Error() string {
return fmt.Sprintf("bulkhead %q: capacidade esgotada", e.Bulkhead)
}
// Uso com isolamento por dependência
var (
paymentBH = NewBulkhead("payment", 50)
emailBH = NewBulkhead("email", 5)
)
func ProcessOrder(ctx context.Context, orderID string) error {
if err := paymentBH.Execute(ctx, func(ctx context.Context) error {
return paymentClient.Charge(ctx, orderID)
}); err != nil {
return fmt.Errorf("payment falhou: %w", err)
}
// email não crítico — degrada silenciosamente
if err := emailBH.Execute(ctx, func(ctx context.Context) error {
return emailClient.SendConfirmation(ctx, orderID)
}); err != nil {
_ = outbox.Enqueue(ctx, orderID)
}
return nil
}
semaphore.TryAcquire retorna false imediatamente se não há capacidade — comportamento de bulkhead sem fila de espera. Para Go, prefira o pacote golang.org/x/sync/semaphore ao canal buffered em código de produção.
Isolamento por tenant — o problema do noisy neighbor
Em sistemas multi-tenant, o bulkhead se aplica em outra dimensão: isolar tenants uns dos outros, não apenas dependências do sistema. O problema do "noisy neighbor" é quando um tenant de alto volume (ou simplesmente mal-comportado) consome uma fração desproporcional de recursos compartilhados — CPU, memória, conexões de banco, slots de fila — e degrada a experiência dos outros tenants.
O módulo 07 cobriu a arquitetura de multi-tenancy (silo, pool, hybrid). O foco aqui é a camada de isolamento de recursos em runtime, independentemente da arquitetura de dados escolhida. Mesmo um sistema pool (banco compartilhado) pode ter isolamento de recurso adequado se implementar bulkheads por tenant.
Estratégias de isolamento por tenant
Rate limiting por tenant: a estratégia mais simples e comum. Cada tenant tem um limite de requisições por segundo (ou por minuto) independente. Um tenant que ultrapassa seu limite recebe 429 — sem afetar os outros. Implementado tipicamente com token bucket ou leaky bucket por tenant, armazenado em Redis para ser compartilhado entre instâncias.
Thread/connection pool por tenant: mais caro, mas garante que um tenant com workload lento não esgota recursos para todos. Prático quando você tem um número pequeno de tenants tier enterprise que pagam por isolamento garantido, e um número maior de tenants tier free/básico em pool compartilhado.
Fila de trabalho por tenant: em sistemas de processamento assíncrono, uma fila dedicada por tenant (ou por tier de tenant) garante que o processamento de um tenant de alto volume não atrasa a fila de outros. Kafka com partições por tenant é uma implementação natural.
Rate limiting por tenant sem observabilidade por tenant é cego. Você sabe que está rejeitando requisições, mas não sabe de quem nem por quê. Sempre instrumentar: taxa de rejeição por tenant, qual tenant está mais próximo do limite, histórico de consumo por tenant. Sem esses dados, rate limiting vira uma caixa preta que clientes reclamam sem que você consiga diagnosticar.
Bulkhead e circuit breaker — papéis diferentes
Bulkhead e circuit breaker (coberto no módulo 05, conceito 09) são frequentemente confundidos ou tratados como alternativos, mas têm papéis complementares distintos.
O circuit breaker monitora a taxa de falha de uma dependência e, quando a taxa ultrapassa um threshold, "abre o circuito" — passa a rejeitar chamadas imediatamente em vez de tentar. Seu papel é evitar chamadas inúteis a uma dependência que provavelmente vai falhar de qualquer jeito, e dar tempo para ela se recuperar.
O bulkhead controla a concorrência de chamadas a uma dependência, independentemente de ela estar falhando ou não. Seu papel é garantir que uma dependência lenta (não necessariamente com falhas) não esgota os recursos do chamador.
Os dois juntos formam uma defesa em profundidade: o bulkhead limita quantas chamadas simultâneas podem estar em voo (protege de sobrecarga por latência); o circuit breaker fecha o circuito quando as chamadas em voo começam a falhar (protege de falha em cascata). Um sistema resiliente tipicamente tem os dois, configurados na mesma pipeline de resiliência.
Observabilidade do bulkhead
Um bulkhead sem métricas é de utilidade limitada. As métricas essenciais para monitorar:
Taxa de rejeição por bulkhead: quantas chamadas foram rejeitadas por segundo. Uma taxa de rejeição crescente indica que o bulkhead está sendo ativado frequentemente — pode ser sinal de que o pool está subdimensionado ou que a dependência está degradada.
Ocupação do pool: quantos slots do pool estão em uso no momento. Uma ocupação consistentemente acima de 80% é sinal de subdimensionamento ou de dependência lenta.
Latência do path com bulkhead: qual fração das chamadas é acelerada pelo fallback após rejeição versus o path normal. Isso mede o custo de experiência do usuário quando o bulkhead atua.
// Métricas OpenTelemetry para bulkhead
var bulkheadActive = meter.CreateObservableGauge(
"bulkhead.active_calls",
description: "Chamadas ativas no pool");
var bulkheadRejected = meter.CreateCounter(
"bulkhead.rejected_total",
description: "Total de rejeições por bulkhead");
// Tags: bulkhead_name, service_name, tenant_tier
Quando não usar bulkhead
Bulkhead por thread pool não é gratuito. Criar pools separados para cada dependência em um microserviço com 20 dependências significa gerenciar 20 pools de threads — overhead de operação não trivial. E pools mal calibrados podem rejeitar requisições legítimas sob carga normal.
O bulkhead por semáforo em código assíncrono é muito mais leve e pode ser aplicado mais liberalmente. Para código síncrono bloqueante, o thread pool é necessário. Para código assíncrono (a maioria dos sistemas modernos), o semáforo é suficiente e o overhead é mínimo.
A decisão de nível mais alto: bulkhead faz sentido quando você tem dependências com comportamento imprevisível e quando a falha de uma não deve afetar as outras. Para sistemas onde todas as dependências são internas, sob seu controle, com SLOs confiáveis — o overhead pode não se justificar.
Como praticar
- Mapear as dependências externas. Inventarie todas as chamadas externas que seu serviço faz: bancos de dados, outros serviços, APIs de terceiros, sistemas de mensageria. Para cada uma, estime throughput e latência P99. Calcule qual seria o pool size adequado usando a Lei de Little. Identifique quais dependências são críticas (falha = falha do core) e quais são opcionais (falha = degradação aceitável).
- Implementar bulkhead em uma dependência opcional. Escolha uma dependência opcional (notificações, recomendações, analytics) e envolva todas as chamadas a ela com um bulkhead com pool pequeno (5-10 slots). Implemente um fallback: quando o bulkhead rejeitar, o sistema degrada graciosamente em vez de falhar. Monitore a taxa de rejeição por uma semana.
- Simular o noisy neighbor. Em ambiente de staging com dados de dois tenants sintéticos, faça um tenant enviar tráfego 10× acima do normal sem rate limiting. Observe o impacto no outro tenant. Depois aplique rate limiting por tenant e repita o experimento. Documentar a diferença de latência P99 do segundo tenant antes e depois do isolamento.
Referências para aprofundar
- livro Release It! — Design and Deploy Production-Ready Software — Michael Nygard (Pragmatic Programmers, 2ª ed. 2018).
- docs Polly — Bulkhead and Rate Limiter — App-vNext/Polly GitHub.
- artigo Netflix Hystrix — How it Works — Netflix Tech Blog, 2012.
- docs Resilience4j — Bulkhead — Resilience4j.github.io.
- artigo Bulkhead Pattern — Azure Architecture Center — Microsoft, 2023.
- livro Building Microservices — Sam Newman (O'Reilly, 2ª ed. 2021).
- artigo golang.org/x/sync/semaphore — Package Documentation.
- artigo Rate Limiting Patterns — Stripe Engineering Blog, 2015.
- vídeo Patterns of Resilience — the Bulkhead Pattern — Uwe Friedrichsen (GOTO, 2019).
- paper Overload Control for Scaling WeChat Microservices — Zhou et al. (SOSP, 2018).
- docs Envoy Proxy — Circuit Breaking — envoyproxy.io.
- artigo Service Mesh and the Sidecar Pattern — Istio.io.