نظرة عميقة في أداء إطار عمل الكيان عند استخدام "يحتوي على"

نشرت: 2022-03-11

أثناء عملي اليومي ، أستخدم 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 وذاكرة وصول عشوائي (RAM) بسعة 8 جيجابايت و SSD.

يبدو مخطط قاعدة البيانات للاختبار كما يلي:

جداول في قاعدة البيانات: الأسعار والأوراق المالية ومصادر الأسعار

ثلاثة جداول فقط: الأسعار والأوراق المالية ومصادر الأسعار. يحتوي جدول الأسعار على عشرات الملايين من السجلات.

الخيار 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)); } }

الخوارزمية بسيطة: لكل عنصر في بيانات الاختبار ، ابحث عن عنصر مناسب في قاعدة البيانات وأضفه إلى مجموعة النتائج. هذا الرمز له ميزة واحدة فقط: إنه سهل التنفيذ للغاية. أيضًا ، يمكن قراءته وصيانته. عيبه الواضح هو أنه أبطأ. على الرغم من فهرسة جميع الأعمدة الثلاثة ، إلا أن الحمل الزائد لاتصالات الشبكة لا يزال يؤدي إلى اختناق في الأداء. فيما يلي المقاييس:

نتائج التجربة الأولى

لذلك ، بالنسبة للحجم الكبير ، يستغرق الأمر حوالي دقيقة واحدة. يبدو أن استهلاك الذاكرة معقول.

الخيار 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); } } } });

من المثير للاهتمام ، بالنسبة لمجموعات بيانات الاختبار الأصغر ، أن هذا النهج يعمل بشكل أبطأ من الحل الأول ، ولكن بالنسبة للعينات الأكبر ، يكون أسرع (مرتين تقريبًا في هذه الحالة). يتغير استهلاك الذاكرة قليلاً ، لكن ليس بشكل كبير.

نتائج التجربة الثانية

الخيار 3. متعدد يحتوي

لنجرب طريقة أخرى:

  • قم بإعداد 3 مجموعات من القيم الفريدة لمؤشر الأسعار و PriceSourceId والتاريخ.
  • قم بإجراء الاستعلام باستخدام عملية تصفية واحدة باستخدام 3 يحتوي على.
  • أعد الفحص محليًا (انظر أدناه).
 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)); } }

هذا النهج هو إشكالية. يعتمد وقت التنفيذ على البيانات بشكل كبير. قد يسترد فقط السجلات المطلوبة (في هذه الحالة سيكون سريعًا جدًا) ، ولكنه قد يعرض أكثر من ذلك بكثير (ربما حتى 100 مرة أكثر).

دعنا نفكر في بيانات الاختبار التالية:

بيانات الاستجابة

أستفسر هنا عن أسعار Ticker1 المتداولة في 2018-01-01 و Ticker2 التي تم تداولها في 2018-01-02. ومع ذلك ، سيتم إرجاع أربعة سجلات بالفعل.

القيم الفريدة لـ Ticker هي Ticker1 و Ticker2 . القيم الفريدة لـ TradedOn هي 2018-01-01 و 2018-01-02 .

إذن ، أربعة سجلات تطابق هذا التعبير.

لهذا السبب هناك حاجة إلى إعادة فحص محلية ولماذا هذا النهج خطير. المقاييس هي كما يلي:

نتائج التجربة الثالثة

استهلاك فظيع للذاكرة! فشلت الاختبارات ذات الأحجام الكبيرة بسبب انتهاء مهلة 10 دقائق.

الخيار 4. منشئ المسند

دعنا نغير النموذج: لنقم ببناء Expression قديم جيد لكل مجموعة بيانات اختبار.

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

الكود الناتج معقد جدًا. إن بناء التعبيرات ليس أسهل شيء ويتضمن انعكاسًا (وهو بحد ذاته ليس بهذه السرعة). ولكنه يساعدنا في بناء استعلام واحد باستخدام الكثير من … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ... هذه هي النتائج:

نتائج التجربة الرابعة

حتى أسوأ من أي من الأساليب السابقة.

الخيار 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(); // 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(); }

المقاييس أولاً:

نتائج التجربة الخامسة

كانت النتيجة جيدة جدا. سريع جدا. استهلاك الذاكرة جيد ايضا. لكن العيوب هي:

  • يجب عليك إنشاء جدول إضافي في قاعدة البيانات لإجراء نوع واحد فقط من الاستعلام ،
  • يجب أن تبدأ معاملة (تستهلك موارد DBMS على أي حال) ، و
  • يجب عليك كتابة شيء ما إلى قاعدة البيانات (في عملية READ!) - ولن ينجح هذا بشكل أساسي إذا كنت تستخدم شيئًا مثل قراءة النسخة المتماثلة.

لكن بصرف النظر عن ذلك ، فإن هذا النهج لطيف وسريع ومقروء. ويتم تخزين خطة الاستعلام مؤقتًا في هذه الحالة!

الخيار 6. تمديد MemoryJoin

سأستخدم هنا حزمة NuGet تسمى EntityFrameworkCore.MemoryJoin. على الرغم من حقيقة أن اسمه يحتوي على كلمة Core ، إلا أنه يدعم أيضًا EF 6. ويسمى MemoryJoin ، ولكنه في الواقع يرسل بيانات الاستعلام المحددة كقيمة إلى الخادم ويتم تنفيذ جميع الأعمال على خادم SQL.

دعنا نتحقق من الكود.

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

المقاييس:

نتائج التجربة النهائية

هذا يبدو رائعا. ثلاث مرات أسرع من الطريقة السابقة - وهذا يجعلها الأسرع حتى الآن. 3.5 ثانية مقابل 64 ألف سجل! الكود بسيط ومفهوم. يعمل هذا مع النسخ المتماثلة للقراءة فقط. دعنا نتحقق من الاستعلام الذي تم إنشاؤه لثلاثة عناصر:

 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 في بناء القيم. وهذه هي الحيلة: تمكن خادم SQL من إجراء عملية انضمام سريع واستخدام الفهارس بشكل صحيح.

ومع ذلك ، هناك بعض العيوب (يمكنك قراءة المزيد في مدونتي):

  • تحتاج إلى إضافة DbSet إضافي إلى نموذجك (ولكن لا داعي لإنشائه في قاعدة البيانات)
  • لا يدعم الامتداد فئات النموذج مع العديد من الخصائص: ثلاث خصائص سلسلة ، وثلاث خصائص للتاريخ ، وثلاث خصائص دليل ، وثلاث خصائص عائمة / مزدوجة ، وثلاث خصائص int / byte / long / decimal. هذا أكثر من كافٍ في 90٪ من الحالات ، على ما أعتقد. ومع ذلك ، إذا لم يكن الأمر كذلك ، فيمكنك إنشاء فصل دراسي مخصص واستخدامه. لذلك ، تلميح: تحتاج إلى تمرير القيم الفعلية في استعلام ، وإلا فإن الموارد تضيع.

خاتمة

من بين الأشياء التي اختبرتها هنا ، سأذهب بالتأكيد إلى MemoryJoin. قد يعترض شخص آخر على أن العيوب لا يمكن التغلب عليها ، وبما أنه لا يمكن حلها جميعًا في الوقت الحالي ، يجب علينا الامتناع عن استخدام الامتداد. حسنًا ، بالنسبة لي ، الأمر أشبه بالقول إنه لا يجب عليك استخدام السكين لأنك قد تجرح نفسك. التحسين لم يكن مهمة للمطورين المبتدئين ولكن لشخص يفهم كيف تعمل EF. تحقيقا لهذه الغاية ، يمكن لهذه الأداة تحسين الأداء بشكل كبير. من تعرف؟ ربما يومًا ما ، سيضيف شخص ما في Microsoft بعض الدعم الأساسي للقيم الديناميكية.

أخيرًا ، إليك بعض المخططات الإضافية لمقارنة النتائج.

يوجد أدناه رسم تخطيطي للوقت المستغرق لإجراء العملية. MemoryJoin هو الوحيد الذي يؤدي المهمة في وقت معقول. يمكن لأربعة طرق فقط معالجة وحدات التخزين الكبيرة: تطبيقان ساذجان ، وجدول مشترك ، و MemoryJoin.

الوقت المستغرق في حالات مختلفة لكل تجربة

المخطط التالي لاستهلاك الذاكرة. توضح جميع الأساليب نفس الأرقام تقريبًا أو أقل باستثناء الرقم الذي Contains عدة محتويات. تم وصف هذه الظاهرة أعلاه.

استهلاك الذاكرة في حالات مختلفة لكل تجربة