Arquitetura

Paralelismo em Node.js: do Banco ao Event Loop

Da fila simples ao prefetch, entenda concorrência, async/await, auto-scaling e bulkhead usando a analogia de um banco com atendentes e gerente.

NodeJSarquiteturaasyncperformance

Introdução

Imagine um banco. Há atendentes nos guichês, uma fila de clientes e um gerente ao fundo que precisa ser consultado nos casos mais complexos.

Esse cenário simples é uma das melhores analogias para entender como Node.js lida com concorrência — e por que as decisões arquiteturais que parecem óbvias no mundo físico se traduzem diretamente em padrões de engenharia que fazem a diferença entre um sistema que escala e um que colapsa sob carga.

Vamos percorrer esse caminho do cenário mais simples ao mais complexo, sempre relacionando o banco com o que acontece no código.

Use as animações de cada seção para visualizar o que acontece em cada etapa. Ou explore todos os cenários de uma vez com a navegação completa abaixo.

animation
bank-parallelism

Cenário 1 — Fila Simples, 6 Atendentes

O banco abre com 6 guichês. Cada atendente recebe um cliente, processa o pedido do início ao fim e só então chama o próximo. Fluxo linear e previsível.

Fora do horário de pico funciona bem. Mas às 12h, o volume de clientes explode. A fila cresce mais rápido do que os atendentes conseguem absorver. Clientes esperando 35 minutos ficam insatisfeitos.

animation
bank-parallelism/basic_queue

No Node.js, o equivalente é processamento síncrono no event loop:

javascript
// ❌ Atendimento síncrono — bloqueia o event loop
app.get('/saldo', (req, res) => {
  const result = calcularSaldo(req.params.id); // CPU-bound, bloqueia tudo
  res.json(result);
});

Uma função síncrona pesada impede que todas as outras requisições sejam processadas enquanto ela roda. Node.js é single-threaded: um atendente bloqueado bloqueia o banco inteiro.

Lição: Nunca execute operações CPU-bound ou bloqueantes no event loop. Use worker_threads para CPU-bound ou torne operações I/O verdadeiramente assíncronas.


Modificação 1 — Mais Atendentes

A solução óbvia: contratar mais atendentes. O gerente dobra o efetivo para 12.

O pico do almoço é absorvido. Mas às 15h, o movimento cai e 10 dos 12 atendentes ficam ociosos — com custo fixo correndo.

animation
bank-parallelism/more_tellers

Na prática de Node.js, isso é escalar horizontalmente com um número fixo de workers. Funciona no pico, mas desperdiça recursos — e dinheiro — nos outros momentos.

Lição: Escala horizontal fixa resolve pico mas cria ociosidade no vale. A solução está no auto-scaling, que veremos adiante.


Cenário 2 — Dependência do Gerente

Voltando a 6 atendentes. A maioria dos pedidos (consulta de saldo, extrato) é rápida. Mas alguns pedidos — análise de crédito, aplicações em renda variável — exigem autorização do gerente.

O atendente vai até o escritório do gerente, aguarda ele terminar a reunião e só então volta para o cliente. Durante todo esse tempo, o guichê está parado e a fila cresce.

animation
bank-parallelism/manager_dep

No Node.js, isso é uma chamada I/O bloqueante — uma thread presa esperando banco de dados ou API externa responder:

javascript
// ❌ Chamada bloqueante
app.get('/credito', (req, res) => {
  const result = http.get(creditServiceUrl); // bloqueia!
  res.json(result);
});

Lição: Identifique quais rotas fazem chamadas lentas. Essas são suas dependências do gerente.


Modificação 2 — Área de Espera (Async/Await)

Solução elegante: quando um pedido precisa do gerente, o atendente orienta o cliente a sentar na área de espera e já chama o próximo da fila. Quando o gerente terminar, ele mesmo chama o cliente de volta.

O guichê fica livre. O throughput aumenta.

animation
bank-parallelism/async_chairs

Node.js foi projetado exatamente para isso. O await real suspende a execução e devolve o controle ao event loop:

javascript
// ✅ async/await — event loop livre
app.get('/credito', async (req, res) => {
  // Suspende essa fn, libera o loop p/ outras reqs
  const result = await creditService.analyze(req.body);
  res.json(result);
});

Mas há um limite: a área de espera tem apenas 4 cadeiras. Promises acumuladas sem controle consomem heap. O custo de manter cada "cliente nas cadeiras" é real.

Lição: async/await libera o event loop, mas memória não é infinita. Sempre limite a concorrência de operações paralelas.


Modificação 3 — Auto-Scaling

O gerente decide não mais contratar número fixo. Em vez disso, ele monitora a fila: quando ultrapassa X clientes, contrata atendentes temporários. Quando a fila esvazia e há ociosidade, dispensa o excedente mantendo um mínimo.

animation
bank-parallelism/auto_scaling

Em Kubernetes, isso é o Horizontal Pod Autoscaler (HPA):

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

O custo agora é proporcional ao uso real: sobe no pico, desce no vale.

Lição: Auto-scaling é a combinação de eficiência de custo e capacidade de absorver pico. É a resposta correta para cargas variáveis.


Modificação 4 — Retry com Exponential Backoff

O gerente está sobrecarregado. Às vezes, quando o atendente vai consultá-lo, ele simplesmente não está disponível. Um atendente ingênuo iria de volta imediatamente — e ficaria perturbando o gerente a cada segundo. Isso piora a situação.

O atendente experiente sabe esperar: tenta uma vez, aguarda 2s, tenta de novo, aguarda 4s, tenta mais uma vez.

animation
bank-parallelism/retry_backoff
javascript
async function withBackoff(fn, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
      // 1s, 2s, 4s — com jitter para evitar sincronismo
      const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

O exponential backoff dá tempo para o serviço se recuperar de uma sobrecarga temporária e evita que múltiplos clientes retroalimentem o problema ao martelar o serviço ao mesmo tempo (thundering herd).

Lição: Retry sem backoff piora falhas em cascata. Exponential backoff + jitter é o padrão de resiliência para dependências externas.


Cenário de Sobrecarga — Sem Limite de Concorrência

Sem nenhum controle, o que acontece quando 500 clientes chegam ao mesmo tempo e todos precisam do gerente? O atendente coloca todos na área de espera sem verificar quantas cadeiras existem. O gerente pergunta ao atendente quantos estão esperando — o atendente entra em colapso nervoso.

animation
bank-parallelism/overflow
javascript
// ❌ Sem backpressure — heap explode
app.post('/operacao', async (req, res) => {
  // 500 reqs simultâneas = 500 Promises no heap
  const result = await servicoExterno(req.body);
  res.json(result);
});

// Terminal:
// FATAL ERROR: Reached heap limit
// Allocation failed - JavaScript heap out of memory

Lição: Concorrência irrestrita é tão destrutiva quanto processamento síncrono. O sistema precisa de backpressure.


Modificação Final — Prefetch e Bulkhead

A solução é o prefetch: o atendente só aceita, no máximo, 3 clientes na área de espera de uma vez. Quando chega um 4º que precisa das cadeiras, ele fica na fila principal até uma vaga abrir.

animation
bank-parallelism/prefetch

Em RabbitMQ, prefetch é nativo:

javascript
// RabbitMQ — prefetch alinhado à capacidade
channel.prefetch(3); // máx 3 mensagens não-acked ao mesmo tempo
channel.consume('operacoes', async (msg) => {
  await processarOperacao(JSON.parse(msg.content));
  channel.ack(msg); // libera slot p/ próxima
});

Para HTTP ou qualquer fluxo de Promises, o padrão Bulkhead com p-limit:

javascript
import pLimit from 'p-limit';

const limit = pLimit(3); // máx 3 Promises simultâneas

const results = await Promise.all(
  requests.map(req => limit(() => servicoExterno(req)))
);

O p-limit mantém uma fila interna: quando há slot livre, despacha a próxima tarefa. O heap permanece estável porque nunca há mais de 3 Promises abertas simultaneamente.

Lição: Prefetch e Bulkhead limitam a concorrência ao que o sistema aguenta, criando backpressure automático e prevenindo esgotamento de recursos.


Resumo dos Padrões

Cenário do BancoPadrão em Node.jsProblema resolvido
Atendente síncronoEvent loop bloqueadoThroughput zero durante operações pesadas
Mais atendentes fixosPM2 cluster / réplicas fixasPico absorvido, mas custo fixo alto
Atendente espera gerenteI/O bloqueanteGuichê parado, fila cresce
Área de espera (cadeiras)async/await não-bloqueanteEvent loop livre durante I/O
Número de cadeiras limitadoLimite de Promises abertasControle de memória
Gerente contrata sob demandaKubernetes HPA / KEDACusto proporcional ao uso
Atendente com paciênciaRetry + Exponential BackoffResiliência a falhas temporárias
Sem limite de cadeirasConcorrência irrestritaHeap esgotado, processo morto
Máximo de cadeirasPrefetch + Bulkhead (p-limit)Throughput máximo seguro

Conclusão

Concorrência em Node.js não é sobre fazer tudo ao mesmo tempo — é sobre fazer o certo ao mesmo tempo, com limites bem definidos. O banco funciona bem não porque tem atendentes infinitos, mas porque tem processos claros: quem espera onde, por quanto tempo, e o que acontece quando a capacidade é atingida.

async/await libera o event loop mas não resolve tudo sozinho. Auto-scaling garante capacidade sem desperdício. Retry com backoff evita tempestades. Prefetch e Bulkhead protegem contra colapso.

Esses padrões juntos formam a base de qualquer sistema Node.js que precisa ser confiável sob carga real.