بعد أكثر من عام بقليل بمشاركتي ، جرى "الحوار" التالي:
.Net App : Hey Entity Framework ، من فضلك أعطني الكثير من البيانات!
إطار الكيان : آسف ، لم أفهمك. ماذا تقصد؟
.Net App : نعم ، أنا فقط حصلت على مجموعة من المعاملات 100K. والآن نحن بحاجة إلى التحقق بسرعة من صحة أسعار الأوراق المالية المشار إليها هناك.
إطار الكيان : حسنًا ، حسنًا ، دعونا نحاول ...
.Net App : هنا هو الكود:
var query = from p in context.Prices join t in transactions on new { p.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; query.ToList();
إطار الكيان :

الكلاسيكية أعتقد أن العديد من الأشخاص على دراية بهذا الموقف: عندما أرغب حقًا "بشكل جميل" وبسرعة في البحث في قاعدة البيانات باستخدام JOIN من المجموعة المحلية و DbSet . عادة ما تكون هذه التجربة مخيبة للآمال.
في هذه المقالة (التي هي ترجمة مجانية لمقالتي الأخرى ) سأجري سلسلة من التجارب وأحاول طرقًا مختلفة للتغلب على هذا القيد. سيكون هناك رمز (غير معقد) ، والأفكار وشيء من هذا القبيل نهاية سعيدة.
مقدمة
يعلم الجميع حول Entity Framework ، والكثير منهم يستخدمونه يوميًا ، وهناك العديد من المقالات الجيدة حول كيفية طبخه بشكل صحيح (استخدم استعلامات أبسط ، واستخدم المعلمات في Skip and Take ، واستخدم VIEW ، واطلب فقط الحقول اللازمة ، ورابط التخزين المؤقت للاستعلام و أخرى) ، ومع ذلك ، فإن سمة JOIN الخاصة بالمجموعة المحلية و DbSet لا تزال نقطة ضعف.
التحدي
افترض أن هناك قاعدة بيانات تحتوي على أسعار وهناك مجموعة من المعاملات التي تحتاج إلى التحقق من صحة الأسعار. ونفترض أن لدينا الكود التالي.
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();
لا يعمل هذا الرمز في Entity Framework 6 على الإطلاق. في Entity Framework Core - يعمل ، لكن كل شيء سيتم على جانب العميل وفي حالة وجود الملايين من السجلات في قاعدة البيانات - هذا ليس خيارًا.
كما قلت ، سأحاول طرق مختلفة للتغلب على هذا. من البسيط الى المعقد. بالنسبة للتجارب التي أجريها ، أستخدم الكود من المستودع التالي. تتم كتابة الرمز باستخدام: C # و .Net Core و EF Core و PostgreSQL .
لقد قمت أيضًا بتصوير بعض المقاييس: الوقت المستغرق واستهلاك الذاكرة. إخلاء المسئولية: إذا تم إجراء الاختبار لأكثر من 10 دقائق ، فقد قاطعته (القيد أعلاه). آلة اختبار Intel Core i5 ، ذاكرة وصول عشوائي سعتها 8 جيجابايت ، SSD.
مخطط قاعدة البيانات
الجداول 3 فقط: الأسعار والأوراق المالية ومصادر الأسعار . الأسعار - تحتوي على 10 مليون إدخال.
طريقة 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: ساذج موازية
دعونا نحاول إضافة التوازي. والفكرة هي الوصول إلى قاعدة البيانات من خيوط متعددة.
رمز الطريقة 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); } } } });
النتيجة:

بالنسبة للمجموعات الصغيرة ، يكون هذا النهج أبطأ من الطريقة الأولى. وللأكبر - 2 مرات أسرع. ومن المثير للاهتمام ، تم إنشاء 4 مؤشرات ترابط على الجهاز الخاص بي ، ولكن هذا لم يؤدي إلى تسارع 4x. يشير هذا إلى أن الحمل في هذه الطريقة مهم: من جانب العميل ومن جانب الخادم. زاد استهلاك الذاكرة ، ولكن ليس بشكل كبير.
يحتوي الأسلوب 3: متعددة
حان الوقت لتجربة شيء آخر ومحاولة تقليل المهمة إلى استعلام واحد. يمكن القيام به على النحو التالي:
- إعداد 3 مجموعات فريدة من شريط ، PriceSourceId ، والتاريخ
- قم بتشغيل الطلب واستخدم 3 Contains
- إعادة فحص النتائج محليًا
رمز الطريقة الثالثة var result = new List<Price>(); using (var context = CreateContext()) {
المشكلة هنا هي أن وقت التنفيذ ومقدار البيانات التي يتم إرجاعها يعتمدان بشكل كبير على البيانات نفسها (سواء في الاستعلام أو في قاعدة البيانات). بمعنى أنه يمكن إرجاع مجموعة من البيانات الضرورية فقط ، ويمكن إرجاع سجلات إضافية (حتى 100 مرة أكثر).
يمكن تفسير ذلك باستخدام المثال التالي. افترض أن الجدول التالي يحتوي على بيانات:

لنفترض أيضًا أنني بحاجة إلى أسعار Ticker1 مع TradedOn = 2018-01-01 و Ticker2 مع TradedOn = 2018-01-02 .
ثم القيم الفريدة لـ Ticker = ( Ticker1 ، Ticker2 )
والقيم الفريدة لـ TradedOn = ( 2018-01-01 ، 2018-01-02 )
ومع ذلك ، سيتم إرجاع 4 سجلات نتيجة لذلك ، لأنها تتوافق حقًا مع هذه المجموعات. الأمر السيئ هو أنه كلما تم استخدام المزيد من الحقول ، زادت فرصة الحصول على سجلات إضافية نتيجة لذلك.
لهذا السبب ، يجب تصفية البيانات التي تم الحصول عليها بهذه الطريقة بالإضافة إلى ذلك من جانب العميل. وهذا هو أكبر عيب.
المقاييس هي كما يلي:

استهلاك الذاكرة أسوأ من جميع الطرق السابقة. عدد الأسطر المقروءة أكبر بكثير من العدد المطلوب. تمت مقاطعة اختبارات المجموعات الكبيرة لأنها استمرت لأكثر من 10 دقائق. هذه الطريقة ليست جيدة.
طريقة 4. المسند البناء
لنجربها على الجانب الآخر: التعبير القديم الجيد. باستخدامهم ، يمكنك إنشاء استعلام كبير واحد في النموذج التالي:
… (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) …
هذا يعطي الأمل في أنه سيكون من الممكن بناء طلب واحد والحصول على البيانات الضرورية فقط لمكالمة واحدة. الرمز:
رمز الطريقة الرابعة 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); }
لقد تبين أن الشفرة أكثر تعقيدًا مما كانت عليه في الطرق السابقة. بناء تعبير يدوي ليس أسهل وأسرع عملية.
المقاييس:

النتائج المؤقتة كانت أسوأ مما كانت عليه في الطريقة السابقة. يبدو أن النفقات العامة أثناء البناء وعند المشي عبر الشجرة تبين أنها أكثر بكثير من المكسب من استخدام طلب واحد.
الطريقة الخامسة: جدول بيانات الاستعلام المشترك
لنجرب خيارًا آخر:
لقد قمت بإنشاء جدول جديد في قاعدة البيانات حيث سأكتب البيانات اللازمة لإكمال الطلب (ضمنيًا أحتاج إلى DbSet جديد في السياق).
الآن ، للحصول على النتيجة التي تحتاجها:
- بدء المعاملة
- تحميل بيانات الاستعلام إلى جدول جديد
- قم بتشغيل الاستعلام نفسه (باستخدام الجدول الجديد)
- استرجاع معاملة (لمسح جدول البيانات للاستعلامات)
الرمز يشبه هذا:
رمز الطريقة 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();
المقاييس الأولى:

جميع الاختبارات عملت وعملت بسرعة! استهلاك الذاكرة مقبول أيضا.
وبالتالي ، من خلال استخدام معاملة ، يمكن استخدام هذا الجدول في وقت واحد من خلال عدة عمليات. نظرًا لأن هذا جدول موجود بالفعل ، فإن جميع ميزات Entity Framework متاحة لنا: ما عليك سوى تحميل البيانات في الجدول ، وبناء استعلام باستخدام JOIN وتنفيذه. للوهلة الأولى ، هذا هو ما تحتاجه ، ولكن هناك عيوب كبيرة:
- يجب عليك إنشاء جدول لنوع معين من الاستعلام
- من الضروري استخدام المعاملات (وإهدار موارد نظم إدارة قواعد البيانات عليها)
- وفكرة أنك تحتاج إلى كتابة شيء ما ، عندما تحتاج إلى قراءة ، تبدو غريبة. وعلى قراءة النسخة المتماثلة ، فإنه لن ينجح.
والباقي هو حل عملي أكثر أو أقل يمكن استخدامه بالفعل.
الطريقة 6. تمديد MemoryJoin
الآن يمكنك محاولة تحسين النهج السابق. الأفكار هي:
- بدلاً من استخدام جدول خاص بنوع واحد من الاستعلام ، يمكنك استخدام خيار معمم. وهي إنشاء جدول باسم مثل Shared_query_data ، وإضافة العديد من حقول Guid ، والعديد من Long ، String ، إلخ. يمكن أخذ أسماء بسيطة: Guid1 ، Guid2 ، String1 ، Long1 ، Date2 ، إلخ. ثم يمكن استخدام هذا الجدول لـ 95٪ من أنواع الاستعلام. يمكن "تعديل" أسماء العقارات لاحقًا باستخدام منظور التحديد .
- بعد ذلك ، تحتاج إلى إضافة DbSet لـ Shared_query_data .
- ولكن ماذا لو ، بدلاً من كتابة البيانات إلى قاعدة البيانات ، تمرير القيم باستخدام VALUES ؟ أي أنه من الضروري أن يتم استخدام نداء إلى VALUES في استعلام SQL النهائي ، بدلاً من الوصول إلى Shared_query_data . كيف نفعل ذلك؟
- في Entity Framework Core - فقط باستخدام FromSql .
- في Entity Framework 6 - يجب عليك استخدام DbInterception - أي تغيير SQL الذي تم إنشاؤه عن طريق إضافة بنية VALUES قبل التنفيذ مباشرة. سيؤدي ذلك إلى وجود قيود: في طلب واحد ، لا يوجد أكثر من بنية VALUES . لكنها ستعمل!
- نظرًا لأننا لن نكتب إلى قاعدة البيانات ، فسنحصل على جدول البيانات المشتركة Shared_query_data الذي تم إنشاؤه في الخطوة الأولى ، أليس هناك حاجة على الإطلاق؟ الإجابة: نعم ، ليست هناك حاجة ، ولكن لا تزال هناك حاجة إلى DbSet ، لأن إطار عمل الكيان يجب أن يعرف نظام البيانات من أجل بناء الاستعلامات. اتضح أننا نحتاج إلى DbSet لبعض النماذج المعممة غير الموجودة في قاعدة البيانات ويتم استخدامها فقط لإلهام Entity Framework ، وهي تعرف ما تقوم به.
تحويل IEnumerable إلى مثال IQueryable- تلقى الإدخال مجموعة من الكائنات من النوع التالي:
class SomeQueryData { public string Ticker {get; set;} public DateTimeTradedOn {get; set;} public int PriceSourceId {get; set;} }
- لدينا تحت تصرفنا DbSet مع الحقول String1 ، String2 ، Date1 ، Long1 ، إلخ
- اسمح ليتم تخزين Ticker في String1 و TradedOn في Date1 و PriceSourceId في Long1 (mapps طويلة ، حتى لا تجعل الحقول منفصلة وكثيرة)
- ثم سيكون FromSql + VALUES مثل هذا:
var query = context.QuerySharedData.FromSql( "SELECT * FROM ( VALUES (1, 'Ticker1', @date1, @id1), (2, 'Ticker2', @date2, @id2) ) AS __gen_query_data__ (id, string1, date1, long1)")
- يمكنك الآن تقديم عرض وإرجاع IQueryable باستخدام نفس النوع الذي كان عند الإدخال:
return query.Select(x => new SomeQueryData() { Ticker = x.String1, TradedOn = x.Date1, PriceSourceId = (int)x.Long1 });
تمكنت من تنفيذ هذا النهج وحتى تصميمه كحزمة NuGet EntityFrameworkCore.MemoryJoin ( تتوفر الكود أيضًا). على الرغم من حقيقة أن الاسم يحتوي على الكلمة الأساسية ، يتم دعم Entity Framework 6 أيضًا. دعوتها MemoryJoin ، لكنها في الواقع ترسل بيانات محلية إلى قواعد بيانات إدارة قواعد البيانات في بناء القيم ويتم كل العمل عليها.
الكود كما يلي:
رمز الطريقة 6 var result = new List<Price>(); using (var context = CreateContext()) {
المقاييس:

هذه هي أفضل نتيجة جربتها على الإطلاق. كان الرمز بسيطًا جدًا ومباشرًا ، وفي الوقت نفسه كان يعمل من أجل قراءة النسخة المتماثلة.
مثال على طلب تم إنشاؤه لاستلام 3 عناصر 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")
هنا يمكنك أيضًا رؤية كيف يتحول النموذج المعمم (مع الحقول String1 و Date1 و Long1 ) باستخدام Select إلى النموذج المستخدم في الكود (مع الحقول Ticker و TradedOn و PriceSourceId ).
كل العمل يتم في استعلام واحد على خادم SQL. وهذه نهاية سعيدة صغيرة ، تحدثت عنها في البداية. ومع ذلك ، فإن استخدام هذه الطريقة يتطلب فهم والخطوات التالية:
- تحتاج إلى إضافة DbSet إضافية إلى السياق الخاص بك (على الرغم من أنه يمكن حذف الجدول نفسه)
- في النموذج المعمم ، والذي يتم استخدامه افتراضيًا ، يتم الإعلان عن 3 حقول من أنواع Guid و String و Double و Long و Date ، إلخ. يجب أن يكون ذلك كافيًا لـ 95٪ من أنواع الطلبات. وإذا قمت بتمرير مجموعة من الكائنات ذات 20 حقلًا إلى FromLocalList ، فسيتم طرح استثناء قائلاً إن الكائن معقد للغاية. هذا تقييد بسيط ويمكن التحايل عليه - يمكنك إعلان نوعك وإضافة 100 حقل على الأقل هناك. ومع ذلك ، المزيد من الحقول أبطأ في العمل.
- ويرد المزيد من التفاصيل التقنية في مقالتي.
الخاتمة
في هذه المقالة ، قدمت أفكاري حول موضوع JOIN local collection و DbSet. بدا لي أن تطوري باستخدام القيم قد يكون ذا أهمية للمجتمع. على الأقل ، لم أواجه مثل هذا النهج عندما حللت هذه المشكلة بنفسي. شخصيًا ، ساعدتني هذه الطريقة في التغلب على عدد من مشكلات الأداء في مشاريعي الحالية ، وربما يساعدك ذلك أيضًا.
سيقول شخص ما أن استخدام MemoryJoin "مفرط" للغاية وأنه يحتاج إلى مزيد من التطوير ، وحتى ذلك الحين لا تحتاج إلى استخدامه. هذا هو بالضبط السبب في أنني كنت مشكوكًا فيه للغاية ولم أكتب هذا المقال لمدة عام تقريبًا. أوافق على أنني أريد أن يكون العمل أسهل (آمل أن يحدث ذلك يومًا ما) ، ولكني أقول أيضًا أن التحسين لم يكن أبدًا مهمة جونيورز. يتطلب التحسين دائمًا فهم كيفية عمل الأداة. وإذا كانت هناك فرصة للحصول على تسريع بنسبة 8 مرات تقريبًا ( Naive Parallel vs MemoryJoin ) ، فسوف أتقن 2 نقطة ووثائق.
وأخيرا ، الرسوم البيانية:
الوقت الذي يقضيه. أكملت 4 طرق فقط المهمة في أقل من 10 دقائق ، و MemoryJoin هي الطريقة الوحيدة التي أكملت المهمة في أقل من 10 ثوانٍ.

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

شكرا للقراءة!