"İçerir" Kullanırken Entity Framework Performansına Derin Bir Bakış
Yayınlanan: 2022-03-11Günlük işlerim sırasında Entity Framework kullanıyorum. Çok uygundur, ancak bazı durumlarda performansı yavaştır. EF performans iyileştirmeleri hakkında çok sayıda iyi makale olmasına ve bazı çok iyi ve faydalı tavsiyelerin verilmesine rağmen (örn. gerçekten, iki veya daha fazla alanda karmaşık Contains
kullanmanız gerektiğinde, yani verileri bir bellek listesine eklediğinizde yapılır .
Sorun
Aşağıdaki örneği kontrol edelim:
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();
Yukarıdaki kod EF 6'da hiç çalışmıyor ve EF Core'da çalışırken birleştirme aslında yerel olarak yapılıyor—veritabanımda on milyon kaydım olduğundan, hepsi indiriliyor ve tüm bellek tüketiliyor . Bu EF'de bir hata değildir. Beklenen buydu. Ancak, bunu çözecek bir şey olsaydı harika olmaz mıydı? Bu yazıda, bu performans darboğazını çözmek için farklı bir yaklaşımla bazı deneyler yapacağım.
Çözüm
Bunu başarmak için en basitten daha gelişmişe farklı yollar deneyeceğim. Her adımda, geçen süre ve bellek kullanımı gibi kod ve ölçümler sağlayacağım. On dakikadan uzun çalışırsa kıyaslama programını durduracağımı unutmayın.
Kıyaslama programı kodu aşağıdaki depoda bulunmaktadır. C#, .NET Core, EF Core ve PostgreSQL kullanır. Intel Core i5, 8 GB RAM ve SSD'ye sahip bir makine kullandım.
Test için DB şeması şöyle görünür:
Seçenek 1. Basit ve Naif
Başlamak için basit bir şey deneyelim.
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)); } }
Algoritma basittir: Test verilerindeki her bir öğe için, veritabanında uygun bir öğe bulun ve onu sonuç koleksiyonuna ekleyin. Bu kodun tek bir avantajı vardır: Uygulaması çok kolaydır. Ayrıca, okunabilir ve bakımı yapılabilir. Bariz dezavantajı, en yavaş olmasıdır. Üç sütunun tümü dizine eklenmiş olsa da, ağ iletişiminin yükü yine de bir performans darboğazı yaratır. İşte metrikler:
Bu nedenle, büyük bir hacim için yaklaşık bir dakika sürer. Bellek tüketimi makul görünüyor.
Seçenek 2. Paralel ile saf
Şimdi koda paralellik eklemeye çalışalım. Buradaki temel fikir, veritabanına paralel iş parçacıklarına vurmanın genel performansı iyileştirebileceğidir.
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); } } } });
Daha küçük test veri kümeleri için bu yaklaşımın ilk çözümden daha yavaş çalışması ilginçtir, ancak daha büyük örnekler için daha hızlıdır (bu örnekte yaklaşık 2 kat). Bellek tüketimi biraz değişir, ancak önemli ölçüde değişmez.
Seçenek 3. Çoklu İçerik
Başka bir yaklaşım deneyelim:
- Ticker, PriceSourceId ve Date'in benzersiz değerlerinden oluşan 3 koleksiyon hazırlayın.
- 3 İçerir'i kullanarak sorguyu tek çalıştırmalı filtreleme ile gerçekleştirin.
- Yerel olarak yeniden kontrol edin (aşağıya bakın).
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)); } }
Bu yaklaşım sorunludur. Yürütme süresi verilere çok bağlıdır. Sadece gerekli kayıtları alabilir (ki bu durumda çok hızlı olur), ancak çok daha fazlasını (hatta 100 kat daha fazlasını) geri getirebilir.
Aşağıdaki test verilerini ele alalım:
Burada 2018-01-01 tarihinde işlem gören Ticker1 ve 2018-01-02 tarihinde işlem gören Ticker2 fiyatlarını sorguluyorum. Ancak, aslında dört kayıt döndürülecektir.
Ticker
için benzersiz değerler Ticker1
ve Ticker2
. TradedOn
için benzersiz değerler 2018-01-01
ve 2018-01-02
.
Yani, dört kayıt bu ifadeyle eşleşiyor.
Bu nedenle yerel bir yeniden kontrole ihtiyaç vardır ve bu yaklaşım neden tehlikelidir. Metrikler aşağıdaki gibidir:
Korkunç bellek tüketimi! Büyük hacimli testler, 10 dakikalık bir zaman aşımı nedeniyle başarısız oldu.
Seçenek 4. Yüklem Oluşturucu
Paradigmayı değiştirelim: Her test veri seti için eski güzel bir Expression
oluşturalım.
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); }
Ortaya çıkan kod oldukça karmaşıktır. İfade oluşturmak en kolay şey değildir ve yansıma içerir (ki bu o kadar hızlı değildir). Ancak, birçok … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ...
kullanarak tek bir sorgu oluşturmamıza yardımcı olur. Bunlar sonuçlar:

Önceki yaklaşımlardan birinden bile daha kötü.
Seçenek 5. Paylaşılan Sorgu Verileri Tablosu
Bir yaklaşım daha deneyelim:
Veritabanına sorgu verilerini tutacak yeni bir tablo ekledim. Her sorgu için şimdi yapabilirim:
- Bir işlem başlatın (henüz başlamadıysa)
- Bu tabloya sorgu verilerini yükleyin (geçici)
- Bir sorgu gerçekleştir
- Bir işlemi geri alın—yüklenen verileri silmek için
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(); }
Önce metrikler:
Sonuç çok iyi. Çok hızlı. Bellek tüketimi de iyidir. Ama dezavantajları şunlardır:
- Yalnızca bir tür sorgu gerçekleştirmek için veritabanında fazladan bir tablo oluşturmanız gerekir,
- (Zaten DBMS kaynaklarını tüketen) bir işlem başlatmanız gerekir ve
- Veritabanına bir şeyler yazmanız gerekir (bir OKUMA işleminde!) ve temel olarak, okuma kopyası gibi bir şey kullanırsanız bu çalışmaz.
Ancak bunun dışında, bu yaklaşım güzel—hızlı ve okunabilir. Ve bu durumda bir sorgu planı önbelleğe alınır!
Seçenek 6. MemoryJoin Uzantısı
Burada EntityFrameworkCore.MemoryJoin adlı bir NuGet paketi kullanacağım. Adının içinde Core kelimesi bulunmasına rağmen EF 6'yı da desteklemektedir. Buna MemoryJoin denmektedir fakat aslında belirtilen sorgu verilerini VALUES olarak sunucuya gönderir ve tüm işler SQL server üzerinde yapılır.
Kodu kontrol edelim.
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); }
Metrikler:
Bu harika görünüyor. Önceki yaklaşımdan üç kat daha hızlı - bu onu şimdiye kadarki en hızlı hale getiriyor. 64K kayıtlar için 3.5 saniye! Kod basit ve anlaşılır. Bu, salt okunur kopyalarla çalışır. Üç öğe için oluşturulan sorguyu kontrol edelim:
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")
Gördüğünüz gibi bu sefer VALUES yapısında gerçek değerler bellekten SQL server'a aktarılıyor. Ve bu hile yapar: SQL sunucusu hızlı bir birleştirme işlemi gerçekleştirmeyi ve dizinleri doğru şekilde kullanmayı başardı.
Ancak, bazı dezavantajlar var (daha fazlasını blogumda okuyabilirsiniz):
- Modelinize fazladan bir DbSet eklemeniz gerekiyor (ancak bunu DB'de oluşturmanıza gerek yok)
- Uzantı, birçok özelliğe sahip model sınıflarını desteklemez: üç dize özelliği, üç tarih özelliği, üç kılavuz özelliği, üç kayan nokta/çift özellik ve üç int/byte/long/decimal özelliği. Bu, vakaların% 90'ında fazlasıyla yeterli, sanırım. Ancak, değilse, özel bir sınıf oluşturabilir ve bunu kullanabilirsiniz. İPUCU: Bir sorgudaki gerçek değerleri iletmeniz gerekir, aksi takdirde kaynaklar boşa harcanır.
Çözüm
Burada test ettiğim şeyler arasında kesinlikle MemoryJoin'e giderdim. Bir başkası, dezavantajların aşılmaz olduğunu ve şu anda hepsinin çözülemeyeceğine itiraz edebilir, uzantıyı kullanmaktan kaçınmalıyız. Benim için bu, kendini kesebileceğin için bıçak kullanmamalısın demek gibi bir şey. Optimizasyon, genç geliştiriciler için değil, EF'nin nasıl çalıştığını anlayan biri için bir görevdi. Bu amaçla, bu araç performansı önemli ölçüde artırabilir. Kim bilir? Belki bir gün Microsoft'tan biri dinamik DEĞERLER için bazı temel destek ekleyecektir.
Son olarak, İşte sonuçları karşılaştırmak için birkaç diyagram daha.
Aşağıda bir işlemi gerçekleştirmek için geçen sürenin bir diyagramı verilmiştir. MemoryJoin, işi makul bir sürede yapan tek kişidir. Büyük hacimleri yalnızca dört yaklaşım işleyebilir: iki saf uygulama, paylaşılan tablo ve MemoryJoin.
Bir sonraki diyagram bellek tüketimi içindir. Tüm yaklaşımlar, birden fazla Contains
olan hariç, aşağı yukarı aynı sayıları gösterir. Bu fenomen yukarıda açıklanmıştır.