تتكون جميع منتجات البرمجيات الحديثة تقريبًا من عدة خدمات. في كثير من الأحيان ، تصبح أوقات استجابة القنوات الطويلة للخدمة مصدراً لمشاكل الأداء. يتمثل الحل القياسي لهذا النوع من المشكلات في حزم العديد من طلبات interservice في حزمة واحدة ، والتي تسمى batching.
إذا كنت تستخدم معالجة الدُفعات ، فقد لا تكون راضيًا عن نتائجه من حيث الأداء أو شمول الشفرة. هذه الطريقة ليست سهلة للمتصل كما تظن. لأغراض مختلفة وفي مواقف مختلفة ، يمكن أن تختلف القرارات اختلافًا كبيرًا. على أمثلة محددة ، سوف أعرض إيجابيات وسلبيات عدة طرق.
مشروع تجريبي
للتوضيح ، خذ مثالاً على إحدى الخدمات الموجودة في التطبيق الذي أعمل عليه حاليًا.
شرح لاختيار النظام الأساسي للحصول على أمثلةمشكلة الأداء الضعيف عامة ولا تنطبق على أي لغات ومنصات محددة. سوف تستخدم هذه المقالة أمثلة لرمز Spring + Kotlin لإظهار المهام والحلول. Kotlin مفهومة بنفس القدر (أو غير مفهومة) لمطوري Java و C # ، بالإضافة إلى ذلك ، الرمز أكثر إحكاما وفهمًا من Java. لتسهيل الفهم لمطوري جافا النقيين ، سأتجنب السحر الأسود لـ Kotlin وأستخدم اللون الأبيض فقط (بروح لومبوك). سيكون هناك عدد قليل من طرق التمديد ، لكنها في الواقع مألوفة لدى جميع مبرمجي Java كطرق ثابتة ، لذلك سيكون القليل من السكر لن يفسد طعم الطبق.
هناك خدمة الموافقة على المستندات. يقوم شخص ما بإنشاء مستند وإرساله للمناقشة ، يتم خلاله إجراء التعديلات ، وفي النهاية تكون الوثيقة متسقة. لا تعرف خدمة التسوية نفسها أي شيء عن المستندات: إنها مجرد دردشة للمنسقين مع وظائف إضافية صغيرة ، والتي لن نأخذها في الاعتبار هنا.
لذلك ، توجد غرف محادثة (مقابلة للوثائق) مع مجموعة محددة مسبقًا من المشاركين في كل منها. كما هو الحال في الدردشات العادية ، تحتوي الرسائل على نصوص وملفات ويمكن أن تكون ردودًا وإعادة توجيه:
data class ChatMessage (
// nullable persist
val id : Long ? = null ,
/** */
val author : UserReference ,
/** */
val message : String ,
/** */
// - JPA+ null,
val files : List < FileReference > ? = null ,
/** , */
val replyTo : ChatMessage ? = null ,
/** , */
val forwardFrom : ChatMessage ? = null
)
الروابط إلى الملف والمستخدم هي روابط إلى مجالات أخرى. إنه يعيش معنا مثل هذا:
typealias FileReference = Long
typealias UserReference = Long
يتم تخزين بيانات المستخدم في Keycloak واسترجاعها عبر REST. الأمر نفسه ينطبق على الملفات: الملفات والمعلومات الوصفية عنها مباشرة في خدمة تخزين ملفات منفصلة.
جميع المكالمات لهذه الخدمات هي
طلبات ثقيلة . هذا يعني أن مقدار الحمل اللازم لنقل هذه الطلبات أكبر بكثير من الوقت المستغرق لمعالجتها مع خدمة تابعة لجهة خارجية. في منصات الاختبار لدينا ، يبلغ وقت الاتصال المعتاد لهذه الخدمات 100 مللي ثانية ، لذلك في المستقبل سوف نستخدم هذه الأرقام.
نحن بحاجة إلى إنشاء وحدة تحكم REST بسيطة لتلقي آخر رسائل N مع جميع المعلومات اللازمة. وهذا هو ، في اعتقادنا أن نموذج الرسالة في الواجهة الأمامية هو نفسه تقريباً ونحتاج إلى إرسال جميع البيانات. يتمثل الاختلاف بين نموذج الواجهة الأمامية في ضرورة تقديم الملف والمستخدم في نموذج تم فك تشفيره قليلاً لجعلهما روابط:
/** */
data class ReferenceUI (
/** url */
val ref : String ,
/** */
val name : String
)
data class ChatMessageUI (
val id : Long ,
/** */
val author : ReferenceUI ,
/** */
val message : String ,
/** */
val files : List < ReferenceUI >,
/** , */
val replyTo : ChatMessageUI ? = null ,
/** , */
val forwardFrom : ChatMessageUI ? = null
)
نحتاج إلى تنفيذ ما يلي:
interface ChatRestApi {
fun getLast ( n : Int ) : List < ChatMessageUI >
}
يعني Postfix UI طرز DTO للواجهة الأمامية ، أي ما يجب أن نقدمه من خلال REST.
قد يبدو من المفاجئ هنا أننا لا نمرر أي مُعرّف للدردشة ، وحتى في نموذج ChatMessage / ChatMessageUI. لقد فعلت ذلك عن قصد حتى لا تشوش الكود الخاص بالأمثلة (الدردشات معزولة ، حتى نفترض أن لدينا واحدة على الإطلاق).
تراجع فلسفيكل من فئة ChatMessageUI وطريقة ChatRestApi.getLast تستخدم نوع بيانات القائمة ، في حين أن هذا هو في الواقع مجموعة مرتبة. في JDK ، يكون كل هذا سيئًا ، لذلك سوف يفشل الإعلان عن ترتيب العناصر على مستوى الواجهة (الحفاظ على الترتيب عند الجمع والاستخراج). لذلك ، من الشائع استخدام القائمة في الحالات التي تحتاج فيها إلى مجموعة مرتبة (لا يزال هناك LinkedHashSet ، لكن هذه ليست واجهة).
أحد القيود المهمة: نفترض أنه لا توجد سلاسل طويلة من الردود أو إلى الأمام. أي أنها ، لكن طولها لا يتجاوز ثلاث رسائل. يجب إرسال سلسلة الرسائل الأمامية بالكامل.
لتلقي البيانات من الخدمات الخارجية ، هناك واجهات برمجة التطبيقات هذه:
interface ChatMessageRepository {
fun findLast ( n : Int ) : List < ChatMessage >
}
data class FileHeadRemote (
val id : FileReference ,
val name : String
)
interface FileRemoteApi {
fun getHeadById ( id : FileReference ) : FileHeadRemote
fun getHeadsByIds ( id : Set < FileReference > ) : Set < FileHeadRemote >
fun getHeadsByIds ( id : List < FileReference > ) : List < FileHeadRemote >
fun getHeadsByChat () : List < FileHeadRemote >
}
data class UserRemote (
val id : UserReference ,
val name : String
)
interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote >
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote >
}
يمكن ملاحظة أنه تم توفير معالجة الدُفعات مبدئيًا في الخدمات الخارجية ، وفي كلتا الحالتين: من خلال Set (بدون الاحتفاظ بترتيب العناصر ، مع مفاتيح فريدة) ومن خلال القائمة (قد يكون هناك تكرارات - يتم الاحتفاظ بالترتيب).
تطبيقات بسيطة
تنفيذ ساذج
سيبدو أول تطبيق ساذج لوحدة التحكم REST لدينا مثل هذا في معظم الحالات:
class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. map { it . toFrontModel () }
private fun ChatMessage . toFrontModel () : ChatMessageUI =
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = userRepository . getUserById ( author ) . toFrontReference () ,
message = message ,
files = files ?. let { files ->
fileRepository . getHeadsByIds ( files )
. map { it . toFrontReference () }
} ?: listOf () ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
}
كل شيء واضح جدا ، وهذا هو زائد كبيرة.
نحن نستخدم معالجة الدُفعات وتلقي البيانات من خدمة خارجية على دفعات. ولكن ماذا يحدث مع الأداء؟
لكل رسالة ، سيتم إجراء مكالمة واحدة إلى UserRemoteApi للحصول على البيانات في حقل المؤلف ومكالمة واحدة إلى FileRemoteApi لاستلام جميع الملفات المرفقة. يبدو أن كل شيء. افترض أنه تم الحصول على حقول forwardFrom و replyTo لـ ChatMessage بحيث لا يتطلب ذلك مكالمات إضافية. لكن تحويلهم إلى ChatMessageUI سيؤدي إلى تكرار ، أي أن أداء عدد المكالمات يمكن أن يزيد بشكل كبير. كما أشرنا سابقًا ، دعنا نقول أنه ليس لدينا الكثير من التعشيش وأن السلسلة تقتصر على ثلاث رسائل.
نتيجة لذلك ، حصلنا على اتصالين إلى ستة مكالمات إلى خدمات خارجية لكل رسالة ومكالمة JPA واحدة إلى حزمة الرسائل بأكملها. سيختلف العدد الإجمالي للمكالمات من 2 * N + 1 إلى 6 * N + 1. كم هو هذا في وحدات حقيقية؟ افترض أنك بحاجة إلى 20 مشاركة لتقديم صفحة. للحصول عليها ، تحتاج من 4 ثوانٍ إلى 10 ثوانٍ. ! فظيعة أود أن أقابل 500 مللي ثانية. وبما أن الواجهة الأمامية تحلم بإجراء تمرير سلس ، فيمكن مضاعفة متطلبات الأداء الخاصة بنقطة النهاية هذه.
الايجابيات:- الكود موجزة وتوثيق ذاتي (حلم الدعم).
- الرمز بسيط ، لذلك لا توجد فرص تقريبًا لإطلاق النار في الساق.
- لا تبدو معالجة الدُفعات غريبة وملائمة عضويا في المنطق.
- سيتم إجراء التغييرات المنطقية بسهولة وستكون محلية.
أقل:أداء فظيع بسبب حقيقة أن الحزم صغيرة جدًا.
غالبًا ما يمكن رؤية هذا النهج في الخدمات البسيطة أو في النماذج الأولية. إذا كانت سرعة التغيير مهمة ، فلا يستحق الأمر تعقيد النظام. في الوقت نفسه ، بالنسبة لخدمتنا البسيطة للغاية ، يكون الأداء فظيعًا ، وبالتالي فإن نطاق قابلية تطبيق هذا النهج ضيق للغاية.
معالجة موازية ساذجة
يمكنك البدء في معالجة جميع الرسائل بالتوازي - سيؤدي ذلك إلى التخلص من الزيادة الخطية في الوقت وفقًا لعدد الرسائل. هذه ليست طريقة جيدة بشكل خاص ، لأنها ستؤدي إلى حمل كبير على الخدمة الخارجية.
تنفيذ المعالجة المتوازية بسيط للغاية:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n ) . parallelStream ()
. map { it . toFrontModel () }
. collect ( toList ())
باستخدام معالجة الرسائل المتوازية ، نحصل على 300 إلى 700 مللي ثانية بشكل مثالي ، وهو أفضل بكثير من التنفيذ الساذج ، لكن لا يزال غير سريع بما فيه الكفاية.
مع هذا النهج ، سيتم تنفيذ طلبات userRepository و fileRepository بشكل متزامن ، وهو غير فعال للغاية. لإصلاح هذا الأمر ، سيتعين عليك تغيير منطق المكالمات كثيرًا. على سبيل المثال ، من خلال CompleteStage (ويعرف أيضًا باسم CompleteableFuture):
private fun ChatMessage . toFrontModel () : ChatMessageUI =
CompletableFuture . supplyAsync {
userRepository . getUserById ( author ) . toFrontReference ()
} . thenCombine (
files ?. let {
CompletableFuture . supplyAsync {
fileRepository . getHeadsByIds ( files ) . map { it . toFrontReference () }
}
} ?: CompletableFuture . completedFuture ( listOf ())
) { author , files ->
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = author ,
message = message ,
files = files ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
} . get () !!
يمكن ملاحظة أن رمز التعيين البسيط في البداية أصبح أقل وضوحًا. هذا لأننا اضطررنا إلى فصل مكالمات الخدمة الخارجية عن المكان الذي استخدمت فيه النتائج. هذا في حد ذاته ليس سيئا. لكن مجموعة المكالمات لا تبدو أنيقة للغاية وتشبه "المعكرونة" التفاعلية.
إذا كنت تستخدم coroutines ، فسيبدو كل شيء أكثر ملاءمة:
private fun ChatMessage . toFrontModel () : ChatMessageUI =
join (
{ userRepository . getUserById ( author ) . toFrontReference () } ,
{ files ?. let { fileRepository . getHeadsByIds ( files )
. map { it . toFrontReference () } } ?: listOf () }
) . let { ( author , files ) ->
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = author ,
message = message ,
files = files ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
}
حيث:
fun < A , B > join ( a : () -> A , b : () -> B ) =
runBlocking ( IO ) {
awaitAll ( async { a () } , async { b () } )
} . let {
it [ 0 ] as A to it [ 1 ] as B
}
نظريًا ، باستخدام هذه المعالجة المتوازية ، نحصل على 200 إلى 400 مللي ثانية ، وهو بالفعل قريب من توقعاتنا.
لسوء الحظ ، لا يحدث مثل هذا التوازي الجيد ، والمكافأة قاسية للغاية: مع وجود عدد قليل من المستخدمين الذين يعملون في نفس الوقت ، ستقع مجموعة كبيرة من الطلبات على الخدمات ، التي ما زالت لن تتم معالجتها بشكل متوازٍ ، لذلك سوف نعود إلى 4 ثوانٍ حزينة.
تكون نتيجتي عند استخدام هذه الخدمة 1300-1700 مللي ثانية لمعالجة 20 رسالة. هذا أسرع مما كان عليه في التطبيق الأول ، ولكن لا يزال لا يحل المشكلة.
الاستخدام البديل للاستعلامات الموازيةماذا لو لم يتم توفير معالجة الدُفعات في خدمات الجهات الخارجية؟ على سبيل المثال ، يمكنك إخفاء نقص تنفيذ معالجة الدُفعات داخل أساليب الواجهة:
interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote > =
id . parallelStream ()
. map { getUserById ( it ) } . collect ( toSet ())
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote > =
id . parallelStream ()
. map { getUserById ( it ) } . collect ( toList ())
}
هذا منطقي إذا كان هناك أمل في أن تظهر معالجة الدُفعات في الإصدارات المستقبلية.
الايجابيات:- سهولة تنفيذ معالجة الرسائل المتزامنة.
- قابلية جيدة.
سلبيات:- الحاجة إلى فصل استلام البيانات عن معالجتها في طلبات المعالجة المتوازية للخدمات المختلفة.
- زيادة الحمل على خدمات الجهات الخارجية.
يمكن ملاحظة أن نطاق قابلية التطبيق هو نفس نطاق النهج الساذج تقريبًا. يعد استخدام طريقة الاستعلام المتوازي منطقيًا إذا كنت ترغب في زيادة أداء خدمتك عدة مرات بسبب الاستغلال القاسي للآخرين. في مثالنا ، زادت الإنتاجية 2.5 مرة ، لكن من الواضح أن هذا لا يكفي.
التخزين المؤقت
يمكنك القيام بتخزين مؤقت بنمط JPA للخدمات الخارجية ، أي تخزين الأشياء المستلمة داخل الجلسة حتى لا تستقبلها مرة أخرى (بما في ذلك أثناء معالجة الدُفعات). يمكنك تنفيذ هذه ذاكرات التخزين المؤقت بنفسك ، ويمكنك استخدام Spring معCacheable ، بالإضافة إلى أنه يمكنك دائمًا استخدام ذاكرة التخزين المؤقت الجاهزة مثل EhCache يدويًا.
سوف ترتبط المشكلة العامة بحقيقة وجود شعور جيد من ذاكرات التخزين المؤقت فقط في حالة وجود إصابات. في حالتنا ، من المحتمل جدًا أن تصل عدد مرات الدخول إلى حقل المؤلف (على سبيل المثال ، 50٪) ، ولن يكون هناك أي ملفات على الإطلاق. سيؤدي هذا النهج إلى بعض التحسينات ، لكن الأداء لن يتغير بشكل جذري (ونحن بحاجة إلى طفرة).
تتطلب ذاكرة التخزين المؤقت Intersession (طويلة) منطق إبطال معقد. بشكل عام ، كلما وصلت إلى نقطة أنك سوف تحل مشاكل الأداء في ذاكرات التخزين المؤقت بين الدورات ، كان ذلك أفضل.
الايجابيات:- تنفيذ التخزين المؤقت دون تغيير التعليمات البرمجية.
- زيادة الأداء عدة مرات (في بعض الحالات).
سلبيات:- إمكانية خفض الأداء إذا تم استخدامها بشكل غير صحيح.
- حمل ذاكرة كبيرة ، خاصة مع ذاكرة التخزين المؤقت الطويلة.
- إبطال معقد ، أخطاء تؤدي إلى مشاكل صعبة في وقت التشغيل.
في كثير من الأحيان ، يتم استخدام ذاكرة التخزين المؤقت فقط لتصحيح مشاكل التصميم بسرعة. هذا لا يعني أنها لا تحتاج إلى استخدامها. ومع ذلك ، من المفيد دائمًا التعامل معهم بحذر وتقييم تقييم مكاسب الأداء الناتجة أولاً ، وعندها فقط اتخاذ قرار.
في المثال الخاص بنا ، ستزيد ذاكرة التخزين المؤقت بنسبة 25٪ تقريبًا. في الوقت نفسه ، فإن المخابئ لديها الكثير من العيوب ، لذلك لن أستخدمها هنا.
النتائج
لذلك ، نظرنا في التطبيق الساذج لخدمة تستخدم معالجة الدُفعات ، وبعض الطرق البسيطة لتسريعها.
الميزة الرئيسية لجميع هذه الأساليب هي البساطة ، والتي من بينها العديد من العواقب السارة.
مشكلة شائعة مع هذه الطرق هي الأداء الضعيف ، ويرجع ذلك أساسا إلى حجم الحزمة. لذلك ، إذا كانت هذه الحلول لا تناسبك ، فمن الجدير النظر في طرق أكثر جذرية.
هناك مجالان رئيسيان يمكنك فيهما البحث عن حلول:
- العمل غير المتزامن مع البيانات (يتطلب نقلة نوعية ، وبالتالي ، لا تعتبر هذه المقالة)
- تكبير حزم مع الحفاظ على معالجة متزامنة.
سيؤدي توسيع الحزم إلى تقليل عدد المكالمات الخارجية إلى حد كبير وفي نفس الوقت الحفاظ على الكود متزامن. سيتم تخصيص الجزء التالي من المقالة لهذا الموضوع.