مشاكل معالجة الاستعلام الدفعي وحلولها (الجزء الأول)

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

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

مشروع مظاهرة

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

شرح اختيار المنصة على سبيل المثالمشكلة الأداء الضعيف عامة جدًا ولا تؤثر على أي لغات أو منصات محددة. ستستخدم هذه المقالة أمثلة رموز Spring + Kotlin لتوضيح المشكلات والحلول. تعتبر Kotlin مفهومة (أو غير مفهومة) لمطوري Java وC#، بالإضافة إلى أن الكود أكثر إحكاما وقابلية للفهم من Java. لتسهيل الفهم على مطوري 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(nInt): List<ChatMessageUI>
}

يعني postfix UI نماذج DTO للواجهة الأمامية، أي ما يجب أن نخدمه عبر REST.

ما قد يكون مفاجئًا هنا هو أننا لا نمرر أي معرف دردشة وحتى نموذج ChatMessage/ChatMessageUI لا يحتوي على معرف. لقد فعلت ذلك عمدًا حتى لا يحدث فوضى في كود الأمثلة (الدردشات معزولة، لذلك يمكننا أن نفترض أن لدينا واحدة فقط).

الاستطراد الفلسفيتستخدم كل من فئة ChatMessageUI وأسلوب ChatRestApi.getLast نوع بيانات القائمة في حين أنها في الواقع مجموعة مرتبة. هذا أمر سيء في JDK، لذا فإن الإعلان عن ترتيب العناصر على مستوى الواجهة (الحفاظ على الترتيب عند الإضافة والإزالة) لن ينجح. لذلك أصبح من الممارسات الشائعة استخدام القائمة في الحالات التي تكون فيها هناك حاجة إلى مجموعة مرتبة (توجد أيضًا LinkedHashSet، ولكنها ليست واجهة).
قيود هامة: سنفترض أنه لا توجد سلاسل طويلة من الردود أو التحويلات. أي أنها موجودة ولكن طولها لا يتجاوز الثلاث رسائل. يجب أن يتم نقل سلسلة الرسائل بأكملها إلى الواجهة الأمامية.

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

interface ChatMessageRepository {
  fun findLast(nInt): List<ChatMessage>
}
data class FileHeadRemote(
  val id: FileReference,
  val name: String
)
interface FileRemoteApi {
  fun getHeadById(idFileReference): FileHeadRemote
  fun getHeadsByIds(idSet<FileReference>): Set<FileHeadRemote>
  fun getHeadsByIds(idList<FileReference>): List<FileHeadRemote>
  fun getHeadsByChat(): List<FileHeadRemote>
}
data class UserRemote(
  val id: UserReference,
  val name: String
)
interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote>
  fun getUsersByIds(idList<UserReference>): List<UserRemote>
}

يمكن ملاحظة أن الخدمات الخارجية توفر في البداية المعالجة المجمعة، وفي كلا الإصدارين: من خلال المجموعة (دون الحفاظ على ترتيب العناصر، بمفاتيح فريدة) ومن خلال القائمة (قد تكون هناك نسخ مكررة - يتم الحفاظ على الترتيب).

تطبيقات بسيطة

تنفيذ ساذج

سيبدو التنفيذ الساذج الأول لوحدة التحكم REST الخاصة بنا على النحو التالي في معظم الحالات:

class ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ChatRestApi {
  override fun getLast(nInt) =
    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 للحصول على جميع الملفات المرفقة. ويبدو أن يكون عليه. لنفترض أنه تم الحصول على حقلي الأمام من والرد على ChatMessage بطريقة لا تتطلب مكالمات غير ضرورية. لكن تحويلها إلى ChatMessageUI سيؤدي إلى التكرار، أي أن عدادات المكالمات يمكن أن تزيد بشكل كبير. كما أشرنا سابقًا، لنفترض أنه ليس لدينا الكثير من التداخل وأن السلسلة تقتصر على ثلاث رسائل.

ونتيجة لذلك، سنتلقى من اثنين إلى ستة مكالمات للخدمات الخارجية لكل رسالة ومكالمة JPA واحدة لحزمة الرسائل بأكملها. سيختلف العدد الإجمالي للمكالمات من 2*N+1 إلى 6*N+1. كم هو هذا في الوحدات الحقيقية؟ لنفترض أن الأمر يستغرق 20 رسالة لعرض الصفحة. لاستلامها سوف يستغرق من 4 إلى 10 ثواني. رهيب! أرغب في الاحتفاظ بها في حدود 500 مللي ثانية. وبما أنهم حلموا بإجراء تمرير سلس في الواجهة الأمامية، فيمكن مضاعفة متطلبات الأداء لنقطة النهاية هذه.

الايجابيات:

  1. الكود موجز وموثق ذاتيًا (حلم فريق الدعم).
  2. الكود بسيط، لذلك لا توجد فرص تقريبًا لإطلاق النار على قدمك.
  3. لا تبدو المعالجة المجمعة وكأنها شيء غريب ويتم دمجها عضويًا في المنطق.
  4. سيتم إجراء التغييرات المنطقية بسهولة وستكون محلية.

أقل:

أداء رهيب بسبب الحزم الصغيرة جدًا.

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

معالجة متوازية ساذجة

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

تنفيذ المعالجة المتوازية بسيط للغاية:

override fun getLast(nInt) =
  messageRepository.findLast(n).parallelStream()
    .map it.toFrontModel() }
    .collect(toList())

باستخدام معالجة الرسائل المتوازية، نحصل على 300-700 مللي ثانية بشكل مثالي، وهو أفضل بكثير من التنفيذ الساذج، ولكنه لا يزال غير سريع بما فيه الكفاية.

باستخدام هذا الأسلوب، سيتم تنفيذ الطلبات المقدمة إلى userRepository وfileRepository بشكل متزامن، وهو أمر غير فعال للغاية. لإصلاح ذلك، سيتعين عليك تغيير منطق الاتصال كثيرًا. على سبيل المثال، عبر CompletionStage (المعروف أيضًا باسم CompletableFuture):

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())
  ) authorfiles ->
    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 <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

من الناحية النظرية، باستخدام مثل هذه المعالجة المتوازية، سنحصل على 200-400 مللي ثانية، وهو قريب بالفعل من توقعاتنا.

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

نتيجتي عند استخدام مثل هذه الخدمة هي 1300-1700 مللي ثانية لمعالجة 20 رسالة. وهذا أسرع مما كان عليه في التنفيذ الأول، لكنه لا يزال لا يحل المشكلة.

الاستخدامات البديلة للاستعلامات الموازيةماذا لو كانت خدمات الطرف الثالث لا توفر معالجة الدفعات؟ على سبيل المثال، يمكنك إخفاء عدم تنفيذ المعالجة المجمعة داخل طرق الواجهة:

interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  fun getUsersByIds(idList<UserReference>): List<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

وهذا أمر منطقي إذا كنت تأمل في رؤية المعالجة المجمعة في الإصدارات المستقبلية.
الايجابيات:

  1. تنفيذ المعالجة المتوازية القائمة على الرسائل بسهولة.
  2. قابلية التوسع جيدة.

سلبيات:

  1. الحاجة إلى فصل الحصول على البيانات عن معالجتها عند معالجة الطلبات إلى خدمات مختلفة بالتوازي.
  2. زيادة الحمل على خدمات الطرف الثالث.

ويمكن ملاحظة أن نطاق التطبيق هو تقريبًا نفس نطاق النهج الساذج. من المنطقي استخدام طريقة الطلب الموازي إذا كنت ترغب في زيادة أداء خدمتك عدة مرات بسبب الاستغلال القاسي للآخرين. في مثالنا، زاد الأداء بمقدار 2,5 مرة، ولكن من الواضح أن هذا لا يكفي.

التخزين المؤقت

يمكنك إجراء التخزين المؤقت بروح JPA للخدمات الخارجية، أي تخزين الكائنات المستلمة خلال الجلسة حتى لا يتم استلامها مرة أخرى (بما في ذلك أثناء المعالجة المجمعة). يمكنك إنشاء مثل هذه ذاكرة التخزين المؤقت بنفسك، ويمكنك استخدام Spring مع @Cacheable، بالإضافة إلى أنه يمكنك دائمًا استخدام ذاكرة تخزين مؤقت جاهزة مثل EhCache يدويًا.

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

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

الايجابيات:

  1. تنفيذ التخزين المؤقت دون تغيير التعليمات البرمجية.
  2. زيادة الإنتاجية عدة مرات (في بعض الحالات).

سلبيات:

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

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

في مثالنا، ستوفر ذاكرة التخزين المؤقت زيادة في الأداء تبلغ حوالي 25%. في الوقت نفسه، تحتوي ذاكرة التخزين المؤقت على الكثير من العيوب، لذلك لن أستخدمها هنا.

نتائج

لذلك، نظرنا إلى التنفيذ الساذج لخدمة تستخدم المعالجة المجمعة، وبعض الطرق البسيطة لتسريعها.

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

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

هناك اتجاهان رئيسيان يمكنك من خلالهما البحث عن الحلول:

  • العمل غير المتزامن مع البيانات (يتطلب نقلة نوعية، لذلك لم تتم مناقشته في هذه المقالة)؛
  • توسيع الدفعات مع الحفاظ على المعالجة المتزامنة.

سيؤدي توسيع الدُفعات إلى تقليل عدد المكالمات الخارجية بشكل كبير وفي نفس الوقت الحفاظ على تزامن الكود. سيتم تخصيص الجزء التالي من المقالة لهذا الموضوع.

المصدر: www.habr.com

إضافة تعليق