Programação Por Equipe DevBras

Capa do artigo: Página lenta no ASP.NET Core: ache o N+1 do EF Core antes de otimizar o código

Página lenta no ASP.NET Core: ache o N+1 do EF Core antes de otimizar o código

Ligue o log de SQL do EF Core, conte as consultas de um request, troque o N+1 por projeção e só depois pense em microdetalhe. Com números antes e depois de cada mudança.

A reunião de desempenho discute se foreach é mais rápido que LINQ, se vale trocar string por Span e se o JSON deveria usar outro serializador. Enquanto isso, a página de pedidos faz 201 consultas ao banco para mostrar 50 linhas, e leva 2,8 segundos. A resposta curta: em aplicação web com banco, o tempo quase sempre está no que você busca e em quantas viagens faz até o banco, não no ciclo de CPU do seu código. Ligue o log de SQL do EF Core, conte as consultas de um request lento, elimine o N+1 com projeção e meça de novo. Microotimização só entra quando o número disser que a CPU é o gargalo.

Se a sua dúvida é por onde medir no geral (rota, cache, profiler), o guia ASP.NET Core mais rápido sem palpite cobre isso. Este aqui vai fundo num caso só, o mais comum: consulta repetida e dado demais vindo do banco.

Por que o N+1 aparece sem ninguém escrever 201 consultas

Ninguém escreve um laço de consultas de propósito. O N+1 nasce do carregamento preguiçoso (lazy loading) ou de uma consulta dentro de um laço que parecia inofensivo. A página busca 50 pedidos numa consulta. Depois, para cada pedido, a view acessa pedido.Cliente.Nome e pedido.Itens.Count. Com lazy loading ligado, cada acesso dispara uma ida ao banco: 1 consulta para a lista, 50 para clientes, 50 para itens, e mais 100 se outra propriedade de navegação for tocada.

Cada consulta isolada é rápida, talvez 5 milissegundos. O problema é a soma: latência de rede, abertura de comando, materialização de objetos e rastreamento de mudanças do EF Core, multiplicados por 201. Em desenvolvimento, com o banco na mesma máquina, ninguém nota. Em produção, com o banco em outro servidor, cada viagem custa mais e a página arrasta.

O segundo vilão é o excesso: carregar entidades inteiras, com todas as colunas, incluindo um campo de observação com texto longo, só para mostrar número, data, nome e total. O banco lê mais, a rede transporta mais, o EF Core cria e rastreia mais objetos.

Como medir antes de mexer

  1. Escolha um request lento e reproduzível. No exemplo, GET /pedidos?pagina=1.
  2. Ligue o log de comandos do EF Core em desenvolvimento. A categoria Microsoft.EntityFrameworkCore.Database.Command no nível Information registra cada SQL executado com o tempo.
  3. Conte as consultas e some os tempos. Anote o tempo total do request no navegador ou no log do ASP.NET Core.
  4. Corrija uma coisa por vez. Uma mudança, uma medição.
  5. Anote o antes e o depois. No commit ou no PR, com os números.
// appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Com isso, cada consulta aparece no console com algo como Executed DbCommand (4ms) seguido do SQL. Se você vê o mesmo SELECT ... FROM Clientes WHERE Id = @p0 repetido dezenas de vezes, achou o N+1. Não ligue EnableSensitiveDataLogging em produção: ele grava os valores dos parâmetros, que podem ser dados pessoais.

Como corrigir: da entidade inteira à projeção

O código original da página de pedidos era assim:

var pedidos = await db.Pedidos
    .OrderByDescending(p => p.CriadoEm)
    .Skip((pagina - 1) * 50).Take(50)
    .ToListAsync();
// a view acessa pedido.Cliente.Nome e pedido.Itens.Count

A primeira tentativa costuma ser Include. Ela resolve o N+1, mas traz o cliente inteiro e todos os itens, com todas as colunas, para contar itens e mostrar um nome. A correção que resolve as duas coisas é a projeção: pedir ao banco exatamente o que a tela mostra.

public record PedidoLinha(int Id, DateTime CriadoEm, string Cliente, int QtdItens, long TotalCentavos);

var pedidos = await db.Pedidos
    .AsNoTracking()
    .OrderByDescending(p => p.CriadoEm)
    .Skip((pagina - 1) * 50).Take(50)
    .Select(p => new PedidoLinha(
        p.Id,
        p.CriadoEm,
        p.Cliente.Nome,
        p.Itens.Count(),
        p.TotalCentavos))
    .ToListAsync();

O EF Core traduz isso num único SQL com junção e subconsulta de contagem. AsNoTracking tira o custo do rastreamento de mudanças, que não serve para uma tela só de leitura. Na prática, uma projeção dentro de Select sem entidades no resultado já não é rastreada, mas deixar explícito ajuda quem lê.

Os números do exemplo

Na loja do exemplo, medindo o mesmo request dez vezes e pegando a mediana:

  • Antes: 201 consultas, 2,8 segundos.
  • Com Include: 1 consulta, 640 milissegundos, muito dado trafegado por causa dos itens completos.
  • Com projeção: 1 consulta, 95 milissegundos.

Os valores vão variar no seu ambiente; o padrão de ganho é o que se repete. Nenhuma troca de laço ou serializador chegaria perto disso. E a página de pedidos tem paginação; se a sua não tem, o problema seguinte é trazer tudo de uma vez, assunto de paginação que não pula registro.

Depois do banco: o que ainda vale a pena

Com as consultas no lugar, olhe o resto do request na ordem de impacto. Índice para a coluna do filtro e da ordenação, conferindo o plano de execução da consulta gerada. Cache para o que é igual para todo mundo e muda pouco, como a lista de categorias, com expiração definida; página logada exige cuidado extra, porque cache público de conteúdo privado vaza dados. Chamadas a APIs externas com timeout, para uma dependência lenta não segurar a página.

Só então, se o profiler mostrar um método específico consumindo CPU de verdade em código quente, vale a microotimização. Código frio, executado uma vez por request, não paga a perda de clareza. E quando um número melhorar, escreva no PR o que mudou e quanto, senão a próxima refatoração reintroduz o Include e ninguém sabe por que a página voltou a pesar.

Erros comuns

  • Otimizar sem medir. A equipe gasta dias em alocação enquanto 200 consultas por request continuam lá.
  • Lazy loading ligado sem vigilância. Qualquer acesso a navegação na view vira consulta, e o N+1 volta a cada tela nova.
  • Trocar N+1 por Include de tudo. Some a repetição, mas a consulta passa a trazer colunas e linhas que a tela não usa.
  • Medir só em desenvolvimento local. Sem latência de rede até o banco, o N+1 parece barato e passa na revisão.
  • Deixar log sensível do EF Core em produção. Valores de parâmetros, com dados pessoais, vão parar no log.
  • Não registrar o antes e o depois. A melhora some na próxima refatoração e ninguém percebe a regressão.

Checklist

  • Você sabe quantas consultas o request mais acessado faz.
  • Nenhuma consulta idêntica se repete dentro de um mesmo request de listagem.
  • Telas de leitura usam projeção ou AsNoTracking.
  • A listagem é paginada e traz só as colunas exibidas.
  • O log de SQL detalhado está ligado só em desenvolvimento.
  • Cada otimização tem número de antes e depois registrado no PR.
  • Microotimizações só existem em código que o profiler apontou como quente.

Ligue o log de comandos do EF Core e abra a página inicial do seu sistema uma vez: quantos Executed DbCommand aparecem para esse único request?

Avalie este artigo

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