Głębokie zanurzenie się w wydajności Entity Framework podczas korzystania z „zawiera”

Opublikowany: 2022-03-11

W codziennej pracy korzystam z Entity Framework. Jest to bardzo wygodne, ale w niektórych przypadkach jego działanie jest powolne. Mimo że istnieje wiele dobrych artykułów na temat ulepszeń wydajności EF i podano kilka bardzo dobrych i przydatnych porad (np. unikaj złożonych zapytań, parametrów w Skip and Take, używaj widoków, wybieraj tylko potrzebne pola itp.), niewiele może naprawdę zrób to, gdy musisz użyć złożonej Contains w dwóch lub więcej polach — innymi słowy, gdy łączysz dane z listą pamięci .

Problem

Sprawdźmy następujący przykład:

 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();

Powyższy kod w ogóle nie działa w EF 6 i chociaż działa w EF Core, łączenie jest faktycznie wykonywane lokalnie — ponieważ mam dziesięć milionów rekordów w mojej bazie danych, wszystkie z nich są pobierane i zużywana jest cała pamięć . To nie jest błąd w EF. Oczekuje się. Jednak czy nie byłoby fantastycznie, gdyby było coś, co można rozwiązać? W tym artykule zamierzam przeprowadzić kilka eksperymentów z innym podejściem do obejścia tego wąskiego gardła wydajności.

Rozwiązanie

Wypróbuję różne sposoby, aby to osiągnąć, od najprostszych do bardziej zaawansowanych. Na każdym kroku podam kod i metryki, takie jak czas i zużycie pamięci. Zwróć uwagę, że przerwę działanie programu testującego, jeśli będzie działać dłużej niż dziesięć minut.

Kod programu benchmarkingowego znajduje się w poniższym repozytorium. Używa C#, .NET Core, EF Core i PostgreSQL. Użyłem maszyny z procesorem Intel Core i5, 8 GB RAM i dyskiem SSD.

Schemat bazy danych do testowania wygląda tak:

Tabele w bazie: ceny, papiery wartościowe i źródła cen

Tylko trzy tabele: ceny, papiery wartościowe i źródła cen. Tabela cen zawiera dziesiątki milionów rekordów.

Opcja 1. Prosty i naiwny

Spróbujmy czegoś prostego, na początek.

 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)); } }

Algorytm jest prosty: dla każdego elementu w danych testowych znajdź odpowiedni element w bazie danych i dodaj go do zbioru wyników. Ten kod ma tylko jedną zaletę: jest bardzo łatwy do wdrożenia. Ponadto jest czytelny i łatwy w utrzymaniu. Jego oczywistą wadą jest to, że jest najwolniejszy. Mimo że wszystkie trzy kolumny są indeksowane, obciążenie komunikacji sieciowej nadal tworzy wąskie gardło wydajności. Oto metryki:

Wyniki pierwszego eksperymentu

Tak więc w przypadku dużej objętości zajmuje to około minuty. Zużycie pamięci wydaje się rozsądne.

Opcja 2. Naiwna z równoległością

Teraz spróbujmy dodać paralelizm do kodu. Podstawową ideą jest to, że trafienie do bazy danych w równoległych wątkach może poprawić ogólną wydajność.

 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); } } } });

Interesujące jest to, że dla mniejszych zestawów danych testowych to podejście działa wolniej niż pierwsze rozwiązanie, ale dla większych próbek jest szybsze (w tym przypadku ok. 2 razy). Zużycie pamięci zmienia się trochę, ale nie znacząco.

Wyniki drugiego eksperymentu

Opcja 3. Wiele zawiera

Spróbujmy innego podejścia:

  • Przygotuj 3 kolekcje unikalnych wartości Ticker, PriceSourceId i Date.
  • Wykonaj zapytanie z filtrowaniem jednego przebiegu, używając 3 Zawiera.
  • Sprawdź ponownie lokalnie (patrz poniżej).
 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)); } }

Takie podejście jest problematyczne. Czas wykonania jest bardzo zależny od danych. Może pobrać tylko wymagane rekordy (w tym przypadku będzie to bardzo szybkie), ale może zwrócić znacznie więcej (może nawet 100 razy więcej).

Rozważmy następujące dane testowe:

Dane odpowiedzi

Tutaj pytam o ceny dla Ticker1 w obrocie 2018-01-01 i dla Ticker2 w obrocie 2018-01-02. Jednak faktycznie zostaną zwrócone cztery rekordy.

Unikalne wartości dla Ticker to Ticker1 i Ticker2 . Unikalne wartości TradedOn to 2018-01-01 i 2018-01-02 .

Tak więc cztery rekordy pasują do tego wyrażenia.

Dlatego potrzebne jest ponowne sprawdzenie na miejscu i dlatego takie podejście jest niebezpieczne. Metryki są następujące:

Wyniki trzeciego eksperymentu

Straszne zużycie pamięci! Testy z dużymi woluminami nie powiodły się z powodu limitu czasu 10 minut.

Opcja 4. Konstruktor predykatów

Zmieńmy paradygmat: zbudujmy stare dobre Expression dla każdego zestawu danych testowych.

 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); }

Otrzymany kod jest dość złożony. Budowanie wyrażeń nie jest rzeczą najłatwiejszą i wymaga refleksji (co samo w sobie nie jest tak szybkie). Ale pomaga nam w tworzeniu pojedynczego zapytania przy użyciu wielu … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ... . Oto wyniki:

Wyniki czwartego eksperymentu

Jeszcze gorzej niż którekolwiek z poprzednich podejść.

Opcja 5. Wspólna tabela danych zapytań

Spróbujmy jeszcze jednego podejścia:

Dodałem nową tabelę do bazy danych, w której będą przechowywane dane zapytania. Dla każdego zapytania mogę teraz:

  • Rozpocznij transakcję (jeśli nie została jeszcze rozpoczęta)
  • Prześlij dane zapytania do tej tabeli (tymczasowo)
  • Wykonaj zapytanie
  • Wycofaj transakcję — aby usunąć przesłane dane
 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(); }

Najpierw metryki:

Wyniki piątego eksperymentu

Wynik jest bardzo dobry. Bardzo szybki. Zużycie pamięci jest również dobre. Ale wady to:

  • Musisz utworzyć dodatkową tabelę w bazie danych, aby wykonać tylko jeden typ zapytania,
  • Musisz rozpocząć transakcję (która i tak zużywa zasoby DBMS) i
  • Musisz coś napisać do bazy danych (w operacji READ!) — iw zasadzie to nie zadziała, jeśli użyjesz czegoś takiego jak replika do odczytu.

Ale poza tym takie podejście jest fajne – szybkie i czytelne. W tym przypadku plan zapytania jest buforowany!

Opcja 6. Rozszerzenie MemoryJoin

Tutaj zamierzam użyć pakietu NuGet o nazwie EntityFrameworkCore.MemoryJoin. Pomimo tego, że w swojej nazwie zawiera słowo Core, obsługuje również EF 6. Nazywa się MemoryJoin, ale w rzeczywistości wysyła określone dane zapytania jako VALUES do serwera i cała praca jest wykonywana na serwerze SQL.

Sprawdźmy kod.

 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); }

Metryka:

Wyniki końcowego eksperymentu

To wygląda niesamowicie. Trzy razy szybciej niż w poprzednim podejściu — to sprawia, że ​​jest jak dotąd najszybszy. 3,5 sekundy dla rekordów 64K! Kod jest prosty i zrozumiały. Działa to z replikami tylko do odczytu. Sprawdźmy wygenerowane zapytanie dla trzech elementów:

 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")

Jak widać, tym razem wartości rzeczywiste są przekazywane z pamięci do serwera SQL w konstrukcji WARTOŚCI. I to załatwia sprawę: serwer SQL zdołał wykonać operację szybkiego łączenia i poprawnie użyć indeksów.

Są jednak pewne wady (więcej możesz przeczytać na moim blogu):

  • Musisz dodać dodatkowy DbSet do swojego modelu (jednak nie musisz go tworzyć w DB)
  • Rozszerzenie nie obsługuje klas modeli z wieloma właściwościami: trzema właściwościami ciągu, trzema właściwościami daty, trzema właściwościami przewodnika, trzema właściwościami float/double i trzema właściwościami int/byte/long/decimal. To chyba więcej niż wystarczające w 90% przypadków. Jeśli jednak tak nie jest, możesz utworzyć niestandardową klasę i użyć jej. WSKAZÓWKA: musisz przekazać rzeczywiste wartości w zapytaniu, w przeciwnym razie zasoby zostaną zmarnowane.

Wniosek

Wśród rzeczy, które tutaj testowałem, zdecydowanie wybrałbym MemoryJoin. Ktoś inny mógłby sprzeciwić się temu, że wady są nie do pokonania, a ponieważ nie wszystkie z nich można w tej chwili rozwiązać, powinniśmy powstrzymać się od korzystania z rozszerzenia. Cóż, dla mnie to tak, jakby powiedzieć, że nie należy używać noża, bo można się skaleczyć. Optymalizacja była zadaniem nie dla młodszych programistów, ale dla kogoś, kto rozumie, jak działa EF. W tym celu to narzędzie może znacznie poprawić wydajność. Kto wie? Może pewnego dnia ktoś z firmy Microsoft doda trochę podstawowej obsługi dynamicznych WARTOŚCI.

Na koniec oto kilka dodatkowych diagramów do porównania wyników.

Poniżej znajduje się wykres czasu potrzebnego na wykonanie operacji. MemoryJoin jest jedynym, który wykonuje pracę w rozsądnym czasie. Tylko cztery podejścia mogą przetwarzać duże ilości: dwie naiwne implementacje, wspólna tabela i MemoryJoin.

Czas potrzebny w różnych przypadkach na każdy eksperyment

Następny diagram dotyczy zużycia pamięci. Wszystkie podejścia wykazują mniej więcej te same liczby, z wyjątkiem tego, które zawiera wiele elementów Contains . Zjawisko to zostało opisane powyżej.

Zużycie pamięci w różnych przypadkach dla każdego eksperymentu