1

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-service
  • payment-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:

  1. Sem proteção
  2. Retry + backoff exponencial + jitter
  3. Circuit Breaker
  4. Bulkhead

O resultado foi interessante:

A latência p95 caiu 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étricaResultado
Requisições286
Throughput8,16 req/s
Erros16,43%
p5033 ms
p903003 ms
p953005 ms
Máximo3035 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étricaResultado
Requisições388
Throughput11,03 req/s
Erros2,58%
p5016,70 ms
p90467,80 ms
p951148,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étricaResultado
Requisições459
Throughput13,00 req/s
Erros7,84%
p5011,73 ms
p90196,96 ms
p95399,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étricaResultado
Requisições443
Throughput12,56 req/s
Erros7,00%
p5016,08 ms
p90105,03 ms
p95204,89 ms
Máximo2561,69 ms

O p95 caiu novamente.


Comparando tudo

Aqui está o resultado das quatro etapas:

Estratégiareq/sErrosp50p90p95
Base8,1616,43%33 ms3003 ms3005 ms
Retry11,032,58%17 ms468 ms1148 ms
Circuit Breaker13,007,84%12 ms197 ms400 ms
Bulkhead12,567,00%16 ms105 ms205 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ãoProblema principal
RetryFalhas temporárias
BackoffEvitar retries agressivos
JitterEvitar sincronização dos retries
Circuit BreakerEvitar chamadas contra serviço degradado
BulkheadLimitar consumo de recursos
TimeoutEvitar 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
Carregando publicação patrocinada...