Exportação que derruba o servidor: memória por request vezes concorrência
Calcule quanto cada request pesado segura, gere o arquivo em stream em vez de montar tudo na memória, limite quantos rodam ao mesmo tempo e devolva 503 em vez de derrubar o processo.
Dia 5, 9h. O financeiro de cinco filiais abre o sistema ao mesmo tempo e clica em "Exportar vendas do ano". Trinta segundos depois, o processo do ASP.NET Core morre por falta de memória, e com ele caem também o PDV, o painel e a API de estoque, que rodavam no mesmo container. No teste, a exportação sempre funcionou. A resposta curta: o custo de memória real é o que um request segura multiplicado por quantos rodam ao mesmo tempo. Gere o arquivo em stream, linha a linha, em vez de montar tudo na memória; limite a concorrência das rotas pesadas; e quando o limite estourar, responda "ocupado, tente de novo" em vez de deixar o processo cair para todos.
Este guia trata do pico, não do vazamento. Se a memória sobe e nunca mais volta, mesmo sem tráfego, o problema é retenção, assunto de memória que sobe e não volta.
A conta que ninguém fez
A rota de exportação original fazia o que quase todo tutorial ensina: buscar tudo com ToListAsync(), montar o CSV num StringBuilder, converter para bytes e devolver com File(). Para o ano inteiro da rede, eram cerca de 600 mil vendas. Uma estimativa grosseira do pico de memória de um único request:
- Lista de entidades rastreadas pelo EF Core: 600 mil objetos com strings e controle de mudanças, na casa de 200 MB.
- O CSV inteiro no
StringBuilder: cerca de 60 MB de texto, que em .NET ocupa o dobro porque cada caractere usa dois bytes, ou seja, uns 120 MB. - O array de bytes final para o
File(): mais 60 MB.
Algo perto de 380 MB por exportação, tudo vivo ao mesmo tempo no fim do processo. O container tinha limite de 2 GB e a aplicação já usava uns 400 MB no normal. Uma exportação passa. Duas passam. Cinco simultâneas pedem 1,9 GB a mais, e o processo é morto. O pico não era ataque nem acesso anormal. Era o desenho, exposto pela primeira segunda-feira do mês.
Há um agravante: objetos acima de 85.000 bytes vão para o Large Object Heap do .NET, que é coletado com menos frequência e fragmenta. Buffers grandes e repetidos deixam o processo ocupando mais memória do que os dados exigem.
Pool não é teto
A reação comum é "vamos usar pool". ArrayPool<byte>.Shared é ótimo para reaproveitar buffers e reduzir alocação, mas ele não limita quanta memória o processo usa. Se 50 requests pedem um buffer de 1 MB ao mesmo tempo e o pool não tem 50 guardados, ele simplesmente aloca mais. O pool reduz o lixo; quem limita o pico é você, controlando o tamanho de cada pedaço e quantos pedaços existem ao mesmo tempo.
var buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
int lidos;
while ((lidos = await origem.ReadAsync(buffer.AsMemory(0, 81920))) > 0)
await destino.WriteAsync(buffer.AsMemory(0, lidos));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Dois detalhes. Rent pode devolver um array maior que o pedido, por isso o código usa o tamanho que pediu, não buffer.Length. E o Return fica no finally: buffer alugado e não devolvido não é vazamento grave, mas anula o benefício do pool.
Como refazer a exportação
- Projete só as colunas do arquivo. Nada de entidade inteira com rastreamento.
- Leia em stream.
AsAsyncEnumerable()entrega linha a linha conforme o banco envia. - Escreva direto na resposta. Um
StreamWritersobreResponse.Body, sem montar o arquivo antes. - Limite a concorrência da rota. Duas exportações ao mesmo tempo, sem fila.
- Recuse com 503 e
Retry-After. A pessoa vê "aguarde", os outros serviços continuam de pé. - Teste com concorrência. Disparar cinco exportações simultâneas faz parte do teste da rota.
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;
o.OnRejected = (ctx, _) =>
{
ctx.HttpContext.Response.Headers.RetryAfter = "30";
return ValueTask.CompletedTask;
};
o.AddConcurrencyLimiter("exportacao", c =>
{
c.PermitLimit = 2;
c.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/relatorios/vendas.csv", async (int ano, LojaDbContext db, HttpContext http) =>
{
var inicio = new DateTime(ano, 1, 1);
var fim = inicio.AddYears(1);
http.Response.ContentType = "text/csv; charset=utf-8";
http.Response.Headers.ContentDisposition = "attachment; filename=vendas-" + ano + ".csv";
await using var writer = new StreamWriter(http.Response.Body, new UTF8Encoding(false));
await writer.WriteLineAsync("id;data;filial;total_centavos");
var vendas = db.Vendas
.AsNoTracking()
.Where(v => v.Data >= inicio && v.Data < fim)
.OrderBy(v => v.Id)
.Select(v => new { v.Id, v.Data, v.FilialCodigo, v.TotalCentavos })
.AsAsyncEnumerable();
await foreach (var v in vendas)
await writer.WriteLineAsync(v.Id + ";" + v.Data.ToString("yyyy-MM-dd") + ";" + v.FilialCodigo + ";" + v.TotalCentavos);
})
.RequireAuthorization()
.RequireRateLimiting("exportacao");
O limitador de concorrência faz parte do ASP.NET Core desde o .NET 7. Com PermitLimit = 2 e QueueLimit = 0, a terceira exportação simultânea recebe 503 na hora. O filtro por intervalo de datas, em vez de v.Data.Year == ano, deixa o banco usar o índice da coluna. Num CSV real, campos de texto livre precisam ter aspas e separadores tratados; aqui as colunas são numéricas e códigos fixos.
Quanto a nova versão segura
Agora cada exportação mantém em memória o buffer do StreamWriter, os buffers de leitura do driver e uma linha por vez. Medindo no mesmo ambiente, o pico por request caiu para poucos megabytes, e com duas simultâneas o processo nem percebe. A exportação demora o mesmo tempo, porque o gargalo era o banco, mas o primeiro byte chega ao navegador em menos de um segundo, em vez de depois de 30. Se a sua exportação precisa de pós-processamento que exige o arquivo completo, como gerar um zip, escreva num arquivo temporário em disco com limite de tamanho e apague ao final, em vez de segurar tudo na memória.
E quando o relatório é pesado demais para o request
Se a exportação leva minutos, o request síncrono é o desenho errado mesmo com stream: o proxy pode cortar por timeout e a pessoa não sabe se deu certo. Nesse caso, o clique cria um pedido de exportação, um processo em segundo plano gera o arquivo e a pessoa baixa quando ficar pronto. A fila também é a forma natural de limitar concorrência. O guia timeout antes do infinito explica por que todo passo precisa de prazo.
Separar processos ajuda a conter o estrago. Se a exportação roda no mesmo container do PDV, a queda de uma derruba a outra. Um worker de relatórios com limite de memória próprio transforma "o sistema caiu" em "o relatório demorou".
Erros comuns
- Testar a exportação só com uma pessoa. A rota passa no teste e cai no primeiro dia de fechamento, quando todos clicam juntos.
ToListAsync()do ano inteiro. Centenas de megabytes de entidades rastreadas para escrever um texto que poderia sair linha a linha.- Montar o arquivo em
StringBuilderouMemoryStream. O arquivo inteiro fica na memória, em mais de uma cópia, até o último byte sair. - Achar que
ArrayPoollimita memória. O pool aloca mais quando falta, e o pico continua sem teto. - Fila infinita no limitador. Requests esperam até o proxy cortar, seguram conexão e a pessoa clica de novo.
- Rodar relatório pesado junto com o serviço crítico. O estouro de uma rota secundária derruba a venda.
Checklist
- Você sabe estimar quantos megabytes um request da rota mais pesada segura.
- Exportações leem em stream e escrevem direto na resposta ou em disco temporário.
- Rotas pesadas têm limite de concorrência com fila pequena ou nenhuma.
- A recusa devolve 503 com
Retry-After, e a tela explica o que fazer. - Buffers alugados de pool são devolvidos num
finally. - Existe teste com pelo menos cinco execuções simultâneas da rota pesada.
- O serviço crítico não divide limite de memória com geração de relatório.
Abra cinco abas e dispare ao mesmo tempo a maior exportação do seu sistema: o processo sobrevive, ou você descobre agora o que aconteceria no próximo fechamento?
Referência: https://docs.microsoft.com/pt-br/aspnet/core/performance/memory