Quel est le moyen le plus efficace de sélectionner plusieurs entités par clé primaire?
public IEnumerable<Models.Image> GetImagesById(IEnumerable<int> ids)
{
//return ids.Select(id => Images.Find(id)); //is this cool?
return Images.Where( im => ids.Contains(im.Id)); //is this better, worse or the same?
//is there a (better) third way?
}
Je me rends compte que je pourrais faire quelques tests de performance pour comparer, mais je me demande s’il existe en fait un meilleur moyen que les deux, et je cherche des éclaircissements sur la différence entre ces deux requêtes, le cas échéant, une fois qu’elles ont été traitées 'traduit'.
UPDATE: Avec l'ajout d'InExpression dans EF6, les performances du traitement Enumerable.Contains ont été considérablement améliorées. L'analyse présentée dans cette réponse est excellente mais largement obsolète depuis 2013.
L'utilisation de Contains
dans Entity Framework est en réalité très lente. Il est vrai que cela se traduit par une clause IN
en SQL et que la requête SQL elle-même est exécutée rapidement. Mais le problème et le goulot d'étranglement des performances réside dans la traduction de votre requête LINQ en SQL. L'arbre d'expression qui sera créé est développé dans une longue chaîne de concaténations OR
car il n'y a pas d'expression native représentant un IN
. Lors de la création du code SQL, cette expression de plusieurs OR
s est reconnue et repliée dans la clause SQL IN
.
Cela ne signifie pas que l'utilisation de Contains
est pire que d'émettre une requête par élément dans votre collection ids
(votre première option). C'est probablement encore mieux - du moins pour les collections pas trop grandes. Mais pour les grandes collections, c'est vraiment mauvais. Je me souviens que j’avais testé il ya quelque temps une requête Contains
avec environ 12 000 éléments qui fonctionnait mais prenait environ une minute bien que la requête SQL s’exécute en moins d’une seconde.
Il peut être intéressant de tester les performances d'une combinaison de plusieurs allers-retours dans la base de données avec un plus petit nombre d'éléments dans une expression Contains
pour chaque aller-retour.
Cette approche, ainsi que les limites d'utilisation de Contains
avec Entity Framework, sont expliquées et expliquées ici:
Il est possible qu'une commande SQL brute fonctionne mieux dans cette situation, ce qui signifie que vous appelez dbContext.Database.SqlQuery<Image>(sqlString)
ou dbContext.Images.SqlQuery(sqlString)
où sqlString
est le code SQL indiqué dans la réponse de @ Rune.
Modifier
Voici quelques mesures:
Je l'ai fait sur une table avec 550000 enregistrements et 11 colonnes (les ID commencent à 1 sans espaces) et j'ai choisi au hasard 20000 ID:
using (var context = new MyDbContext())
{
Random Rand = new Random();
var ids = new List<int>();
for (int i = 0; i < 20000; i++)
ids.Add(Rand.Next(550000));
Stopwatch watch = new Stopwatch();
watch.Start();
// here are the code snippets from below
watch.Stop();
var msec = watch.ElapsedMilliseconds;
}
Test 1
var result = context.Set<MyEntity>()
.Where(e => ids.Contains(e.ID))
.ToList();
Résultat -> msec = 85.5 sec
Test 2
var result = context.Set<MyEntity>().AsNoTracking()
.Where(e => ids.Contains(e.ID))
.ToList();
Résultat -> msec = 84.5 sec
Cet effet minuscule de AsNoTracking
est très inhabituel. Cela indique que le goulot d'étranglement n'est pas une matérialisation d'objet (et non pas SQL, comme indiqué ci-dessous).
SQL Profiler permet de constater que la requête SQL parvient très tard à la base de données. (Je n'ai pas mesuré exactement mais cela a pris plus de 70 secondes.) Évidemment, la traduction de cette requête LINQ en SQL est très coûteuse.
Test 3
var values = new StringBuilder();
values.AppendFormat("{0}", ids[0]);
for (int i = 1; i < ids.Count; i++)
values.AppendFormat(", {0}", ids[i]);
var sql = string.Format(
"SELECT * FROM [MyDb].[dbo].[MyEntities] WHERE [ID] IN ({0})",
values);
var result = context.Set<MyEntity>().SqlQuery(sql).ToList();
Résultat -> msec = 5.1 sec
Test 4
// same as Test 3 but this time including AsNoTracking
var result = context.Set<MyEntity>().SqlQuery(sql).AsNoTracking().ToList();
Résultat -> msec = 3.8 sec
Cette fois, l’effet de désactiver le suivi est plus visible.
Test 5
// same as Test 3 but this time using Database.SqlQuery
var result = context.Database.SqlQuery<MyEntity>(sql).ToList();
Résultat -> msec = 3.7 sec
D'après ce que je comprends, context.Database.SqlQuery<MyEntity>(sql)
est identique à context.Set<MyEntity>().SqlQuery(sql).AsNoTracking()
; il n'y a donc pas de différence attendue entre le test 4 et le test 5.
(La longueur des ensembles de résultats n'était pas toujours la même en raison des doublons possibles après la sélection id aléatoire, mais elle était toujours comprise entre 19600 et 19640.)
Edit 2
Test 6
Même 20000 allers-retours à la base de données sont plus rapides que d'utiliser Contains
:
var result = new List<MyEntity>();
foreach (var id in ids)
result.Add(context.Set<MyEntity>().SingleOrDefault(e => e.ID == id));
Résultat -> msec = 73.6 sec
Notez que j'ai utilisé SingleOrDefault
au lieu de Find
. L'utilisation du même code avec Find
est très lente (j'ai annulé le test après plusieurs minutes) car Find
appelle DetectChanges
en interne. La désactivation de la détection de changement automatique (context.Configuration.AutoDetectChangesEnabled = false
) entraîne à peu près les mêmes performances que SingleOrDefault
. Utiliser AsNoTracking
réduit le temps d'une ou deux secondes.
Les tests ont été effectués avec le client de base de données (application console) et le serveur de base de données sur le même ordinateur. Le dernier résultat pourrait être considérablement pire avec une base de données "distante" en raison des nombreux allers-retours.
La deuxième option est définitivement meilleure que la première. La première option entraînera des requêtes ids.Length
dans la base de données, tandis que la seconde option peut utiliser un opérateur 'IN'
dans la requête SQL. Cela transformera votre requête LINQ en quelque chose comme le code SQL suivant:
SELECT *
FROM ImagesTable
WHERE id IN (value1,value2,...)
où valeur1, valeur2 etc. sont les valeurs de votre variable ids. Sachez cependant que, selon moi, le nombre de valeurs pouvant être sérialisées dans une requête peut être limité de cette manière. Je verrai si je peux trouver de la documentation ...
Nous avons récemment rencontré un problème similaire. Le meilleur moyen que j’ai trouvé est d’insérer la liste des éléments contenus dans une table temporaire et de créer une jointure.
private List<Foo> GetFoos(IEnumerable<long> ids)
{
var sb = new StringBuilder();
sb.Append("DECLARE @Temp TABLE (Id bitint PRIMARY KEY)\n");
foreach (var id in ids)
{
sb.Append("INSERT INTO @Temp VALUES ('");
sb.Append(id);
sb.Append("')\n");
}
sb.Append("SELECT f.* FROM [dbo].[Foo] f inner join @Temp t on f.Id = t.Id");
return this.context.Database.SqlQuery<Foo>(sb.ToString()).ToList();
}
Ce n'est pas beau, mais pour les grandes listes, c'est très performant.
Transformer la liste en tableau avec toArray () augmente les performances. Vous pouvez le faire de cette façon:
ids.Select(id => Images.Find(id));
return Images.toArray().Where( im => ids.Contains(im.Id));
J'utilise Entity Framework 6.1 et découvre en utilisant votre code que, il est préférable d'utiliser:
return db.PERSON.Find(id);
plutôt que:
return db.PERSONA.FirstOrDefault(x => x.ID == id);
Performances de Find () par rapport à FirstOrDefault sont quelques réflexions à ce sujet.