Programação Por Equipe DevBras

Capa do artigo: Memória do Node.js que só sobe: como achar o cache sem teto antes do OOM

Memória do Node.js que só sobe: como achar o cache sem teto antes do OOM

Descubra se o processo vaza ou só segura demais: meça o heap ao longo do dia, compare snapshots, ponha limite e expiração no cache e solte listeners. Restart diário não é correção.

A API de cotação de frete sobe às 8h usando 120 MB. Às 14h está em 600 MB. Às 19h o container é morto por falta de memória, reinicia sozinho e durante 40 segundos os pedidos de cotação falham. Alguém agenda um restart às 3h "para dar folga" e o problema some do painel, mas não do código. A resposta curta: memória que só cresce e não volta ao patamar depois que o tráfego cai é referência que nunca é solta, quase sempre um cache sem limite, uma coleção global ou um listener registrado a cada requisição. Meça o heap ao longo do tempo, compare dois snapshots para ver o que cresceu, ponha teto e expiração no que guarda dados e remova o que é registrado sem nunca sair.

O exemplo aqui é Node.js, mas o raciocínio vale para qualquer runtime com coletor de lixo. Se o seu caso é ASP.NET e um objeto criado a cada requisição, veja memória por requisição no ASP.NET.

Por que o coletor de lixo não resolve sozinho

O coletor de lixo do V8 libera objetos que ninguém mais alcança. Ele não libera objetos que você ainda referencia, mesmo que nunca mais vá usá-los. Um Map no topo do módulo, que recebe uma chave nova a cada CEP consultado, é perfeitamente alcançável: está pendurado no módulo, que está pendurado no processo. Para o coletor, aquilo é dado em uso.

Por isso existe a distinção que guia o diagnóstico. Um gráfico em serra, que sobe e cai a cada coleta mas volta sempre ao mesmo patamar, é normal. Um gráfico em escada, em que cada vale fica mais alto que o anterior, é retenção. E retenção tem poucas origens comuns:

  • Cache sem limite de tamanho nem expiração, indexado por algo com variedade infinita (CEP, id de usuário, URL com query string).
  • Coleção global que só recebe, como um array de "últimas requisições" para debug que ninguém esvazia.
  • Listener registrado a cada requisição num emissor que vive o processo inteiro, mantendo vivo tudo o que o closure referencia.
  • Timer que nunca é cancelado, com setInterval criado por conexão e sem clearInterval.

O código que vazava

A API de frete tinha dois problemas, ambos com cara de inofensivos:

const EventEmitter = require('node:events');
const tabelaAtualizada = new EventEmitter();
const cacheCotacao = new Map();

app.get('/frete', async (req, res) => {
  const chave = req.query.cep + ':' + req.query.peso;

  tabelaAtualizada.on('mudou', () => cacheCotacao.delete(chave));

  if (cacheCotacao.has(chave)) return res.json(cacheCotacao.get(chave));

  const cotacao = await consultarTransportadora(req.query.cep, req.query.peso);
  cacheCotacao.set(chave, cotacao);
  res.json(cotacao);
});

O Map recebe uma entrada para cada combinação de CEP e peso, e o Brasil tem dezenas de milhares de faixas de CEP. Nada sai dele, a não ser quando a tabela muda. E cada requisição registra mais um listener em tabelaAtualizada, que vive para sempre. Depois do décimo listener, o Node escreve no log o aviso MaxListenersExceededWarning: Possible EventEmitter memory leak detected. O aviso estava lá desde a primeira manhã, ignorado.

Como diagnosticar, passo a passo

  1. Registre o uso de memória ao longo do dia. Um log por minuto com process.memoryUsage() basta para ver se o padrão é serra ou escada.
  2. Procure avisos no log. MaxListenersExceededWarning é uma pista direta.
  3. Confirme a morte por memória. Em Docker, docker inspect mostra se o container foi encerrado por OOM, e o código de saída 137 indica que o processo recebeu SIGKILL.
  4. Tire dois heap snapshots em horários diferentes, com tráfego entre eles.
  5. Compare os snapshots no Chrome DevTools, na visão de comparação, ordenando pelo crescimento.
  6. Corrija a origem e repita a medição por um dia inteiro, sem restart agendado.
setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    rssMB: Math.round(m.rss / 1048576),
    heapUsedMB: Math.round(m.heapUsed / 1048576),
    cacheCotacao: cacheCotacao.size,
    listeners: tabelaAtualizada.listenerCount('mudou')
  }));
}, 60000).unref();

O unref() impede que esse timer segure o processo vivo no desligamento. Logar o tamanho do cache e a contagem de listeners junto com a memória é o que transforma suspeita em evidência: se heapUsedMB cresce na mesma proporção que cacheCotacao, o culpado está nomeado.

docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' api-frete

node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid-do-node>

Com --heapsnapshot-signal, o Node grava um arquivo .heapsnapshot no diretório de trabalho a cada sinal recebido. Tirar o snapshot pausa o processo e pode usar bastante memória extra, então faça fora do pico ou numa réplica. Na comparação, você vai ver milhares de objetos de cotação e de closures retidos, apontando para cacheCotacao e para a lista de listeners de tabelaAtualizada.

A correção: teto, expiração e um listener só

O cache ganha limite de entradas e tempo de vida. O Map do JavaScript mantém a ordem de inserção, o que permite remover a entrada mais antiga sem biblioteca. Em produção, um pacote de cache LRU maduro faz o mesmo com mais recursos; o importante é o teto existir.

const LIMITE = 5000;
const TTL_MS = 10 * 60 * 1000;
const cacheCotacao = new Map();

function lerCache(chave) {
  const item = cacheCotacao.get(chave);
  if (!item) return undefined;
  if (Date.now() > item.expiraEm) {
    cacheCotacao.delete(chave);
    return undefined;
  }
  return item.valor;
}

function gravarCache(chave, valor) {
  cacheCotacao.delete(chave);
  cacheCotacao.set(chave, { valor, expiraEm: Date.now() + TTL_MS });
  if (cacheCotacao.size > LIMITE) {
    const maisAntiga = cacheCotacao.keys().next().value;
    cacheCotacao.delete(maisAntiga);
  }
}

tabelaAtualizada.on('mudou', () => cacheCotacao.clear());

app.get('/frete', async (req, res) => {
  const chave = req.query.cep + ':' + req.query.peso;
  const emCache = lerCache(chave);
  if (emCache) return res.json(emCache);

  const cotacao = await consultarTransportadora(req.query.cep, req.query.peso);
  gravarCache(chave, cotacao);
  res.json(cotacao);
});

O listener agora é registrado uma vez, fora da rota, e limpa o cache inteiro quando a tabela muda. Com 5.000 entradas de poucos kilobytes cada, o teto do cache fica em alguns megabytes, um número que você consegue calcular e defender. No dia seguinte à correção, a API ficou entre 140 e 190 MB das 8h às 22h, em serra, sem restart.

Limite de memória como alarme, não como plano

Defina o limite de memória do container e, se fizer sentido, o tamanho máximo do heap do V8 com --max-old-space-size, deixando folga para o que fica fora do heap (buffers, código nativo). O limite serve para a falha ser previsível e para o alerta disparar antes, por exemplo quando o uso passa de 80% do limite. Restart automático é a rede de segurança que mantém o serviço de pé enquanto você investiga; restart agendado para esconder a escada é dívida com juros, porque o vazamento continua e a janela de erro só muda de horário.

Se o crescimento for de cache distribuído, como Redis, o raciocínio é o mesmo: chave sem expiração acumula até o limite. E se a memória sobe só em pico de concorrência e volta depois, o problema é outro, o de quanto cada requisição segura ao mesmo tempo; está em pool e buffer sem limite.

Erros comuns

  • Cache em Map global sem limite nem expiração. Cresce com a variedade de chaves até o processo morrer.
  • Registrar listener dentro da rota. Cada requisição deixa mais um closure preso a um emissor que vive o processo inteiro.
  • Ignorar MaxListenersExceededWarning. O Node avisou do vazamento e o aviso virou ruído no log.
  • Agendar restart diário como solução. A escada continua, o OOM apenas muda de horário, e a janela de erro fica mais difícil de explicar.
  • Aumentar a memória do container sem diagnóstico. Compra horas, não resolve, e a conta de infraestrutura sobe.
  • Tirar heap snapshot no pico de produção. O processo pausa e pode estourar a memória justamente pelo snapshot.

Checklist

  • Existe um gráfico ou log de memória do processo cobrindo pelo menos um dia de tráfego.
  • Todo cache em memória tem limite de entradas e tempo de expiração definidos.
  • Nenhum listener, timer ou assinatura é criado por requisição sem ser removido.
  • O log não tem MaxListenersExceededWarning.
  • O container tem limite de memória e alerta antes de chegar nele.
  • Não existe restart agendado escondendo crescimento de memória.
  • Você sabe gerar e comparar dois heap snapshots do serviço.

Olhe o gráfico de memória do seu serviço principal nas últimas 24 horas: depois do pico da tarde, ele volta ao patamar da manhã ou cada vale fica mais alto que o anterior?

Avalie este artigo

Referência: https://www.infoq.com/br/news/2018/03/Memory-Optimization/