Arquitetura de Sistemas Distribuídos e Resiliência

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

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.

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

“RAM do payment-service durante o teste (2 ciclos de leak → OOM-kill → restart)” é um gráfico interativo, disponível apenas na versão completa do artigo.

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:

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

Arquitetura Final:

Este é um diagrama interativo, disponível apenas na versão completa do artigo.

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:

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

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

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

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

“Latência (ms) por percentil, por branch” é um gráfico interativo, disponível apenas na versão completa do artigo.

“Taxa de erro HTTP (%) por branch” é um gráfico interativo, disponível apenas na versão completa do artigo.

“Pedidos confirmados vs. falhos, por branch” é um gráfico interativo, disponível apenas na versão completa do artigo.

“Throughput (requisições/s), por branch” é um gráfico interativo, disponível apenas na versão completa do artigo.

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:

“Mecanismos de resiliência disparados no branch 04 (por evento)” é um gráfico interativo, disponível apenas na versão completa do artigo.


Pontos observados

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: