Découper les requêtes pour éviter les jointures coûteuses
Introduction
Lorsque l'exécution d’une requête Linq to Entities par EF Core charge une entité accompagnée de plusieurs collections à l’aide de Include, EF Core peut générer une requête SQL unique contenant plusieurs jointures. Cette approche peut devenir coûteuse. En effet, les jointures entre plusieurs les tables peuvent multiplier les lignes renvoyées par la base de données : c’est le phénomène de l’explosion cartésienne. Les informations de l’entité principale, associées à des entités liées, sont alors répétées de nombreuses fois dans le résultat de l'exécution de la requête SQL.
Dans certains cas, découper le chargement en plusieurs requêtes permet de réduire le volume de données transférées et de simplifier les requêtes exécutées. Cette optimisation doit toutefois être appliquée avec discernement, car elle augmente le nombre d’allers-retours entre l'application et la base de données.
Problématique
Imaginons une application qui doit afficher des informations sur des clients, leurs adresses postales et leurs commandes :
List<Customer> customers = await context.Customer
.Include(c => c.Order)
.Include(c => c.Address)
.ToListAsync();
EF Core peut traduire cette requête en une requête SQL unique contenant des jointures entre les tables Customer, Order et Address. Si un client possède 10 commandes et 3 adresses, la jointure peut produire jusqu’à 30 lignes pour ce seul client. Les données du client seront alors répétées dans chaque ligne, de même que les données des commandes et des adresses. Plus le nombre de collections chargées et leur taille augmentent, plus le volume de données retournées peut devenir important, et cela risque de dégrader les performances de l'application.
Découper les requêtes
Nous avons deux possibilités pour améliorer les performances en découpant les requêtes :
-
Soit utiliser la méthode AsSplitQuery() pour un découpage automatique
-
Soit implémenter un découpage manuel
Découper les requêtes avec AsSplitQuery()
L'utilisation de la méthode AsSplitQuery() demande à EF Core d’exécuter une requête distincte pour chacune des collections chargées :
List<Customer> customers = await context.Customers
.Include(c => c.Orders)
.Include(c => c.Addresses)
.AsSplitQuery()
.ToListAsync();
Dans cet exemple, EF Core exécute plusieurs requêtes SQL : une requête pour les clients, une autre pour les commandes et une autre pour les adresses. EF Core associe ensuite les entités chargées grâce aux clés étrangères. Cette approche évite la multiplication des lignes provoquée par les jointures entre collections. Elle est particulièrement pertinente lorsqu’une requête charge plusieurs collections importantes.
L'utilisation de la méthode AsSplitQuery() peut améliorer les performances lorsque :
-
Plusieurs collections sont chargées avec Include
-
Chaque collection contient potentiellement de nombreux éléments
-
La requête SQL générée contient de nombreuses jointures
-
Le volume de données dupliquées est significatif
En revanche, cette méthode augmente le nombre de requêtes envoyées à la base de données. Elle ne doit donc pas être utilisée systématiquement, notamment lorsque les collections sont petites ou lorsqu’un aller-retour supplémentaire vers la base est plus coûteux que les jointures.
Il est également possible de configurer ce comportement par défaut au niveau du DbContext :
optionsBuilder.UseSqlServer(connectionString, options =>
options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
Cette configuration peut être utile si l’application charge fréquemment plusieurs collections. Elle doit cependant être évaluée avec attention, car elle s’appliquera à toutes les requêtes concernées.
Découper les requêtes manuellement
Pour certains traitements, notamment lorsqu’il est nécessaire de maîtriser précisément les données chargées, il peut être préférable d’exécuter plusieurs requêtes ciblées :
var customers = await context.Customer
.Where(c => c.IsActive)
.ToListAsync();
var customerIds = customers
.Select(c => c.Id)
.ToArray();
var orders = await context.Order
.Where(o => customerIds.Contains(o.CustomerId))
.ToListAsync();
var ordersByCustomer = orders.ToLookup(o => o.CustomerId);
foreach (Customer customer in customers)
customer.Orders = ordersByCustomer[customer.Id].ToList();
Cette approche permet de filtrer indépendamment les clients et les commandes, de sélectionner uniquement les données utiles et d’éviter une requête SQL excessivement complexe.
Elle implique toutefois davantage de code, plusieurs requêtes SQL et une consommation mémoire supplémentaire, puisque les résultats sont associés côté application.