Entity Framework Core : Optimisation des Performances et Bonnes Pratiques en 2026

Guide complet sur l'optimisation des performances Entity Framework Core 10 avec .NET 10. AsNoTracking, requêtes compilées, mises à jour par lot, split queries et LeftJoin.

Entity Framework Core performance optimization guide

Entity Framework Core 10, livré avec .NET 10 LTS en novembre 2025, apporte des opérateurs LeftJoin, la recherche vectorielle, les filtres de requêtes nommés et des améliorations significatives de la traduction SQL. Ce guide couvre les bonnes pratiques EF Core qui impactent directement la vitesse des requêtes, l'allocation mémoire et la scalabilité en production.

EF Core 10 fonctionne uniquement sur .NET 10

EF Core 10 est une version Long-Term Support, maintenue jusqu'en novembre 2028. Il nécessite le SDK et le runtime .NET 10. Les applications encore sur .NET 8 doivent cibler EF Core 8 (LTS) jusqu'à la migration complète.

Suivi des Requêtes : Quand le Désactiver et Pourquoi

Chaque appel à DbSet<T> attache les entités retournées au change tracker par défaut. Ce mécanisme maintient un instantané des valeurs originales des propriétés, calcule les différences lors de SaveChanges et résout les conflits d'identité entre les navigations. Cette surcharge est inutile lorsque les données sont directement envoyées vers une réponse API ou un modèle de vue en lecture seule.

ProductRepository.cscsharp
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
    int categoryId, CancellationToken ct)
{
    return await _context.Products
        .AsNoTracking()              // ignorer complètement le change tracker
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .OrderBy(p => p.Name)
        .Select(p => new ProductDto   // projeter en DTO au niveau de la base de données
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(ct);
}

AsNoTracking supprime la surcharge par entité liée à la création d'instantanés et à la résolution d'identité. Combiné avec Select, seules les colonnes nécessaires transitent sur le réseau. Sur une table de 50 000 lignes, ce pattern réduit généralement les allocations mémoire de 40 à 60 % par rapport à une requête complète avec suivi.

Pour les contextes qui ne modifient jamais les données, il est possible de définir le comportement par défaut lors de l'enregistrement :

Program.cscsharp
builder.Services.AddDbContext<CatalogContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Split Queries pour Éviter l'Explosion Cartésienne

Charger une entité avec plusieurs navigations de collection via Include génère une seule instruction SQL avec des JOINs. Lorsque deux collections ou plus sont chargées simultanément, l'ensemble de résultats croît comme un produit cartésien des collections, dupliquant les données de la ligne parente à travers chaque combinaison.

OrderRepository.cscsharp
public async Task<Order?> GetWithDetailsAsync(int orderId, CancellationToken ct)
{
    return await _context.Orders
        .AsSplitQuery()              // un SQL par Include
        .Include(o => o.Items)
            .ThenInclude(i => i.Product)
        .Include(o => o.Payments)
        .Include(o => o.ShippingEvents)
        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
}

AsSplitQuery décompose le chargement en instructions SQL séparées par navigation. Le compromis : plusieurs aller-retours au lieu d'un seul, mais chaque ensemble de résultats reste petit et évite le problème de duplication des lignes. EF Core 10 corrige également une incohérence de longue date dans l'ordonnancement des split queries, garantissant que l'ordre de la sous-requête correspond à celui de la requête principale.

Quand utiliser les requêtes uniques

Les requêtes uniques restent préférables lors du chargement d'une seule navigation de collection ou lorsque la latence des aller-retours est élevée (appels base de données cross-région). Il convient de benchmarker les deux modes pour le pattern d'accès spécifique avant de se décider.

Opérations par Lot avec ExecuteUpdate et ExecuteDelete

Le workflow EF traditionnel charge les entités, modifie les propriétés, puis appelle SaveChanges. Pour les opérations en masse affectant des milliers de lignes, cela crée des milliers d'instances suivies et des instructions UPDATE individuelles. EF Core 7 a introduit ExecuteUpdateAsync et ExecuteDeleteAsync pour envoyer l'opération en une seule commande SQL. EF Core 10 simplifie davantage l'API en acceptant une lambda simple au lieu d'un arbre d'expressions.

PromotionService.cscsharp
public async Task ApplySeasonalDiscountAsync(
    int categoryId, decimal discountPercent, CancellationToken ct)
{
    var affected = await _context.Products
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .ExecuteUpdateAsync(s =>
        {
            s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100));
            s.SetProperty(p => p.LastModified, DateTime.UtcNow);
        }, ct);

    // affected = nombre de lignes mises à jour
}

Cela se traduit par une seule instruction UPDATE ... SET ... WHERE. Aucune entité n'est chargée en mémoire. Pour une mise à jour de 10 000 lignes, le temps d'exécution passe de plusieurs secondes (approche avec suivi) à quelques millisecondes.

Le même pattern s'applique aux suppressions :

CleanupService.cscsharp
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
    await _context.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .ExecuteDeleteAsync(ct);
}

Prêt à réussir tes entretiens .NET ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

L'Opérateur LeftJoin dans EF Core 10

Avant EF Core 10, réaliser un LEFT JOIN nécessitait de combiner GroupJoin, SelectMany et DefaultIfEmpty dans un pattern spécifique que la plupart des développeurs devaient rechercher à chaque fois. EF Core 10 ajoute LeftJoin comme opérateur LINQ de première classe.

ReportService.cscsharp
public async Task<List<EmployeeReportDto>> GetEmployeeDepartmentReportAsync(
    CancellationToken ct)
{
    return await _context.Employees
        .LeftJoin(
            _context.Departments,
            employee => employee.DepartmentId,
            department => department.Id,
            (employee, department) => new EmployeeReportDto
            {
                FullName = employee.FirstName + " " + employee.LastName,
                Department = department.Name ?? "Unassigned",
                HiredAt = employee.HiredAt
            })
        .OrderBy(r => r.FullName)
        .ToListAsync(ct);
}

Le SQL généré utilise une clause LEFT JOIN standard. L'opérateur RightJoin est également disponible. Ces deux opérateurs éliminent la chaîne verbeuse des trois méthodes précédemment requises.

Filtres de Requêtes Nommés pour le Filtrage Multi-Préoccupations

Les filtres de requêtes globaux sont limités à un seul filtre par type d'entité depuis EF Core 2.0. Les applications implémentant à la fois la suppression logique et le multi-tenancy devaient combiner les conditions en une seule expression et ne pouvaient pas les désactiver sélectivement. EF Core 10 introduit les filtres de requêtes nommés.

AppDbContext.cscsharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
        .HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}

Les endpoints d'administration peuvent désactiver le filtre de suppression logique tout en gardant l'isolation des tenants active :

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // le filtre tenant reste appliqué
        .ToListAsync(ct);
}

Cela élimine le besoin de méthodes d'extension IQueryable personnalisées qui ajoutaient manuellement les conditions de filtrage.

Résilience des Connexions et Configuration du Pool

Les défaillances transitoires de base de données (coupures réseau, basculements Azure SQL, épuisement du pool de connexions) provoquent des exceptions qui font souvent planter les pipelines de requêtes. EF Core fournit une logique de retry intégrée, mais les valeurs par défaut nécessitent une configuration explicite.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);    // retry sur toutes les erreurs transitoires
        sqlOptions.CommandTimeout(30);   // timeout de commande de 30 secondes
    }));

Les paramètres de pooling de la chaîne de connexion ont également leur importance :

text
Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15;

Min Pool Size maintient des connexions prêtes pour les pics de trafic. Max Pool Size limite le nombre total de connexions ouvertes pour éviter de surcharger la base de données. La valeur par défaut de 100 convient à la plupart des applications web, mais les services à haut débit peuvent nécessiter un ajustement en fonction du volume réel de requêtes concurrentes.

Logique de retry et transactions

Les retries automatiques ne fonctionnent pas à l'intérieur des transactions initiées par l'utilisateur. Il faut envelopper l'ensemble de la transaction dans une stratégie de retry manuelle en utilisant context.Database.CreateExecutionStrategy().ExecuteAsync(...) pour gérer les défaillances transitoires sur plusieurs opérations.

Stratégie d'Indexation et Analyse des Requêtes

Les migrations EF Core permettent de définir des index de manière déclarative, mais le choix des colonnes à indexer nécessite une compréhension des patterns de requêtes.

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(entity =>
{
    // index composite pour un pattern de requête fréquent
    entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
          .HasDatabaseName("IX_Order_Customer_Status_Date");

    // index filtré pour les commandes actives uniquement
    entity.HasIndex(o => o.Status)
          .HasFilter("[Status] <> 'Cancelled'")
          .HasDatabaseName("IX_Order_ActiveStatus");
});

Les index filtrés réduisent la taille de l'index en excluant les lignes que les requêtes ne ciblent jamais. Pour une table avec 80 % de commandes annulées, un index filtré sur les statuts actifs peut être 5 fois plus petit et plus rapide à parcourir.

Pour analyser le SQL généré, il suffit d'activer la journalisation en développement :

Program.cs (développement uniquement)csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());

Il est recommandé de copier le SQL journalisé dans SQL Server Management Studio et de l'exécuter avec SET STATISTICS IO ON pour vérifier les lectures logiques, ou d'utiliser EXPLAIN ANALYZE sur PostgreSQL. Les suggestions d'index manquants du plan de requête révèlent souvent les opportunités d'optimisation à plus fort impact.

Requêtes Compilées pour les Chemins Critiques

Les arbres d'expressions LINQ sont analysés et traduits en SQL à chaque exécution. Pour les requêtes exécutées des milliers de fois par minute (recherches d'authentification, validation de sessions), ce coût de traduction s'accumule. Les requêtes compilées mettent en cache la traduction au démarrage de l'application.

CompiledQueries.cscsharp
public static class UserQueries
{
    // compilée une fois, réutilisée à chaque appel
    public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
        GetActiveSession = EF.CompileAsyncQuery(
            (AppDbContext ctx, string token, CancellationToken ct) =>
                ctx.UserSessions
                    .AsNoTracking()
                    .FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow));
}

// Utilisation dans le middleware
var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct);

Les requêtes compilées sautent entièrement la phase d'analyse de l'arbre d'expressions. La différence de performance n'est mesurable que sur les chemins à haute fréquence (plus de 1 000 appels/minute). Pour les endpoints CRUD standards, le cache de requêtes intégré à EF Core gère déjà la réutilisation de la traduction.

Traduction des Collections Paramétrées dans EF Core 10

Les requêtes filtrant par une liste d'identifiants représentent l'un des patterns les plus courants dans l'accès aux données. EF Core 10 modifie la stratégie de traduction par défaut pour les collections paramétrées. Au lieu d'encoder la liste sous forme de tableau JSON (EF Core 8-9) ou d'inliner les constantes (EF Core 7 et versions antérieures), EF Core 10 traduit chaque valeur en un paramètre SQL distinct.

csharp
// Avant (EF Core 8-9): paramètre tableau JSON
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))

// EF Core 10: paramètres individuels avec padding
// SELECT ... WHERE Id IN (@ids1, @ids2, @ids3)

var orderIds = new[] { 101, 205, 389 };
var orders = await _context.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync(ct);

La nouvelle approche donne au planificateur de requêtes de la base de données des informations de cardinalité tout en évitant la saturation du cache de plans grâce au padding des paramètres. Pour les cas où l'approche JSON est plus performante (très grandes collections), il est possible de surcharger le comportement par requête :

csharp
var orders = await _context.Orders
    .Where(o => EF.Parameter(orderIds).Contains(o.Id))  // forcer le mode JSON
    .ToListAsync(ct);

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Conclusion

  • Utiliser AsNoTracking() et les projections Select sur chaque requête en lecture seule pour éliminer la surcharge du change tracker
  • Appliquer AsSplitQuery() lors du chargement de plusieurs navigations de collection pour éviter l'explosion cartésienne
  • Remplacer les patterns de chargement-modification-sauvegarde avec suivi par ExecuteUpdateAsync et ExecuteDeleteAsync pour les opérations en masse
  • Adopter l'opérateur LeftJoin d'EF Core 10 pour remplacer la chaîne verbeuse GroupJoin/SelectMany/DefaultIfEmpty
  • Configurer les filtres de requêtes nommés pour gérer la suppression logique et le multi-tenancy indépendamment
  • Mettre en place EnableRetryOnFailure et ajuster la taille du pool de connexions pour la résilience en production
  • Définir des index composites et filtrés basés sur les patterns de requêtes réels, pas sur des suppositions
  • Réserver les requêtes compilées aux chemins véritablement critiques dépassant 1 000 exécutions par minute
  • Laisser EF Core 10 gérer la traduction des collections paramétrées par défaut, et ne surcharger que lorsque les benchmarks le justifient

Pour approfondir : le module avancé EF Core couvre ces patterns dans un contexte d'entretien technique, et le guide d'architecture Clean avec .NET montre comment structurer les couches repository et service qui encapsulent ces requêtes.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#dotnet
#entity-framework-core
#performance
#best-practices

Partager

Articles similaires