To'plamli so'rovlarni qayta ishlash muammolari va ularning echimlari (1-qism)

To'plamli so'rovlarni qayta ishlash muammolari va ularning echimlari (1-qism)Deyarli barcha zamonaviy dasturiy mahsulotlar bir nechta xizmatlardan iborat. Ko'pincha, xizmatlararo kanallarning uzoq javob vaqtlari ishlash muammolarining manbai bo'lib qoladi. Bunday muammoning standart yechimi bir nechta xizmatlararo so'rovlarni bitta paketga to'plashdan iborat bo'lib, bu paketlash deb ataladi.

Agar siz ommaviy ishlov berishdan foydalansangiz, ishlash yoki kod ravshanligi nuqtai nazaridan natijalar sizni qoniqtirmasligi mumkin. Bu usul qo‘ng‘iroq qiluvchiga siz o‘ylagandek oson emas. Turli maqsadlarda va turli vaziyatlarda echimlar juda farq qilishi mumkin. Aniq misollar yordamida men bir nechta yondashuvlarning ijobiy va salbiy tomonlarini ko'rsataman.

Namoyish loyihasi

Aniqlik uchun men hozir ishlayotgan ilovadagi xizmatlardan birining misolini ko'rib chiqaylik.

Misollar uchun platforma tanlashni tushuntirishYomon ishlash muammosi juda umumiy bo'lib, hech qanday maxsus tillar yoki platformalarga taalluqli emas. Ushbu maqola muammolar va echimlarni ko'rsatish uchun Spring + Kotlin kod misollaridan foydalanadi. Kotlin Java va C# ishlab chiquvchilari uchun bir xil darajada tushunarli (yoki tushunarsiz); Bundan tashqari, kod Java-ga qaraganda ancha ixcham va tushunarli. Sof Java dasturchilariga tushunarli bo‘lishi uchun men Kotlinning qora sehridan qochaman va faqat oq sehrdan foydalanaman (Lombok ruhida). Bir nechta kengaytma usullari bo'ladi, lekin ular aslida barcha Java dasturchilariga statik usullar sifatida tanish, shuning uchun bu taomning ta'mini buzmaydigan kichik shakar bo'ladi.
Hujjatlarni tasdiqlash xizmati mavjud. Kimdir hujjat yaratadi va uni muhokamaga qo'yadi, uning davomida tahrirlar kiritiladi va yakunda hujjat kelishib olinadi. Tasdiqlash xizmatining o'zi hujjatlar haqida hech narsa bilmaydi: bu shunchaki kichik qo'shimcha funktsiyalarga ega tasdiqlovchilarning suhbati, biz bu erda ko'rib chiqmaymiz.

Shunday qilib, har birida oldindan belgilangan ishtirokchilar to'plamiga ega suhbat xonalari (hujjatlarga mos keladi) mavjud. Oddiy chatlarda bo'lgani kabi, xabarlar matn va fayllarni o'z ichiga oladi va ular javob yoki yo'naltiruvchi bo'lishi mumkin:

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
)

Fayl va foydalanuvchi havolalari boshqa domenlarga havolalardir. Mana biz shunday yashaymiz:

typealias FileReference Long
typealias UserReference Long

Foydalanuvchi ma'lumotlari Keycloak-da saqlanadi va REST orqali qabul qilinadi. Xuddi shu narsa fayllar uchun ham amal qiladi: fayllar va ular haqidagi metama'lumotlar alohida fayllarni saqlash xizmatida yashaydi.

Ushbu xizmatlarga barcha qo'ng'iroqlar og'ir so'rovlar. Bu shuni anglatadiki, ushbu so'rovlarni tashish uchun qo'shimcha xarajatlar ularni uchinchi tomon xizmati tomonidan ko'rib chiqilishi uchun ketadigan vaqtdan ancha katta. Sinov skameykalarimizda bunday xizmatlar uchun odatda qo'ng'iroq qilish vaqti 100 ms ni tashkil qiladi, shuning uchun biz kelajakda bu raqamlardan foydalanamiz.

Barcha kerakli ma'lumotlarga ega bo'lgan oxirgi N xabarni olish uchun oddiy REST kontrollerini yaratishimiz kerak. Ya'ni, biz frontendda xabar modeli deyarli bir xil va barcha ma'lumotlarni yuborish kerakligiga ishonamiz. Front-end modeli o'rtasidagi farq shundaki, fayl va foydalanuvchi havolalarini yaratish uchun biroz shifrlangan shaklda taqdim etilishi kerak:

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

Biz quyidagilarni amalga oshirishimiz kerak:

interface ChatRestApi {
  fun getLast(nInt): List<ChatMessageUI>
}

Postfix UI frontend uchun DTO modellarini, ya'ni biz REST orqali xizmat qilishimiz kerak bo'lgan narsalarni anglatadi.

Bu erda ajablanarli narsa shundaki, biz hech qanday chat identifikatorini o'tkazmayapmiz va hatto ChatMessage/ChatMessageUI modelida ham yo'q. Men misollar kodini chalkashtirib yubormaslik uchun buni ataylab qildim (chatlar izolyatsiya qilingan, shuning uchun bizda faqat bittasi bor deb taxmin qilishimiz mumkin).

Falsafiy chekinishChatMessageUI klassi ham, ChatRestApi.getLast usuli ham List ma'lumotlar turidan foydalanadi, lekin aslida bu tartiblangan to'plamdir. JDK-da bularning barchasi yomon, shuning uchun interfeys darajasida elementlarning tartibini e'lon qilish (qo'shish va olishda tartibni saqlash) ishlamaydi. Shunday qilib, buyurtma qilingan to'plam zarur bo'lgan hollarda List-dan foydalanish odatiy holga aylandi (LinkedHashSet ham bor, lekin bu interfeys emas).
Muhim cheklov: Biz javoblar yoki o'tkazmalarning uzoq zanjirlari yo'q deb taxmin qilamiz. Ya'ni, ular mavjud, ammo ularning uzunligi uchta xabardan oshmaydi. Xabarlarning butun zanjiri frontendga uzatilishi kerak.

Tashqi xizmatlardan ma'lumotlarni olish uchun quyidagi API mavjud:

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

Ko'rinib turibdiki, tashqi xizmatlar dastlab paketli ishlov berishni ta'minlaydi va ikkala variantda: Set orqali (elementlar tartibini saqlamasdan, noyob kalitlarga ega) va Ro'yxat orqali (dublikatlar bo'lishi mumkin - tartib saqlanadi).

Oddiy amalga oshirish

Oddiy amalga oshirish

REST kontrollerimizning birinchi sodda amalga oshirilishi ko'p hollarda shunday ko'rinadi:

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

Hamma narsa juda aniq va bu katta ortiqcha.

Biz ommaviy ishlov berishdan foydalanamiz va tashqi xizmatdan ma'lumotlarni to'plamlarda qabul qilamiz. Ammo bizning mahsuldorligimiz nima bo'lmoqda?

Har bir xabar uchun muallif maydonidagi ma'lumotlarni olish uchun UserRemoteApi-ga bitta qo'ng'iroq va barcha biriktirilgan fayllarni olish uchun FileRemoteApi-ga bitta qo'ng'iroq qilinadi. Xuddi shunday tuyuladi. Aytaylik, ChatMessage uchun forwardFrom va replyTo maydonlari keraksiz qo'ng'iroqlarni talab qilmaydigan tarzda olingan. Ammo ularni ChatMessageUI-ga aylantirish rekursiyaga olib keladi, ya'ni qo'ng'iroqlar hisoblagichlari sezilarli darajada oshishi mumkin. Yuqorida aytib o'tganimizdek, bizda juda ko'p uyalar yo'q va zanjir uchta xabar bilan cheklangan deb faraz qilaylik.

Natijada, biz har bir xabar uchun tashqi xizmatlarga ikkitadan oltitagacha qo'ng'iroqlarni va butun xabarlar to'plami uchun bitta JPA qo'ng'iroqlarini olamiz. Qo'ng'iroqlarning umumiy soni 2*N+1 dan 6*N+1 gacha o'zgaradi. Bu haqiqiy birliklarda qancha? Aytaylik, sahifani ko'rsatish uchun 20 ta xabar kerak bo'ladi. Ularni olish uchun sizga 4 soniyadan 10 soniyagacha vaqt kerak bo'ladi. Qo'rqinchli! Men uni 500ms ichida saqlamoqchiman. Va ular old tomondan uzluksiz aylantirishni orzu qilganliklari sababli, ushbu oxirgi nuqta uchun ishlash talablari ikki baravar oshirilishi mumkin.

Taroziga soling:

  1. Kod ixcham va o'z-o'zidan hujjatlashtirilgan (qo'llab-quvvatlash guruhining orzusi).
  2. Kod oddiy, shuning uchun o'zingizni oyog'ingizga otish imkoniyati deyarli yo'q.
  3. Ommaviy ishlov berish begona narsaga o'xshamaydi va mantiqqa organik ravishda birlashtirilgan.
  4. Mantiqiy o'zgarishlar qilish oson va mahalliy bo'ladi.

Yo'q:

Juda kichik paketlar tufayli dahshatli ishlash.

Bunday yondashuvni oddiy xizmatlarda yoki prototiplarda tez-tez ko'rish mumkin. Agar o'zgarishlarni amalga oshirish tezligi muhim bo'lsa, tizimni murakkablashtirish qiyin. Shu bilan birga, bizning juda oddiy xizmatimiz uchun ishlash dahshatli, shuning uchun ushbu yondashuvning qo'llanilishi doirasi juda tor.

Oddiy parallel ishlov berish

Siz barcha xabarlarni parallel ravishda qayta ishlashni boshlashingiz mumkin - bu sizga xabarlar soniga qarab vaqtning chiziqli o'sishidan xalos bo'lishga imkon beradi. Bu, ayniqsa, yaxshi yo'l emas, chunki bu tashqi xizmatga katta yuklanishga olib keladi.

Parallel ishlov berishni amalga oshirish juda oddiy:

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

Parallel xabarlarni qayta ishlashdan foydalanib, biz ideal tarzda 300-700 milodiy tezlikka ega bo'lamiz, bu sodda dasturga qaraganda ancha yaxshi, lekin hali ham tez emas.

Ushbu yondashuv bilan userRepository va fileRepository so'rovlari sinxron tarzda bajariladi, bu unchalik samarali emas. Buni tuzatish uchun siz qo'ng'iroq mantig'ini juda ko'p o'zgartirishingiz kerak bo'ladi. Masalan, CompletionStage (aka CompletableFuture) orqali:

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()!!

Ko'rinib turibdiki, dastlab oddiy xaritalash kodi kamroq tushunarli bo'lib qoldi. Buning sababi, biz tashqi xizmatlarga qo'ng'iroqlarni natijalar ishlatiladigan joydan ajratishimiz kerak edi. Bu o'z-o'zidan yomon emas. Ammo qo'ng'iroqlarni birlashtirish ayniqsa oqlangan ko'rinmaydi va odatiy reaktiv "noodle" ga o'xshaydi.

Agar siz koroutinlardan foydalansangiz, hamma narsa yanada yaxshi ko'rinadi:

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

Qaerda:

fun <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

Nazariy jihatdan, bunday parallel ishlov berishdan foydalanib, biz 200-400 milodiy tezlikka ega bo'lamiz, bu allaqachon bizning taxminlarimizga yaqin.

Afsuski, bunday yaxshi parallelizatsiya sodir bo'lmaydi va to'lash uchun narx juda shafqatsiz: bir vaqtning o'zida bir nechta foydalanuvchi ishlayotgan bo'lsa, xizmatlar baribir parallel ravishda ko'rib chiqilmaydigan so'rovlar to'lqiniga duchor bo'ladi, shuning uchun biz qayg'uli 4larimizga qaytamiz.

Bunday xizmatdan foydalanganda mening natijam 1300 ta xabarni qayta ishlash uchun 1700–20 ms. Bu birinchi dasturga qaraganda tezroq, lekin baribir muammoni hal qilmaydi.

Parallel so'rovlardan muqobil foydalanishAgar uchinchi tomon xizmatlari ommaviy ishlov berishni ta'minlamasa-chi? Masalan, interfeys usullari ichida ommaviy ishlov berishni amalga oshirishning etishmasligini yashirishingiz mumkin:

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

Agar siz keyingi versiyalarda ommaviy qayta ishlashni ko'rishni xohlasangiz, bu mantiqiy.
Taroziga soling:

  1. Xabarga asoslangan parallel ishlov berishni osonlik bilan amalga oshiring.
  2. Yaxshi miqyoslilik.

Kamchiliklari:

  1. Parallel ravishda turli xizmatlarga so'rovlarni qayta ishlashda ma'lumotlarni yig'ishni uni qayta ishlashdan ajratish zarurati.
  2. Uchinchi tomon xizmatlariga yuk ortdi.

Ko'rinib turibdiki, qo'llash doirasi sodda yondashuv bilan taxminan bir xil. Agar siz boshqalarning shafqatsiz ekspluatatsiyasi tufayli xizmatingiz samaradorligini bir necha bor oshirmoqchi bo'lsangiz, parallel so'rov usulidan foydalanish mantiqan to'g'ri keladi. Bizning misolimizda hosildorlik 2,5 barobar oshdi, ammo bu etarli emasligi aniq.

keshlash

Siz tashqi xizmatlar uchun JPA ruhida keshlashni amalga oshirishingiz mumkin, ya'ni qabul qilingan ob'ektlarni ularni qayta qabul qilmaslik uchun (shu jumladan, paketli ishlov berish paytida) seans ichida saqlashingiz mumkin. Siz bunday keshlarni o'zingiz qilishingiz mumkin, siz Spring-dan @Cacheable-dan foydalanishingiz mumkin, shuningdek, har doim EhCache kabi tayyor keshni qo'lda ishlatishingiz mumkin.

Keng tarqalgan muammo shundaki, keshlar faqat xitlar bo'lsa foydali bo'ladi. Bizning holatda, muallif maydonidagi xitlar juda katta ehtimol (aytaylik, 50%), lekin fayllarda hech qanday xit bo'lmaydi. Ushbu yondashuv ba'zi yaxshilanishlarni ta'minlaydi, ammo unumdorlikni tubdan o'zgartirmaydi (va bizga yutuq kerak).

Intersession (uzoq) keshlar murakkab bekor qilish mantiqini talab qiladi. Umuman olganda, sessiyalararo keshlar yordamida ishlash muammolarini hal qilishga qanchalik kech kirsangiz, shuncha yaxshi bo'ladi.

Taroziga soling:

  1. Kodni o'zgartirmasdan keshlashni amalga oshiring.
  2. Hosildorlikning bir necha marta ortishi (ba'zi hollarda).

Kamchiliklari:

  1. Agar noto'g'ri ishlatilsa, unumdorlikni pasaytirish imkoniyati.
  2. Katta xotira yuki, ayniqsa uzoq keshlar bilan.
  3. Murakkab bekor qilish, xatolar ish vaqtida qayta ishlab chiqarish qiyin bo'lgan muammolarga olib keladi.

Ko'pincha keshlar faqat dizayn muammolarini tezda tuzatish uchun ishlatiladi. Bu ularni ishlatmaslik kerak degani emas. Biroq, siz har doim ularga ehtiyotkorlik bilan munosabatda bo'lishingiz va birinchi navbatda natijada olingan samaradorlikni baholashingiz kerak va shundan keyingina qaror qabul qilishingiz kerak.

Bizning misolimizda keshlar ishlash samaradorligini taxminan 25% ga oshiradi. Shu bilan birga, keshlarning juda ko'p kamchiliklari bor, shuning uchun men ularni bu erda ishlatmayman.

natijalar

Shunday qilib, biz ommaviy ishlov berishdan foydalanadigan xizmatning sodda amalga oshirilishini va uni tezlashtirishning bir necha oddiy usullarini ko'rib chiqdik.

Ushbu usullarning barchasining asosiy afzalligi soddalik bo'lib, undan ko'p yoqimli oqibatlar mavjud.

Ushbu usullar bilan bog'liq keng tarqalgan muammo - bu, birinchi navbatda, paketlar hajmi bilan bog'liq bo'lgan yomon ishlash. Shuning uchun, agar bu echimlar sizga mos kelmasa, unda ko'proq radikal usullarni ko'rib chiqishga arziydi.

Yechimlarni izlashingiz mumkin bo'lgan ikkita asosiy yo'nalish mavjud:

  • ma'lumotlar bilan asenkron ishlash (paradigma o'zgarishini talab qiladi, shuning uchun bu maqolada muhokama qilinmaydi);
  • sinxron ishlov berishni saqlab qolgan holda partiyalarni kattalashtirish.

To'plamlarni kattalashtirish tashqi qo'ng'iroqlar sonini sezilarli darajada kamaytiradi va shu bilan birga kodni sinxronlashtiradi. Maqolaning keyingi qismi ushbu mavzuga bag'ishlanadi.

Manba: www.habr.com

a Izoh qo'shish