Entity Framework Core facilita demais escrever código que parece eficiente mas destrói a performance em produção. O problema clássico — o famoso N+1 — é invisível durante o desenvolvimento com poucos registros e aparece de forma brutal quando o banco tem 100 mil linhas.
Este guia cobre os problemas de performance mais comuns no EF Core e como corrigir cada um deles.
O problema N+1: o mais comum e mais destrutivo
N+1 acontece quando você carrega uma lista de entidades e depois acessa uma propriedade de navegação dentro de um loop — gerando uma query por registro:
// ERRADO — gera N+1 queries var pedidos = await _context.Pedidos.ToListAsync(); // 1 query: SELECT * FROM pedidos
foreach (var pedido in pedidos) { // NOVA query para cada pedido: SELECT * FROM clientes WHERE id = @id Console.WriteLine(pedido.Cliente.Nome); // Com 1.000 pedidos = 1.001 queries ao banco! }
Solução 1: Eager Loading com Include
// CORRETO — carrega tudo em uma query com JOIN var pedidos = await _context.Pedidos .Include(p => p.Cliente) .Include(p => p.Itens) .ThenInclude(i => i.Produto) // Include aninhado .ToListAsync();
// Apenas 1 query com JOINs — independente de quantos pedidos
Cuidado: Include em coleções grandes pode trazer dados demais. Se um pedido tem 50 itens e você lista 100 pedidos, você carrega 5.000 itens — mesmo que só exiba o número de itens na tela.
Solução 2: Projeções com Select (melhor prática)
// MELHOR — traz só o que a tela precisa var pedidos = await _context.Pedidos .Select(p => new PedidoResumoDto( p.Id, p.Cliente.Nome, // EF Core resolve o JOIN automaticamente p.Status.ToString(), p.Itens.Count, // COUNT no banco, não em memória p.Itens.Sum(i => i.Preco * i.Quantidade) // SUM no banco )) .ToListAsync();
// Gera SQL eficiente com subquery/JOIN só para os campos necessários // Nunca carrega entidades completas desnecessariamente
Projeções com Select são sempre mais eficientes que Include para listagens, porque o EF Core traduz a expressão para SQL e traz só os campos necessários.
Lazy Loading: o modo que parece conveniente e causa N+1
// NUNCA habilite lazy loading em aplicações web:
builder.Services.AddDbContext<AppDbContext>(opt =>
{
opt.UseLazyLoadingProxies(); // EVITE — causa N+1 silenciosamente
opt.UseNpgsql(connectionString);
});
// Lazy loading parece mágico: pedido.Cliente funciona sem Include
// Mas cada acesso dispara uma nova query ao banco
// Em controllers com serialização JSON de listas = desastre garantido
A regra: use explicit loading (com Include ou Select) sempre. Deixe as queries explícitas — você sabe exatamente o que está indo ao banco.
AsNoTracking: essencial para queries de leitura
// PADRÃO com tracking (operações de escrita): var pedido = await _context.Pedidos.FindAsync(id); pedido.Status = StatusPedido.Confirmado; await _context.SaveChangesAsync(); // EF Core detecta a mudança// Para LEITURA PURA: sempre use AsNoTracking var pedidos = await _context.Pedidos .AsNoTracking() // não rastreia entidades no ChangeTracker .Include(p => p.Cliente) .Where(p => p.Status == StatusPedido.Confirmado) .ToListAsync();
// AsNoTracking é 20-40% mais rápido em listagens // e reduz consumo de memória significativamente
Configure AsNoTracking como padrão para queries de leitura usando UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) e ative o tracking explicitamente apenas quando necessário.
Paginação: nunca use Skip/Take sem ordernar
// ERRADO — sem ordenação, o resultado é não-determinístico var pagina = await _context.Pedidos .Skip(pageNumber * pageSize) .Take(pageSize) .ToListAsync();// CORRETO — sempre ordene antes de paginar var pagina = await _context.Pedidos .AsNoTracking() .OrderByDescending(p => p.CriadoEm) // ordem definida = resultado determinístico .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Select(p => new PedidoDto(…)) .ToListAsync();
// Para paginação de alta performance em tabelas grandes, use cursor-based pagination: // WHERE id > @ultimoId ORDER BY id LIMIT @pageSize // Muito mais eficiente que OFFSET em tabelas com milhões de registros
Índices: a diferença entre 1ms e 5s
// No DbContext — configuração de índices protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Pedido>(entity => { // Índice para busca por cliente (query mais comum) entity.HasIndex(p => p.ClienteId) .HasDatabaseName("IX_Pedidos_ClienteId");// Índice composto para filtrar por status + data entity.HasIndex(p => new { p.Status, p.CriadoEm }) .HasDatabaseName("IX_Pedidos_Status_CriadoEm"); // Índice único em campo de negócio entity.HasIndex(p => p.NumeroPedido) .IsUnique() .HasDatabaseName("IX_Pedidos_NumeroPedido"); }); modelBuilder.Entity<Item>(entity => { // Índice em FK para otimizar JOINs entity.HasIndex(i => i.PedidoId) .HasDatabaseName("IX_Itens_PedidoId"); });}
// Via migration — para adicionar índice em tabela existente: migrationBuilder.CreateIndex( name: “IX_Pedidos_ClienteId”, table: “pedidos”, column: “cliente_id”);
Regra prática: toda coluna usada em WHERE, JOIN ON ou ORDER BY frequentemente deve ter índice. Use EXPLAIN ANALYZE no PostgreSQL para identificar queries fazendo full scan.
Bulk Operations: INSERT/UPDATE em massa
O EF Core padrão processa registros um por um. Para operações em massa, isso é lento demais:
// LENTO — EF Core padrão: INSERT um por um foreach (var item in itens) { _context.Itens.Add(item); } await _context.SaveChangesAsync(); // N INSERTs individuais// RÁPIDO — EF Core 8+ nativo com ExecuteUpdateAsync/ExecuteDeleteAsync // Para UPDATE em massa sem carregar entidades: await _context.Pedidos .Where(p => p.Status == StatusPedido.Rascunho && p.CriadoEm < DateTime.UtcNow.AddDays(-30)) .ExecuteUpdateAsync(setters => setters .SetProperty(p => p.Status, StatusPedido.Expirado) .SetProperty(p => p.AtualizadoEm, DateTime.UtcNow)); // Gera: UPDATE pedidos SET status = ‘Expirado’, atualizado_em = NOW() // WHERE status = ‘Rascunho’ AND criado_em < NOW() - INTERVAL ‘30 days’ // Zero entidades carregadas em memória!
// Para DELETE em massa: await _context.Logs .Where(l => l.CriadoEm < DateTime.UtcNow.AddYears(-1)) .ExecuteDeleteAsync();
// Para INSERT em massa com EF Core.BulkExtensions (pacote externo): dotnet add package EFCore.BulkExtensions await _context.BulkInsertAsync(listaDeItens); // muito mais rápido para >100 registros
Compiled Queries: reutilizando o plano de execução
// Para queries executadas frequentemente com os mesmos parâmetros: private static readonly Func<AppDbContext, Guid, Task<Pedido?>> _getPedidoById = EF.CompileAsyncQuery((AppDbContext ctx, Guid id) => ctx.Pedidos .Include(p => p.Itens) .FirstOrDefault(p => p.Id == id));// Uso — o EF Core reutiliza o plano compilado, sem re-parsear a expressão var pedido = await _getPedidoById(_context, pedidoId);
// Útil para queries em hot paths (chamadas centenas de vezes por segundo)
Quando sair do EF Core e usar Dapper
EF Core é excelente para operações de escrita (com o ChangeTracker gerenciando entidades). Para queries de leitura complexas — joins múltiplos, agregações, relatórios — Dapper frequentemente produz SQL mais eficiente com menos overhead:
// Query de relatório — difícil expressar em LINQ, muito mais claro em SQL public async Task<RelatorioVendasDto> GetRelatorioAsync(DateOnly inicio, DateOnly fim) { const string sql = @" SELECT DATE_TRUNC('day', p.confirmado_em) AS data, COUNT(p.id) AS total_pedidos, SUM(p.total) AS faturamento, AVG(p.total) AS ticket_medio, COUNT(DISTINCT p.cliente_id) AS clientes_unicos FROM pedidos p WHERE p.status = 'Confirmado' AND p.confirmado_em::date BETWEEN @Inicio AND @Fim GROUP BY DATE_TRUNC('day', p.confirmado_em) ORDER BY data";var rows = await _connection.QueryAsync<RelatorioVendasRow>(sql, new { Inicio = inicio, Fim = fim }); return new RelatorioVendasDto(rows.ToList());
}
A regra prática: EF Core para escrita (commands), Dapper para leituras complexas (queries). Essa divisão é a essência do lado de leitura no CQRS.
Diagnosticando queries lentas
// Habilitar logging de queries lentas em desenvolvimento builder.Services.AddDbContext<AppDbContext>(opt => { opt.UseNpgsql(connectionString) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging() // mostra valores dos parâmetros (só em dev!) .EnableDetailedErrors(); // mensagens de erro mais descritivas });// Em produção: use o slow query log do PostgreSQL (log_min_duration_statement = 100ms) // + pg_stat_statements para identificar as queries mais custosas do sistema
// Identificando N+1 programaticamente (usando pacote DetectOpenHandlesDotNet ou MiniProfiler): dotnet add package MiniProfiler.AspNetCore.Mvc dotnet add package MiniProfiler.EntityFrameworkCore // Acesse /profiler/results em desenvolvimento para ver queries por request
Checklist de performance EF Core
- Nunca use lazy loading em aplicações web
AsNoTracking()em toda query de leitura- Use
Selectpara projetar só os campos necessários - Índices em todas as colunas de filtro, JOIN e ordenação frequentes
- Paginação sempre com
OrderBydefinido - Bulk operations com
ExecuteUpdateAsync/ExecuteDeleteAsyncpara operações em massa - Queries complexas de relatório: Dapper em vez de LINQ
- Ative slow query log no PostgreSQL em produção
Conclusão
A maioria dos problemas de performance com EF Core vem de poucos padrões recorrentes: N+1 por falta de Include ou projeção, ausência de índices em colunas de filtro, e carregamento de dados desnecessários sem AsNoTracking ou Select. Identificar e corrigir esses padrões geralmente resolve 80% dos problemas de performance de banco de dados em aplicações .NET.
Se você tem queries lentas em produção e quer uma análise de performance do seu banco e camada de dados, a Neryx pode ajudar com revisão de código e infraestrutura. Consultoria inicial gratuita.
Leitura complementar: