Blog

Exportação de relatório que derruba o servidor: gerar arquivo grande em streaming sem estourar memória

No fechamento do terceiro trimestre, a analista do financeiro abriu o painel administrativo, escolheu o trimestre inteiro e clicou em exportar pedidos. Eram um milhão e oitocentas mil linhas. Por quarenta segundos a tela mostrou só o indicador de carregando, então ela clicou de novo, e depois mais uma vez. A API rodava em três pods de 1 GiB, e cada clique caiu em um pod diferente. Os três passaram de um gigabyte, foram encerrados pelo Kubernetes com OOMKilled e, durante quase quatro minutos, todos os clientes da loja receberam 502 no checkout. O relatório nunca chegou a ser baixado. O código da exportação estava correto no sentido mais estreito: buscava os pedidos certos, formatava as colunas certas e devolvia um CSV válido em homologação, onde o trimestre tinha oito mil linhas. O defeito era de forma, e não de regra: o código montava o arquivo inteiro na memória antes de enviar o primeiro byte, e o tamanho do relatório crescia junto com a empresa. Este artigo mostra por que a exportação ingênua derruba o processo inteiro e não só a si mesma, como gerar o arquivo em streaming do cursor do banco até o socket do cliente com memória constante, quais detalhes quebram essa solução em produção, como fazer o mesmo com XLSX, quando mover a exportação para um job assíncrono com upload direto para armazenamento de objetos e como provar com um teste que a memória não cresce mais com o tamanho do relatório.

2026-10-01 / Arquitetura / 16 min

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.

EtapaO que fica na memóriaOrdem de grandeza com 1,8 milhão de linhas
Resultado do driverUm objeto por linha, cada valor como string, número ou Date700 MB a 1,2 GB
Linhas formatadasUm array com uma string por linha300 a 500 MB
Arquivo montadoUma única string com o CSV inteiro250 a 400 MB, em um bloco contíguo
Enviores.send converte a string em Buffer antes de escrever no socketMais 250 a 400 MB
StreamingUm lote do cursor e um bloco de texto por vezPoucos 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.

02

Streaming de ponta a ponta: do cursor do banco ao socket do cliente

A correção é nunca ter o relatório inteiro em lugar nenhum. O banco entrega as linhas em lotes por um cursor, a aplicação transforma cada lote em texto e escreve no socket, e o próximo lote só é pedido quando o anterior saiu. A memória usada passa a depender do tamanho do lote, e não do tamanho do relatório. Uma exportação de dez mil linhas e uma de dez milhões usam praticamente a mesma quantidade de memória; a segunda só demora mais.

Sem streaming (tudo em memória antes do primeiro byte)
------------------------------------------------------
banco --[1,8 mi linhas]--> rows[] --map--> linhas[] --join--> csv --send--> cliente
                           heap        heap               heap      Buffer
                           pico: soma das quatro cópias, cresce com o período

Com streaming (memória constante, independente do total)
--------------------------------------------------------
banco --cursor, lote de 1000--> paraCsv --bloco de 64 KB--> res --> cliente
   ^                               |                         |
   |       socket cheio: write() devolve false, pipeline espera 'drain'
   +---------- o gerador para e o cursor não pede o próximo lote ----------+

A peça que torna isso seguro é o backpressure. Quando o cliente baixa mais devagar do que o servidor produz, o buffer do socket enche e res.write passa a devolver false. O stream.pipeline do Node respeita esse sinal: para de puxar dados do gerador até receber o evento drain, o gerador para no for await e o QueryStream deixa de pedir o próximo lote ao banco. Sem backpressure, um cliente lento em uma conexão móvel faria o servidor acumular o relatório inteiro no buffer de saída, e o problema voltaria por outro caminho.

import express from 'express';
import pg from 'pg';
import QueryStream from 'pg-query-stream';
import { pipeline } from 'node:stream/promises';

// Em producao, aponte para uma replica de leitura: a exportacao longa nao
// disputa CPU nem segura o vacuum do primario.
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });

export const SQL_PEDIDOS =
  'SELECT p.id, p.criado_em, c.nome AS cliente, p.status, p.total ' +
  '  FROM pedidos p JOIN clientes c ON c.id = p.cliente_id ' +
  ' WHERE p.criado_em >= $1 AND p.criado_em < $2 ' +
  ' ORDER BY p.criado_em, p.id';

export const COLUNAS = ['id', 'criado_em', 'cliente', 'status', 'total'];
const NUMERO = /^-?\d+(\.\d+)?$/;
const DATA = /^\d{4}-\d{2}-\d{2}$/;

// Uma celula de CSV para o Excel em pt-BR: separador ponto e virgula,
// aspas dobradas e protecao contra formula em texto digitado por usuario.
export function celula(valor) {
  if (valor === null || valor === undefined) return '';
  let texto = valor instanceof Date ? valor.toISOString() : String(valor);
  if (/^[=+\-@\t\r]/.test(texto) && !NUMERO.test(texto)) texto = "'" + texto;
  if (/[";\r\n]/.test(texto)) texto = '"' + texto.replace(/"/g, '""') + '"';
  return texto;
}

// Transforma o fluxo de linhas do banco em fluxo de texto. Junta as linhas
// em blocos de cerca de 64 KB para nao entregar um pedaco minusculo por vez.
export async function* paraCsv(linhas) {
  let bloco = '\uFEFF' + COLUNAS.join(';') + '\r\n';
  for await (const linha of linhas) {
    bloco += COLUNAS.map((c) => celula(linha[c])).join(';') + '\r\n';
    if (bloco.length >= 65536) {
      yield bloco;
      bloco = '';
    }
  }
  if (bloco) yield bloco;
}

const app = express();
const MAX_EXPORTACOES = 2;
const DIAS_MAX_DOWNLOAD_DIRETO = 31;
let ativas = 0;

app.get('/relatorios/pedidos.csv', async (req, res) => {
  const { de, ate } = req.query;
  if (!DATA.test(de || '') || !DATA.test(ate || '') || de >= ate) {
    return res.status(400).json({ erro: 'use de=AAAA-MM-DD e ate=AAAA-MM-DD, com de < ate' });
  }
  if ((Date.parse(ate) - Date.parse(de)) / 86400000 > DIAS_MAX_DOWNLOAD_DIRETO) {
    return res.status(422).json({ erro: 'periodo longo demais; use POST /exportacoes' });
  }
  // Cada exportacao segura uma conexao do pool e CPU de serializacao.
  // Acima do limite, o cliente tenta de novo em vez de derrubar a API.
  if (ativas >= MAX_EXPORTACOES) {
    res.set('Retry-After', '30');
    return res.status(429).json({ erro: 'muitas exportacoes em andamento' });
  }

  ativas += 1;
  let client;
  let descartar = false;
  try {
    client = await pool.connect();
    await client.query('BEGIN READ ONLY');
    const linhas = client.query(new QueryStream(SQL_PEDIDOS, [de, ate], { batchSize: 1000 }));
    res.set({
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': 'attachment; filename="pedidos-' + de + '-a-' + ate + '.csv"',
      'Cache-Control': 'no-store',
    });
    // pipeline respeita backpressure: se o cliente baixa devagar, o socket
    // enche, o gerador para e o cursor deixa de pedir lotes ao banco.
    await pipeline(linhas, paraCsv, res);
    await client.query('COMMIT');
  } catch (err) {
    descartar = true;
    // Se o pipeline falhou, ele ja destruiu a resposta. Sem o bloco final do
    // chunked, o navegador marca o download como falho em vez de salvar um
    // CSV truncado que parece completo. Cliente que desistiu nao e erro.
    if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error('exportacao falhou', err);
    if (!res.headersSent && !res.destroyed) res.status(500).json({ erro: 'falha na exportacao' });
  } finally {
    ativas -= 1;
    // Conexao com cursor interrompido volta em estado incerto: descarta.
    client?.release(descartar);
  }
});

app.listen(3000);

Algumas escolhas do código merecem explicação. O gerador paraCsv junta as linhas em blocos de cerca de 64 KB porque escrever uma linha por vez no socket gera milhões de chamadas pequenas e custa CPU sem ganhar nada em memória. O lote de mil linhas do cursor é um equilíbrio entre idas e voltas ao banco e memória por lote; acima de alguns milhares, o ganho de vazão é pequeno. A rota limita o período do download direto e o número de exportações simultâneas por instância, porque streaming resolve memória, mas não resolve CPU nem conexões do pool: cada exportação ainda serializa milhões de valores e segura uma conexão durante todo o download.

O mesmo vale para qualquer outra fonte. Em ORMs, procure o modo de iteração em lotes ou cursor, como stream no Knex, cursor no Prisma por meio de paginação por chave ou iterate no TypeORM com QueryRunner. Em MySQL, o mysql2 oferece query().stream(). O princípio é sempre o mesmo: se a função devolve um array, ela já carregou tudo.

03

Os detalhes que quebram a exportação em produção

Trocar o array por um stream é a parte fácil. O que separa uma exportação que funciona no notebook de uma que aguenta produção são os casos em que algo dá errado no meio de um download de dez minutos.

  • Erro depois do primeiro byte. Os cabeçalhos com status 200 já foram enviados e não existe mais como responder 500. Se o servidor simplesmente encerrar a resposta normalmente, o cliente salva um CSV truncado que parece completo e alguém fecha o mês com metade dos pedidos. Por isso o código deixa o pipeline destruir a resposta sem o bloco final do Transfer-Encoding chunked: o navegador marca o download como falho e o curl termina com erro.
  • Cliente que desiste. Quando a pessoa fecha a aba, o pipeline rejeita com ERR_STREAM_PREMATURE_CLOSE, destrói o cursor e libera a conexão. Sem isso, o banco continua lendo e o servidor continua formatando um arquivo que ninguém vai receber. A conexão com cursor interrompido volta ao pool com release(true), que a descarta em vez de reaproveitar uma sessão em estado incerto.
  • CSV que o Excel abre errado. Excel em português espera ponto e vírgula como separador e só reconhece UTF-8 com o BOM no início; sem ele, João vira João. Campos com aspas, ponto e vírgula ou quebra de linha precisam ir entre aspas, com aspas internas dobradas.
  • Injeção de fórmula. Um cliente que se cadastrou com o nome =HYPERLINK("https://...") vira uma fórmula ativa quando o financeiro abre o arquivo. Texto que começa com =, +, -, @, tabulação ou retorno de carro recebe um apóstrofo na frente, e números negativos legítimos ficam de fora da regra.
  • Proxy e balanceador. Timeouts de ociosidade de 60 segundos, comuns em balanceadores e no proxy_read_timeout padrão do nginx, só derrubam a conexão quando nenhum byte trafega. Com streaming, o cabeçalho sai em milissegundos e os dados fluem continuamente, então o timeout de ociosidade deixa de ser um problema; o tempo total máximo da requisição, se existir, continua valendo e define o limite do download direto.
  • Transação longa no primário. Um cursor aberto por dez minutos segura um snapshot, e enquanto ele existir o vacuum não remove versões mortas de linhas em nenhuma tabela. Exportações devem ler de uma réplica de leitura, e o atraso da réplica é aceitável para relatórios, desde que a tela informe até que horário os dados vão.

A consistência do arquivo vem de graça: no PostgreSQL, uma única instrução SELECT lê um snapshot só do início ao fim, mesmo que o cursor leve minutos para ser consumido. Pedidos criados durante o download não aparecem pela metade. Essa garantia se perde quando a exportação é feita em várias consultas paginadas, que é um dos motivos para preferir o cursor sempre que a infraestrutura permitir.

04

XLSX sem carregar a planilha inteira

O pedido de exportar em Excel costuma reintroduzir o problema por dentro da biblioteca. A API mais conhecida das bibliotecas de planilha monta o workbook inteiro como objetos em memória e só gera o arquivo no final, com consumo ainda maior do que o CSV, porque cada célula vira um objeto com valor, tipo e estilo. Um arquivo XLSX é um zip de arquivos XML, e o XML de cada aba pode ser escrito linha a linha. As bibliotecas que suportam isso oferecem um modo de escrita incremental.

import ExcelJS from 'exceljs';
import { COLUNAS } from './exportacao.js';

const LIMITE_EXCEL = 1048576 - 1; // linhas por aba, menos o cabecalho

// Escrita incremental: cada linha vira XML dentro do zip assim que recebe
// commit() e sai da memoria. useSharedStrings: false evita a tabela de
// textos repetidos, que cresce junto com o arquivo.
export async function escreverXlsx(linhas, destino) {
  const livro = new ExcelJS.stream.xlsx.WorkbookWriter({
    stream: destino,
    useStyles: false,
    useSharedStrings: false,
  });
  const aba = livro.addWorksheet('Pedidos');
  aba.columns = COLUNAS.map((c) => ({ header: c, key: c }));

  let total = 0;
  for await (const linha of linhas) {
    if (++total > LIMITE_EXCEL) {
      throw new RangeError('acima do limite de linhas do Excel; exporte em CSV');
    }
    // numeric chega do pg como string; converte para a celula ser numero
    aba.addRow({ ...linha, total: Number(linha.total) }).commit();
  }
  await aba.commit();
  await livro.commit();
}

Três limitações precisam estar claras antes de oferecer XLSX. A primeira é o limite do próprio Excel, de 1.048.576 linhas por aba: acima disso, a escolha honesta é recusar e oferecer CSV, ou dividir em abas, e não truncar em silêncio. A segunda é que o modo incremental do ExcelJS não espera o evento drain do destino, então ele deve escrever em um destino rápido, como um arquivo local ou um upload para armazenamento de objetos, e não diretamente na resposta para um cliente lento. A terceira é o custo de CPU: gerar XML e comprimir em zip custa várias vezes mais do que gerar CSV, o que é mais um motivo para a exportação grande rodar fora do processo da API.

05

Quando o relatório não cabe em uma requisição: exportação assíncrona

Streaming resolve a memória, mas uma requisição HTTP de quinze minutos continua frágil: um deploy no meio reinicia o pod, uma troca de rede no celular derruba o download, e o usuário fica preso olhando a barra de progresso. Acima de um volume que você define medindo, a exportação deixa de ser uma resposta e passa a ser um trabalho.

  1. O usuário pede a exportação e a API grava um job com status pendente e os filtros, devolvendo 202 com o identificador. A tela mostra que o arquivo está sendo gerado e que a pessoa será avisada.
  2. Um worker separado da API pega o job da fila, abre o cursor na réplica e escreve o arquivo em streaming direto para o armazenamento de objetos, com upload multipart e compressão.
  3. Ao terminar, o worker grava o status concluído e um link pré-assinado com validade curta, e avisa o usuário por e-mail ou notificação no painel.
  4. Uma regra de ciclo de vida no bucket apaga os arquivos depois de alguns dias, porque relatórios com dados de clientes não devem ficar armazenados para sempre.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import QueryStream from 'pg-query-stream';
import { PassThrough } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { pool, SQL_PEDIDOS, paraCsv } from './exportacao.js';

const s3 = new S3Client({});
const BUCKET = process.env.EXPORTS_BUCKET;

// Roda no worker, fora do processo que atende a API. O job chega de uma
// fila com { id, de, ate } e devolve um link temporario para o arquivo.
export async function gerarExportacao(job) {
  const chave = 'exportacoes/' + job.id + '/pedidos.csv';
  const corpo = new PassThrough();
  const upload = new Upload({
    client: s3,
    params: {
      Bucket: BUCKET,
      Key: chave,
      Body: corpo,
      ContentType: 'text/csv; charset=utf-8',
      ContentEncoding: 'gzip',
      ContentDisposition: 'attachment; filename="pedidos-' + job.de + '-a-' + job.ate + '.csv"',
    },
    // Envio multipart: no maximo 2 partes de 8 MB em memoria ao mesmo tempo
    partSize: 8 * 1024 * 1024,
    queueSize: 2,
  });

  const client = await pool.connect();
  let descartar = false;
  try {
    await client.query('BEGIN READ ONLY');
    const linhas = client.query(new QueryStream(SQL_PEDIDOS, [job.de, job.ate], { batchSize: 1000 }));
    // Os dois lados precisam terminar: o pipeline que produz e o upload que
    // consome. Se um falha, o outro e interrompido e o erro sobe.
    await Promise.all([pipeline(linhas, paraCsv, createGzip(), corpo), upload.done()]);
    await client.query('COMMIT');
  } catch (err) {
    descartar = true;
    await upload.abort().catch(() => {});
    throw err;
  } finally {
    client.release(descartar);
  }

  return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: chave }), {
    expiresIn: 3600,
  });
}

O upload multipart com partes de 8 MB e fila de 2 limita a memória do worker a algumas dezenas de megabytes, e o PassThrough propaga o backpressure: se o envio para o bucket fica lento, o pipeline para e o cursor espera. O Promise.all garante que a função só termina quando os dois lados terminaram, e o abort no erro descarta as partes já enviadas, que de outro modo ficariam cobradas no bucket sem formar um arquivo. A compressão gzip com Content-Encoding reduz o arquivo de CSV em cinco a dez vezes, e o navegador descomprime de forma transparente ao baixar pelo link.

Esse desenho também resolve o clique repetido. Antes de criar um job, a API verifica se já existe um pendente com os mesmos filtros para o mesmo usuário e devolve o identificador existente. O worker processa poucos jobs em paralelo, e uma fila de exportações cheia significa espera maior, e não checkout fora do ar.

06

Como provar que a memória ficou constante

Um teste que exporta cem linhas e confere o conteúdo não pega nada disso. O que precisa ser provado é que o pico de memória não depende do tamanho do relatório e que o arquivo chega completo mesmo com um cliente lento. O teste usa volume real, um cliente limitado de propósito e uma comparação de contagem.

# 2 milhoes de pedidos sinteticos no banco de teste
psql "$DATABASE_URL" -c "
  INSERT INTO pedidos (cliente_id, criado_em, status, total)
  SELECT 1 + g % 50000, timestamptz '2026-07-01' + g * interval '3 seconds',
         'pago', (g % 900) + 0.99
    FROM generate_series(1, 2000000) g"

# Cliente lento de proposito, 2 MB/s, para exercitar o backpressure.
# Durante a execucao, o servidor registra process.memoryUsage().rss a cada segundo.
curl -s --limit-rate 2M -o /tmp/pedidos.csv -w '%{http_code} %{size_download}\n' \
  'http://localhost:3000/relatorios/pedidos.csv?de=2026-07-01&ate=2026-07-31'

# O arquivo precisa ter cabecalho + todas as linhas do periodo
wc -l /tmp/pedidos.csv
psql "$DATABASE_URL" -Atc "
  SELECT count(*) FROM pedidos
   WHERE criado_em >= '2026-07-01' AND criado_em < '2026-07-31'"

Rode o mesmo teste com duzentas mil e com dois milhões de linhas e compare o pico de RSS registrado pelo servidor. Na versão com streaming, os dois picos ficam praticamente iguais; se o segundo for dez vezes maior, algo no caminho ainda está acumulando, e o suspeito mais comum é um middleware de compressão ou de log que guarda o corpo da resposta. Em seguida, interrompa o curl no meio e confirme na pg_stat_activity que a consulta foi cancelada e que a conexão saiu do pool. Por fim, rode três exportações ao mesmo tempo e confirme que a terceira recebe 429 enquanto a latência das outras rotas fica estável.

MétricaAntesDepois
Pico de RSS do pod em uma exportação do trimestreAcima de 1 GiB, OOMKilled140 MiB
Tempo até o primeiro byte48 s, quando chegava a terminar0,4 s
p99 das outras rotas durante a exportação9 s210 ms
Exportações simultâneas por instânciaNenhuma sem risco de derrubar o pod2, o restante em 429 ou na fila assíncrona

Depois da mudança, a exportação do trimestre passou a sair pelo fluxo assíncrono em pouco mais de três minutos, com um arquivo de 38 MB comprimido, e os downloads diretos de até um mês começam em menos de meio segundo. Desde então, o painel de memória dos pods da API não mostra mais degraus na hora do fechamento do mês.

FAQ

Perguntas frequentes

Não seria mais simples aumentar a memória do servidor?

Compra tempo, não resolve. O consumo da exportação ingênua cresce linearmente com o volume, e o volume cresce com a empresa: o relatório que hoje cabe em 4 GiB não cabe no ano que vem. Além disso, aumentar a memória de todos os pods da API para atender um uso raro é caro, e ainda há o teto do tamanho de string do V8, que nenhuma quantidade de memória remove. Com streaming, o mesmo pod de 1 GiB exporta qualquer volume, e o limite passa a ser o tempo, que você trata movendo a exportação para um job.

Uso PgBouncer em modo transação. O cursor continua funcionando?

Funciona, desde que todo o cursor fique dentro de uma única transação, como no código do artigo, porque o PgBouncer mantém a mesma conexão do servidor até o COMMIT. O custo é que essa conexão fica presa durante toda a exportação. Se isso pesar, a alternativa é paginação por chave: buscar lotes com WHERE (criado_em, id) > ($1, $2) ORDER BY criado_em, id LIMIT 5000, guardando a última chave de cada lote. Cada lote é uma transação curta, mas o arquivo deixa de ser um snapshot único, e pedidos alterados durante a exportação podem aparecer com valores de momentos diferentes.

Devo oferecer CSV, XLSX ou os dois?

CSV como padrão para volume, porque é gerado em streaming verdadeiro, comprime muito bem e é lido por qualquer ferramenta de análise. XLSX quando o destino é uma pessoa que vai abrir no Excel e precisa de tipos corretos, como datas e números formatados, e desde que o volume caiba no limite de linhas por aba. Na prática, muitos times oferecem XLSX até um limite de linhas e, acima dele, geram CSV automaticamente com um aviso na tela, em vez de deixar o usuário escolher um formato que vai falhar.

Exportação grande não é um problema de memória, é um problema de forma

Montar o relatório inteiro antes de enviar funciona em homologação e derruba o processo inteiro no dia em que o volume real chega, levando junto as rotas que não têm nada a ver com o relatório. A correção é tratar a exportação como fluxo: cursor no banco, transformação em blocos e escrita no socket com backpressure, com cuidado explícito para o cliente que desiste, o erro no meio do download, o CSV que o Excel precisa abrir e a transação longa que não pode ficar no primário. Acima de um volume medido, a exportação vira um job assíncrono que escreve direto no armazenamento de objetos e devolve um link. Posso revisar as exportações e relatórios do seu sistema, implementar o streaming e o fluxo assíncrono e montar o teste de carga que prova que a memória não cresce mais com o tamanho do arquivo.