EF Core et SQL Server : diagnostiquer une requête lente avant de blâmer l'ORM
Plan d'exécution, index manquants et pièges classiques d'EF Core : une méthode pragmatique pour retrouver des temps de réponse sains sur des données financières volumineuses.
« C'est EF Core qui est lent. » Dans 9 cas sur 10, non. Après dix ans passés sur des applications financières adossées à SQL Server, le schéma est presque toujours le même : une requête LINQ innocente, un plan d'exécution qui part en vrille, et un index qui n'existe pas. Voici la méthode que j'applique systématiquement.
1. Voir le SQL réellement exécuté
Première étape, non négociable : regarder ce qu'EF Core envoie vraiment au serveur.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()); // uniquement en dev !
Le ToQueryString() est encore plus direct pour une requête précise :
var query = context.Ecritures
.Where(e => e.Exercice == 2026 && e.Statut == StatutEcriture.Validee)
.OrderByDescending(e => e.DateComptable);
Console.WriteLine(query.ToQueryString());
2. Lire le plan d'exécution, pas le deviner
Copiez le SQL dans SSMS, activez le plan réel (Ctrl+M) et cherchez trois choses :
- Scan vs Seek : un Clustered Index Scan sur une table de plusieurs millions d'écritures comptables, c'est votre coupable.
- Key Lookup répété : l'index existe, mais il ne couvre pas les colonnes projetées.
- Warnings (triangle jaune) : conversions implicites, spills tempdb, cardinalités fausses.
Les conversions implicites méritent une mention spéciale : un paramètre nvarchar
comparé à une colonne varchar suffit à rendre un index inutilisable. Avec EF Core,
c'est souvent un mapping de chaîne mal configuré :
// Force varchar au lieu de nvarchar (défaut EF Core pour string)
modelBuilder.Entity<Ecriture>()
.Property(e => e.NumeroPiece)
.HasColumnType("varchar(20)");
3. Les trois pièges EF Core que je retrouve partout
Le N+1 silencieux. Une navigation lazy-loadée dans une boucle et chaque ligne
déclenche sa requête. Solution : Include ciblé, ou mieux, une projection.
// Au lieu de charger des entités complètes...
var lignes = await context.Ecritures
.Where(e => e.Exercice == exercice)
.Select(e => new EcritureResume(e.Id, e.Libelle, e.Montant, e.Compte.Numero))
.ToListAsync(ct);
Le tracking inutile. Pour de la lecture pure (rapports, listes, exports),
AsNoTracking() fait gagner mémoire et CPU, un gain significatif au-delà de quelques
milliers de lignes.
La pagination Skip/Take profonde. Au-delà de quelques dizaines de milliers de
lignes, préférez la pagination par curseur (keyset pagination) : filtrer sur la
dernière clé lue plutôt que compter des lignes à ignorer.
4. L'index qui couvre, plutôt que l'index qui existe
Un bon réflexe : partir de la requête, pas de la table. Pour la requête d'écritures ci-dessus, l'index utile est :
CREATE NONCLUSTERED INDEX IX_Ecritures_Exercice_Statut
ON dbo.Ecritures (Exercice, Statut, DateComptable DESC)
INCLUDE (Libelle, Montant, CompteId);
Les colonnes de filtre en clé, le tri respecté, les colonnes projetées en INCLUDE :
le Key Lookup disparaît, et avec lui l'essentiel du coût.
En résumé
- Ne jamais optimiser à l'aveugle :
ToQueryString()+ plan d'exécution réel. - Chercher scans, lookups et conversions implicites avant de toucher au code.
- Projeter plutôt qu'inclure,
AsNoTrackingen lecture, keyset pour la pagination. - Construire les index depuis les requêtes réelles, avec colonnes couvrantes.
Cette méthode m'a évité bien des réécritures inutiles : l'ORM est rarement le problème, mais il est excellent pour masquer le vrai.