Resiliência sob Estresse de Carga: Análise Comparativa de Circuit Breaker, Retry e Bulkhead em Microsserviços Node.js
Resiliência sob estresse de carga: Retry, Circuit Breaker e Bulkhead na prática
O que acontece quando um microsserviço começa a consumir memória até morrer em produção?
Resolvi testar isso na prática criando um cenário controlado com dois microsserviços em Node.js:
order-servicepayment-service
O payment-service possui propositalmente um memory leak, fazendo o consumo de memória crescer até atingir o limite do container e provocar um OOM-kill.
A partir desse cenário, fui evoluindo o order-service em quatro etapas:
- Sem proteção
- Retry + backoff exponencial + jitter
- Circuit Breaker
- Bulkhead
O resultado foi interessante:
A latência
p95caiu de aproximadamente 3005 ms para 205 ms, uma redução de quase 14x, enquanto o throughput aumentou em aproximadamente 53%.
Mas existe um detalhe importante: a taxa de erro aumentou em algumas etapas.
E isso não significa necessariamente que o sistema piorou.
O cenário
A arquitetura é simples:
┌───────────────┐
│ k6 │
│ Load Testing │
└───────┬───────┘
│
▼
┌───────────────┐
│ order-service │
└───────┬───────┘
│ HTTP
▼
┌────────────────┐
│ payment-service│
│ │
│ Memory Leak │
└────────────────┘
O order-service precisa chamar o payment-service para confirmar um pedido.
O problema é que o payment-service é propositalmente instável.
A cada requisição ele aloca e mantém um buffer em memória. Com carga contínua, o consumo cresce até atingir aproximadamente 256 MB, que é o limite definido para o container.
Quando isso acontece, o Docker encerra o processo por falta de memória:
OOM-kill
exit code 137
O container então reinicia.
Ou seja, temos um pequeno crash-loop acontecendo durante o teste.
1. Cenário sem nenhuma proteção
Na primeira versão, o order-service simplesmente chama o payment-service e espera a resposta.
Existe apenas um timeout de 3 segundos.
order-service
│
│ HTTP
▼
payment-service
│
├── memória crescendo
│
├── OOM
│
└── restart
Resultado:
| Métrica | Resultado |
|---|---|
| Requisições | 286 |
| Throughput | 8,16 req/s |
| Erros | 16,43% |
| p50 | 33 ms |
| p90 | 3003 ms |
| p95 | 3005 ms |
| Máximo | 3035 ms |
O problema aparece claramente na latência.
Quando o payment-service entra em degradação, várias requisições ficam aguardando até atingir o timeout.
Isso significa que o problema de um serviço começa a consumir recursos do outro.
É assim que começam muitos problemas de cascata em sistemas distribuídos.
2. Retry com backoff exponencial + jitter
A primeira tentativa de melhorar o sistema foi adicionar retry.
A ideia é simples:
requisição
│
▼
payment-service
│
├── falhou
│
▼
espera
│
▼
tenta novamente
Mas simplesmente repetir imediatamente pode piorar o problema.
Imagine que 100 requisições falhem ao mesmo tempo.
Se todas fizerem retry imediatamente:
100 requisições
│
├── falham
│
└── retry simultâneo
│
▼
payment-service
O serviço que acabou de voltar pode receber uma nova avalanche de requisições.
Por isso usamos backoff exponencial + jitter.
function backoffDelay(attempt, baseDelayMs, maxDelayMs) {
const exponential = Math.min(
maxDelayMs,
baseDelayMs * 2 ** attempt
);
return Math.random() * exponential;
}
O jitter adiciona uma quantidade aleatória ao intervalo.
Assim, as requisições deixam de tentar novamente exatamente no mesmo instante.
Resultado
| Métrica | Resultado |
|---|---|
| Requisições | 388 |
| Throughput | 11,03 req/s |
| Erros | 2,58% |
| p50 | 16,70 ms |
| p90 | 467,80 ms |
| p95 | 1148,36 ms |
Uma melhora significativa.
Mas ainda existe um problema.
Durante uma indisponibilidade prolongada, continuamos tentando chamar um serviço que está quebrado.
3. Circuit Breaker
Aqui entra o Circuit Breaker.
A ideia é parar de insistir quando já sabemos que o serviço está falhando.
O circuito possui três estados:
falhas
CLOSED ─────────────────► OPEN
▲ │
│ │ timeout
│ ▼
└────── sucesso ───── HALF_OPEN
CLOSED
Tudo funciona normalmente.
As requisições são encaminhadas para o payment-service.
OPEN
O serviço está apresentando muitas falhas.
Então o order-service simplesmente para de fazer chamadas.
order-service
│
X
│
▼
fail-fast
Isso é importante.
Em vez de deixar uma requisição esperando 3 segundos por um serviço que provavelmente está indisponível, ela falha imediatamente.
HALF_OPEN
Depois de determinado período, permitimos uma nova tentativa.
Se funcionar:
HALF_OPEN → CLOSED
Se falhar:
HALF_OPEN → OPEN
Uma implementação simplificada:
canAttempt() {
if (this.state !== STATE.OPEN) {
return true;
}
if (Date.now() - this.openedAt >= this.openDurationMs) {
this.state = STATE.HALF_OPEN;
return true;
}
return false;
}
Resultado
| Métrica | Resultado |
|---|---|
| Requisições | 459 |
| Throughput | 13,00 req/s |
| Erros | 7,84% |
| p50 | 11,73 ms |
| p90 | 196,96 ms |
| p95 | 399,79 ms |
Aqui aparece uma coisa que pode parecer estranha.
A taxa de erro aumentou.
Mas o sistema ficou mais saudável.
Por que mais erros podem significar um sistema melhor?
Compare:
Retry
Erro → espera → retry → espera → retry → timeout
Circuit Breaker
Erro → circuito abre → fail-fast
No segundo cenário, algumas requisições são rejeitadas imediatamente.
Isso aumenta a quantidade de erros observados.
Por outro lado, o order-service não fica cheio de requisições esperando um serviço que está indisponível.
Isso libera recursos para continuar atendendo outras requisições.
Em sistemas distribuídos, muitas vezes é melhor:
Falhar rápido do que ficar lento até cair.
Essa foi uma das principais conclusões do experimento.
4. Bulkhead
O próximo problema era outro.
Mesmo com Circuit Breaker, precisamos controlar quantas chamadas simultâneas podem consumir recursos do order-service.
É aqui que entra o Bulkhead.
O conceito vem dos compartimentos de um navio.
Se um compartimento começa a encher de água, ele é isolado para impedir que o navio inteiro afunde.
Em software:
order-service
┌──────────────────────────┐
│ │
│ chamadas concorrentes │
│ │
│ █ █ █ █ █ █ █ █ █ █ │
│ │
└──────────────────────────┘
│
limite de concorrência
│
▼
payment-service
Definimos um número máximo de chamadas simultâneas.
Por exemplo:
maxConcurrent = 10
Se já existem 10 chamadas em execução, novas chamadas não podem simplesmente continuar ocupando recursos indefinidamente.
No experimento, o excesso é rejeitado rapidamente.
Uma implementação simplificada:
async run(fn) {
if (this.active >= this.maxConcurrent) {
if (this.queue.length >= this.maxQueue) {
throw new BulkheadRejectedError(
`Bulkhead cheio`
);
}
await new Promise((resolve) => {
this.queue.push(resolve);
});
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
}
}
Resultado
| Métrica | Resultado |
|---|---|
| Requisições | 443 |
| Throughput | 12,56 req/s |
| Erros | 7,00% |
| p50 | 16,08 ms |
| p90 | 105,03 ms |
| p95 | 204,89 ms |
| Máximo | 2561,69 ms |
O p95 caiu novamente.
Comparando tudo
Aqui está o resultado das quatro etapas:
| Estratégia | req/s | Erros | p50 | p90 | p95 |
|---|---|---|---|---|---|
| Base | 8,16 | 16,43% | 33 ms | 3003 ms | 3005 ms |
| Retry | 11,03 | 2,58% | 17 ms | 468 ms | 1148 ms |
| Circuit Breaker | 13,00 | 7,84% | 12 ms | 197 ms | 400 ms |
| Bulkhead | 12,56 | 7,00% | 16 ms | 105 ms | 205 ms |
O resultado mais interessante está aqui:
p95
Base ██████████████████████████████ 3005 ms
Retry ███████████ 1148 ms
Circuit Breaker ████ 400 ms
Bulkhead ██ 205 ms
A latência p95 caiu aproximadamente 14x.
Ao mesmo tempo, o throughput saiu de:
8,16 req/s
para:
12,56 req/s
Um aumento de aproximadamente 53%.
Mas existe um trade-off
É tentador olhar apenas para:
erro %
e concluir:
"Retry é melhor porque teve apenas 2,58% de erros."
Mas isso seria uma análise incompleta.
O Circuit Breaker apresentou:
7,84% de erros
e mesmo assim entregou:
p95 = 400 ms
contra:
p95 = 1148 ms
do Retry.
Ou seja, estamos fazendo uma troca:
menos erros
↓
mais tempo esperando
ou
mais falhas rápidas
↓
mais previsibilidade
Qual é melhor?
Depende do negócio.
Para algumas operações, perder uma requisição é muito pior.
Para outras, manter o sistema responsivo é mais importante.
Uma descoberta interessante sobre o Bulkhead
O Bulkhead também trouxe uma diferença importante.
A latência máxima chegou a aproximadamente:
2562 ms
enquanto o Circuit Breaker ficou em:
1371 ms
Por quê?
Porque o Bulkhead limita a concorrência, mas não impede que as requisições permitidas fiquem esperando.
Imagine que o limite seja:
10 chamadas simultâneas
As primeiras 10 podem entrar.
Se o payment-service estiver prestes a sofrer OOM, essas 10 chamadas ainda podem ficar presas aguardando uma resposta.
As chamadas excedentes são rejeitadas rapidamente.
Portanto:
Bulkhead
│
├── chamadas dentro do limite
│ └── podem ficar lentas
│
└── chamadas excedentes
└── rejeitadas rapidamente
Já o Circuit Breaker pode simplesmente impedir novas chamadas quando identifica que o serviço está degradado.
São problemas diferentes sendo tratados.
O que cada padrão resolve?
Uma forma simples de pensar:
| Padrão | Problema principal |
|---|---|
| Retry | Falhas temporárias |
| Backoff | Evitar retries agressivos |
| Jitter | Evitar sincronização dos retries |
| Circuit Breaker | Evitar chamadas contra serviço degradado |
| Bulkhead | Limitar consumo de recursos |
| Timeout | Evitar espera indefinida |
E eles podem trabalhar juntos.
┌──────────────┐
│ Request │
└──────┬───────┘
│
▼
┌──────────────┐
│ Bulkhead │
└──────┬───────┘
│
▼
┌──────────────┐
│ Circuit │
│ Breaker │
└──────┬───────┘
│
▼
┌──────────────┐
│ Retry │
│ + Backoff │
│ + Jitter │
└──────┬───────┘
│
▼
┌──────────────┐
│ Payment │
│ Service │
└──────────────┘
E o problema da entrega?
Existe uma limitação importante nesse experimento.
Nenhum desses padrões garante que uma requisição será processada durante uma indisponibilidade prolongada.
Se o payment-service estiver realmente fora do ar, eventualmente precisamos aceitar:
falha
ou criar uma estratégia de processamento assíncrono.
Por exemplo:
order-service
│
▼
Outbox
│
▼
RabbitMQ
│
▼
payment-service
Com uma arquitetura assíncrona, podemos introduzir mecanismos como:
- filas;
- Outbox Pattern;
- reprocessamento;
- Dead Letter Queue;
- idempotência;
- processamento posterior.
Mas isso muda o problema.
Agora entramos em consistência eventual e garantia de entrega.
Por isso deixei essa parte fora deste experimento.
O principal aprendizado
O experimento reforçou algo que é fácil de esquecer quando estamos olhando apenas para código:
resiliência não significa impedir todas as falhas.
Um serviço vai falhar.
Uma dependência vai ficar lenta.
Um container vai morrer.
Uma rede vai apresentar problemas.
A pergunta é:
O que acontece com o restante do sistema quando isso acontecer?
No cenário inicial:
payment-service
↓
degrada
↓
order-service espera
↓
timeouts
↓
recursos ocupados
↓
mais degradação
Depois das proteções:
payment-service
↓
degrada
↓
Circuit Breaker / Bulkhead
↓
fail-fast
↓
recursos preservados
↓
sistema continua respondendo
Esse é o ponto que considero mais importante.
Um sistema resiliente não é aquele que nunca falha. É aquele que consegue falhar de maneira controlada.
Código e experimentos
Todo o código está disponível no GitHub, incluindo:
- as quatro branches evolutivas;
order-service;payment-service;- cenário de memory leak;
- Docker Compose;
- scripts do k6;
- relatórios dos testes;
- resultados de cada etapa.
Repositório:
https://github.com/marcelo3macedo/node-microservices-resilience-patterns
As quatro etapas são:
feature/01-base-service-unstable
feature/02-pattern-retry-backoff
feature/03-pattern-circuit-breaker
feature/04-pattern-bulkhead-isolation