مشکلات پردازش دسته ای پرس و جو و راه حل های آنها (قسمت 1)

مشکلات پردازش دسته ای پرس و جو و راه حل های آنها (قسمت 1)تقریباً تمام محصولات نرم افزاری مدرن از چندین سرویس تشکیل شده اند. اغلب، زمان‌های پاسخ طولانی کانال‌های بین‌سرویس به منبع مشکلات عملکرد تبدیل می‌شوند. راه حل استاندارد برای این نوع مشکل، بسته بندی چندین درخواست بین سرویس در یک بسته است که به آن دسته بندی می گویند.

اگر از پردازش دسته ای استفاده می کنید، ممکن است از نظر عملکرد یا وضوح کد از نتایج راضی نباشید. این روش آنطور که فکر می کنید برای تماس گیرنده آسان نیست. برای اهداف مختلف و در موقعیت های مختلف، راه حل ها می توانند بسیار متفاوت باشند. با استفاده از مثال های خاص، جوانب مثبت و منفی چندین رویکرد را نشان خواهم داد.

پروژه نمایشی

برای وضوح، بیایید به نمونه‌ای از یکی از سرویس‌های اپلیکیشنی که در حال حاضر روی آن کار می‌کنم نگاه کنیم.

توضیح انتخاب پلت فرم برای مثالمشکل عملکرد ضعیف کاملاً عمومی است و هیچ زبان یا پلتفرم خاصی را تحت تأثیر قرار نمی دهد. این مقاله از مثال‌های کد Spring + Kotlin برای نشان دادن مشکلات و راه‌حل‌ها استفاده می‌کند. کاتلین برای توسعه دهندگان جاوا و سی شارپ به همان اندازه قابل درک (یا غیرقابل درک) است، علاوه بر این، کد فشرده تر و قابل درک تر از جاوا است. برای اینکه درک آن برای توسعه دهندگان جاوا خالص آسان تر شود، از جادوی سیاه Kotlin اجتناب می کنم و فقط از جادوی سفید (در روح Lombok) استفاده می کنم. چند روش توسعه وجود خواهد داشت، اما آنها در واقع برای همه برنامه نویسان جاوا به عنوان روش های ثابت آشنا هستند، بنابراین این یک شکر کوچک خواهد بود که طعم غذا را خراب نمی کند.
خدمات تأیید اسناد وجود دارد. شخصی سندی ایجاد می کند و آن را برای بحث ارائه می کند و طی آن ویرایش هایی انجام می شود و در نهایت سند مورد توافق قرار می گیرد. خود سرویس تأیید چیزی در مورد اسناد نمی داند: این فقط یک گفتگوی تأیید کنندگان با عملکردهای کوچک اضافی است که ما در اینجا در نظر نخواهیم گرفت.

بنابراین، اتاق های گفتگو (مرتبط با اسناد) با مجموعه ای از شرکت کنندگان از پیش تعریف شده در هر یک از آنها وجود دارد. مانند چت های معمولی، پیام ها حاوی متن و فایل هستند و می توانند پاسخ یا فوروارد باشند:

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 پیام را با تمام اطلاعات لازم دریافت کنیم. یعنی ما معتقدیم که مدل پیام در frontend تقریباً یکسان است و همه داده ها باید ارسال شوند. تفاوت بین مدل front-end این است که فایل و کاربر باید به شکل کمی رمزگشایی شده ارائه شوند تا آنها را پیوند دهند:

/** В таком виде отдаются ссылки на сущности для фронта */
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>
}

UI postfix به معنای مدل‌های DTO برای فرانت‌اند است، یعنی چیزی که باید از طریق REST ارائه کنیم.

چیزی که در اینجا ممکن است تعجب آور باشد این است که ما هیچ شناسه چت را ارسال نمی کنیم و حتی مدل ChatMessage/ChatMessageUI هم چنین شناسه ای ندارد. من این کار را عمدا انجام دادم تا کد نمونه ها به هم نریزم (چت ها ایزوله هستند، بنابراین می توانیم فرض کنیم که فقط یک مورد داریم).

انحراف فلسفیهم کلاس ChatMessageUI و هم متد ChatRestApi.getLast از نوع داده List استفاده می کنند در حالی که در واقع یک مجموعه مرتب شده است. این در JDK بد است، بنابراین اعلام ترتیب عناصر در سطح رابط (حفظ ترتیب هنگام افزودن و حذف) کار نخواهد کرد. بنابراین استفاده از یک لیست در مواردی که به یک مجموعه سفارشی نیاز است، رایج شده است (یک LinkedHashSet نیز وجود دارد، اما این یک رابط نیست).
محدودیت مهم: فرض می کنیم که هیچ زنجیره طولانی پاسخ یا انتقال وجود ندارد. یعنی وجود دارند، اما طول آنها از سه پیام بیشتر نمی شود. کل زنجیره پیام ها باید به قسمت جلویی منتقل شود.

برای دریافت داده ها از سرویس های خارجی API های زیر وجود دارد:

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

مشاهده می شود که خدمات خارجی در ابتدا پردازش دسته ای را ارائه می دهند و در هر دو نسخه: از طریق Set (بدون حفظ ترتیب عناصر، با کلیدهای منحصر به فرد) و از طریق List (ممکن است موارد تکراری وجود داشته باشد - ترتیب حفظ می شود).

پیاده سازی های ساده

اجرای ساده لوحانه

اولین پیاده سازی ساده کنترل کننده 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 برای دریافت همه فایل‌های پیوست شده برقرار می‌شود. به نظر می رسد همین است. بیایید بگوییم که فیلدهای forwardFrom و replyTo برای 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()!!

مشاهده می شود که کد نگاشت ساده اولیه کمتر قابل درک شده است. این به این دلیل است که ما مجبور شدیم تماس‌های سرویس‌های خارجی را از محل استفاده از نتایج جدا کنیم. این به خودی خود بد نیست. اما ترکیب تماس ها خیلی ظریف به نظر نمی رسد و شبیه یک "نودل" واکنشی معمولی است.

اگر از کوروتین ها استفاده کنید، همه چیز مناسب تر به نظر می رسد:

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

اضافه کردن نظر