além do script

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.

arquiteturacircuit-breakerresiliencia

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:

BranchO que foi adicionado
feature/01-base-service-unstableCenário base: payment-service com memory leak real, sem nenhuma proteção
feature/02-pattern-retry-backoffRetry com backoff exponencial + jitter, e restart: on-failure no payment-service
feature/03-pattern-circuit-breakerCircuit breaker: falha rápido quando o payment-service está degradado
feature/04-pattern-bulkhead-isolationBulkhead: 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ãoAlternativa ConsideradaPor 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 3000msO 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 maiorEvitar 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:

js
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:

js
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:

js
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.

BranchRequisições (req/s)Erro %p50p90p95Máx
Base286 (8,16)16,43%33,11ms3002,77ms3004,54ms3034,73ms
Retry388 (11,03)2,58%16,70ms467,80ms1148,36ms2432,52ms
Circuit Breaker459 (13,00)7,84%11,73ms196,96ms399,79ms1370,63ms
Bulkhead443 (12,56)7,00%16,08ms105,03ms204,89ms2561,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-service reiniciado. 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-service esteja 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.
  • 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: