Programação Por Equipe DevBras

Capa do artigo: ASP.NET Core lento: ache a rota e as consultas culpadas antes de pôr cache

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

  1. 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.
  2. 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).
  3. Ligue o log de comandos do EF Core em desenvolvimento e conte as consultas de uma única chamada.
  4. Olhe o pool de threads sob carga para descobrir bloqueio síncrono.
  5. 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 .Result ou .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 do Where: 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 Take e projeção com Select.
  • Leitura pura usa AsNoTracking.
  • Não há .Result nem .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?

Avalie este artigo

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