Partager via


Interrogation efficace

L’interrogation efficace est un vaste sujet, qui couvre les sujets aussi larges que les index, les stratégies de chargement d’entités associées, et bien d’autres. Cette section détaille certains thèmes courants pour accélérer vos requêtes et les pièges rencontrés par les utilisateurs.

Utiliser correctement les index

Le principal facteur déterminant dans le fait qu'une requête s'exécute rapidement ou non est son utilisation correcte des index là où c'est approprié. Les bases de données sont généralement utilisées pour contenir de grandes quantités de données, et les requêtes qui parcourent les tables dans leur intégralité sont généralement des sources de problèmes de performances graves. Les problèmes d’indexation ne sont pas faciles à repérer, car il n’est pas immédiatement évident si une requête donnée utilisera un index ou non. Par exemple:

// Matches on start, so uses an index (on SQL Server)
var posts1 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
// Matches on end, so does not use the index
var posts2 = await context.Posts.Where(p => p.Title.EndsWith("A")).ToListAsync();

Un bon moyen d’identifier les problèmes d’indexation consiste d’abord à identifier une requête lente, puis à examiner son plan de requête via l’outil favori de votre base de données ; consultez la page diagnostic des performances pour plus d’informations sur la procédure à suivre. Le plan de requête indique si la requête traverse la table entière ou utilise un index.

En règle générale, il n’existe aucune connaissance EF spéciale pour utiliser des index ou diagnostiquer les problèmes de performances liés à ces derniers ; les connaissances générales relatives aux index sont tout aussi pertinentes pour les applications EF que pour les applications qui n’utilisent pas EF. Voici quelques instructions générales à garder à l’esprit lors de l’utilisation d’index :

  • Tandis que les index accélèrent les requêtes, ils ralentissent également les mises à jour, car elles doivent être conservées up-to-date. Évitez de définir des index qui ne sont pas nécessaires et envisagez d’utiliser des filtres d’index pour limiter l’index à un sous-ensemble des lignes, réduisant ainsi cette surcharge.
  • Les index composites peuvent accélérer les requêtes qui filtrent sur plusieurs colonnes, mais elles peuvent également accélérer les requêtes qui ne filtrent pas sur toutes les colonnes de l’index, en fonction de l’ordre. Par exemple, un index sur les colonnes A et B accélère le filtrage des requêtes par A et B, ainsi que le filtrage des requêtes uniquement par A, mais il n’accélère pas uniquement le filtrage des requêtes sur B.
  • Si une requête filtre par une expression sur une colonne (par exemple price / 2), un index simple ne peut pas être utilisé. Toutefois, vous pouvez définir une colonne persistante stockée pour votre expression et créer un index sur celui-ci. Certaines bases de données prennent également en charge les index d’expression, qui peuvent être utilisés directement pour accélérer le filtrage des requêtes par n’importe quelle expression.
  • Différentes bases de données permettent de configurer les index de différentes manières et, dans de nombreux cas, les fournisseurs EF Core les exposent via l’API Fluent. Par exemple, le fournisseur SQL Server vous permet de configurer si un index est cluster ou si son facteur de remplissage est défini. Pour plus d’informations, consultez la documentation de votre fournisseur.

Projetez uniquement les propriétés dont vous avez besoin

EF Core facilite l’interrogation des instances d’entité, puis l’utilisation de ces instances dans le code. Toutefois, l’interrogation d’instances d’entité peut fréquemment extraire plus de données que nécessaire à partir de votre base de données. Tenez compte des éléments suivants :

await foreach (var blog in context.Blogs.AsAsyncEnumerable())
{
    Console.WriteLine("Blog: " + blog.Url);
}

Bien que ce code ait uniquement besoin de la propriété de Url chaque blog, l’entité blog entière est extraite et les colonnes inutiles sont transférées à partir de la base de données :

SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]

Cela peut être optimisé en utilisant Select pour indiquer à EF quelles colonnes projeter :

await foreach (var blogName in context.Blogs.Select(b => b.Url).AsAsyncEnumerable())
{
    Console.WriteLine("Blog: " + blogName);
}

Le code SQL résultant récupère uniquement les colonnes nécessaires :

SELECT [b].[Url]
FROM [Blogs] AS [b]

Si vous devez projeter plusieurs colonnes, projetez vers un type anonyme C# avec les propriétés souhaitées.

Notez que cette technique est très utile pour les requêtes en lecture seule, mais les choses se compliquent si vous devez mettre à jour les blogs récupérés, car le suivi des modifications d’EF fonctionne uniquement avec les instances d’entité. Il est possible d’effectuer des mises à jour sans charger des entités entières en attachant une instance de blog modifiée et en indiquant à EF quelles propriétés ont changé, mais il s’agit d’une technique plus avancée qui ne vaut peut-être pas la peine.

Limiter la taille de l’ensemble de résultats

Par défaut, une requête retourne toutes les lignes qui correspondent à ses filtres :

var blogsAll = await context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .ToListAsync();

Étant donné que le nombre de lignes retournées dépend des données réelles de votre base de données, il est impossible de savoir combien de données seront chargées à partir de la base de données, de la quantité de mémoire prise en charge par les résultats et de la quantité de charge supplémentaire générée lors du traitement de ces résultats (par exemple, en les envoyant à un navigateur utilisateur sur le réseau). Crucialment, les bases de données de test contiennent fréquemment peu de données, de sorte que tout fonctionne bien lors du test, mais les problèmes de performances apparaissent soudainement lorsque la requête commence à s’exécuter sur des données réelles et de nombreuses lignes sont retournées.

Par conséquent, il vaut généralement la peine de réfléchir à limiter le nombre de résultats :

var blogs25 = await context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .Take(25)
    .ToListAsync();

Au minimum, votre interface utilisateur peut afficher un message indiquant que d’autres lignes peuvent exister dans la base de données (et permettre de les récupérer d’une autre manière). Une solution complète implémente la pagination, où votre interface utilisateur affiche uniquement un certain nombre de lignes à la fois et permet aux utilisateurs de passer à la page suivante en fonction des besoins ; Consultez la section suivante pour plus d’informations sur la façon d’implémenter cela efficacement.

Pagination efficace

La pagination fait référence à la récupération des résultats dans les pages, plutôt qu’à la fois ; Cette opération est généralement effectuée pour les jeux de résultats volumineux, où une interface utilisateur s’affiche, ce qui permet à l’utilisateur d’accéder à la page suivante ou précédente des résultats. Une façon courante d’implémenter la pagination avec des bases de données consiste à utiliser les Skip opérateurs Take (OFFSET et LIMIT dans SQL) ; bien qu’il s’agit d’une implémentation intuitive, elle est également très inefficace. Pour la pagination qui permet de déplacer une page à la fois (plutôt que de passer à des pages arbitraires), envisagez plutôt d’utiliser la pagination du jeu de clés .

Pour plus d’informations, consultez la page de documentation sur la pagination.

Dans les bases de données relationnelles, toutes les entités associées sont chargées en introduisant des JOIN dans une requête unique.

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url], [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Post] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId], [p].[PostId]

Si un blog classique comporte plusieurs billets connexes, les lignes de ces billets dupliqueront les informations du blog. Cette duplication entraîne le problème « d’explosion cartésienne ». À mesure que plusieurs relations un-à-plusieurs sont chargées, la quantité de données dupliquées peut augmenter et affecter négativement les performances de votre application.

EF permet d’éviter cet effet via l’utilisation de « requêtes fractionnées », qui chargent les entités associées via des requêtes distinctes. Pour plus d’informations, lisez la documentation sur les requêtes fractionnées et uniques.

Remarque

L’implémentation actuelle des requêtes fractionnées exécute un aller-retour pour chaque requête. Nous prévoyons d’améliorer cela à l’avenir et d’exécuter toutes les requêtes dans un seul aller-retour.

Il est recommandé de lire la page dédiée sur les entités associées avant de continuer avec cette section.

Lorsque vous traitez des entités associées, nous savons généralement à l’avance ce que nous devons charger : un exemple typique consiste à charger un certain ensemble de blogs, ainsi que tous leurs billets. Dans ces scénarios, il est toujours préférable d’utiliser le chargement impatient, afin qu’EF puisse extraire toutes les données requises dans un aller-retour. La fonctionnalité include filtrée vous permet également de limiter les entités associées que vous souhaitez charger, tout en conservant le processus de chargement synchrone et donc réalisable en un seul aller-retour.

using (var context = new BloggingContext())
{
    var filteredBlogs = await context.Blogs
        .Include(
            blog => blog.Posts
                .Where(post => post.BlogId == 1)
                .OrderByDescending(post => post.Title)
                .Take(5))
        .ToListAsync();
}

Dans d’autres scénarios, nous ne savons peut-être pas quelle entité associée nous allons avoir besoin avant d’obtenir son entité principale. Par exemple, lors du chargement d’un blog, nous devrons peut-être consulter d’autres sources de données ( éventuellement un service web) afin de savoir si nous sommes intéressés par les billets de ce blog. Dans ces cas, le chargement explicite ou différé peut être utilisé pour extraire séparément des entités associées et alimenter la fonctionnalité de navigation des billets du blog. Notez que, étant donné que ces méthodes ne sont pas hâtives, elles nécessitent des allers-retours supplémentaires à la base de données, ce qui est source de ralentissement. Selon votre scénario spécifique, il peut être plus efficace de charger toujours tous les articles, plutôt que d'effectuer les allers-retours supplémentaires et d'obtenir uniquement les articles dont vous avez besoin de manière sélective.

Attention au chargement différé

Le chargement différé semble souvent être un moyen très utile d’écrire une logique de base de données, car EF Core charge automatiquement les entités associées à partir de la base de données, car elles sont accessibles par votre code. Cela évite de charger des entités associées qui ne sont pas nécessaires (comme le chargement explicite) et libère apparemment le programmeur d’avoir à traiter complètement les entités associées. Toutefois, le chargement différé a tendance à engendrer des aller-retours supplémentaires inutiles qui peuvent ralentir l’application.

Tenez compte des éléments suivants :

foreach (var blog in await context.Blogs.ToListAsync())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

Ce morceau de code apparemment innocent itère à travers tous les blogs et leurs billets, et les imprime. L’activation de l'enregistrement des instructions EF Core révèle les éléments suivants :

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [b].[BlogId], [b].[Rating], [b].[Url]
      FROM [Blogs] AS [b]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@__p_0='1'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='2'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0

... and so on

Comment cela se fait-il ? Pourquoi toutes ces requêtes sont-elles envoyées pour les boucles simples ci-dessus ? Avec le chargement différé, les billets d’un blog ne sont chargés que lorsque la propriété Posts est consultée ; en conséquence, chaque itération dans le foreach interne déclenche une requête supplémentaire à la base de données, dans son propre cycle indépendant. Par conséquent, après le chargement initial de la requête tous les blogs, nous avons ensuite une autre requête par blog, en chargeant tous ses billets ; il s’agit parfois du problème N+1 , ce qui peut entraîner des problèmes de performances très significatifs.

En supposant que nous allons avoir besoin de tous les billets des blogs, il est logique d’utiliser ici le chargement anticipé. Nous pouvons utiliser l’opérateur Include pour effectuer le chargement, mais étant donné que nous avons uniquement besoin des URL des blogs (et nous devons uniquement charger ce qui est nécessaire). Nous allons donc utiliser une projection à la place :

await foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).AsAsyncEnumerable())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

Cela permet à EF Core d’extraire tous les blogs, ainsi que leurs billets, dans une seule requête. Dans certains cas, il peut également être utile d’éviter les effets d’explosion cartesiens à l’aide de requêtes fractionnées.

Avertissement

Étant donné que le chargement différé rend extrêmement facile de déclencher par inadvertance le problème N+1, il est recommandé de l’éviter. Le chargement désireux ou explicite rend très clair dans le code source lorsqu’un aller-retour de base de données se produit.

Mise en mémoire tampon et diffusion en continu

La bufferisation fait référence au chargement de tous vos résultats de requête en mémoire, tandis que la lecture en continu signifie qu’EF fournit à l’application un résultat unique à chaque fois, ne stocke jamais l'ensemble du jeu de résultats en mémoire. En principe, les exigences en mémoire d’une requête de diffusion en continu sont fixes : elles sont identiques si la requête retourne 1 ligne ou 1 000 ; Une requête de mise en mémoire tampon, d’autre part, nécessite davantage de mémoire, plus de lignes sont retournées. Pour les requêtes qui résultent d’ensembles de résultats volumineux, cela peut être un facteur de performance important.

Que les requêtes soient mises en mémoire tampon ou transmises en flux dépend de la façon dont elles sont évaluées :

// ToList and ToArray cause the entire resultset to be buffered:
var blogsList = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
var blogsArray = await context.Posts.Where(p => p.Title.StartsWith("A")).ToArrayAsync();

// Foreach streams, processing one row at a time:
await foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")).AsAsyncEnumerable())
{
    // ...
}

// AsAsyncEnumerable also streams, allowing you to execute LINQ operators on the client-side:
var doubleFilteredBlogs = context.Posts
    .Where(p => p.Title.StartsWith("A")) // Translated to SQL and executed in the database
    .AsAsyncEnumerable()
    .Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results

Si vos requêtes retournent seulement quelques résultats, vous n’avez probablement pas à vous soucier de cela. Toutefois, si votre requête risque de retourner un grand nombre de lignes, il vaut la peine de réfléchir à la lecture en continu plutôt qu'à la mise en mémoire tampon.

Remarque

Évitez d’utiliser ToList ou ToArray si vous envisagez d’utiliser un autre opérateur LINQ sur le résultat. Cela met inutilement en mémoire tampon tous les résultats. Utilisez AsEnumerable à la place.

Mise en mémoire tampon interne par EF

Dans certaines situations, EF met en mémoire tampon l’ensemble de résultats en interne, quelle que soit la façon dont vous évaluez votre requête. Les deux cas où cela se produit sont les suivants :

  • Lorsqu'une stratégie de réessai est en place. Cela permet de vérifier que les mêmes résultats sont retournés si la requête est retentée ultérieurement.
  • Lorsque la requête fractionnée est utilisée, les jeux de résultats de toutes les requêtes, mais la dernière requête est mise en mémoire tampon, sauf si MARS (Plusieurs jeux de résultats actifs) est activé sur SQL Server. Cela est dû au fait qu’il est généralement impossible d’avoir plusieurs jeux de résultats de requête actifs en même temps.

Notez que cette mise en mémoire tampon interne se produit en plus de toute mise en mémoire tampon que vous provoquez via des opérateurs LINQ. Par exemple, si vous utilisez ToList une requête et qu’une stratégie d’exécution de nouvelle tentative est en place, l’ensemble de résultats est chargé en mémoire deux fois : une fois en interne par EF, et une fois par ToList.

Suivi, non-suivi et résolution de l'identité

Il est recommandé de lire la page dédiée sur le suivi et le suivi sans suivi avant de continuer avec cette section.

EF suit par défaut les instances des entités, afin que les modifications soient détectées et sauvegardées lorsque SaveChanges est appelé. Un autre effet des requêtes de suivi est qu’EF détecte si une instance a déjà été chargée pour vos données et retourne automatiquement cette instance suivie plutôt que de retourner une nouvelle instance ; il s’agit de la résolution d’identité. Du point de vue des performances, le suivi des modifications signifie ce qui suit :

  • EF gère en interne un dictionnaire d’instances suivies. Lorsque de nouvelles données sont chargées, EF vérifie le dictionnaire pour voir si une instance est déjà suivie pour la clé de cette entité (résolution d’identité). La maintenance du dictionnaire et les recherches prennent un certain temps lors du chargement des résultats de la requête.
  • Avant de remettre une instance chargée à l’application, EF capture cette instance par le biais de captures instantanées et conserve l’instantané en interne. Quand SaveChanges est appelée, l’instance de l’application est comparée à l’instantané pour découvrir les modifications à persister. L’instantané prend plus de mémoire, et le processus d’instantané lui-même prend du temps ; Il est parfois possible de spécifier un comportement d’instantané différent, éventuellement plus efficace via des comparateurs de valeurs, ou d’utiliser des proxys de suivi des modifications pour contourner complètement le processus de capture instantanée (bien que cela soit fourni avec son propre ensemble d’inconvénients).

Dans les scénarios en lecture seule où les modifications ne sont pas enregistrées dans la base de données, les surcharges ci-dessus peuvent être évitées à l’aide de requêtes sans suivi. Toutefois, étant donné que les requêtes sans suivi n’effectuent pas de résolution d’identité, une ligne de base de données référencée par plusieurs autres lignes chargées sera matérialisée en tant qu’instances différentes.

Pour illustrer, supposons que nous chargeons un grand nombre de billets à partir de la base de données, ainsi que le blog référencé par chaque billet. Si 100 billets font référence au même blog, une requête de suivi détecte cela via la résolution d’identité, et toutes les instances post font référence à la même instance de blog dédupliquée. Une requête sans suivi, en revanche, duplique le même blog 100 fois , et le code de l’application doit être écrit en conséquence.

Voici les résultats d’un benchmark comparant le suivi par rapport au comportement sans suivi d’une requête qui charge 10 blogs avec 20 billets chacun. Le code source est disponible ici, n’hésitez pas à l’utiliser comme base pour vos propres mesures.

Méthode NumBlogs NombreDePostsParBlog Moyenne Erreur StdDev Médian Proportions RatioSD Gen 0 Gen1 Gen2 Affecté
AsTracking 10 20 1 414.7 nous 27.20 nous 45.44 nous 1 405,5 us 1.00 0,00 60,5469 13.6719 - 380,11 Ko
AsNoTracking 10 20 993.3 nous 24.04 nous 65.40 nous 966.2 nous 0.71 0,05 37.1094 6.8359 - 232,89 Ko

Enfin, il est possible d’effectuer des mises à jour sans surcharge de suivi des modifications, en utilisant une requête sans suivi, puis en attachant l’instance retournée au contexte, en spécifiant les modifications à apporter. Cela transfère le fardeau du suivi des modifications d’EF à l’utilisateur et ne doit être tenté que si la surcharge de suivi des modifications a été démontrée comme inacceptable par le biais du profilage ou de l’évaluation.

Utilisation de requêtes SQL

Dans certains cas, sql plus optimisé existe pour votre requête, ce qu’EF ne génère pas. Cela peut se produire lorsque la construction SQL est une extension spécifique à votre base de données qui n’est pas prise en charge, ou simplement parce qu’EF ne le traduit pas encore. Dans ces cas, l’écriture manuelle de SQL peut fournir une amélioration substantielle des performances, et EF prend en charge plusieurs façons de le faire.

  • Utilisez des requêtes SQL directement dans votre requête, par exemple via FromSqlRaw. EF vous permet même de composer sur sql avec des requêtes LINQ régulières, ce qui vous permet d’exprimer uniquement une partie de la requête dans SQL. Il s’agit d’une bonne technique lorsque le sql doit uniquement être utilisé dans une requête unique dans votre codebase.
  • Définissez une fonction définie par l’utilisateur (UDF), puis appelez-la à partir de vos requêtes. Notez qu'EF permet aux fonctions définies par l'utilisateur de retourner des ensembles de résultats complets - ce qu'on appelle des fonctions table-values (TVFs) - et permet également de mapper un DbSet à une fonction, la rendant semblable à une table.
  • Définissez une vue de base de données et une requête à partir de celle-ci dans vos requêtes. Notez que contrairement aux fonctions, les vues ne peuvent pas accepter de paramètres.

Remarque

Sql brut doit généralement être utilisé comme dernier recours, après avoir fait en sorte qu’EF ne puisse pas générer le sql souhaité, et quand les performances sont suffisamment importantes pour la requête donnée pour la justifier. L’utilisation de SQL brut présente des inconvénients de maintenance considérables.

Programmation asynchrone

En règle générale, pour que votre application soit évolutive, il est important d’utiliser toujours des API asynchrones plutôt que synchrones (par exemple SaveChangesAsync , plutôt que SaveChanges). Les API synchrones bloquent le thread pendant la durée des entrées/sorties de base de données, ce qui augmente la demande de threads et le nombre de commutateurs de contexte de threads devant se produire.

Pour plus d’informations, consultez la page sur la programmation asynchrone.

Avertissement

Évitez de mélanger du code synchrone et asynchrone dans la même application : il est très facile de déclencher par inadvertance des problèmes subtils de saturation de pool de threads.

Avertissement

L’implémentation asynchrone de Microsoft.Data.SqlClient présente malheureusement des problèmes connus (par exemple, #593, #601, etc.). Si vous rencontrez des problèmes de performances inattendus, essayez d’utiliser l’exécution des commandes de synchronisation à la place, en particulier lorsque vous traitez de valeurs texte ou binaire volumineuses.

Ressources supplémentaires