كيفية تحسين أداء تطبيق ASP.NET في Web Farm باستخدام التخزين المؤقت

نشرت: 2022-03-11

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

  • المؤلف: فيل كارلتون

مقدمة موجزة للتخزين المؤقت

يعد التخزين المؤقت تقنية قوية لزيادة الأداء من خلال خدعة بسيطة: بدلاً من القيام بعمل مكلف (مثل عملية حسابية معقدة أو استعلام قاعدة بيانات معقد) في كل مرة نحتاج فيها إلى نتيجة ، يمكن للنظام تخزين - أو تخزين - نتيجة هذا العمل وببساطة قم بتزويده في المرة التالية التي يُطلب فيها ذلك دون الحاجة إلى إعادة أداء هذا العمل (وبالتالي يمكن الاستجابة بشكل أسرع بشكل كبير).

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

التخزين المؤقت هو تقنية قوية لزيادة الأداء

ذاكرة التخزين المؤقت في الذاكرة ASP.NET سريعة للغاية
ومثالية لحل مشكلة التخزين المؤقت لمزرعة الويب الموزعة.
سقسقة

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

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

مشكلة التعامل مع حمولة عالية

المشكلة الفعلية التي كان علي حلها لم تكن مشكلة أصلية. كانت مهمتي هي أن أجعل نموذجًا أوليًا لتطبيق الويب ASP.NET MVC قادرًا على التعامل مع حمولة عالية.

الخطوات اللازمة لتحسين قدرات الإنتاجية لتطبيق ويب مترابط هي:

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

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

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

كانت كل هذه القضايا ذات صلة في حالتي ، لذلك كان علي استكشاف خيارات بديلة.

كيف يعمل التخزين المؤقت

ذاكرة التخزين المؤقت المضمنة في ASP.NET ( System.Web.Caching.Cache ) سريعة للغاية ويمكن استخدامها بدون تحميل التسلسل وإلغاء التسلسل ، سواء أثناء التطوير أو في وقت التشغيل. ومع ذلك ، فإن ذاكرة التخزين المؤقت في الذاكرة لـ ASP.NET لها عيوبها أيضًا:

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

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

تقديم حل قائم على ASP.NET

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

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

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

مجموعة من عقد طبقة ويب ASP.NET باستخدام لوحة الكترونية معززة Redis

تعتبر ذاكرة التخزين المؤقت في الذاكرة الخاصة بـ ASP.NET مباشرة من بعض النواحي ومعقدة في أخرى. على وجه الخصوص ، من الواضح أنه يعمل كخريطة لأزواج المفتاح / القيمة ، ومع ذلك هناك الكثير من التعقيد المتعلق باستراتيجيات الإبطال والتبعيات.

لحسن الحظ ، حالات الاستخدام النموذجية بسيطة بدرجة كافية ، ومن الممكن استخدام إستراتيجية إبطال افتراضية لجميع العناصر ، مما يتيح لكل عنصر ذاكرة تخزين مؤقت تبعية واحدة فقط على الأكثر. في حالتي ، انتهيت برمز ASP.NET التالي لواجهة خدمة التخزين المؤقت. (لاحظ أن هذا ليس الرمز الفعلي ، لأنني حذفت بعض التفاصيل من أجل البساطة وترخيص الملكية.)

 public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }

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

لإلغاء صلاحية عناصر ذاكرة التخزين المؤقت ، قمت بتقديم خدمة منفصلة ، والتي بدت على النحو التالي:

 public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }

إلى جانب الطرق الأساسية لإسقاط العناصر بالبيانات ومفاتيح اللمس ، والتي تحتوي فقط على عناصر بيانات تابعة ، هناك بعض الطرق المتعلقة بنوع من "الجلسة".

استخدم تطبيق الويب الخاص بنا Autofac لحقن التبعية ، وهو تنفيذ لنمط تصميم انعكاس التحكم (IoC) لإدارة التبعيات. تتيح هذه الميزة للمطورين إنشاء فئاتهم دون الحاجة إلى القلق بشأن التبعيات ، حيث تدير حاوية IoC هذا العبء نيابة عنهم.

خدمة ذاكرة التخزين المؤقت ومبطل ذاكرة التخزين المؤقت لهما دورات حياة مختلفة تمامًا بخصوص IoC. تم تسجيل خدمة ذاكرة التخزين المؤقت كمثيل واحد (مثيل واحد ، مشترك بين جميع العملاء) ، بينما تم تسجيل مفسد ذاكرة التخزين المؤقت كمثيل لكل طلب (تم إنشاء نسخة منفصلة لكل طلب وارد). لماذا ا؟

الجواب يتعلق ببراعة إضافية نحتاج إلى التعامل معها. يستخدم تطبيق الويب بنية Model-View-Controller (MVC) ، والتي تساعد بشكل أساسي في فصل اهتمامات واجهة المستخدم والمنطق. لذلك ، يتم تغليف إجراء تحكم نموذجي في فئة فرعية من ActionFilterAttribute . في إطار عمل ASP.NET MVC ، يتم استخدام سمات C # لتزيين منطق عمل وحدة التحكم بطريقة ما. كانت هذه السمة الخاصة مسؤولة عن فتح اتصال قاعدة بيانات جديد وبدء معاملة في بداية الإجراء. أيضًا ، في نهاية الإجراء ، كانت الفئة الفرعية لخاصية عامل التصفية مسؤولة عن تنفيذ المعاملة في حالة النجاح وإعادتها مرة أخرى في حالة الفشل.

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

كان هذا هو الغرض الدقيق من الأجزاء المتعلقة "بالجلسة" في مبطل ذاكرة التخزين المؤقت. أيضًا ، هذا هو الغرض من كونه مرتبطًا بالطلب. بدا رمز ASP.NET كما يلي:

 class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }

أسلوب PublishRedisMessageSafe هنا مسؤول عن إرسال الرسالة (الوسيطة الثانية) إلى قناة معينة (الوسيطة الأولى). في الواقع ، هناك قنوات منفصلة للإسقاط واللمس ، لذا فإن معالج الرسائل لكل منها يعرف بالضبط ما يجب فعله - أسقط / المس المفتاح الذي يساوي حمولة الرسالة المستلمة.

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

 class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }

هنا ، يعد ConnectionMultiplexer نوعًا من مكتبة StackExchange.Redis ، وهو المسؤول عن العمل الشفاف مع Redis الأساسي. الجزء المهم هنا هو أنه عندما تفقد عقدة معينة الاتصال بـ Redis ، فإنها تعود إلى وضع عدم وجود ذاكرة تخزين مؤقت للتأكد من عدم تلقي أي طلب لبيانات قديمة. بعد استعادة الاتصال ، تبدأ العقدة في استخدام ذاكرة التخزين المؤقت في الذاكرة مرة أخرى.

فيما يلي أمثلة للإجراء بدون استخدام خدمة ذاكرة التخزين المؤقت ( SomeActionWithoutCaching ) وعملية مماثلة تستخدمها ( SomeActionUsingCache ):

 class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }

يمكن أن يبدو مقتطف الشفرة من تطبيق ISomeService كما يلي:

 class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }

المقارنة المعيارية والنتائج

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

  • لقياس حالات الاستخدام الرئيسية هذه عبر طلبات HTTP.
  • لمحاكاة الحمل العالي لعقدة الويب قيد الاختبار.

للحصول على ملف تعريف أداء ، يمكن استخدام أي ملف تعريف قادر على الارتباط بعملية عامل IIS. في حالتي ، استخدمت JetBrains dotTrace Performance. بعد قضاء بعض الوقت في التجريب لتحديد معلمات jMeter الصحيحة (مثل عدد الطلبات المتزامنة) ، يصبح من الممكن البدء في تجميع لقطات الأداء ، والتي تكون مفيدة جدًا في تحديد النقاط الفعالة والاختناقات.

في حالتي ، أظهرت بعض حالات الاستخدام أنه تم قضاء حوالي 15٪ -45٪ من الوقت الإجمالي لتنفيذ التعليمات البرمجية في قراءة قاعدة البيانات مع الاختناقات الواضحة. بعد أن قمت بتطبيق التخزين المؤقت ، تضاعف الأداء تقريبًا (أي كان أسرع مرتين) لمعظمهم.

الموضوعات ذات الصلة: ثمانية أسباب تجعل Microsoft Stack لا يزال خيارًا قابلاً للتطبيق

خاتمة

كما قد ترى ، قد تبدو حالتي كمثال لما يسمى عادةً "إعادة اختراع العجلة": لماذا نتعب أنفسنا بمحاولة إنشاء شيء جديد ، في حين أن هناك بالفعل أفضل الممارسات المطبقة على نطاق واسع هناك؟ فقط قم بإعداد Memcached أو Redis ، واتركه يذهب.

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

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

في النهاية ، مع وجود التخزين المؤقت المناسب ، حصلت على زيادة في الأداء بنسبة 50٪ تقريبًا عن الحل الأولي.