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.
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 bancoO 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.
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 cheiasA 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;
} 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
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.
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.
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.
Comentários
Deixar um comentárioVocê precisa ter uma conta no CuritibaBlog para comentar.