O que é o problema N+1

O problema N+1 e um anti-padrão de acesso a banco de dados onde, em vez de uma consulta buscar todos os dados necessários de uma vez, o código faz 1 consulta para obter uma lista e depois N consultas adicionais -- uma para cada item da lista -- para buscar dados relacionados. O resultado: se a lista tem 50 itens, você faz 51 chamadas ao banco onde deveria fazer 1.

Em ASP.NET Core com Entity Framework, o N+1 e especialmente traiçoeiro porque o código parece correto a primeira vista. Um loop foreach sobre uma coleção de entidades, acessando uma propriedade de navegação, parece inocente -- até você ligar o profiler e ver 50 queries disparando.

Um artigo no Dev.to sobre o tema mostrou um caso real: uma API que demorava mais de 2 segundos para retornar uma lista de 50 pedidos. Depois de diagnosticar com MiniProfiler, o desenvolvedor encontrou 51 queries ao banco. Após a correção, caiu para 1 query e 80ms de resposta. A diferença de performance foi de mais de 25x.

⚠️
Atenção

O N+1 não gera erro -- o código roda normalmente. Só aparece como lentidão em produção quando o volume de dados cresce. E comum passar meses sem detectar porque em desenvolvimento os datasets são pequenos.

Como o problema acontece no Entity Framework

O mecanismo por trás do N+1 no EF Core e o lazy loading -- quando uma propriedade de navegação e acessada, o EF dispara automaticamente uma query ao banco para carrega-la. Em um loop, isso multiplica as queries pelo tamanho da coleção.

Veja o exemplo clássico:

// PROBLEMA: lazy loading em loop - 1 + N queries
var pedidos = await _context.Pedidos.ToListAsync(); // 1 query

foreach (var pedido in pedidos)
{
    // EF dispara 1 query por iteração para carregar Cliente
    Console.WriteLine(pedido.Cliente.Nome); // N queries!
}

// Com 50 pedidos: 51 queries ao banco

O código parece correto. Acessa pedido.Cliente.Nome de forma natural. O problema e que Cliente e uma propriedade de navegação que não foi carregada junto com os pedidos, então o EF faz lazy loading a cada acesso.

O lazy loading só funciona se estiver habilitado no contexto (requer o pacote Microsoft.EntityFrameworkCore.Proxies e a configuração UseLazyLoadingProxies()). Com EF Core 5+ sem proxies, esse código faria lazy loading silencioso via implicit loading em alguns cenários ou lançar NullReferenceException em outros, dependendo da configuração.

Como identificar o problema

As principais ferramentas para diagnosticar N+1 em ASP.NET Core:

  • MiniProfiler: middleware que injeta um painel de profiling na aplicação, mostrando todas as queries SQL executadas por request com timing. Ideal para desenvolvimento local.
  • EF Core Logging: habilitar o log de queries do EF Core para ver no console/arquivo cada SQL disparado.
  • Application Insights / OpenTelemetry: em produção, traces de dependência mostram o número e duração de chamadas ao banco por request.
  • SQL Profiler / pg_stat_statements: no lado do banco, monitorar queries repetidas com parâmetros variando e alto volume.
// EF Core logging para diagnóstico em desenvolvimento
services.AddDbContext(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging() // mostra parâmetros
);

// No appsettings.json, alternativa mais controlada:
// "Microsoft.EntityFrameworkCore.Database.Command": "Information"

Com o logging ativo, você vera no console exatamente quais queries estão sendo disparadas. Se ver a mesma query rodando N vezes com parâmetros diferentes, encontrou o N+1.

💡
Dica

Em ambiente de desenvolvimento, habilite sempre o logging de queries do EF. O overhead e mínimo e economiza horas de debugging em produção. Adicione no appsettings.Development.json, não no padrão.

Como corrigir: passo a passo

A correção principal e usar eager loading com Include() para carregar as propriedades de navegação na query principal:

// CORRETO: eager loading com Include - 1 query
var pedidos = await _context.Pedidos
    .Include(p => p.Cliente)          // JOIN na query
    .Include(p => p.Itens)            // também carrega Itens
        .ThenInclude(i => i.Produto)  // e Produto de cada Item
    .ToListAsync();

// Agora o loop não dispara queries adicionais
foreach (var pedido in pedidos)
{
    Console.WriteLine(pedido.Cliente.Nome); // já carregado!
}

Para casos onde você não precisa de todas as propriedades, projection com Select() e ainda melhor -- busca apenas o que é necessário:

// MELHOR: projection - busca apenas os campos necessários
var pedidos = await _context.Pedidos
    .Select(p => new PedidoResumoDto
    {
        Id = p.Id,
        NomeCliente = p.Cliente.Nome, // EF resolve o JOIN
        TotalItens = p.Itens.Count(),
        ValorTotal = p.Itens.Sum(i => i.Preço * i.Quantidade)
    })
    .ToListAsync();

// Uma query, apenas os dados necessários, sem entidades cheias

A projection e frequentemente a melhor solução para endpoints de listagem: você controla exatamente o que o SQL busca, sem carregar entidades completas que serão convertidas para DTO de qualquer forma.

O papel do cache nesse problema

A conexão entre cache miss e N+1 e sutil mas importante. Em aplicações que usam cache de segundo nível (como Redis ou cache em memoria), a primeira chamada após expiração do cache (o cache miss) vai ao banco. Se o código tem um N+1 latente, o cache miss vai expor isso completamente: a primeira request após o miss faz 51 queries, e só depois o resultado e cacheado para as próximas requests.

O efeito pode ser mais dramático em cache busting simultâneo (thundering herd): se muitas requests chegam ao mesmo tempo com cache expirado, todas fazem as 51 queries simultaneamente. O banco recebe uma rajada de carga que pode causar timeout ou degradação geral do sistema.

// Pattern: cache aside com EF + eager loading correto
public async Task> GetPedidosAsync(int clienteId)
{
    var cacheKey = $"pedidos:{clienteId}";
    
    if (_cache.TryGetValue(cacheKey, out IEnumerable cached))
        return cached;

    // Query correta: eager loading antes de cachear
    var pedidos = await _context.Pedidos
        .Where(p => p.ClienteId == clienteId)
        .Include(p => p.Itens)
        .Select(p => new PedidoDto { ... })
        .ToListAsync();

    _cache.Set(cacheKey, pedidos, TimeSpan.FromMinutes(5));
    return pedidos;
}
🚀
Pro tip

Para evitar thundering herd em cache busting, implemente o padrão de cache com jitter: adicione um tempo aleatório pequeno ao TTL de cada entrada (ex: 5 minutos + random(0-30s)). Isso distribui as expirações ao longo do tempo em vez de todas expirarem juntas.

Comparação de abordagens

Existem diferentes estratégias para resolver N+1, cada uma com tradeoffs:

  • Eager loading (Include): solução mais comum. Faz um JOIN na query principal. Pode gerar queries com muitos JOINs em grafos complexos de entidades, potencialmente retornando muita dados duplicados.
  • Projection (Select + DTO): busca apenas os campos necessários. Mais eficiente que Include para listagens. Exige mais código (definir DTOs e mapeamentos), mas e a abordagem recomendada para endpoints de leitura.
  • Split queries: EF Core pode dividir automaticamente queries complexas com Include em múltiplas queries simples em vez de um JOIN gigante. Habilite com .AsSplitQuery().
  • Raw SQL / Dapper: para consultas complexas onde o EF não gera SQL eficiente, usar SQL direto ou Dapper da controle total. Mais verboso mas sem surpresas de performance.
  • Paginação no banco: sempre paginar resultados grandes -- nenhuma solução acima ajuda se você busca 10.000 registros de uma vez.

Pontos positivos e limitações

Usar Include corretamente elimina o N+1, mas pode introduzir outro problema: cartesian explosion. Se você inclui múltiplas coleções no mesmo Include (ex: Include Itens e Include Pagamentos), o EF faz um JOIN que multiplica as linhas -- um pedido com 5 itens e 3 pagamentos retorna 15 linhas no resultado do JOIN, com dados repetidos. Para esses casos, use AsSplitQuery() ou Dapper.

A projection com Select e a abordagem mais robusta para listagens, mas exige disciplina: você precisa definir DTOs específicos para cada query e manter os mapeamentos. Em projetos maiores, ferramentas como AutoMapper ou Mapster automatizam isso.

A raiz do problema e muitas vezes arquitetural: repositórios genéricos que retornam IQueryable sem Include forcing os consumidores a acessar propriedades de navegação depois. Prefira repositórios específicos que controlam o que é carregado por caso de uso.

Casos de uso reais

APIs de listagem: endpoints GET /api/pedidos que retornam lista paginada com dados de entidades relacionadas. O N+1 e o bug mais frequente nesse tipo de endpoint em APIs com EF Core.

Relatórios e dashboards: queries que agregam dados de múltiplas tabelas. Projection com cálculos no banco (Sum, Count, GroupBy) e muito mais eficiente do que carregar entidades e calcular no C#.

Background jobs: workers que processam lotes de registros. N+1 em um job que processa 10.000 registros por hora pode ser a diferença entre rodar em 5 minutos ou em 2 horas.

Exportação de dados: funcionalidades de export CSV/Excel que carregam grandes volumes. Projection para DTO plano antes de serializar e essencial para não estourar memoria e tempo de resposta.

Dicas e boas práticas

💡
Dica

Adicione o pacote MiniProfiler.AspNetCore ao seu projeto de API e habilite em Development. E gratuito, open source e mostra todas as queries com timing por request no browser. O ROI em debugging e enorme.

💡
Dica

Para endpoints de leitura, prefira sempre AsNoTracking() nas queries do EF. Desabilita o change tracking, reduz uso de memoria e melhora performance em 10-20% para queries que não precisam salvar mudanças depois.

🔴
Cuidado

Incluir coleções grandes com Include sem paginação pode ser pior que o N+1 original. Se cada Pedido tem 1.000 Itens e você lista 50 pedidos com Include(Itens), esta carregando 50.000 entidades de uma vez. Sempre pagine e considere se Include ou projection e mais adequado para o volume real de dados.

Vale a pena investir nisso?

Sim, e uma das otimizações de maior impacto por unidade de esforço em aplicações .NET. O N+1 e um dos bugs de performance mais comuns e também um dos mais simples de corrigir uma vez que é identificado. A correção geralmente e adicionar um Include ou reescrever para projection -- poucas linhas de código que podem melhorar performance em 10x ou mais.

Para quem esta construindo APIs novas, o hábito de revisar queries EF com profiler desde o inicio evita surpresas em produção. Para quem tem APIs legadas lentas, investigar N+1 com MiniProfiler e o primeiro lugar a olhar antes de qualquer outra otimização.

O próximo passo: adicione MiniProfiler ao seu projeto ASP.NET Core hoje, rode a API localmente e abra qualquer endpoint de listagem. O painel vai mostrar exatamente quantas queries estão sendo disparadas. Você provavelmente vai se surpreender.