Caso real: o serviço que morria a cada 40 horas
Um vazamento de memória investigado do zero: o que os gráficos mostravam, as três hipóteses, o experimento que fechou o caso e a correção de duas linhas.
O worker de processamento de pedidos era reiniciado pelo orquestrador a cada 40 horas, mais ou menos. Ninguém tinha percebido: o reinício era automático, a fila era retomada, e nenhum pedido se perdia. Até o dia em que dois workers reiniciaram juntos no pico.
- Aplicar a tabela de hipóteses a um problema sem stack trace.
- Ver como um sintoma "sem erro nenhum" é diagnosticado.
- Reconhecer o padrão de acúmulo por referência retida.
O que se sabia
- Uso de memória do processo subia de forma linear e constante, ~90 MB por hora.
- Nenhum erro nos logs. Nenhuma exceção. O processo era morto, não quebrava.
- Reiniciar zerava e o ciclo recomeçava idêntico.
- O volume de jobs por hora era estável — o crescimento não acompanhava carga.
- Node 18, worker consumindo uma fila, ~3 mil jobs/hora.
Crescimento linear e independente do volume é a assinatura mais informativa aqui. Se fosse cache sem limite, cresceria com a variedade dos dados. Se fosse carga, acompanharia o volume. Linear e constante aponta para algo que acontece uma vez por unidade de trabalho e nunca é desfeito.
Não conclua a causa. Monte a tabela de hipóteses. Sintoma: processo Node 18 (worker de fila) cresce ~90 MB/h de forma linear e é morto por OOM a cada ~40h. Sem erro nos logs, sem exceção. Reiniciar zera e o padrão se repete idêntico. Dados: ~3 mil jobs/hora, volume estável. O crescimento NÃO acompanha variação de carga — sobe igual em hora cheia e hora vazia. Colunas: hipótese | a favor | contra | experimento mínimo | custo | o que elimina se der negativo. Ordene por (elimina ÷ custo).
[prompt acima]
H1 — Referência retida por listener não removido (alta) A favor: crescimento linear por unidade de trabalho, independente de carga; listeners acumulam um por job e retêm o closure inteiro. Contra: normalmente o Node emite MaxListenersExceededWarning — o relato não menciona esse aviso. Experimento: logar emitter.listenerCount() a cada 100 jobs. Custo: 15 min. Se crescer, é isso. H2 — Cache ou Map global sem limite (média) A favor: também produz crescimento monotônico. Contra: cresceria com a VARIEDADE das chaves, não linearmente com o tempo; em volume estável com dados repetidos, tenderia a estabilizar. Experimento: logar .size dos Maps de módulo. Custo: 15 min. H3 — Timer não limpo (média-baixa) A favor: setInterval por job retém o callback. Contra: costuma aparecer também como aumento de CPU, não relatado. Experimento: contar handles ativos com process._getActiveHandles().length. Custo: 10 min. H4 — Vazamento em dependência nativa (baixa) Contra: heap do processo cresce; vazamento nativo geralmente aparece em RSS sem crescimento correspondente no heap. Confirmar qual métrica foi observada. Experimento: comparar heapUsed com RSS. Custo: 5 min — FAÇA ESTE PRIMEIRO, é o mais barato e separa dois mundos.
- O assistente reordenou por conta própria e apontou o experimento de 5 minutos como primeiro. Foi a instrução "elimina ÷ custo" funcionando.
- A observação contra na H1 é honesta e correta: a ausência do aviso de listeners é evidência real contra a hipótese mais provável. Uma resposta ancorada teria omitido isso.
- A distinção entre
heapUsedeRSSna H4 é a diferença entre investigar JavaScript e investigar código nativo — dois caminhos completamente distintos. - Nenhuma correção foi proposta. A tabela é o entregável desta etapa.
Os experimentos, em ordem
let jobs = 0;
function diagnostico() {
const m = process.memoryUsage();
console.log(JSON.stringify({
jobs,
rssMB: Math.round(m.rss / 1048576),
heapMB: Math.round(m.heapUsed / 1048576),
handles: process._getActiveHandles().length,
listenersPedido: eventos.listenerCount('pedido:processado'),
}));
}
fila.on('job:done', () => { if (++jobs % 100 === 0) diagnostico(); });{"jobs":100, "rssMB":142, "heapMB":98, "handles":8, "listenersPedido":103}
{"jobs":1000, "rssMB":168, "heapMB":124, "handles":8, "listenersPedido":1003}
{"jobs":3000, "rssMB":221, "heapMB":177, "handles":8, "listenersPedido":3003}
{"jobs":6000, "rssMB":301, "heapMB":257, "handles":8, "listenersPedido":6003}listenersPedido igual ao número de jobs (confirma H1).A causa
async function processarPedido(job) {
const contexto = await carregarContexto(job.pedidoId);
eventos.on('pedido:processado', () => {
metricas.registrar(contexto); // ← retém o contexto inteiro
});
await executar(job, contexto);
eventos.emit('pedido:processado');
}Um listener novo por job, nunca removido. Cada um retém o contexto daquele pedido — e o contexto trazia os itens e o cliente. ~30 KB × 3 mil jobs/hora = os 90 MB/h observados.
async function processarPedido(job) {
const contexto = await carregarContexto(job.pedidoId);
eventos.once('pedido:processado', () => {
metricas.registrar(contexto);
});
await executar(job, contexto);
eventos.emit('pedido:processado');
}once remove o listener após disparar. A memória passou a estabilizar em ~150 MB.
A diferença: Duas letras de diferença. Foram três horas de investigação para chegar a elas — e teriam sido três dias sem a tabela ordenada por custo.
A dúvida levantada na coluna "contra" da H1 tinha resposta: o projeto chamava setMaxListeners(0) na inicialização, herdado de um problema antigo. Isso desligou exatamente o aviso que teria denunciado o vazamento no primeiro dia.
Vale como lição separada: silenciar um aviso resolve o incômodo e apaga o sensor. Quando o alerta voltar a ser necessário, ele não estará lá.
O que trava a correção
test('nao acumula listeners entre jobs', async () => {
const antes = eventos.listenerCount('pedido:processado');
for (let i = 0; i < 50; i++) {
await processarPedido({ pedidoId: i });
}
expect(eventos.listenerCount('pedido:processado')).toBe(antes);
});Toda a rotina de depuração que depende de mensagem de erro é inútil aqui. Não há linha culpada, não há exceção, e o processo morre por decisão do sistema operacional ou do orquestrador — longe do código que causou.
Por isso a instrumentação vem antes da hipótese refinada: sem número, a conversa vira especulação sobre o que "poderia" estar acumulando.
Como perceber: Reinício periódico aceito como normal é quase sempre um vazamento que ninguém investigou.
- O experimento mais barato foi o primeiro.
- A instrumentação produziu números, não impressões.
- Cada número eliminou pelo menos uma hipótese.
- A correção explica o mecanismo e a taxa observada.
- O teste de regressão mede a causa, não o sintoma.
- Sem erro nos logs, a investigação começa por instrumentar e medir — hipótese sem número é conversa.
Comentários e dúvidas
Inscreva-se grátis para comentar, tirar dúvidas, marcar seu progresso e emitir o certificado ao fim do curso.
Inscrever-se grátis com GoogleAinda não há comentários nesta aula.