Un peu plus d'un an avec ma participation, le "dialogue" suivant a eu lieu:
Application .Net : Hey Entity Framework, veuillez me donner beaucoup de données!
Entity Framework : Désolé, je ne vous ai pas compris. Que voulez-vous dire?
Application .Net : Oui, je viens de recevoir une collection de 100 000 transactions. Et maintenant, nous devons vérifier rapidement l'exactitude des prix des titres qui y sont indiqués.
Entity Framework : Ahh, eh bien, essayons ...
Application .Net : voici le code:
var query = from p in context.Prices join t in transactions on new { p.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; query.ToList();
Cadre d'entité :

Classique Je pense que beaucoup de gens connaissent cette situation: quand je veux vraiment "magnifiquement" et faire rapidement une recherche dans la base de données en utilisant le JOIN de la collection locale et DbSet . Habituellement, cette expérience est décevante.
Dans cet article (qui est une traduction gratuite de mon autre article ), je vais mener une série d'expériences et essayer différentes maniÚres de contourner cette limitation. Il y aura un code (simple), des pensées et quelque chose comme une fin heureuse.
Présentation
Tout le monde connaĂźt Entity Framework , beaucoup l'utilisent tous les jours et il existe de nombreux bons articles sur la façon de le cuisiner correctement (utilisez des requĂȘtes plus simples, utilisez les paramĂštres de Skip and Take, utilisez VIEW, demandez uniquement les champs nĂ©cessaires, surveillez la mise en cache des requĂȘtes et autre), cependant, le thĂšme JOIN de la collection locale et DbSet est toujours un point faible.
Défi
Supposons qu'il existe une base de données avec les prix et qu'il existe une collection de transactions pour lesquelles vous devez vérifier l'exactitude des prix. Et supposons que nous ayons le code suivant.
var localData = GetDataFromApiOrUser(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in localData on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; var result = query.ToList();
Ce code ne fonctionne pas du tout dans Entity Framework 6 . Dans Entity Framework Core - cela fonctionne, mais tout sera fait cĂŽtĂ© client et dans le cas oĂč il y a des millions d'enregistrements dans la base de donnĂ©es - ce n'est pas une option.
Comme je l'ai dit, je vais essayer différentes façons de contourner ce problÚme. Du simple au complexe. Pour mes expériences, j'utilise le code du référentiel suivant. Le code est écrit en utilisant: C # , .Net Core , EF Core et PostgreSQL .
J'ai également réalisé quelques métriques: le temps passé et la consommation de mémoire. Avertissement: si le test a été effectué pendant plus de 10 minutes, je l'ai interrompu (la restriction vient d'en haut). Test machine Intel Core i5, 8 Go de RAM, SSD.
Schéma DB
Seulement 3 tableaux: prix , titres et sources de prix . Prix - contient 10 millions d'entrées.
Méthode 1. Naive
Commençons simplement et utilisons le code suivant:
Code pour la méthode 1 var result = new List<Price>(); using (var context = CreateContext()) { foreach (var testElement in TestData) { result.AddRange(context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId)); } }
L'idĂ©e est simple: dans une boucle, nous lisons les enregistrements de la base de donnĂ©es un par un et les ajoutons Ă la collection rĂ©sultante. Ce code n'a qu'un seul avantage: la simplicitĂ©. Et un inconvĂ©nient est la faible vitesse: mĂȘme s'il y a un index dans la base de donnĂ©es, la plupart du temps il faudra une communication avec le serveur de base de donnĂ©es. Les mesures sont les suivantes:

La consommation de mémoire est faible. Une grande collection prend 1 minute. Pour commencer, pas mal, mais je le veux plus vite.
Méthode 2: parallÚle naïf
Essayons d'ajouter du parallélisme. L'idée est d'accéder à la base de données à partir de plusieurs threads.
Code pour la méthode 2 var result = new ConcurrentBag<Price>(); var partitioner = Partitioner.Create(0, TestData.Count); Parallel.ForEach(partitioner, range => { var subList = TestData.Skip(range.Item1) .Take(range.Item2 - range.Item1) .ToList(); using (var context = CreateContext()) { foreach (var testElement in subList) { var query = context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId); foreach (var el in query) { result.Add(el); } } } });
Résultat:

Pour les petites collections, cette approche est encore plus lente que la premiÚre méthode. Et pour le plus grand - 2 fois plus rapide. Fait intéressant, 4 threads ont été générés sur ma machine, mais cela n'a pas entraßné d'accélération 4x. Cela suggÚre que la surcharge de cette méthode est importante: à la fois cÎté client et cÎté serveur. La consommation de mémoire a augmenté, mais pas de maniÚre significative.
Méthode 3: Contient plusieurs
Il est temps d'essayer autre chose et de rĂ©duire la tĂąche Ă une seule requĂȘte. Cela peut ĂȘtre fait comme suit:
- Préparez 3 collections uniques de Ticker , PriceSourceId et Date
- Exécutez la demande et utilisez 3 Contient
- Revérifiez les résultats localement
Code pour la méthode 3 var result = new List<Price>(); using (var context = CreateContext()) {
Le problĂšme ici est que le temps d'exĂ©cution et la quantitĂ© de donnĂ©es renvoyĂ©es dĂ©pendent fortement des donnĂ©es elles-mĂȘmes (Ă la fois dans la requĂȘte et dans la base de donnĂ©es). Autrement dit, un ensemble de seules les donnĂ©es nĂ©cessaires peut renvoyer et des enregistrements supplĂ©mentaires peuvent ĂȘtre retournĂ©s (mĂȘme 100 fois plus).
Cela peut ĂȘtre expliquĂ© Ă l'aide de l'exemple suivant. Supposons qu'il existe le tableau suivant avec des donnĂ©es:

Supposons également que j'ai besoin des prix pour Ticker1 avec TradedOn = 2018-01-01 et pour Ticker2 avec TradedOn = 2018-01-02 .
Puis des valeurs uniques pour Ticker = ( Ticker1 , Ticker2 )
Et des valeurs uniques pour TradedOn = ( 2018-01-01 , 2018-01-02 )
Cependant, 4 enregistrements seront retournés en conséquence, car ils correspondent vraiment à ces combinaisons. La mauvaise chose est que plus les champs sont utilisés, plus les chances d'obtenir des enregistrements supplémentaires en conséquence.
Pour cette raison, les donnĂ©es obtenues par cette mĂ©thode doivent en outre ĂȘtre filtrĂ©es cĂŽtĂ© client. Et c'est le plus gros inconvĂ©nient.
Les mesures sont les suivantes:

La consommation de mémoire est pire que toutes les méthodes précédentes. Le nombre de lignes lues est plusieurs fois supérieur au nombre demandé. Les tests pour les grandes collections ont été interrompus car ils ont fonctionné pendant plus de 10 minutes. Cette méthode n'est pas bonne.
Méthode 4. Générateur de prédicats
Essayons de l'autre cĂŽtĂ©: la bonne vieille expression . En les utilisant, vous pouvez crĂ©er 1 grande requĂȘte sous la forme suivante:
⊠(.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) âŠ
Cela donne l'espoir qu'il sera possible de générer 1 demande et d'obtenir uniquement les données nécessaires pour 1 appel. Code:
Code pour la méthode 4 var result = new List<Price>(); using (var context = CreateContext()) { var baseQuery = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId select new TestData() { Ticker = s.Ticker, TradedOn = p.TradedOn, PriceSourceId = p.PriceSourceId, PriceObject = p }; var tradedOnProperty = typeof(TestData).GetProperty("TradedOn"); var priceSourceIdProperty = typeof(TestData).GetProperty("PriceSourceId"); var tickerProperty = typeof(TestData).GetProperty("Ticker"); var paramExpression = Expression.Parameter(typeof(TestData)); Expression wholeClause = null; foreach (var td in TestData) { var elementClause = Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, tradedOnProperty), Expression.Constant(td.TradedOn) ), Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, priceSourceIdProperty), Expression.Constant(td.PriceSourceId) ), Expression.Equal( Expression.MakeMemberAccess( paramExpression, tickerProperty), Expression.Constant(td.Ticker)) )); if (wholeClause == null) wholeClause = elementClause; else wholeClause = Expression.OrElse(wholeClause, elementClause); } var query = baseQuery.Where( (Expression<Func<TestData, bool>>)Expression.Lambda( wholeClause, paramExpression)).Select(x => x.PriceObject); result.AddRange(query); }
Le code s'est avéré plus compliqué que dans les méthodes précédentes. Construire manuellement Expression n'est pas l'opération la plus simple et la plus rapide.
Mesures:

Les rĂ©sultats temporaires Ă©taient encore pires que dans la mĂ©thode prĂ©cĂ©dente. Il semble que les frais gĂ©nĂ©raux pendant la construction et lors de la marche dans l'arbre se soient avĂ©rĂ©s ĂȘtre bien plus que le gain liĂ© Ă l'utilisation d'une seule demande.
MĂ©thode 5: table de donnĂ©es de requĂȘte partagĂ©e
Essayons une autre option:
J'ai créé une nouvelle table dans la base de données dans laquelle j'écrirai les données nécessaires pour compléter la demande (implicitement j'ai besoin d'un nouveau DbSet dans le contexte).
Maintenant, pour obtenir le résultat dont vous avez besoin:
- Lancer la transaction
- TĂ©lĂ©charger des donnĂ©es de requĂȘte dans une nouvelle table
- ExĂ©cutez la requĂȘte elle-mĂȘme (en utilisant la nouvelle table)
- Annuler une transaction (pour effacer le tableau de donnĂ©es des requĂȘtes)
Le code ressemble Ă ceci:
Code pour la méthode 5 var result = new List<Price>(); using (var context = CreateContext()) { context.Database.BeginTransaction(); var reducedData = TestData.Select(x => new SharedQueryModel() { PriceSourceId = x.PriceSourceId, Ticker = x.Ticker, TradedOn = x.TradedOn }).ToList();
PremiĂšres mesures:

Tous les tests ont fonctionné et ont fonctionné rapidement! La consommation de mémoire est également acceptable.
Ainsi, grĂące Ă l'utilisation d'une transaction, cette table peut ĂȘtre utilisĂ©e simultanĂ©ment par plusieurs processus. Et comme il s'agit d'une vĂ©ritable table existante, toutes les fonctionnalitĂ©s d' Entity Framework sont Ă notre disposition: il vous suffit de charger les donnĂ©es dans la table, de crĂ©er une requĂȘte Ă l'aide de JOIN et de l'exĂ©cuter. Ă premiĂšre vue, c'est ce dont vous avez besoin, mais il y a des inconvĂ©nients importants:
- Vous devez crĂ©er une table pour un type de requĂȘte spĂ©cifique
- Il est nécessaire d'utiliser des transactions (et de gaspiller des ressources SGBD sur celles-ci)
- Et l'idĂ©e mĂȘme que vous devez ĂCRIRE quelque chose, quand vous avez besoin de LIRE, semble Ă©trange. Et sur Read Replica, cela ne fonctionnera tout simplement pas.
Et le reste est une solution plus ou moins fonctionnelle qui peut dĂ©jĂ ĂȘtre utilisĂ©e.
Méthode 6. Extension MemoryJoin
Vous pouvez maintenant essayer d'améliorer l'approche précédente. Les pensées sont:
- Au lieu d'utiliser une table spĂ©cifique Ă un type de requĂȘte, vous pouvez utiliser une option gĂ©nĂ©ralisĂ©e. Ă savoir, crĂ©ez une table avec un nom comme shared_query_data et ajoutez-y plusieurs champs Guid , plusieurs Long , plusieurs String , etc. Des noms simples peuvent ĂȘtre pris: Guid1 , Guid2 , String1 , Long1 , Date2 , etc. Ensuite, ce tableau peut ĂȘtre utilisĂ© pour 95% des types de requĂȘte. Les noms de propriĂ©tĂ© peuvent ĂȘtre «ajustĂ©s» ultĂ©rieurement Ă l'aide de la perspective SĂ©lectionner .
- Vous devez ensuite ajouter un DbSet pour shared_query_data .
- Mais que se passe-t-il si, au lieu d'Ă©crire des donnĂ©es dans la base de donnĂ©es, en passant des valeurs Ă l'aide de la construction VALUES ? Autrement dit, il est nĂ©cessaire que dans la requĂȘte SQL finale, au lieu d'accĂ©der Ă shared_query_data, il y ait un appel Ă VALUES . Comment faire
- Dans Entity Framework Core - en utilisant simplement FromSql .
- Dans Entity Framework 6 - vous devez utiliser DbInterception - c'est-à -dire, changez le SQL généré en ajoutant la construction VALUES juste avant l'exécution. Cela entraßnera une limitation: dans une seule demande, pas plus d'une construction VALUES . Mais ça va marcher!
- Puisque nous n'allons pas Ă©crire dans la base de donnĂ©es, nous obtenons alors la table shared_query_data créée Ă la premiĂšre Ă©tape, n'est-elle pas nĂ©cessaire du tout? RĂ©ponse: oui, ce n'est pas nĂ©cessaire, mais DbSet est toujours nĂ©cessaire, car Entity Framework doit connaĂźtre le schĂ©ma de donnĂ©es afin de gĂ©nĂ©rer des requĂȘtes. Il s'avĂšre que nous avons besoin d'un DbSet pour un modĂšle gĂ©nĂ©ralisĂ© qui n'existe pas dans la base de donnĂ©es et n'est utilisĂ© que pour inspirer Entity Framework, qu'il sait ce qu'il fait.
Convertir IEnumerable en exemple IQueryable- L'entrée a reçu une collection d'objets du type suivant:
class SomeQueryData { public string Ticker {get; set;} public DateTimeTradedOn {get; set;} public int PriceSourceId {get; set;} }
- Nous avons Ă notre disposition DbSet avec les champs String1 , String2 , Date1 , Long1 , etc.
- Laissez Ticker ĂȘtre stockĂ© dans String1 , TradedOn dans Date1 et PriceSourceId dans Long1 ( int mapps en long , afin de ne pas sĂ©parer les champs pour int et long )
- Alors FromSql + VALUES sera comme ceci:
var query = context.QuerySharedData.FromSql( "SELECT * FROM ( VALUES (1, 'Ticker1', @date1, @id1), (2, 'Ticker2', @date2, @id2) ) AS __gen_query_data__ (id, string1, date1, long1)")
- Vous pouvez maintenant faire une projection et renvoyer un IQueryable pratique en utilisant le mĂȘme type qui Ă©tait Ă l'entrĂ©e:
return query.Select(x => new SomeQueryData() { Ticker = x.String1, TradedOn = x.Date1, PriceSourceId = (int)x.Long1 });
J'ai rĂ©ussi Ă implĂ©menter cette approche et mĂȘme Ă la concevoir comme un package NuGet EntityFrameworkCore.MemoryJoin (le code est Ă©galement disponible). MalgrĂ© le fait que le nom contient le mot Core , Entity Framework 6 est Ă©galement pris en charge. Je l'ai appelĂ© MemoryJoin , mais en fait, il envoie des donnĂ©es locales au SGBD dans la construction VALUES et tout le travail est fait dessus.
Le code est le suivant:
Code pour la méthode 6 var result = new List<Price>(); using (var context = CreateContext()) {
Mesures:

C'est le meilleur rĂ©sultat que j'aie jamais essayĂ©. Le code Ă©tait trĂšs simple et direct, et fonctionnait en mĂȘme temps pour Read Replica.
Un exemple de requĂȘte gĂ©nĂ©rĂ©e pour recevoir 3 Ă©lĂ©ments SELECT "p"."PriceId", "p"."ClosePrice", "p"."OpenPrice", "p"."PriceSourceId", "p"."SecurityId", "p"."TradedOn", "t"."Ticker", "t"."TradedOn", "t"."PriceSourceId" FROM "Price" AS "p" INNER JOIN "Security" AS "s" ON "p"."SecurityId" = "s"."SecurityId" INNER JOIN ( SELECT "x"."string1" AS "Ticker", "x"."date1" AS "TradedOn", CAST("x"."long1" AS int4) AS "PriceSourceId" FROM ( SELECT * FROM ( VALUES (1, @__gen_q_p0, @__gen_q_p1, @__gen_q_p2), (2, @__gen_q_p3, @__gen_q_p4, @__gen_q_p5), (3, @__gen_q_p6, @__gen_q_p7, @__gen_q_p8) ) AS __gen_query_data__ (id, string1, date1, long1) ) AS "x" ) AS "t" ON (("s"."Ticker" = "t"."Ticker") AND ("p"."PriceSourceId" = "t"."PriceSourceId")
Ici, vous pouvez également voir comment le modÚle généralisé (avec les champs String1 , Date1 , Long1 ) utilisant Select se transforme en celui qui est utilisé dans le code (avec les champs Ticker , TradedOn , PriceSourceId ).
Tout le travail est effectuĂ© en 1 requĂȘte sur le serveur SQL. Et c'est une petite fin heureuse, dont j'ai parlĂ© au dĂ©but. NĂ©anmoins, l'utilisation de cette mĂ©thode nĂ©cessite une comprĂ©hension et les Ă©tapes suivantes:
- Vous devez ajouter un DbSet supplĂ©mentaire Ă votre contexte (bien que la table elle-mĂȘme puisse ĂȘtre omise )
- Dans le modĂšle gĂ©nĂ©ralisĂ©, utilisĂ© par dĂ©faut, 3 champs de types Guid , String , Double , Long , Date , etc. sont dĂ©clarĂ©s. Cela devrait suffire pour 95% des types de demandes. Et si vous passez une collection d'objets avec 20 champs Ă FromLocalList , une exception sera levĂ©e, disant que l'objet est trop complexe. Il s'agit d'une restriction souple et peut ĂȘtre contournĂ©e - vous pouvez dĂ©clarer votre type et y ajouter au moins 100 champs. Cependant, plus de champs sont plus lents Ă travailler.
- Plus de détails techniques sont décrits dans mon article .
Conclusion
Dans cet article, j'ai prĂ©sentĂ© mes rĂ©flexions sur le sujet de la collection locale JOIN et de DbSet. Il me semblait que mon dĂ©veloppement Ă l'aide de VALUES pouvait intĂ©resser la communautĂ©. Au moins, je n'ai pas rencontrĂ© une telle approche lorsque j'ai rĂ©solu ce problĂšme moi-mĂȘme. Personnellement, cette mĂ©thode m'a aidĂ© Ă surmonter un certain nombre de problĂšmes de performances dans mes projets en cours, peut-ĂȘtre qu'elle vous aidera Ă©galement.
Quelqu'un dira que l'utilisation de MemoryJoin est trop "abstraite" et doit ĂȘtre dĂ©veloppĂ©e davantage, et jusque-lĂ , elle ne devrait pas ĂȘtre utilisĂ©e. C'est exactement la raison pour laquelle j'Ă©tais trĂšs douteux et pendant prĂšs d'un an, je n'ai pas Ă©crit cet article. Je suis d'accord pour que cela fonctionne plus facilement (j'espĂšre qu'un jour ça le sera), mais je dirai aussi que l'optimisation n'a jamais Ă©tĂ© la tĂąche des Juniors. L'optimisation nĂ©cessite toujours une comprĂ©hension du fonctionnement de l'outil. Et s'il est possible d'obtenir une accĂ©lĂ©ration d'environ 8 fois ( Naive Parallel vs MemoryJoin ), je maĂźtriserais 2 points et la documentation.
Et enfin, les diagrammes:
Le temps passé. Seules 4 méthodes ont terminé la tùche en moins de 10 minutes, et MemoryJoin est le seul moyen qui a terminé la tùche en moins de 10 secondes.

Consommation de mĂ©moire. Toutes les mĂ©thodes ont montrĂ© approximativement la mĂȘme consommation de mĂ©moire, Ă l'exception de Contient plusieurs . Cela est dĂ» Ă la quantitĂ© de donnĂ©es retournĂ©es.

Merci d'avoir lu!