ASP.NET Core lento: ache a rota e as consultas culpadas antes de pôr cache
Meça a rota que a pessoa sente, conte as consultas ao banco, corrija N+1 e projeção, e só então use output cache para o que é público e muda pouco.
A tela de pedidos leva 2,4 segundos para abrir e a primeira proposta da reunião é "colocar Redis". Quando alguém finalmente liga o log do Entity Framework, aparecem 51 consultas para montar uma lista de 50 linhas. A resposta curta: meça o tempo da rota que o usuário sente, conte quantas consultas ela dispara, corrija N+1, carga sem limite e trabalho síncrono, e só depois cacheie o que é igual para todo mundo, com prazo de validade.
Cache na frente de uma consulta errada entrega o erro mais depressa, até o dado ficar velho e ninguém saber por quê. A ordem importa: primeiro o número, depois a correção, por último o cache. A versão genérica dessa ideia, para qualquer site, está em site mais rápido: meça uma página antes de aplicar vinte técnicas. Aqui o foco é o ASP.NET Core com EF Core.
Por que o palpite costuma errar
Em aplicação web típica, o tempo de uma requisição quase nunca está no C#. Está esperando: o banco, outro serviço HTTP, o disco. Um laço que roda em microssegundos não aparece na conta, e uma consulta extra de 30 ms repetida 50 vezes aparece com 1,5 segundo.
O EF Core torna isso fácil de esconder. Um foreach que acessa uma navegação, um FindAsync dentro de laço ou um ToListAsync antes do filtro parecem inocentes no código e viram dezenas de idas ao banco. Por isso a primeira ferramenta não é profiler de CPU, é o log das consultas.
Como medir a rota que dói
- Escolha uma rota. A que o usuário reclama: a lista de pedidos, o login, o relatório. Não tente melhorar o sistema inteiro.
- Meça a duração no servidor. O log de requisição do ASP.NET Core já registra o tempo total; em produção, use o tracing que você tiver (OpenTelemetry, Application Insights ou similar).
- Ligue o log de comandos do EF Core em desenvolvimento e conte as consultas de uma única chamada.
- Olhe o pool de threads sob carga para descobrir bloqueio síncrono.
- Corrija uma coisa e meça de novo. Sem o número depois, você não sabe se melhorou.
Para ver cada SQL com o tempo de execução, suba o nível da categoria de comandos no appsettings.Development.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
Cada consulta aparece como Executed DbCommand (12ms) seguida do SQL. Abra a tela uma vez e conte as linhas. Se o número cresce com a quantidade de itens da lista, você achou um N+1.
Para observar o processo rodando, a ferramenta dotnet-counters mostra contadores ao vivo, inclusive requisições por segundo e o tamanho da fila do pool de threads:
dotnet tool install --global dotnet-counters
dotnet-counters monitor -n LojaSol.Api --counters System.Runtime,Microsoft.AspNetCore.Hosting
Fila do pool de threads crescendo sob carga moderada é sinal de código bloqueando thread: .Result, .Wait() ou chamada síncrona de I/O no meio de uma requisição. Esse é um dos primeiros itens das boas práticas oficiais, e com razão: poucas requisições lentas travam todas as outras.
Exemplo: a lista de pedidos de 2,4 s para 90 ms
O código original carregava os pedidos da loja e buscava o cliente de cada um:
var pedidos = await db.Pedidos
.Where(p => p.LojaId == lojaId)
.ToListAsync(ct);
var resultado = new List<PedidoResumo>();
foreach (var p in pedidos)
{
var cliente = await db.Clientes.FindAsync(new object[] { p.ClienteId }, ct);
resultado.Add(new PedidoResumo(p.Id, p.CriadoEm, cliente!.Nome, p.TotalCentavos));
}
Três problemas numa tela só. Uma consulta por cliente (o N+1), todos os pedidos da loja carregados quando a tela mostra 50, e entidades completas rastreadas pelo contexto quando só quatro campos interessam. A versão corrigida faz uma consulta só:
var resultado = await db.Pedidos
.AsNoTracking()
.Where(p => p.LojaId == lojaId)
.OrderByDescending(p => p.CriadoEm)
.Take(50)
.Select(p => new PedidoResumo(p.Id, p.CriadoEm, p.Cliente.Nome, p.TotalCentavos))
.ToListAsync(ct);
A projeção com Select vira um único SQL com JOIN, traz só as colunas usadas e limita as linhas no banco. Faltava um índice que atendesse filtro e ordenação juntos:
CREATE INDEX ix_pedidos_loja_criado ON pedidos (loja_id, criado_em DESC);
Resultado medido no mesmo ambiente: de 51 consultas para 1, de 2,4 s para cerca de 90 ms. Nenhum cache envolvido. Se a lista tem "próxima página", veja paginação que não pula antes de usar Skip com números grandes.
Cache só depois, e com dono
Com a consulta certa, sobra o que de fato se repete: a página de categorias, a tabela de fretes, a vitrine pública. Para isso o ASP.NET Core tem o output cache:
builder.Services.AddOutputCache();
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/categorias", async (AppDb db, CancellationToken ct) =>
await db.Categorias.AsNoTracking().OrderBy(c => c.Nome).ToListAsync(ct))
.CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)));
A política padrão do output cache não guarda resposta de requisição autenticada, e isso é uma proteção, não um obstáculo. O que pertence à conta da pessoa não entra em cache compartilhado; o motivo está em cache de página logada não pode ser cache de página pública. E todo cache precisa de uma resposta para "o que acontece quando o dado muda": prazo curto ou invalidação explícita.
Ganhos baratos que não substituem a consulta: compressão de resposta (muitas vezes já feita pelo proxy), arquivos estáticos com hash no nome e cabeçalhos de cache longos, e HTTP/2 ou HTTP/3 no servidor da frente.
Erros comuns
- Cachear antes de medir: o N+1 continua lá, aparece em todo cache frio e a tela fica velha sem ninguém saber.
- Usar
.Resultou.Wait()em código de requisição: sob carga, o pool de threads esgota e todas as rotas ficam lentas, não só a culpada. ToListAsync()antes doWhere: a tabela inteira vem para a memória para ser filtrada no C#.- Carregar entidade inteira para mostrar três campos: mais colunas, mais memória, mais rastreamento, sem nenhum ganho.
- Cachear resposta com dado de usuário: a pessoa B vê o carrinho da pessoa A.
- Medir só no notebook com banco vazio: com 30 linhas tudo é rápido; com 300 mil, o índice que faltava aparece.
Checklist
- Existe uma rota escolhida, com o tempo atual anotado.
- O número de consultas por chamada dessa rota é conhecido.
- Nenhuma consulta roda dentro de laço.
- Listas têm
Takee projeção comSelect. - Leitura pura usa
AsNoTracking. - Não há
.Resultnem.Wait()no caminho da requisição. - O que está em cache é público, tem prazo e tem regra de invalidação.
- O tempo foi medido de novo depois de cada mudança.
Abra o log de comandos do EF Core e carregue a tela mais usada do seu sistema. Quantas consultas ela dispara, e esse número muda quando a lista cresce?
Referência: https://docs.microsoft.com/pt-br/aspnet/core/performance/performance-best-practices