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
- Observe o processo ao vivo com
dotnet-counters monitor -n LojaSol.Api --counters System.Runtimee acompanhe o tamanho do heap do GC, o working set e as coletas de geração 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.
- 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.
- 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. - 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
StringBuilderouMemoryStream: cada exportação vira objeto no LOH, e várias simultâneas derrubam o processo. IMemoryCachesemSizeLimite 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 comobyte[]. - Leituras grandes usam
AsNoTrackinge 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?
Referência: https://docs.microsoft.com/pt-br/aspnet/core/performance/memory