01
Por que um botão de exportar derruba o servidor inteiro
A versão que quase todo sistema tem começa assim: uma consulta que devolve todas as linhas do período, um map que transforma cada linha em texto, um join que cola tudo e um res.send no final. Cada passo é simples e funciona com dados de teste. O problema é que cada passo guarda uma cópia completa do relatório e nenhum deles libera a anterior até a função terminar.
// Versao que derruba o servidor: carrega tudo, monta tudo, envia tudo
app.get('/relatorios/pedidos.csv', async (req, res) => {
const { rows } = await pool.query(SQL_PEDIDOS, [req.query.de, req.query.ate]);
// Antes desta linha, rows ja tem 1,8 milhao de objetos no heap
const linhas = rows.map((r) =>
[r.id, r.criado_em.toISOString(), r.cliente, r.status, r.total].join(';'),
);
const csv = ['id;criado_em;cliente;status;total', ...linhas].join('\n');
// Agora existem tres copias dos mesmos dados: rows, linhas e csv.
// res.send ainda cria uma quarta, o Buffer que vai para o socket.
res.send(csv);
});O primeiro custo aparece antes de qualquer linha do código da aplicação: por padrão, o node-postgres, assim como a maioria dos drivers e ORMs, acumula o resultado inteiro e só então resolve a Promise. Um objeto JavaScript por linha, com uma string ou um Date para cada coluna, custa na prática algumas centenas de bytes por linha mesmo quando os dados brutos ocupam bem menos. Com catorze colunas, como no relatório real do incidente, o array de resultado sozinho passou de 800 MB.
| Etapa | O que fica na memória | Ordem de grandeza com 1,8 milhão de linhas |
|---|---|---|
| Resultado do driver | Um objeto por linha, cada valor como string, número ou Date | 700 MB a 1,2 GB |
| Linhas formatadas | Um array com uma string por linha | 300 a 500 MB |
| Arquivo montado | Uma única string com o CSV inteiro | 250 a 400 MB, em um bloco contíguo |
| Envio | res.send converte a string em Buffer antes de escrever no socket | Mais 250 a 400 MB |
| Streaming | Um lote do cursor e um bloco de texto por vez | Poucos MB, qualquer que seja o total |
O processo não morre só por falta de memória. Bem antes do OOM, o coletor de lixo do V8 passa a rodar ciclos completos de vários segundos tentando liberar espaço que não pode ser liberado, porque tudo ainda está referenciado. Durante esses ciclos, o event loop fica parado: o health check não responde, as requisições de checkout que estavam no mesmo processo estouram o timeout e o balanceador marca o pod como doente. A exportação de um usuário vira indisponibilidade para todos. E existe um teto que nem a memória resolve: o V8 não cria strings com mais de cerca de 512 milhões de caracteres, e o join de um relatório maior lança RangeError: Invalid string length, depois de ter consumido toda a memória para chegar lá.
Há um terceiro efeito, mais sutil: o usuário não recebe nada durante todo o processamento. Sem primeiro byte, o navegador mostra a página carregando, o balanceador com timeout de ociosidade de 60 segundos corta a conexão em relatórios grandes e a pessoa clica de novo, multiplicando a carga exatamente no momento em que o servidor está mais frágil. Foi isso que transformou um pod derrubado em três.