Le problème N+1
Le piège classique qui tue les performances :
Problème :
// 1 requête pour les commandes + N requêtes pour les clients
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // Requête à chaque itération !
}
Solution : Include (Eager Loading)
// 1 seule requête avec JOIN
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync();
Solution : Projection
// Encore mieux : ne récupère que ce dont on a besoin
var orderDtos = await _context.Orders
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Total
})
.ToListAsync();
Désactiver le tracking
Par défaut, EF Core "track" toutes les entités pour détecter les changements. Coûteux en lecture seule :
// Pour une requête spécifique
var products = await _context.Products
.AsNoTracking()
.ToListAsync();
// Pour tout le DbContext (lecture seule)
_context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
Split Queries
Pour les Include multiples, évitez les jointures cartésiennes :
// Peut exploser en taille si beaucoup de données
var orders = await _context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.ToListAsync();
// Mieux : requêtes séparées
var orders = await _context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
Projections vs Entités complètes
Ne chargez que les colonnes nécessaires :
// Mauvais : charge toutes les colonnes
var customers = await _context.Customers.ToListAsync();
// Bon : charge uniquement Id et Name
var customerNames = await _context.Customers
.Select(c => new { c.Id, c.Name })
.ToListAsync();
Compiled Queries
Pour les requêtes fréquentes, pré-compilez :
private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Products.FirstOrDefault(p => p.Id == id));
// Utilisation
var product = await _getProductById(_context, productId);
Raw SQL quand nécessaire
Parfois, le SQL brut est plus efficace :
// Pour des requêtes complexes
var products = await _context.Products
.FromSqlRaw(@"
SELECT p.* FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
WHERE c.IsActive = 1 AND p.Price > {0}", minPrice)
.ToListAsync();
// Pour les opérations bulk
await _context.Database.ExecuteSqlRawAsync(
"UPDATE Products SET Price = Price * 1.1 WHERE CategoryId = {0}", categoryId);
Indexation
Configurez vos index dans OnModelCreating :
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasIndex(p => p.Sku)
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });
}
Outils de diagnostic
- Logging SQL :
optionsBuilder.LogTo(Console.WriteLine) - MiniProfiler : Visualise les requêtes en temps réel
- EF Core Power Tools : Extension VS pour analyser le modèle
Conseil : Activez les warnings EF Core pendant le développement pour détecter les problèmes tôt :
optionsBuilder.ConfigureWarnings(w =>
w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));
Conclusion
EF Core peut être très performant si utilisé correctement. Les trois règles d'or : éviter N+1, projeter au lieu de charger, désactiver le tracking en lecture.
Besoin d'un audit performance de votre application .NET ? Contactez-moi.