Bulkhead Pattern com .NET | Padrões de Resiliência | Arquitetura
- Bulkhead Pattern com .NET | Padrões de Resiliência | Arquitetura
O que é Bulkhead Pattern
Bulkhead é um padrão de resiliência usado para isolar recursos dentro de uma aplicação.
A ideia é impedir que uma falha, lentidão ou sobrecarga em uma parte do sistema consuma todos os recursos disponíveis e afete o restante da aplicação.
Em português, pode ser entendido como:
Compartimentalização de recursos para limitar o impacto de uma falha.
O nome vem da engenharia naval. Navios possuem compartimentos internos que impedem que a água invada toda a embarcação caso uma parte seja danificada.
No software, o raciocínio é o mesmo:
Sem Bulkhead:
Uma falha pode afetar tudo.
Com Bulkhead:
A falha fica limitada a um compartimento.
Problema que o Bulkhead resolve
Imagine uma API em .NET que executa três tipos de operação:
- Consulta fiscal
- Envio de e-mail
- Processamento de pagamento
Sem isolamento, todas essas operações podem usar os mesmos recursos:
API
└── Recursos compartilhados
├── Threads
├── Conexões HTTP
├── Conexões com banco
└── Memória
Se a consulta fiscal começar a ficar lenta, ela pode prender muitas threads, abrir muitas conexões e degradar o sistema inteiro.
O Bulkhead evita isso criando limites por área:
API
├── Bulkhead Fiscal: máximo 10 operações simultâneas
├── Bulkhead E-mail: máximo 20 operações simultâneas
└── Bulkhead Pagamento: máximo 50 operações simultâneas
Assim, se a parte fiscal travar, apenas o compartimento fiscal sofre impacto.
Objetivo do Bulkhead
O objetivo principal é:
Impedir que um fluxo problemático consuma recursos que pertencem a outros fluxos.
Ele ajuda a melhorar:
| Característica | Como ajuda |
|---|---|
| Resiliência | A falha fica isolada |
| Disponibilidade | Outras partes continuam funcionando |
| Controle de carga | Cada fluxo tem seu próprio limite |
| Previsibilidade | O sistema degrada de forma controlada |
| Proteção contra efeito dominó | Uma dependência lenta não derruba tudo |
Onde aplicar Bulkhead
Bulkhead pode ser aplicado em vários níveis.
| Nível | Exemplo |
|---|---|
| Código | SemaphoreSlim, RateLimiter, filas internas |
| HTTP | Limite de conexões por destino |
| Banco de dados | Pools separados por tipo de carga |
| Mensageria | Filas separadas por prioridade |
| Workers | Background services separados |
| Infraestrutura | Pods, containers ou serviços isolados |
| Gateway | Rate limit e limite por rota/cliente |
Bulkhead com SemaphoreSlim
Uma forma simples de aplicar Bulkhead no .NET é usando SemaphoreSlim.
Exemplo: limitar chamadas simultâneas para uma API fiscal.
public sealed class FiscalClient
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _bulkhead = new(
initialCount: 10,
maxCount: 10);
public FiscalClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> ConsultarAsync(CancellationToken ct)
{
bool entrouNoBulkhead = await _bulkhead.WaitAsync(
timeout: TimeSpan.FromSeconds(1),
cancellationToken: ct);
if (!entrouNoBulkhead)
{
throw new InvalidOperationException(
"Bulkhead fiscal cheio. A operação foi rejeitada.");
}
try
{
return await _httpClient.GetStringAsync("/consulta", ct);
}
finally
{
_bulkhead.Release();
}
}
}
Nesse exemplo:
- No máximo 10 chamadas fiscais rodam ao mesmo tempo.
- Se o limite estiver cheio, a chamada espera até 1 segundo.
- Se não conseguir entrar, a operação é rejeitada.
- O restante da aplicação continua funcionando.
Bulkhead com registro no DI
Exemplo registrando o cliente no Program.cs:
builder.Services.AddHttpClient<FiscalClient>(client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
client.Timeout = TimeSpan.FromSeconds(10);
});
Uso em um endpoint:
app.MapGet("/fiscal/consulta", async (
FiscalClient fiscalClient,
CancellationToken ct) =>
{
try
{
string resultado = await fiscalClient.ConsultarAsync(ct);
return Results.Ok(new
{
sucesso = true,
resultado
});
}
catch (InvalidOperationException ex)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
});
Nesse caso, quando o Bulkhead estiver cheio, o endpoint retorna 429 Too Many Requests.
Bulkhead com RateLimiter do .NET
O .NET possui APIs de rate limiting que também podem ser usadas para controlar concorrência.
Exemplo com ConcurrencyLimiter:
using System.Threading.RateLimiting;
public sealed class FiscalBulkhead
{
private readonly ConcurrencyLimiter _limiter = new(new ConcurrencyLimiterOptions
{
PermitLimit = 10,
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
public async Task<IDisposable?> TentarEntrarAsync(CancellationToken ct)
{
RateLimitLease lease = await _limiter.AcquireAsync(
permitCount: 1,
cancellationToken: ct);
if (!lease.IsAcquired)
return null;
return lease;
}
}
Uso:
public sealed class FiscalService
{
private readonly FiscalBulkhead _bulkhead;
private readonly HttpClient _httpClient;
public FiscalService(FiscalBulkhead bulkhead, HttpClient httpClient)
{
_bulkhead = bulkhead;
_httpClient = httpClient;
}
public async Task<string> ConsultarAsync(CancellationToken ct)
{
using IDisposable? lease = await _bulkhead.TentarEntrarAsync(ct);
if (lease is null)
{
throw new InvalidOperationException(
"Bulkhead fiscal cheio.");
}
return await _httpClient.GetStringAsync("/consulta", ct);
}
}
Registro no DI:
builder.Services.AddSingleton<FiscalBulkhead>();
builder.Services.AddHttpClient<FiscalService>(client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
});
Diferença entre SemaphoreSlim e RateLimiter
| Opção | Quando usar |
|---|---|
SemaphoreSlim | Controle simples de concorrência |
RateLimiter | Controle mais estruturado com fila, lease e políticas |
| Polly Bulkhead | Quando já usa Polly para resiliência |
| Filas internas | Quando precisa absorver carga e processar em background |
Para sistemas simples, SemaphoreSlim resolve bem.
Para aplicações com políticas mais explícitas de limitação, RateLimiter ou Polly tendem a ser mais adequados.
Bulkhead com Channel
Também é possível aplicar Bulkhead usando Channel.
Esse modelo é útil quando você quer controlar a quantidade de itens em memória e processar em background.
Exemplo: fila isolada para publicação no RabbitMQ.
using System.Threading.Channels;
public sealed class FilaPublicacao
{
private readonly Channel<MensagemPublicacao> _channel;
public FilaPublicacao()
{
_channel = Channel.CreateBounded<MensagemPublicacao>(
new BoundedChannelOptions(capacity: 1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
}
public async ValueTask EnfileirarAsync(
MensagemPublicacao mensagem,
CancellationToken ct)
{
await _channel.Writer.WriteAsync(mensagem, ct);
}
public IAsyncEnumerable<MensagemPublicacao> LerAsync(CancellationToken ct)
{
return _channel.Reader.ReadAllAsync(ct);
}
}
public sealed record MensagemPublicacao(
Guid RequestId,
string Cnpj,
string Payload);
Worker consumidor:
public sealed class PublicacaoWorker : BackgroundService
{
private readonly FilaPublicacao _fila;
private readonly ILogger<PublicacaoWorker> _logger;
public PublicacaoWorker(
FilaPublicacao fila,
ILogger<PublicacaoWorker> logger)
{
_fila = fila;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (MensagemPublicacao mensagem in _fila.LerAsync(stoppingToken))
{
try
{
await PublicarAsync(mensagem, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Erro ao publicar mensagem {RequestId}",
mensagem.RequestId);
}
}
}
private static Task PublicarAsync(
MensagemPublicacao mensagem,
CancellationToken ct)
{
// Publicação no RabbitMQ, Kafka, HTTP, etc.
return Task.CompletedTask;
}
}
Registro:
builder.Services.AddSingleton<FilaPublicacao>();
builder.Services.AddHostedService<PublicacaoWorker>();
Nesse modelo, a fila tem capacidade limitada. Isso impede crescimento infinito de memória.
Bulkhead por cliente
Em alguns cenários, o problema não é uma dependência externa, mas um cliente específico gerando carga excessiva.
Exemplo: limitar processamento por CNPJ.
using System.Collections.Concurrent;
using System.Threading.RateLimiting;
public sealed class BulkheadPorCliente
{
private readonly ConcurrentDictionary<string, ConcurrencyLimiter> _limiters = new();
public async ValueTask<RateLimitLease> EntrarAsync(
string cnpj,
CancellationToken ct)
{
ConcurrencyLimiter limiter = _limiters.GetOrAdd(cnpj, _ =>
new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 5,
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
return await limiter.AcquireAsync(1, ct);
}
}
Uso em endpoint:
app.MapPost("/envio-comando/{cnpj}", async (
string cnpj,
BulkheadPorCliente bulkhead,
CancellationToken ct) =>
{
using RateLimitLease lease = await bulkhead.EntrarAsync(cnpj, ct);
if (!lease.IsAcquired)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
// Processa comando para esse CNPJ.
Guid requestId = Guid.NewGuid();
return Results.Accepted(value: new
{
requestId
});
});
Esse modelo impede que um único cliente monopolize a aplicação.
Bulkhead por tipo de operação
Também é possível separar limites por tipo de operação.
public sealed class BulkheadsOperacionais
{
public SemaphoreSlim EnvioComando { get; } = new(100, 100);
public SemaphoreSlim RetornoCliente { get; } = new(200, 200);
public SemaphoreSlim PublicacaoRabbitMq { get; } = new(50, 50);
public SemaphoreSlim ConsultaExterna { get; } = new(10, 10);
}
Registro:
builder.Services.AddSingleton<BulkheadsOperacionais>();
Uso:
public sealed class RetornoService
{
private readonly BulkheadsOperacionais _bulkheads;
public RetornoService(BulkheadsOperacionais bulkheads)
{
_bulkheads = bulkheads;
}
public async Task ProcessarRetornoAsync(CancellationToken ct)
{
bool entrou = await _bulkheads.RetornoCliente.WaitAsync(
TimeSpan.FromSeconds(1),
ct);
if (!entrou)
{
throw new InvalidOperationException(
"Bulkhead de retorno cheio.");
}
try
{
// Processa retorno.
}
finally
{
_bulkheads.RetornoCliente.Release();
}
}
}
Bulkhead em HttpClient
Além do controle no código, também é possível limitar conexões HTTP por destino.
builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
client.Timeout = TimeSpan.FromSeconds(10);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
MaxConnectionsPerServer = 20
});
Esse limite impede que sua aplicação abra conexões demais contra o mesmo servidor.
Uso:
public sealed class FiscalHttpClient
{
private readonly IHttpClientFactory _httpClientFactory;
public FiscalHttpClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> ConsultarAsync(CancellationToken ct)
{
HttpClient client = _httpClientFactory.CreateClient("Fiscal");
return await client.GetStringAsync("/consulta", ct);
}
}
Esse tipo de limite é muito importante para dependências externas lentas.
Bulkhead com Polly
Polly é uma biblioteca bastante usada para resiliência em .NET.
Um exemplo comum é combinar:
- Timeout
- Retry
- Circuit Breaker
- Bulkhead
Exemplo conceitual com Polly:
builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
})
.AddStandardResilienceHandler();
Em versões recentes do .NET, o pacote de resiliência com Microsoft.Extensions.Http.Resilience facilita a composição dessas políticas.
Exemplo com pipeline customizado:
builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
})
.AddResilienceHandler("fiscal-pipeline", builder =>
{
builder.AddTimeout(TimeSpan.FromSeconds(5));
builder.AddConcurrencyLimiter(new HttpRateLimiterStrategyOptions
{
PermitLimit = 10,
QueueLimit = 20
});
});
Esse exemplo cria um limite de concorrência para as chamadas HTTP feitas por esse client.
Bulkhead, timeout e circuit breaker
Bulkhead não substitui timeout nem circuit breaker.
Eles resolvem problemas diferentes.
| Padrão | Responsabilidade |
|---|---|
| Timeout | Evita esperar indefinidamente |
| Retry | Tenta novamente quando faz sentido |
| Circuit Breaker | Para de chamar uma dependência ruim |
| Bulkhead | Limita quanto recurso uma parte pode consumir |
| Rate Limit | Controla volume de entrada |
Uma configuração saudável geralmente combina os padrões:
Requisição
↓
Rate Limit
↓
Bulkhead
↓
Timeout
↓
Retry controlado
↓
Circuit Breaker
↓
Dependência externa
Exemplo aplicado em uma API de comandos
Contexto:
- Integrações enviam comandos.
- API retorna um
requestId. - Cliente recebe o comando.
- Cliente envia o retorno para uma API de retorno.
- API de retorno publica o resultado em uma fila.
Sem Bulkhead:
API de Retorno
└── Todos os retornos usam os mesmos recursos
├── Cliente A
├── Cliente B
├── Cliente C
└── Publicação RabbitMQ
Se o Cliente A enviar carga demais, ele pode afetar os outros clientes.
Com Bulkhead:
API de Retorno
├── Bulkhead por cliente
│ ├── Cliente A: máximo 5 simultâneos
│ ├── Cliente B: máximo 5 simultâneos
│ └── Cliente C: máximo 5 simultâneos
│
├── Fila bounded para publicação
│ └── Capacidade máxima: 1000 mensagens
│
└── Worker RabbitMQ
└── Máximo 50 publicações simultâneas
Esse desenho protege:
- A API
- A memória
- O RabbitMQ
- Os demais clientes
- O tempo de resposta da rota
Exemplo completo de endpoint com Bulkhead por CNPJ
using System.Threading.RateLimiting;
app.MapPost("/retornos/{cnpj}", async (
string cnpj,
RetornoDto retorno,
BulkheadPorCliente bulkhead,
FilaPublicacao fila,
CancellationToken ct) =>
{
using RateLimitLease lease = await bulkhead.EntrarAsync(cnpj, ct);
if (!lease.IsAcquired)
{
return Results.Problem(
title: "Limite de processamento atingido",
detail: "O cliente excedeu o limite de operações simultâneas.",
statusCode: StatusCodes.Status429TooManyRequests);
}
var mensagem = new MensagemPublicacao(
RequestId: retorno.RequestId,
Cnpj: cnpj,
Payload: retorno.Payload);
await fila.EnfileirarAsync(mensagem, ct);
return Results.Accepted(value: new
{
retorno.RequestId
});
});
public sealed record RetornoDto(
Guid RequestId,
string Payload);
Cuidados ao usar Bulkhead
Bulkhead mal configurado também pode causar problema.
| Cuidado | Motivo |
|---|---|
| Limite baixo demais | Pode rejeitar tráfego saudável |
| Limite alto demais | Não protege recurso nenhum |
| Fila grande demais | Pode aumentar memória e latência |
| Sem timeout | Operações podem ficar presas |
| Sem métrica | Você não sabe quando o bulkhead está saturando |
| Sem fallback | O cliente recebe erro sem alternativa |
Métricas importantes
Ao usar Bulkhead, monitore:
| Métrica | O que indica |
|---|---|
| Operações aceitas | Volume que entrou no bulkhead |
| Operações rejeitadas | Saturação do limite |
| Tempo aguardando permissão | Pressão no compartimento |
| Tamanho da fila | Backpressure interno |
| Tempo de processamento | Lentidão no recurso protegido |
| Erros por dependência | Falhas externas |
| Uso de memória | Crescimento causado por filas |
Exemplo simples de métrica:
public sealed class BulkheadMetricas
{
private long _aceitos;
private long _rejeitados;
public void RegistrarAceito()
{
Interlocked.Increment(ref _aceitos);
}
public void RegistrarRejeitado()
{
Interlocked.Increment(ref _rejeitados);
}
public long Aceitos => Interlocked.Read(ref _aceitos);
public long Rejeitados => Interlocked.Read(ref _rejeitados);
}
Quando usar Bulkhead
Use Bulkhead quando:
- Uma integração externa pode ficar lenta.
- Um cliente pode enviar carga excessiva.
- Um fluxo não crítico pode afetar um fluxo crítico.
- Existe concorrência alta.
- Existem filas internas em memória.
- Existe risco de esgotar threads, conexões ou memória.
- O sistema precisa degradar parcialmente sem cair inteiro.
Quando não usar Bulkhead
Evite aplicar Bulkhead quando:
- O sistema é pequeno e não possui concorrência relevante.
- O custo operacional do controle é maior que o risco.
- Não há recurso compartilhado crítico.
- Você ainda não tem métricas básicas.
- O limite seria arbitrário e sem validação.
Bulkhead não deve ser usado como “enfeite arquitetural”. Ele deve proteger um recurso real.
Diferença entre Bulkhead e fila
Bulkhead e fila não são a mesma coisa.
| Conceito | Função |
|---|---|
| Bulkhead | Isola capacidade |
| Fila | Armazena trabalho para processar depois |
Eles podem ser usados juntos:
Bulkhead:
Limita quantas operações entram.
Fila:
Controla quanto trabalho fica pendente.
Worker:
Processa em ritmo controlado.
Exemplo:
Request HTTP
↓
Bulkhead por cliente
↓
Channel bounded
↓
Worker
↓
RabbitMQ
Diferença entre Bulkhead e Rate Limit
| Conceito | Pergunta que responde |
|---|---|
| Rate Limit | Quantas requisições podem entrar por período? |
| Bulkhead | Quantas operações podem consumir esse recurso ao mesmo tempo? |
Exemplo:
Rate Limit:
Cliente pode fazer 100 requisições por minuto.
Bulkhead:
Cliente pode ter no máximo 5 operações simultâneas.
Os dois podem coexistir.