O scufundare profundă în performanța Entity Framework atunci când utilizați „Conține”
Publicat: 2022-03-11În timpul muncii mele de zi cu zi, folosesc Entity Framework. Este foarte convenabil, dar în unele cazuri, performanța sa este lentă. În ciuda faptului că există o mulțime de articole bune despre îmbunătățirea performanței EF și sunt oferite câteva sfaturi foarte bune și utile (de exemplu, evitați interogările complexe, parametrii în Skip and Take, utilizați vizualizări, selectați numai câmpurile necesare etc.), nu prea multe se pot. cu adevărat, atunci când trebuie să utilizați Contains
complexe pe două sau mai multe câmpuri - cu alte cuvinte, atunci când asociați date la o listă de memorie .
Problemă
Să verificăm următorul exemplu:
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();
Codul de mai sus nu funcționează deloc în EF 6 și, deși funcționează în EF Core, alăturarea se face de fapt la nivel local - deoarece am zece milioane de înregistrări în baza mea de date, toate sunt descărcate și toată memoria este consumată . Aceasta nu este o eroare în EF. E de așteptat. Totuși, nu ar fi fantastic dacă ar exista ceva care să rezolve acest lucru? În acest articol, voi face câteva experimente cu o abordare diferită pentru a rezolva acest blocaj de performanță.
Soluţie
Voi încerca diferite moduri de a realiza acest lucru, începând de la cele mai simple până la mai avansate. La fiecare pas, voi furniza cod și valori, cum ar fi timpul necesar și utilizarea memoriei. Rețineți că voi întrerupe rularea programului de benchmarking dacă funcționează mai mult de zece minute.
Codul pentru programul de analiză comparativă se află în următorul depozit. Utilizează C#, .NET Core, EF Core și PostgreSQL. Am folosit o mașină cu Intel Core i5, 8 GB RAM și un SSD.
Schema DB pentru testare arată astfel:
Opțiunea 1. Simplu și naiv
Să încercăm ceva simplu, doar pentru a începe.
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)); } }
Algoritmul este simplu: pentru fiecare element din datele de testare, găsiți un element corespunzător în baza de date și adăugați-l la colecția de rezultate. Acest cod are doar un singur avantaj: este foarte ușor de implementat. De asemenea, este ușor de citit și de întreținut. Dezavantajul său evident este că este cel mai lent. Chiar dacă toate cele trei coloane sunt indexate, suprasolicitarea comunicării în rețea creează în continuare un blocaj de performanță. Iată valorile:
Deci, pentru un volum mare, durează aproximativ un minut. Consumul de memorie pare a fi rezonabil.
Opțiunea 2. Naiv cu paralel
Acum să încercăm să adăugăm paralelism la cod. Ideea de bază aici este că accesarea bazei de date în fire paralele poate îmbunătăți performanța generală.
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); } } } });
Este interesant că, pentru seturi mai mici de date de testare, această abordare funcționează mai lent decât prima soluție, dar pentru mostre mai mari, este mai rapidă (de aproximativ 2 ori în acest caz). Consumul de memorie se modifică puțin, dar nu semnificativ.
Opțiunea 3. Conține multiple
Să încercăm o altă abordare:
- Pregătiți 3 colecții de valori unice ale Ticker, PriceSourceId și Date.
- Efectuați interogarea cu o singură rulare de filtrare utilizând 3 Conține.
- Verificați din nou local (vezi mai jos).
var result = new List<Price>(); using (var context = CreateContext()) { var tickers = TestData.Select(x => x.Ticker).Distinct().ToList(); var dates = TestData.Select(x => x.TradedOn).Distinct().ToList(); var ps = TestData.Select(x => x.PriceSourceId) .Distinct().ToList(); var data = context.Prices .Where(x => tickers.Contains(x.Security.Ticker) && dates.Contains(x.TradedOn) && ps.Contains(x.PriceSourceId)) .Select(x => new { x.PriceSourceId, Price = x, Ticker = x.Security.Ticker, }) .ToList(); var lookup = data.ToLookup(x => $"{x.Ticker}, {x.Price.TradedOn}, {x.PriceSourceId}"); foreach (var el in TestData) { var key = $"{el.Ticker}, {el.TradedOn}, {el.PriceSourceId}"; result.AddRange(lookup[key].Select(x => x.Price)); } }
Această abordare este problematică. Timpul de execuție depinde foarte mult de date. Poate prelua doar înregistrările necesare (caz în care va fi foarte rapid), dar poate returna mult mai multe (poate chiar de 100 de ori mai multe).
Să luăm în considerare următoarele date de testare:
Aici interog prețurile pentru Ticker1 tranzacționat pe 2018-01-01 și pentru Ticker2 tranzacționat pe 2018-01-02. Cu toate acestea, patru înregistrări vor fi efectiv returnate.
Valorile unice pentru Ticker
sunt Ticker1
și Ticker2
. Valorile unice pentru TradedOn
sunt 2018-01-01
și 2018-01-02
.
Deci, patru înregistrări se potrivesc cu această expresie.
De aceea este necesară o reverificare locală și de ce această abordare este periculoasă. Valorile sunt după cum urmează:
Consum groaznic de memorie! Testele cu volume mari au eșuat din cauza unui timeout de 10 minute.
Opțiunea 4. Generator de predicate
Să schimbăm paradigma: să construim o Expression
veche bună pentru fiecare set de date de testare.
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); }
Codul rezultat este destul de complex. Construirea de expresii nu este cel mai ușor lucru și implică reflecție (care, în sine, nu este atât de rapidă). Dar ne ajută să construim o singură interogare folosind o mulțime de … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ...
. Acestea sunt rezultatele:

Chiar mai rău decât oricare dintre abordările anterioare.
Opțiunea 5. Tabel de date de interogare partajat
Să mai încercăm o abordare:
Am adăugat un nou tabel la baza de date care va conține date de interogare. Pentru fiecare interogare acum pot:
- Începeți o tranzacție (dacă nu a început încă)
- Încărcați datele de interogare în acel tabel (temporar)
- Efectuați o interogare
- Derulați înapoi o tranzacție - pentru a șterge datele încărcate
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(); // Here query data is stored to shared table context.QueryDataShared.AddRange(reducedData); context.SaveChanges(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in context.QueryDataShared on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); context.Database.RollbackTransaction(); }
În primul rând, valorile:
Rezultatul este foarte bun. Foarte rapid. Consumul de memorie este de asemenea bun. Dar dezavantajele sunt:
- Trebuie să creați un tabel suplimentar în baza de date pentru a efectua un singur tip de interogare,
- Trebuie să începeți o tranzacție (care consumă oricum resurse DBMS) și
- Trebuie să scrieți ceva în baza de date (într-o operațiune READ!) și, practic, acest lucru nu va funcționa dacă utilizați ceva de genul read replica.
Dar în afară de asta, această abordare este plăcută - rapidă și lizibilă. Și un plan de interogare este stocat în cache în acest caz!
Opțiunea 6. Extensia MemoryJoin
Aici voi folosi un pachet NuGet numit EntityFrameworkCore.MemoryJoin. În ciuda faptului că numele său are cuvântul Core în el, acceptă și EF 6. Se numește MemoryJoin, dar de fapt, trimite datele de interogare specificate ca VALORI către server și toată munca se face pe serverul SQL.
Să verificăm codul.
var result = new List<Price>(); using (var context = CreateContext()) { // better to select needed properties only, for better performance var reducedData = TestData.Select(x => new { x.Ticker, x.TradedOn, x.PriceSourceId }).ToList(); var queryable = context.FromLocalList(reducedData); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in queryable on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); }
Valori:
Asta arată grozav. De trei ori mai rapid decât abordarea anterioară - asta o face cea mai rapidă de până acum. 3,5 secunde pentru înregistrări de 64K! Codul este simplu și de înțeles. Acest lucru funcționează cu replici numai în citire. Să verificăm interogarea generată pentru trei elemente:
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")
După cum puteți vedea, de data aceasta valorile reale sunt transmise din memorie către serverul SQL în construcția VALUES. Și asta face truc: serverul SQL a reușit să efectueze o operație rapidă de conectare și să folosească indecșii corect.
Cu toate acestea, există câteva dezavantaje (puteți citi mai multe pe blogul meu):
- Trebuie să adăugați un DbSet suplimentar la modelul dvs. (totuși nu este nevoie să îl creați în DB)
- Extensia nu acceptă clase de model cu multe proprietăți: trei proprietăți șir, trei proprietăți date, trei proprietăți ghid, trei proprietăți float/double și trei proprietăți int/byte/long/zecimal. Acest lucru este mai mult decât suficient în 90% din cazuri, cred. Cu toate acestea, dacă nu este, puteți crea o clasă personalizată și o puteți folosi. Deci, SFAT: trebuie să treceți valorile reale într-o interogare, altfel resursele sunt irosite.
Concluzie
Printre lucrurile pe care le-am testat aici, cu siguranță aș opta pentru MemoryJoin. Altcineva ar putea obiecta că dezavantajele sunt insurmontabile și, deoarece nu toate pot fi rezolvate în acest moment, ar trebui să ne abținem de la a folosi extensia. Ei bine, pentru mine, este ca și cum ai spune că nu ar trebui să folosești un cuțit pentru că te-ai putea tăia. Optimizarea a fost o sarcină nu pentru dezvoltatorii juniori, ci pentru cineva care înțelege cum funcționează EF. În acest scop, acest instrument poate îmbunătăți performanța dramatic. Cine știe? Poate într-o zi, cineva de la Microsoft va adăuga suport de bază pentru VALORI dinamice.
În cele din urmă, Iată câteva diagrame pentru a compara rezultatele.
Mai jos este o diagramă a timpului necesar pentru a efectua o operație. MemoryJoin este singurul care face treaba într-un timp rezonabil. Doar patru abordări pot procesa volume mari: două implementări naive, tabel partajat și MemoryJoin.
Următoarea diagramă este pentru consumul de memorie. Toate abordările demonstrează mai mult sau mai puțin aceleași numere, cu excepția celui cu mai multe Contains
. Acest fenomen a fost descris mai sus.