web-dev-qa-db-fra.com

Pourquoi l'opérateur Contains () dégrade-t-il les performances d'Entity Framework de manière si spectaculaire?

MISE À JOUR 3: Selon cette annonce , cela a été résolu par l'équipe EF dans EF6 alpha 2.

MISE À JOUR 2: J'ai créé une suggestion pour résoudre ce problème. Pour voter, allez ici .

Prenons une base de données SQL avec une table très simple.

CREATE TABLE Main (Id INT PRIMARY KEY)

Je remplis la table avec 10 000 enregistrements.

WITH Numbers AS
(
  SELECT 1 AS Id
  UNION ALL
  SELECT Id + 1 AS Id FROM Numbers WHERE Id <= 10000
)
INSERT Main (Id)
SELECT Id FROM Numbers
OPTION (MAXRECURSION 0)

Je crée un modèle EF pour la table et exécute la requête suivante dans LINQPad (j'utilise le mode "Instructions C #" pour que LINQPad ne crée pas de vidage automatiquement).

var rows = 
  Main
  .ToArray();

Le temps d'exécution est d'environ 0,07 seconde. J'ajoute maintenant l'opérateur Contains et réexécute la requête.

var ids = Main.Select(a => a.Id).ToArray();
var rows = 
  Main
  .Where (a => ids.Contains(a.Id))
  .ToArray();

Le temps d'exécution pour ce cas est 20,14 secondes (288 fois plus lent)!

Au début, je soupçonnais que le T-SQL émis pour la requête prenait plus de temps à s'exécuter, j'ai donc essayé de le couper et de le coller du volet SQL de LINQPad dans SQL Server Management Studio.

SET NOCOUNT ON
SET STATISTICS TIME ON
SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Primary] AS [Extent1]
WHERE [Extent1].[Id] IN (1,2,3,4,5,6,7,8,...

Et le résultat était

SQL Server Execution Times:
  CPU time = 0 ms,  elapsed time = 88 ms.

Ensuite, je soupçonnais que LINQPad était à l'origine du problème, mais les performances sont les mêmes que je l'exécute dans LINQPad ou dans une application console.

Il semble donc que le problème se situe quelque part dans Entity Framework.

Est-ce que je fais quelque chose de mal ici? Ceci est une partie critique de mon code, alors est-ce que je peux faire pour accélérer les performances?

J'utilise Entity Framework 4.1 et Sql Server 2008 R2.

MISE À JOUR 1:

Dans la discussion ci-dessous, certaines questions ont été posées quant à savoir si le retard s'est produit pendant que EF construisait la requête initiale ou pendant qu'il analysait les données reçues. Pour tester cela, j'ai exécuté le code suivant,

var ids = Main.Select(a => a.Id).ToArray();
var rows = 
  (ObjectQuery<MainRow>)
  Main
  .Where (a => ids.Contains(a.Id));
var sql = rows.ToTraceString();

ce qui oblige EF à générer la requête sans l'exécuter sur la base de données. Le résultat a été que ce code a nécessité environ 20 secondes pour s'exécuter, il semble donc que presque tout le temps est consacré à la création de la requête initiale.

CompiledQuery à la rescousse alors? Pas si vite ... CompiledQuery requiert que les paramètres passés dans la requête soient des types fondamentaux (int, chaîne, float, etc.). Il n'accepte pas les tableaux ou IEnumerable, je ne peux donc pas l'utiliser pour une liste d'ID.

79
Mike

MISE À JOUR: Avec l'ajout d'InExpression dans EF6, les performances du traitement Enumerable.Contains se sont considérablement améliorées. L'approche décrite dans cette réponse n'est plus nécessaire.

Vous avez raison de dire que la plupart du temps est consacré au traitement de la traduction de la requête. Le modèle de fournisseur d'EF n'inclut pas actuellement une expression qui représente une clause IN, par conséquent, les fournisseurs ADO.NET ne peuvent pas prendre en charge IN de manière native. Au lieu de cela, l'implémentation de Enumerable.Contains le traduit en un arbre d'expressions OR, c'est-à-dire pour quelque chose qui en C # ressemble à ceci:

new []{1, 2, 3, 4}.Contains(i)

... nous allons générer un arbre DbExpression qui pourrait être représenté comme ceci:

((1 = @i) OR (2 = @i)) OR ((3 = @i) OR (4 = @i))

(Les arbres d'expression doivent être équilibrés car si nous avions tous les blocs opératoires sur une seule colonne vertébrale longue, il y aurait plus de chances que le visiteur d'expression atteigne un débordement de pile (oui, nous l'avons effectivement fait dans nos tests))

Nous envoyons plus tard une arborescence comme celle-ci au fournisseur ADO.NET, qui peut avoir la capacité de reconnaître ce modèle et de le réduire à la clause IN lors de la génération SQL.

Lorsque nous avons ajouté la prise en charge d'Enumerable.Contains dans EF4, nous avons pensé qu'il était souhaitable de le faire sans avoir à introduire la prise en charge des expressions IN dans le modèle de fournisseur, et honnêtement, 10000 est bien plus que le nombre d'éléments auxquels nous nous attendions à ce que les clients passent Enumerable.Contains. Cela dit, je comprends que c'est une gêne et que la manipulation des expressions des arbres rend les choses trop chères dans votre scénario particulier.

J'en ai discuté avec l'un de nos développeurs et nous pensons qu'à l'avenir, nous pourrions changer la mise en œuvre en ajoutant un support de première classe pour IN. Je ferai en sorte que cela soit ajouté à notre arriéré, mais je ne peux pas promettre quand cela arrivera, étant donné que nous aimerions apporter de nombreuses autres améliorations.

Aux solutions de contournement déjà suggérées dans le fil, j'ajouterais ce qui suit:

Envisagez de créer une méthode qui équilibre le nombre d'aller-retour de base de données avec le nombre d'éléments que vous transmettez à Contient. Par exemple, lors de mes propres tests, j'ai observé que le calcul et l'exécution sur une instance locale de SQL Server la requête avec 100 éléments prend 1/60 de seconde. Si vous pouvez écrire votre requête de manière à ce que l'exécution de 100 requêtes avec 100 ensembles d'ID différents vous donne un résultat équivalent à la requête avec 10000 éléments, alors vous pouvez obtenir les résultats en environ 1,67 seconde au lieu de 18 secondes.

Différentes tailles de blocs devraient mieux fonctionner en fonction de la requête et de la latence de la connexion à la base de données. Pour certaines requêtes, c'est-à-dire si la séquence transmise a des doublons ou si Enumerable.Contains est utilisé dans un état imbriqué, vous pouvez obtenir des éléments en double dans les résultats.

Voici un extrait de code (désolé si le code utilisé pour découper l'entrée en morceaux semble un peu trop complexe. Il existe des moyens plus simples de réaliser la même chose, mais j'essayais de trouver un modèle qui préserve le streaming pour la séquence et Je n'ai rien trouvé de tel dans LINQ, donc j'ai probablement exagéré cette partie :)):

Usage:

var list = context.GetMainItems(ids).ToList();

Méthode pour le contexte ou le référentiel:

public partial class ContainsTestEntities
{
    public IEnumerable<Main> GetMainItems(IEnumerable<int> ids, int chunkSize = 100)
    {
        foreach (var chunk in ids.Chunk(chunkSize))
        {
            var q = this.MainItems.Where(a => chunk.Contains(a.Id));
            foreach (var item in q)
            {
                yield return item;
            }
        }
    }
}

Méthodes d'extension pour découper des séquences énumérables:

public static class EnumerableSlicing
{

    private class Status
    {
        public bool EndOfSequence;
    }

    private static IEnumerable<T> TakeOnEnumerator<T>(IEnumerator<T> enumerator, int count, 
        Status status)
    {
        while (--count > 0 && (enumerator.MoveNext() || !(status.EndOfSequence = true)))
        {
            yield return enumerator.Current;
        }
    }

    public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> items, int chunkSize)
    {
        if (chunkSize < 1)
        {
            throw new ArgumentException("Chunks should not be smaller than 1 element");
        }
        var status = new Status { EndOfSequence = false };
        using (var enumerator = items.GetEnumerator())
        {
            while (!status.EndOfSequence)
            {
                yield return TakeOnEnumerator(enumerator, chunkSize, status);
            }
        }
    }
}

J'espère que cela t'aides!

65
divega

Si vous trouvez un problème de performance qui bloque pour vous, n'essayez pas de passer beaucoup de temps à le résoudre, car vous ne réussirez probablement pas et vous devrez le communiquer directement avec MS (si vous avez un support premium) et cela prend âge.

Utilisez une solution de contournement et une solution de contournement en cas de problème de performances et EF signifie SQL direct. Il n'y a rien de mal à cela. L'idée globale qu'utiliser EF = ne plus utiliser SQL est un mensonge. Vous avez SQL Server 2008 R2 donc:

  • Créer une procédure stockée acceptant un paramètre de valeur de table pour transmettre vos identifiants
  • Laissez votre procédure stockée retourner plusieurs jeux de résultats pour émuler la logique Include de manière optimale
  • Si vous avez besoin d'une construction de requête complexe, utilisez SQL dynamique dans la procédure stockée
  • Utilisez SqlDataReader pour obtenir des résultats et construire vos entités
  • Attachez-les au contexte et travaillez avec eux comme s'ils étaient chargés depuis EF

Si la performance est critique pour vous, vous ne trouverez pas de meilleure solution. Cette procédure ne peut pas être mappée et exécutée par EF car la version actuelle ne prend en charge ni les paramètres de valeur de table ni les jeux de résultats multiples.

24
Ladislav Mrnka

Nous avons pu résoudre le problème EF Contains en ajoutant une table intermédiaire et en nous joignant à cette table à partir de la requête LINQ qui devait utiliser la clause Contains. Nous avons pu obtenir des résultats étonnants avec cette approche. Nous avons un grand modèle EF et comme "Contient" n'est pas autorisé lors de la pré-compilation des requêtes EF, nous obtenions de très mauvaises performances pour les requêtes qui utilisent la clause "Contient".

Un aperçu:

  • Créez une table dans SQL Server - par exemple HelperForContainsOfIntType avec HelperID de Guid type de données et ReferenceID de int colonnes de type de données . Créez différentes tables avec ReferenceID de différents types de données selon vos besoins.

  • Créez un Entity/EntitySet pour HelperForContainsOfIntType et d'autres tables de ce type dans le modèle EF. Créez différents Entity/EntitySet pour différents types de données selon vos besoins.

  • Créez une méthode d'assistance dans le code .NET qui prend l'entrée d'un IEnumerable<int> et renvoie un Guid. Cette méthode génère un nouveau Guid et insère les valeurs de IEnumerable<int> dans HelperForContainsOfIntType avec le Guid généré. Ensuite, la méthode renvoie ce Guid nouvellement généré à l'appelant. Pour une insertion rapide dans la table HelperForContainsOfIntType, créez une procédure stockée qui prend en entrée une liste de valeurs et effectue l'insertion. Voir Paramètres table dans SQL Server 2008 (ADO.NET) . Créez différents assistants pour différents types de données ou créez une méthode d'assistance générique pour gérer différents types de données.

  • Créez une requête compilée EF qui ressemble à quelque chose comme ci-dessous:

    static Func<MyEntities, Guid, IEnumerable<Customer>> _selectCustomers =
        CompiledQuery.Compile(
            (MyEntities db, Guid containsHelperID) =>
                from cust in db.Customers
                join x in db.HelperForContainsOfIntType on cust.CustomerID equals x.ReferenceID where x.HelperID == containsHelperID
                select cust 
        );
    
  • Appelez la méthode d'assistance avec les valeurs à utiliser dans la clause Contains et obtenez la Guid à utiliser dans la requête. Par exemple:

    var containsHelperID = dbHelper.InsertIntoHelperForContainsOfIntType(new int[] { 1, 2, 3 });
    var result = _selectCustomers(_dbContext, containsHelperID).ToList();
    
9
Dhwanil Shah

Modification de ma réponse d'origine - Il existe une solution de contournement possible, selon la complexité de vos entités. Si vous connaissez le sql généré par EF pour remplir vos entités, vous pouvez l'exécuter directement à l'aide de DbContext.Database.SqlQuery . Dans EF 4, je pense que vous pourriez utiliser ObjectContext.ExecuteStoreQuery , mais je ne l'ai pas essayé.

Par exemple, en utilisant le code de ma réponse d'origine ci-dessous pour générer l'instruction sql en utilisant un StringBuilder, j'ai pu faire ce qui suit

var rows = db.Database.SqlQuery<Main>(sql).ToArray();

et le temps total est passé d'environ 26 secondes à 0,5 seconde.

Je serai le premier à dire que c'est moche, et j'espère qu'une meilleure solution se présentera.

mise à jour

Après un peu plus de réflexion, j'ai réalisé que si vous utilisez une jointure pour filtrer vos résultats, EF n'a pas à construire cette longue liste d'identifiants. Cela peut être complexe en fonction du nombre de requêtes simultanées, mais je pense que vous pouvez utiliser des identifiants utilisateur ou des identifiants de session pour les isoler.

Pour tester cela, j'ai créé une table Target avec le même schéma que Main. J'ai ensuite utilisé un StringBuilder pour créer des commandes INSERT pour remplir la table Target par lots de 1 000, car c'est le plus que SQL Server acceptera en un seul INSERT . L'exécution directe des instructions sql était beaucoup plus rapide que de passer par EF (environ 0,3 seconde contre 2,5 secondes), et je pense que ce serait bien puisque le schéma de la table ne devrait pas changer.

Enfin, la sélection à l'aide d'un join a abouti à une requête beaucoup plus simple et exécutée en moins de 0,5 seconde.

ExecuteStoreCommand("DELETE Target");

var ids = Main.Select(a => a.Id).ToArray();
var sb = new StringBuilder();

for (int i = 0; i < 10; i++)
{
    sb.Append("INSERT INTO Target(Id) VALUES (");
    for (int j = 1; j <= 1000; j++)
    {
        if (j > 1)
        {
            sb.Append(",(");
        }
        sb.Append(i * 1000 + j);
        sb.Append(")");
    }
    ExecuteStoreCommand(sb.ToString());
    sb.Clear();
}

var rows = (from m in Main
            join t in Target on m.Id equals t.Id
            select m).ToArray();

rows.Length.Dump();

Et le sql généré par EF pour la jointure:

SELECT 
[Extent1].[Id] AS [Id]
FROM  [dbo].[Main] AS [Extent1]
INNER JOIN [dbo].[Target] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]

(réponse originale)

Ce n'est pas une réponse, mais je voulais partager quelques informations supplémentaires et il est beaucoup trop long pour tenir dans un commentaire. J'ai pu reproduire vos résultats et j'ai quelques autres choses à ajouter:

SQL Profiler indique que le délai est compris entre l'exécution de la première requête (Main.Select) et le deuxième Main.Where requête, donc je soupçonnais que le problème était de générer et d'envoyer une requête de cette taille (48 980 octets).

Cependant, la construction dynamique de la même instruction sql dans T-SQL prend moins de 1 seconde et la prise de ids de votre Main.Select, la construction de la même instruction sql et son exécution à l'aide d'un SqlCommand a pris 0,112 seconde, ce qui inclut le temps d'écrire le contenu sur la console.

À ce stade, je soupçonne que EF effectue une analyse/un traitement pour chacun des 10 000 ids lors de la génération de la requête. J'aimerais pouvoir fournir une réponse définitive et une solution :(.

Voici le code que j'ai essayé dans SSMS et LINQPad (veuillez ne pas critiquer trop sévèrement, je suis pressé de quitter le travail):

declare @sql nvarchar(max)

set @sql = 'SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Main] AS [Extent1]
WHERE [Extent1].[Id] IN ('

declare @count int = 0
while @count < 10000
begin
    if @count > 0 set @sql = @sql + ','
    set @count = @count + 1
    set @sql = @sql + cast(@count as nvarchar)
end
set @sql = @sql + ')'

exec(@sql)

var ids = Mains.Select(a => a.Id).ToArray();

var sb = new StringBuilder();
sb.Append("SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] WHERE [Extent1].[Id] IN (");
for(int i = 0; i < ids.Length; i++)
{
    if (i > 0) 
        sb.Append(",");     
    sb.Append(ids[i].ToString());
}
sb.Append(")");

using (SqlConnection connection = new SqlConnection("server = localhost;database = Test;integrated security = true"))
using (SqlCommand command = connection.CreateCommand())
{
    command.CommandText = sb.ToString();
    connection.Open();
    using(SqlDataReader reader = command.ExecuteReader())
    {
        while(reader.Read())
        {
            Console.WriteLine(reader.GetInt32(0));
        }
    }
}
5
Jeff Ogata

Je ne connais pas Entity Framework, mais la performance est-elle meilleure si vous procédez comme suit?

Au lieu de cela:

var ids = Main.Select(a => a.Id).ToArray();
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();

que diriez-vous de cela (en supposant que l'ID est un int):

var ids = new HashSet<int>(Main.Select(a => a.Id));
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();
5
Shiv
3

Le problème est lié à la génération SQL d'Entity Framework. Il ne peut pas mettre en cache la requête si l'un des paramètres est une liste.

Pour que EF mette en cache votre requête, vous pouvez convertir votre liste en chaîne et faire un .Contains sur la chaîne.

Ainsi, par exemple, ce code s'exécuterait beaucoup plus rapidement car EF pourrait mettre en cache la requête:

var ids = Main.Select(a => a.Id).ToArray();
var idsString = "|" + String.Join("|", ids) + "|";
var rows = Main
.Where (a => idsString.Contains("|" + a.Id + "|"))
.ToArray();

Lorsque cette requête est générée, elle sera probablement générée à l'aide d'un Like au lieu d'un In afin d'accélérer votre C #, mais cela pourrait potentiellement ralentir votre SQL. Dans mon cas, je n'ai remarqué aucune baisse de performances dans mon exécution SQL, et le C # s'est exécuté beaucoup plus rapidement.

2
user2704238

Une alternative en cache à Contient?

Cela m'a juste mordu, j'ai donc ajouté mes deux pence au lien Suggestions de fonctionnalités d'Entity Framework.

Le problème se pose définitivement lors de la génération du SQL. J'ai un client sur les données de qui la génération de requête a été de 4 secondes mais l'exécution a été de 0,1 seconde.

J'ai remarqué que lors de l'utilisation de LINQ dynamique et des OR, la génération sql prenait autant de temps, mais elle générait quelque chose qui pouvait être mis en cache . Ainsi, lors de sa nouvelle exécution, la durée était passée à 0,2 seconde.

Notez qu'un SQL in était toujours généré.

Juste autre chose à considérer si vous pouvez supporter le hit initial, votre nombre de tableaux ne change pas beaucoup et exécutez beaucoup la requête. (Testé dans LINQ Pad)

2
Dave