Programação Por Equipe DevBras

Capa do artigo: Memória do ASP.NET que estoura à tarde: o que cada requisição aloca

Memória do ASP.NET que estoura à tarde: o que cada requisição aloca

Ache o endpoint que carrega dado demais, troque lista inteira e string gigante por streaming, limite o cache e confirme com dotnet-counters e dotnet-gcdump.

O container da API tem 512 MB. De manhã, o processo usa 180 MB. Às 15h, alguém do financeiro exporta o relatório de vendas do mês, a memória vai a 600 MB e o orquestrador mata o processo. Não houve deploy nem bug novo, e o log só mostra o reinício. A resposta curta: ache o endpoint que monta a resposta inteira na memória (lista completa, string gigante, arquivo num array de bytes) e troque por streaming; dê limite a todo cache em memória; e confirme com dotnet-counters se a memória volta depois da carga ou fica presa.

Memória de servidor web é orçamento por requisição multiplicado por requisições simultâneas. Um endpoint que aloca 300 MB uma vez por dia derruba o processo tanto quanto um vazamento lento. Os dois casos têm sintomas parecidos e soluções diferentes, por isso o diagnóstico vem antes da correção.

Como o .NET usa e devolve a memória

O coletor de lixo (GC) do .NET separa objetos por idade. Os de vida curta, criados e descartados dentro de uma requisição, são recolhidos rápido e barato. Objetos de 85.000 bytes ou mais vão para o heap de objetos grandes (LOH), que só é recolhido nas coletas mais caras e não é compactado por padrão. Uma lista de 400 mil itens, um StringBuilder com o CSV inteiro ou um byte[] com o PDF entram nessa categoria.

Há dois padrões que importam. No pico, a memória sobe durante uma requisição pesada e desce depois que ela termina e o GC roda. No vazamento, a memória sobe e não volta, porque alguma coisa de vida longa (um singleton, um campo estático, um cache sem teto, um evento sem desinscrição) segura a referência. Pico se resolve alocando menos por requisição. Vazamento se resolve achando quem segura. O segundo caso tem um guia próprio: uso de memória que só sobe é vazamento ou cache sem teto.

Em container, o runtime enxerga o limite de memória do cgroup e ajusta o GC a ele. Isso ajuda, mas não faz milagre: se uma requisição precisa de 600 MB vivos ao mesmo tempo, não há coleta que caiba em 512 MB.

Diagnosticar: pico ou referência presa

  1. Observe o processo ao vivo com dotnet-counters monitor -n LojaSol.Api --counters System.Runtime e acompanhe o tamanho do heap do GC, o working set e as coletas de geração 2.
  2. Reproduza a carga real: o relatório do mês, a lista sem filtro, dez pessoas ao mesmo tempo. Um clique solitário não mostra o problema.
  3. Pare a carga e espere. Se a memória desce, foi pico. Se fica no alto mesmo após coletas de geração 2, há referência presa.
  4. Tire um retrato do heap com dotnet-gcdump collect -p <PID> e abra no Visual Studio ou no PerfView. Os tipos com mais bytes mostram quem está ocupando.
  5. Compare o endpoint com a tela: quantas linhas ele devolve e quantas a pessoa vê.
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-gcdump

dotnet-counters ps
dotnet-counters monitor -p 4821 --counters System.Runtime
dotnet-gcdump collect -p 4821 -o api-15h.gcdump

No caso do relatório, o retrato do heap mostrou um List<Venda> com 412 mil entidades rastreadas e um StringBuilder de 140 MB. Era pico, não vazamento: dez minutos depois a memória voltava a 200 MB. Mas um único pico bastava para matar o processo.

Exemplo: exportar 400 mil linhas sem carregar 400 mil linhas

O endpoint original fazia o caminho mais óbvio: carregar tudo, montar o texto, devolver o texto.

app.MapGet("/relatorios/vendas.csv", async (AppDb db) =>
{
    var vendas = await db.Vendas.ToListAsync();
    var sb = new StringBuilder("id;data;total\n");
    foreach (var v in vendas)
        sb.Append(v.Id).Append(';').Append(v.Data.ToString("yyyy-MM-dd"))
          .Append(';').Append(v.TotalCentavos).Append('\n');
    return Results.Text(sb.ToString(), "text/csv");
});

Cada linha existe três vezes na memória ao mesmo tempo: como entidade rastreada, como trecho do StringBuilder e como parte da string final. A versão com streaming lê do banco aos poucos e escreve direto na resposta:

app.MapGet("/relatorios/vendas.csv", async (AppDb db, HttpResponse response, CancellationToken ct) =>
{
    response.ContentType = "text/csv; charset=utf-8";
    response.Headers.ContentDisposition = "attachment; filename=vendas.csv";

    await using var escritor = new StreamWriter(
        response.Body, new UTF8Encoding(false), bufferSize: 16 * 1024, leaveOpen: true);

    await escritor.WriteLineAsync("id;data;total");

    var linhas = db.Vendas
        .AsNoTracking()
        .OrderBy(v => v.Id)
        .Select(v => new { v.Id, v.Data, v.TotalCentavos })
        .AsAsyncEnumerable();

    await foreach (var v in linhas.WithCancellation(ct))
        await escritor.WriteLineAsync($"{v.Id};{v.Data:yyyy-MM-dd};{v.TotalCentavos}");

    await escritor.FlushAsync(ct);
});

AsAsyncEnumerable faz o EF Core entregar uma linha por vez enquanto lê o resultado do banco, AsNoTracking evita que o contexto guarde cada entidade, e a projeção traz só três colunas. A memória do endpoint passou a depender do tamanho do buffer, não do número de linhas. Medido: pico de 600 MB virou variação de menos de 30 MB, com o mesmo arquivo de saída. Se a pessoa cancela o download, o CancellationToken interrompe a leitura do banco.

Arquivos e corpos de requisição

A mesma lógica vale para arquivos. Ler um PDF inteiro com File.ReadAllBytesAsync para devolvê-lo coloca o arquivo no LOH a cada download. Devolva o Stream com Results.File ou File(stream, ...) no controller. Na entrada, ler o corpo inteiro numa string para depois desserializar faz o mesmo estrago; deixe o desserializador ler do fluxo.

Cache em memória com teto

Cache sem tamanho máximo é a tabela inteira de novo, com outro nome. O IMemoryCache aceita um limite, e com ele cada entrada precisa declarar o próprio tamanho, numa unidade que você escolhe:

builder.Services.AddMemoryCache(o => o.SizeLimit = 5_000);

// ao gravar
cache.Set($"produto:{id}", produto, new MemoryCacheEntryOptions
{
    Size = 1,
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});

Com SizeLimit definido, gravar uma entrada sem Size lança exceção, o que é bom: ninguém esquece. Pool e buffer reaproveitados seguem a mesma regra de ter teto, como mostra pool e buffer sem limite viram a queda que você chama de pico de acesso.

Erros comuns

  • Reiniciar o processo como solução: funciona até a próxima tarde e esconde qual endpoint estoura.
  • Carregar a tabela e filtrar no C#: a memória cresce com o banco, não com a tela, e um dia passa do limite.
  • Montar resposta grande em StringBuilder ou MemoryStream: cada exportação vira objeto no LOH, e várias simultâneas derrubam o processo.
  • IMemoryCache sem SizeLimit e sem expiração: o cache cresce até ser a maior coisa do processo.
  • Guardar dados de requisição em singleton ou campo estático: eles nunca morrem e a memória só sobe.
  • Testar com um clique só: o problema depende de volume e de concorrência, e um clique não mostra nenhum dos dois.

Checklist

  • Você sabe qual endpoint aloca mais, medido com carga real.
  • Você sabe se o padrão é pico ou referência presa.
  • Exportações e listas grandes usam streaming, não lista completa.
  • Arquivos são devolvidos como Stream, não como byte[].
  • Leituras grandes usam AsNoTracking e projeção.
  • Todo cache em memória tem tamanho máximo e expiração.
  • O limite de memória do container tem folga para o pior endpoint sob concorrência.

Procure no seu código o endpoint que devolve mais dados do que a tela mostra. Ele carrega tudo com ToListAsync antes de responder, e quanta memória ele usaria se dez pessoas o chamassem ao mesmo tempo?

Avalie este artigo

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