Partager via


Requêtes uniques vs. fractionnées Requêtes uniques vs. fractionnées

Problèmes de performances liés aux requêtes uniques

Lors de l’utilisation de bases de données relationnelles, EF charge les entités associées en introduisant des JOIN dans une requête unique. Bien que les JOIN soient assez standard lors de l’utilisation de SQL, ils peuvent créer des problèmes de performances significatifs en cas d’utilisation incorrecte. Cette page décrit ces problèmes de performances et montre une autre façon de charger des entités associées qui les contournent.

Explosion cartésienne

Examinons la requête LINQ suivante et son équivalent SQL traduit :

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToList();
SELECT [b].[Id], [b].[Name], [p].[Id], [p].[BlogId], [p].[Title], [c].[Id], [c].[BlogId], [c].[FirstName], [c].[LastName]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id], [p].[Id]

Dans cet exemple, étant donné que les deux Posts et Contributors sont des navigations de collection de Blog (elles sont au même niveau), les bases de données relationnelles retournent un produit croisé : chaque ligne de Posts est jointe à chaque ligne de Contributors. Cela signifie que si un blog donné a 10 billets et 10 contributeurs, la base de données retourne 100 lignes pour ce blog unique. Ce phénomène, parfois appelé explosion cartésienne, peut entraîner des quantités énormes de données à transférer involontairement vers le client, en particulier lorsque d’autres JOIN frères sont ajoutés à la requête. Cela peut amener un gros problème de performances dans les applications de base de données.

Notez que l’explosion cartésienne ne se produit pas lorsque les deux JOIN ne sont pas au même niveau :

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToList();
SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Title], [t].[Id0], [t].[Content], [t].[PostId]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Comment] AS [c] ON [p].[Id] = [c].[PostId]
ORDER BY [b].[Id], [t].[Id]

Dans cette requête, Comments est une navigation de collection de Post, contrairement à Contributors dans la requête précédente, qui était une navigation de collection de Blog. Dans ce cas, une seule ligne est retournée pour chaque commentaire qu’un blog a (via ses billets) et aucun produit croisé ne se produit.

Duplication des données

Les JOIN peuvent créer un autre type de problème de performances. Examinons la requête suivante, qui ne charge qu’une seule navigation de collection :

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ToList();
SELECT [b].[Id], [b].[Name], [b].[HugeColumn], [p].[Id], [p].[BlogId], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

En observant les colonnes projetées, nous constatons que chaque ligne retournée par cette requête contient les propriétés des tables Blogs et Posts. Cela signifie que les propriétés du blog sont dupliquées pour chaque billet du blog. Bien que cela soit généralement normal et ne provoque aucun problème, si la table Blogs a une très grande colonne (par exemple, des données binaires ou un texte très long), cette colonne est dupliquée et renvoyée au client plusieurs fois. Cela peut augmenter considérablement le trafic et affecter considérablement les performances de votre application.

Si vous n’avez pas réellement besoin de l’énorme colonne, il suffit simplement de ne pas envoyer de requête la concernant :

var blogs = ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToList();

En utilisant une projection pour choisir explicitement les colonnes souhaitées, vous pouvez omettre les grandes colonnes et améliorer les performances. Notez qu’il s’agit d’une bonne idée, quelle que soit la duplication des données, donc envisagez de le faire même quand vous ne chargez pas de navigation de collection. Toutefois, étant donné que cela projette le blog sur un type anonyme, le blog n’est pas suivi par EF et les modifications apportées ne peuvent pas être enregistrées comme d’habitude.

Il est important de noter que contrairement à l’explosion cartésienne, la duplication des données causée par les JOIN n’est généralement pas importante, car la taille des données dupliquées est négligeable ; c’est quelque chose dont il faut se préoccuper uniquement si vous avez des grandes colonnes dans votre table principale.

Fractionner des requêtes

Pour contourner les problèmes de performances décrits ci-dessus, EF vous permet de spécifier qu’une requête LINQ donnée doit être fractionner en plusieurs requêtes SQL. À la place de JOIN, les requêtes fractionnées génèrent une requête SQL supplémentaire pour chaque navigation de collection incluse :

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToList();
}

Cela génère le code SQL suivant :

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
ORDER BY [b].[BlogId]

SELECT [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title], [b].[BlogId]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId]

Avertissement

Lorsque vous utilisez des requêtes fractionnées avec Skip/Take, faites particulièrement attention à rendre votre commande de requête entièrement unique, sinon cela peut entraîner le retour de données incorrectes. Par exemple, si les résultats sont classés uniquement par date, mais qu’il peut y avoir plusieurs résultats avec la même date, alors chacune des requêtes fractionnées peut obtenir des résultats différents de la base de données. Le classement par date et par ID (ou toute autre propriété unique ou combinaison de propriétés) rend l’ordre entièrement unique et évite ce problème. Notez que les bases de données relationnelles n’appliquent aucun ordre par défaut, même sur la clé primaire.

Remarque

Les entités associées une-à-une sont toujours chargées via des JOIN dans la même requête, car cela n’a aucun impact sur les performances.

Activation globale des requêtes fractionnées

Vous pouvez également configurer des requêtes fractionnées comme valeur par défaut pour le contexte de votre application :

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

Lorsque les requêtes fractionnées sont configurées en tant que valeurs par défaut, il est toujours possible de configurer des requêtes spécifiques pour qu’elles s’exécutent en tant que requêtes uniques :

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

EF Core utilise le mode de requête unique par défaut en l’absence de configuration. Dans la mesure où cela peut entraîner des problèmes de performances, EF Core génère un avertissement chaque fois que les conditions suivantes sont remplies :

  • EF Core détecte que la requête charge plusieurs collections.
  • L’utilisateur n’a pas configuré globalement le mode de fractionnement de requête.
  • L’utilisateur n’a pas utilisé l’opérateur AsSingleQuery/AsSplitQuery sur la requête.

Pour désactiver l’avertissement, configurez le mode de fractionnement des requêtes globalement ou au niveau de la requête avec une valeur appropriée.

Caractéristiques des requêtes fractionnées

Si la requête fractionnée évite les problèmes de performances associés aux JOIN et à l’explosion cartésienne, elle présente tout de même certains inconvénients :

  • Alors que la plupart des bases de données garantissent la cohérence des données pour les requêtes uniques, il n’existe pas de garantie de ce type pour les requêtes multiples. Si la base de données est mise à jour en même temps que l’exécution de vos requêtes, les données résultantes peuvent ne pas être cohérentes. Vous pouvez l’atténuer en enveloppant les requêtes dans une transaction sérialisable ou d’instantané, même si cela peut entraîner des problèmes de performances. Pour plus d'informations, consultez la documentation de la base de données.
  • Chaque requête implique actuellement un aller-retour réseau supplémentaire vers votre base de données. Plusieurs allers-retours réseau peuvent dégrader les performances, en particulier lorsque la latence est élevée pour la base de données (par exemple, les services cloud).
  • Bien que certaines bases de données autorisent l’utilisation des résultats de plusieurs requêtes en même temps (SQL Server avec MARS, Sqlite), la plupart n’autorisent l’activation que d’une seule requête à un moment donné. Ainsi, tous les résultats des requêtes précédentes doivent être mis en mémoire tampon dans la mémoire de votre application avant d’exécuter d’autres requêtes, ce qui entraîne une augmentation des besoins en mémoire.
  • Lors de l’inclusion de navigations de référence et de navigations de collection, chacune des requêtes fractionnées inclut des jointures aux navigations de référence. Cela peut nuire aux performances, en particulier si le nombre de navigations de référence est élevé. Veuillez voter pour #29182 si vous souhaitez que ce problème soit résolu.

Malheureusement, il n’existe pas de stratégie unique pour charger des entités associées qui convienne à tous les scénarios. Examinez attentivement les avantages et les inconvénients des requêtes uniques et fractionnées afin de sélectionner celle qui répond à vos besoins.