Resiliência sob Estresse de Carga: Análise Comparativa de Circuit Breaker, Retry e Bulkhead em Microsserviços Node.js
Este é um case de resiliência: o payment-service simula um memory leak real (não latência/erro artificial) que cresce até o limite de memória do container, entra em crash-loop (OOM-kill + restart automático) sob carga contínua, e o order-service foi evoluindo, branch a branch, para lidar com isso — retry com backoff exponencial e jitter, circuit breaker e bulkhead.
Nível: Avançado | Tempo de Leitura: 12 min
por Marcelo Macedo
Introdução
Este é um case de resiliência: o payment-service simula um memory leak real que cresce até o limite de memória do container, entra em crash-loop (OOM-kill + restart automático) sob carga contínua, e o order-service foi evoluindo, branch a branch, para lidar com isso: retry com backoff exponencial e jitter, circuit breaker e bulkhead.
- Escopo: Sistemas Distribuídos / Tolerância a Falhas Parciais.
- Linguagens e Stack: Node.js (Express), Docker Compose, k6 (Grafana) para teste de carga.
- Impacto de Negócio: A latência $p(95)$ despencou 14x (de 3005ms para 204ms) e o throughput de requisições aumentou em 53%. O sistema passou a adotar uma estratégia Fail-Fast, priorizando a proteção de recursos do Event Loop em relação à retenção de chamadas zumbis.
Código Fonte e Relatórios de Carga: Repositório completo no GitHub contendo as 4 branches evolutivas, os scripts do k6 e instruções do Docker Compose:
🔗 github.com/marcelo3macedo/node-microservices-resilience-patterns
O Problema
O ponto de partida do case é a branch feature/01-base-service-unstable: o k6 dispara requisições de carga contra o order-service, que por sua vez consulta o payment-service para confirmar cada pedido.
Só que o payment-service é um serviço instável, com um memory leak real embutido de propósito. Cada requisição que ele recebe aloca e retém um buffer, o RSS (memória física) do processo cresce continuamente sob carga até encostar no limite de memória do container (256MB), o Docker mata o processo por OOM (exit code 137).
A RAM sobe quase linearmente até ~250MB, despenca em torno de t+31s (OOM-kill + restart), e sobe de novo até o fim do teste.
O order-service apenas repassa a chamada ao payment-service com um timeout de 3s, sem nenhuma proteção.
O resultado sob carga: 286 requisições, 16,43% de erro, p(95) de 3004,54ms , o timeout sendo atingido em quase 1 a cada 6 pedidos, concentrados exatamente na janela em que o payment-service está com a memória no limite.
A Arquitetura da Solução
Situação Inicial:
Arquitetura Final:
O order-service é o gateway que chama o payment-service de forma síncrona via HTTP. Ao longo de quatro branches, ele foi ganhando camadas de proteção:
| Branch | O que foi adicionado |
|---|---|
feature/01-base-service-unstable | Cenário base: payment-service com memory leak real, sem nenhuma proteção |
feature/02-pattern-retry-backoff | Retry com backoff exponencial + jitter, e restart: on-failure no payment-service |
feature/03-pattern-circuit-breaker | Circuit breaker: falha rápido quando o payment-service está degradado |
feature/04-pattern-bulkhead-isolation | Bulkhead: limite de concorrência simultânea, isolando o event loop |
Análise de Decisão
Informações detalhada sobre as principais decisões deste case:
| Decisão | Alternativa Considerada | Por que foi Escolhida? | Trade-off Aceito |
|---|---|---|---|
| Comunicação Síncrona HTTP (com Retry + Circuit Breaker + Bulkhead) | Mensageria Assíncrona (SQS/RabbitMQ + Outbox Pattern) | Isolar e mensurar os padrões de resiliência em fluxos síncronos, sem a camada de consistência eventual de filas. | Ausência de garantia de entrega de 100%. Indisponibilidade gera falha rápida (fail-fast) em vez de reprocessamento diferido. |
| Janela do Circuit Breaker em 1500ms (Reduzida dos 3000ms padrão) | Manter a janela padrão de 3000ms | O container do payment-service se recupera em ~1-2s após o restart. A janela menor transita para Half-Open mais rápido. | Leve risco de disparar uma requisição de teste (Half-Open) enquanto o serviço de destino ainda finaliza a inicialização. |
| Retry limitado a 3 tentativas (Teto de 1000ms no Backoff) | Mais tentativas / Teto de backoff maior | Evitar retenção prolongada do cliente no Event Loop durante cenários de Crash-Loop no serviço. | Requisições que se recuperariam em uma 4ª tentativa falham antecipadamente. |
Ao aplicar Circuit breaker, por que a taxa de erro subiu de 2,58% para 7,84%?
Ao analisar os dados da Seção Métricas e Resultados, nota-se que a taxa de erro absoluta aumentou dos branches iniciais (2,58% no retry+backoff) para os mais avançados (7,84% no circuit-breaker e 7,00% no bulkhead).
Isso não é uma regressão, mas sim o comportamento esperado dos padrões Circuit Breaker e Bulkhead:
- Fail-Fast vs. Retenção de Conexões: Em vez de manter requisições presas aguardando o timeout de um serviço instável (o que esgotaria os recursos do
order-service), a arquitetura opta por rejeitar chamadas rapidamente. - O Impacto Positivo: Embora a contagem pontual de erros suba, a latência $p(95)$ despenca drasticamente, liberando a CPU/Memória da aplicação para continuar servindo tráfego saudável de outros módulos.
Em sistemas de alta disponibilidade, falhar rápido para proteger a saúde global do cluster é preferível a tentar salvar requisições individuais a qualquer custo.
Implementação Prática
Retry com backoff exponencial e jitter
- Trecho:
order-service/utils/retry.js - Branch:
feature/02-pattern-retry-backoff
O jitter evita que várias requisições retentem exatamente no mesmo instante contra um serviço que acabou de voltar:
function backoffDelay(attempt, baseDelayMs, maxDelayMs) {
const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
return Math.random() * exponential;
}
if (response.ok || !isRetryableStatus(response.status) || isLastAttempt) {
return response;
}
const delay = backoffDelay(attempt, baseDelayMs, maxDelayMs);
await sleep(delay);
Circuit breaker
- Trecho:
order-service/utils/circuit-breaker.js - Branch:
feature/03-pattern-circuit-breaker
A máquina de estados CLOSED → OPEN → HALF_OPEN é a "mágica" do padrão: enquanto aberto, canAttempt() corta a chamada sem sequer tocar o payment-service:
canAttempt() {
if (this.state !== STATE.OPEN) return true;
if (Date.now() - this.openedAt >= this.openDurationMs) {
this.state = STATE.HALF_OPEN;
return true;
}
return false;
}
onFailure() {
this.failureCount += 1;
const shouldOpen = this.state === STATE.HALF_OPEN || this.failureCount >= this.failureThreshold;
if (shouldOpen) {
this.state = STATE.OPEN;
this.openedAt = Date.now();
this.failureCount = 0;
}
}
Bulkhead
- Trecho:
order-service/utils/bulkhead.js - Branch:
feature/04-pattern-bulkhead-isolation
Um contador simples de chamadas ativas; sem fila configurada, o excesso é rejeitado na hora em vez de esperar:
async run(fn) {
if (this.active >= this.maxConcurrent) {
if (this.queue.length >= this.maxQueue) {
throw new BulkheadRejectedError(
`Bulkhead "${this.name}" cheio (${this.active}/${this.maxConcurrent} em execução, ${this.queue.length}/${this.maxQueue} na fila)`
);
}
await new Promise((resolve) => this.queue.push(resolve));
}
this.active += 1;
try { return await fn(); } finally { this.active -= 1; }
}
Métricas, Resultados e Lições Aprendidas
Números de cada execução (uma por branch), estão detalhados em cada branch nos arquivos results/report.txt e results/k6-summary.json.
| Branch | Requisições (req/s) | Erro % | p50 | p90 | p95 | Máx |
|---|---|---|---|---|---|---|
| Base | 286 (8,16) | 16,43% | 33,11ms | 3002,77ms | 3004,54ms | 3034,73ms |
| Retry | 388 (11,03) | 2,58% | 16,70ms | 467,80ms | 1148,36ms | 2432,52ms |
| Circuit Breaker | 459 (13,00) | 7,84% | 11,73ms | 196,96ms | 399,79ms | 1370,63ms |
| Bulkhead | 443 (12,56) | 7,00% | 16,08ms | 105,03ms | 204,89ms | 2561,69ms |
Na última branch, com os três padrões ativos ao mesmo tempo, dá para ver exatamente qual mecanismo interceptou cada tipo de degradação:
Pontos observados
- Retry + restart automático são um combo, não peças isoladas: das 26 chamadas que falharam na primeira tentativa no branch (retry-backoff), 16 foram salvas porque a tentativa seguinte já pegou o
payment-servicereiniciado. O retry sozinho não salvaria uma indisponibilidade sustentada. - Latência p(95) caiu 14x (3005ms → 205ms), mas não de forma monotônica em cada métrica: a latência máxima do branch (bulkhead) (2562ms) é maior que a do branch (circuit-breaker) (1371ms), porque o bulkhead sem fila deixa passar direto quem não foi rejeitado, mesmo que o
payment-serviceesteja num momento ruim do ciclo de memória. - Por que a Latência Máxima subiu no Bulkhead (2562 ms) em comparação ao Circuit Breaker (1371 ms)?
- A diferença entre as latências máximas ocorre devido à forma como cada padrão lida com chamadas a um serviço degradado (payment-service em ciclo de gargalo de memória):
- Circuit Breaker (Fail-Fast): Ao detectar a taxa de erros, o circuito "abre" e passa a rejeitar hamadas imediatamente (em poucos milissegundos), sem sequer repassá-las ao serviço de destino. Como nenhuma requisição fica esperando pelo processamento lento do serviço instável, a latência máxima registrada permanece baixa (1371 ms).
- Bulkhead sem Fila (Concorrência Limitada): O Bulkhead limita apenas a quantidade de chamadas simultâneas (ex.: máximo de 10).
- Excedentes (11ª em diante): São rejeitadas instantaneamente com erro
503. - Permitidas (as 10 primeiras): Conseguem passar para o serviço de pagamento. Contudo, se o serviço estiver extremamente lento (prestes a dar Out Of Memory), essas poucas requisições permitidas ficam retidas aguardando a resposta, o que eleva a latência máxima isolada para 2562 ms.
- Excedentes (11ª em diante): São rejeitadas instantaneamente com erro
- A diferença entre as latências máximas ocorre devido à forma como cada padrão lida com chamadas a um serviço degradado (payment-service em ciclo de gargalo de memória):
- Taxa de erro não é a métrica que deve ser otimizada isoladamente. Os branches (circuit-breaker e bulkhead) aceitam mais erro (7,84% / 7,00%) que o branch (retry backoff) (2,58%) em troca de latência muito mais previsível e do isolamento entre serviços. A decisão certa depende do que o negócio valoriza mais: menos pedidos falhos ou um sistema que nunca trava em cascata.
- Garantia de entrega é um problema arquitetural diferente. Nenhum desses três padrões entrega 100% dos pedidos sob uma indisponibilidade sustentada do
payment-service, isso exigiria uma camada assíncrona (fila/outbox), fora do escopo deste case.
O código completo, com os quatro branches, os READMEs congelados de cada etapa e os relatórios brutos de cada execução, está em: