Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

03 - Bulkhead

#bulkhead#contencao#isolamento#pools-de-conexao#resiliencia
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione video: https://youtu.be/... no frontmatter da nota.

Bulkhead

Bulkhead é o padrão de isolar os recursos de execução de um sistema em compartimentos independentes, de modo que o esgotamento de um compartimento não contamine os demais. O nome vem dos compartimentos estanques de cascos de navio: uma perfuração em um deles inunda apenas aquele espaço, e o casco permanece flutuando porque as divisas impedem a água de se espalhar. Em software, os compartimentos são pools de threads, pools de conexão, workers de fila, memória e cota de concorrência, e a água é a carga excessiva que uma dependência lenta provoca. O padrão é central em produção porque a maioria das quedas totais começa como queda parcial: uma integração externa degrada, os recursos compartilhados se esgotam e o serviço inteiro morre junto, inclusive as funcionalidades que nada tinham a ver com a falha original.

Conceito e metáfora do navio

Navios antigos afundavam com uma única perfuração no casco; a engenharia naval respondeu dividindo o interior em compartimentos estanques, aceitando a perda parcial como preço da sobrevivência total. O trade-off é claro e ensina a essência do padrão: as divisas ocupam espaço e complicam a construção, mas transformam desastre total em incidente local.

A tradução para software segue a mesma troca. Sem bulkhead, todo recurso é compartilhado, o que maximiza a utilização média e minimiza o custo em operação saudável; um pool único de trinta conexões serve tudo com menos memória que três pools de dez. Com bulkhead, parte da capacidade fica ociosa em cada compartimento, e o preço é pago exatamente para que o pior dia tenha forma de incidente local e não de indisponibilidade total.

O padrão opera em duas dimensões que costumam ser confundidas: o compartimento por dependência, que separa o consumo de recursos entre integrações distintas, e o compartimento por tenant ou por tipo de requisição, que separa classes de tráfego diferentes, como leitura e escrita, ou cliente gratuito e cliente pagante, dentro da mesma integração.

O problema que resolve: esgotamento global de recursos

O mecanismo do desastre é sempre o mesmo: um recurso compartilhado finito, threads de execução, conexões de banco, conexões HTTP, memória, se esgota porque uma classe de trabalho segura os recursos por muito mais tempo que o previsto. Um serviço de recomendação que passa a responder em dez segundos não derruba apenas recomendações; se ele compartilha o pool de threads com autenticação e com checkout, cada thread presa na espera é uma thread roubada das operações saudáveis.

O exemplo clássico é o servidor de aplicação com duzentas threads de execução servindo todas as rotas. Uma integração externa lenta prende as duzentas threads em chamadas pendentes; a partir daí, nem a verificação de saúde responde, e o orquestrador reinicia pods que reiniciam doentes. Nada está tecnicamente quebrado, exceto o desenho da divisão de recursos, que não existia.

Fila sofre a mesma doença em outra roupa. Um único worker consumindo uma fila mista, com e-mails e cobranças, para de processar tudo quando o provedor de e-mail fica lento, e as cobranças, sensíveis a prazo, ficam atrás dos e-mails travados. A resposta do bulkhead é separar as filas e os workers, para que o atraso de um tipo de trabalho apenas encolha o throughput dele próprio.

Funcionamento interno: particionamento de recursos

A implementação concreta é limitar a concorrência por compartimento, com três ingredientes: um semáforo ou pool que impõe o teto de trabalhos simultâneos, uma fila de espera com limite próprio que absorve picos breves e uma política de rejeição que responde rápido quando fila e teto estão cheios. O limite transforma lentidão infinita em fila limitada, e a fila cheia transforma espera silenciosa em erro explícito e rápido.

O particionamento vale para qualquer recurso finito, e a escolha da unidade define a granularidade da contenção. Nas aplicações web, a unidade mais comum é o pool por dependência remota; em processamento de eventos, é o conjunto de workers por fila ou por tópico; em sistemas multi-tenant, é a cota de concorrência por tenant, que impede um cliente anômalo de consumir a capacidade dos outros.

Bulkhead não decide quando parar de chamar, isso é papel do circuit breaker, nem quanto esperar, papel do timeout. Ele decide quanto da capacidade própria cada trabalho pode consumir ao mesmo tempo, e essa delimitação é o que dá sentido aos outros padrões: com teto de concorrência, uma dependência lenta degrada a funcionalidade dela, e o resto do sistema nem percebe.

O particionamento também existe fora do código da aplicação, e reconhecê-lo evita duplicar esforço: pools de conexão do banco, limites de conexão no balanceador e filas com consumidores dedicados são bulkheads no nível de infraestrutura. A arquitetura completa empilha esses níveis, com a aplicação impondo o teto fino por dependência e a infraestrutura impondo o teto grosseiro por serviço.

Dimensões de bulkhead na prática

Dimensão Unidade do compartimento Sinal de saturação Alívio típico
Threads de execução Pool de threads por dependência Fila do pool crescendo Reduzir timeout, revisar tamanho do pool
Conexões HTTP Pool de conexões por host remoto Espera por conexão livre Mais conexões ou concorrência limitada
Conexões de banco Pool por classe de consulta Checkout de conexão lento Pool de leitura separado do de escrita
Workers de fila Consumidores por fila Backlog crescente Escalar consumidores, separar filas
Memória Cota por processo ou por tenant GC frequente, OOM kills Limites por tenant, admissão controlada

Cada linha da tabela é um compartimento possível, e sistemas maduros adotam vários deles ao mesmo tempo, porque os recursos esgotam por camadas diferentes. A regra de prioridade é observar onde o incidente real aconteceu: não há valor em particionar memória se o histórico de produção mostra esgotamento de threads.

Aplicação prática: pools separados por dependência

O caso de uso mais frequente é a separação de pools por dependência remota, em que cada integração recebe cota própria de threads e de conexões, com rejeição rápida quando a cota esgota.

from concurrent.futures import ThreadPoolExecutor

# Bulkhead por dependência: cada pool tem cota própria de concorrência
POOLS = {
    "pagamentos":   ThreadPoolExecutor(max_workers=8,  thread_name_prefix="pg"),
    "notificacoes": ThreadPoolExecutor(max_workers=4,  thread_name_prefix="nt"),
    "consultas":    ThreadPoolExecutor(max_workers=16, thread_name_prefix="qy"),
}

def chamar_com_bulkhead(nome_pool, operacao, *args, timeout_s=3, fila_max=10):
    pool = POOLS[nome_pool]
    if pool._work_queue.qsize() >= fila_max:
        # fila cheia: falha rápida em vez de esperar em pé
        raise CompartimentoSaturado(f"bulkhead {nome_pool} com fila cheia")
    try:
        futuro = pool.submit(operacao, *args)
        return futuro.result(timeout=timeout_s)   # timeout por chamada
    except TimeoutError:
        raise DependenciaLenta(f"{nome_pool} excedeu {timeout_s}s")

Com esse desenho, a lentidão do provedor de e-mails satura o compartimento de quatro threads de notificações, rejeita novos envios com erro explícito e não rouba uma única thread de pagamentos ou de consultas. O dano fica confinado à funcionalidade que depende da dependência doente, que é exatamente o comportamento dos compartimentos do navio.

Em ambientes JVM, os mesmos contornos aparecem como semáforos do Resilience4j em vez de pools físicos, o que reduz custo de troca de contexto; a semântica de teto, fila e rejeição é idêntica. Em malha de serviço, limites de conexão e de requisições pendentes por upstream cumprem o papel no nível de transporte.

A configuração declarativa torna o compartimento visível e auditável, como no exemplo abaixo.

# Bulkhead de semáforo por dependência no Resilience4j
resilience4j:
  bulkhead:
    instances:
      pagamentos:
        max-concurrent-calls: 8        # teto de concorrência do compartimento
        max-wait-duration: 0ms         # sem espera: rejeição imediata ao saturar
      notificacoes:
        max-concurrent-calls: 4        # cota isolada da dependência lenta
        max-wait-duration: 100ms       # espera curta antes de rejeitar

Sizing de bulkheads

Dimensionar compartimento é aplicar o teorema de Little ao tráfego esperado: a concorrência necessária é o throughput alvo multiplicado pela latência esperada por trabalho.

# Dimensionamento pelo teorema de Little
# concorrencia = throughput alvo x latencia esperada (p99)
# exemplo: 200 req/s x 0,25 s = 50 trabalhos simultâneos no pico
# bulkhead = concorrencia x fator de seguranca (1,2 a 1,5) + folga de sondagem

Três ajustes refinam a conta inicial. O primeiro é usar a latência p99 e não a média, porque o compartimento precisa acomodar o dia ruim; usar a média dimensiona para o dia bom que nunca aparece. O segundo é reservar folga para sondagem e recuperação, pois um compartimento que só tem vaga para a carga normal não consegue executar as chamadas de verificação do circuit breaker ao despertar. O terceiro é revisar depois de medir: o teto certo aparece na observação de produção, em que fila nunca espera muito e rejeição quase nunca dispara.

Compartimento pequeno demais é tão nocivo quanto compartimento único grande. Um pool de duas threads para uma dependência que atinge dez requisições por segundo fabrica rejeição artificial, e o sintoma de engenharia reversa é taxa de erro que não aparece na dependência, apenas no chamador. A calibragem entre saturar e superprovisionar é contínua, e por isso o valor pertence a configuração, não a código.

Bulkhead versus outros padrões de resiliência

Padrão Pergunta que responde Escopo de atuação Limitação própria
Bulkhead Quanto do recurso próprio uma dependência pode consumir Concorrência e capacidade local Não detecta recuperação nem evita chamada inútil
Circuit Breaker Vale a pena continuar chamando o destino Estado de saúde da dependência Não protege recursos próprios da concorrência
Timeout Quanto tempo esperar cada resposta Chamada individual Não limita quantas chamadas coexistem
Rate limiting Quantas requisições aceitar por intervalo Tráfego total ou por cliente Não isola classes de tráfego internamente
Retry com backoff Quando repetir uma falha transiente Tentativa individual Amplifica carga se sem orçamento

A tabela evidencia a complementaridade: timeout limita o tempo, bulkhead limita o simultaneismo, circuit breaker limita a insistência e rate limiting limita a entrada. Nenhum substitui outro, e incidentes reais quase sempre envolvem falha de mais de um deles ao mesmo tempo; a pergunta de arquitetura é quais recursos são finitos e quem pode esgotá-los, e a resposta desenha os bulkheads.

Erros comuns

  • Pool único compartilhado por todas as dependências, que reproduz o esgotamento global exatamente no cenário que o padrão existe para evitar.
  • Particionar por dependência e esquecer as filas, deixando workers mistos onde um trabalho lento trava os demais; a fila também é bulkhead.
  • Teto dimensionado pela média de latência, que deixa o compartimento subdimensionado nos picos em que ele seria mais necessário.
  • Rejeição silenciosa, com erro genérico sem identificação do compartimento; sem nomes e métricas por bulkhead, a triagem vira adivinhação.
  • Cotas iguais para dependências de peso muito diferente, pois consultas em cache e geração de PDF não têm a mesma unidade de custo e merecem compartimentos distintos.
  • Semáforo sem fila e sem política de rejeição definida, o que apenas empilha espera infinita em outro lugar da pilha.
  • Bulkhead por tenant sem limites por requisição, permitindo que um único tenant com requisições pesadas sature a própria cota e gere falsa indisponibilidade percebida.

Boas práticas

  • Começar pelos recursos finitos que já causaram incidentes, particionando pools de threads e conexões por dependência remota como primeiro passo.
  • Dar nome a cada compartimento e publicar métricas de ocupação, tamanho de fila e rejeições por bulkhead, porque contenção invisível não se governa.
  • Dimensionar com p99 e revisar com dados de produção, tratando o tamanho como configuração viva e não como decisão de uma vez.
  • Definir falha rápida explícita para compartimento saturado, com exceção própria e resposta de fallback adequada ao caso.
  • Combinar com timeout por chamada e circuit breaker por dependência, formando o conjunto que limita tempo, simultaneismo e insistência.
  • Separar filas e workers por classe de trabalho no processamento assíncrono, evitando que trabalho em lento bloqueie trabalho sensível a prazo.
  • Revisar os limites a cada mudança relevante de tráfego ou de dependência, pois compartimento dimensionado para o tráfego antigo vira gargalo novo.
  • Testar a contenção em ambiente, saturando deliberadamente um compartimento e verificando que os demais continuam respondendo dentro do SLO.