Programação Por Equipe DevBras

Capa do artigo: Exportação que derruba o servidor: memória por request vezes concorrência

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

  1. Projete só as colunas do arquivo. Nada de entidade inteira com rastreamento.
  2. Leia em stream. AsAsyncEnumerable() entrega linha a linha conforme o banco envia.
  3. Escreva direto na resposta. Um StreamWriter sobre Response.Body, sem montar o arquivo antes.
  4. Limite a concorrência da rota. Duas exportações ao mesmo tempo, sem fila.
  5. Recuse com 503 e Retry-After. A pessoa vê "aguarde", os outros serviços continuam de pé.
  6. 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 StringBuilder ou MemoryStream. O arquivo inteiro fica na memória, em mais de uma cópia, até o último byte sair.
  • Achar que ArrayPool limita 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?

Avalie este artigo

Referência: https://docs.microsoft.com/pt-br/aspnet/core/performance/memory