По време на ежедневната си работа използвам Entity Framework. Той е много удобен, но в някои случаи работата му е бавна. Въпреки че има много добри статии за подобрения на производителността на EF и са дадени някои много добри и полезни съвети (например, избягвайте сложни заявки, параметри в Skip and Take, използвайте изгледи, избирайте само необходимите полета и т.н.), не толкова много наистина да се направи, когато трябва да използвате сложни Contains
на две или повече полета - с други думи, когато присъедините данни към списък с памет .
Нека проверим следния пример:
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();
Кодът по-горе изобщо не работи в EF 6 и въпреки това прави работа в EF Core, присъединяването всъщност се извършва локално - тъй като в моята база данни има десет милиона записа, всичко от тях се изтеглят и цялата памет се изразходва. Това не е грешка в EF. Очаква се. Не би ли било фантастично обаче, ако имаше нещо, което да реши това? В тази статия ще направя някои експерименти с различен подход, за да заобиколя това затруднение в изпълнението.
Ще изпробвам различни начини да постигна това, започвайки от най-простия до по-напредналия. На всяка стъпка ще предоставя код и показатели, като време и използване на паметта. Имайте предвид, че ще прекъсна изпълнението на програмата за сравнителен анализ, ако тя работи по-дълго от десет минути.
Кодът за програмата за сравнителен анализ се намира в следното хранилище . Той използва C #, .NET Core, EF Core и PostgreSQL. Използвах машина с Intel Core i5, 8 GB RAM и SSD.
Схемата на DB за тестване изглежда така:
Нека опитаме нещо просто, само за да започнем.
var result = new List(); 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)); } }
Алгоритъмът е прост: За всеки елемент в тестовите данни намерете подходящ елемент в базата данни и го добавете към колекцията от резултати. Този код има само едно предимство: Той е много лесен за изпълнение. Освен това е четлив и поддържаем. Очевидният му недостатък е, че е най-бавният. Въпреки че и трите колони са индексирани, режийните разходи за мрежова комуникация все още създават пречка за ефективността. Ето показателите:
Така че за голям обем отнема приблизително една минута. Консумацията на памет изглежда разумна.
Сега нека се опитаме да добавим паралелизъм към кода. Основната идея тук е, че удрянето на базата данни в паралелни нишки може да подобри цялостната производителност.
var result = new ConcurrentBag(); 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); } } } });
Интересното е, че за по-малки набори от тестови данни този подход работи по-бавно от първото решение, но за по-големи проби е по-бърз (в този случай приблизително 2 пъти). Консумацията на памет се променя малко, но не значително.
Нека опитаме друг подход:
var result = new List(); 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)); } }
Този подход е проблематичен. Времето за изпълнение зависи много от данните. Може да извлече само необходимите записи (в този случай ще бъде много бързо), но може да върне много повече (може би дори 100 пъти повече).
Нека разгледаме следните тестови данни:
Тук попитам цените за Ticker1, търгувани на 01.01.2018 и за Ticker2, търгувани на 02.01.2018. Четири записа обаче всъщност ще бъдат върнати.
Уникалните стойности за Ticker
са Ticker1
и Ticker2
. Уникалните стойности за TradedOn
са 2018-01-01
и 2018-01-02
.
И така, четири записа съответстват на този израз.
Ето защо е необходима локална повторна проверка и защо този подход е опасен. Показателите са както следва:
Ужасно потребление на памет! Тестовете с големи обеми се провалиха поради изчакване от 10 минути.
Нека променим парадигмата: Нека изградим добро старо Expression
за всеки набор от тестови данни.
var result = new List(); 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)Expression.Lambda( wholeClause, paramExpression)).Select(x => x.PriceObject); result.AddRange(query); }
Полученият код е доста сложен. Изграждането на изрази не е най-лесното и включва размисъл (който сам по себе си не е толкова бърз). Но това ни помага да изградим една заявка, използвайки много … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ...
. Това са резултатите:
Дори по-лошо от който и да е от предишните подходи.
Нека опитаме още един подход:
Добавих нова таблица към базата данни, която ще съдържа данни за заявки. За всяка заявка вече мога:
var result = new List(); 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(); }
Първо метрики:
Резултатът е много добър. Много бързо. Консумацията на памет също е добра. Но недостатъците са:
Но освен това, този подход е хубав - бърз и четим. И в този случай план за заявки се кешира!
Тук ще използвам NuGet пакет, наречен EntityFrameworkCore.MemoryJoin . Въпреки факта, че в името му има думата Core, той също поддържа EF 6. Нарича се MemoryJoin, но всъщност изпраща посочените данни за заявка като VALUES на сървъра и цялата работа се извършва на SQL сървъра.
Нека проверим кода.
var result = new List(); 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); }
Метрика:
Това изглежда страхотно. Три пъти по-бърз от предишния подход - това го прави най-бързият досега. 3,5 секунди за 64K записи! Кодът е прост и разбираем. Това работи с реплики само за четене. Нека проверим генерираната заявка за три елемента:
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')
Както можете да видите, този път действителните стойности се предават от паметта на SQL сървъра в конструкцията VALUES. И това прави трика: SQL сървърът успя да извърши операция за бързо присъединяване и да използва правилно индексите.
Има обаче някои недостатъци (може да прочетете повече на моя Блог ):
Сред нещата, които съм тествал тук, определено бих избрал MemoryJoin. Някой друг може да възрази, че недостатъците са непреодолими и тъй като в момента не всички от тях могат да бъдат разрешени, трябва да се въздържаме от използването на разширението. Е, за мен това е все едно да кажа, че не трябва да използвате нож, защото можете да се порежете. Оптимизацията беше задача не за млади разработчици, а за някой, който разбира как работи EF. За тази цел този инструмент може значително да подобри производителността. Кой знае? Може би някой ден някой от Microsoft ще добави основна поддръжка за динамични ЦЕННОСТИ.
И накрая, Ето още няколко диаграми за сравняване на резултатите.
По-долу има диаграма за времето, необходимо за извършване на операция. MemoryJoin е единственият, който върши работата в разумен срок. Само четири подхода могат да обработват големи обеми: две наивни реализации, споделена таблица и MemoryJoin.
Следващата диаграма е за консумация на памет. Всички подходи показват повече или по-малко еднакви числа, с изключение на този с множество Contains
. Това явление беше описано по-горе.
DBSet е абстракция, която буквално представлява колекция от обекти (обикновено лениво заредени), съхранявани в таблица. Операциите, извършени на DBSet, всъщност се извършват върху действителните записи на базата данни чрез SQL заявки.
Entity Framework е рамка за релационно картографиране на обекти, която осигурява стандартен интерфейс за достъп до данни, съхранявани в релационни бази данни (на различни доставчици).
Подходът с първи код означава, че разработчикът създава класове модели първо преди да бъде създадена действителната DB. Едно от най-големите предимства е съхраняването на модела на базата данни в системите за управление на източника.