Toplu sorğuların işlənməsi problemləri və onların həlli yolları (1-ci hissə)

Toplu sorğuların işlənməsi problemləri və onların həlli yolları (1-ci hissə)Demək olar ki, bütün müasir proqram məhsulları bir neçə xidmətdən ibarətdir. Çox vaxt xidmətlərarası kanalların uzun cavab müddəti performans problemlərinin mənbəyinə çevrilir. Bu cür problemin standart həlli birdən çox xidmətlərarası sorğuları bir paketə yığmaqdır ki, bu da paketləşdirmə adlanır.

Toplu emaldan istifadə edirsinizsə, performans və ya kod aydınlığı baxımından nəticələrdən məmnun olmaya bilərsiniz. Bu üsul zəng edən şəxs üçün düşündüyünüz qədər asan deyil. Fərqli məqsədlər üçün və müxtəlif vəziyyətlərdə həllər çox fərqli ola bilər. Konkret nümunələrdən istifadə edərək bir neçə yanaşmanın müsbət və mənfi tərəflərini göstərəcəyəm.

Nümayiş layihəsi

Aydınlıq üçün, hazırda üzərində işlədiyim tətbiqdəki xidmətlərdən birinin nümunəsinə baxaq.

Nümunələr üçün platforma seçiminin izahıZəif performans problemi olduqca ümumidir və heç bir xüsusi dilə və ya platformaya aid deyil. Bu məqalə problemləri və həll yollarını nümayiş etdirmək üçün Spring + Kotlin kod nümunələrindən istifadə edəcək. Kotlin Java və C# tərtibatçıları üçün eyni dərəcədə başa düşüləndir (və ya anlaşılmazdır); əlavə olaraq, kod Java ilə müqayisədə daha yığcam və başa düşüləndir. Saf Java tərtibatçıları üçün hər şeyi başa düşməyi asanlaşdırmaq üçün mən Kotlinin qara sehrindən qaçacağam və yalnız ağ sehrdən (Lombok ruhunda) istifadə edəcəyəm. Bir neçə genişləndirmə üsulları olacaq, lakin onlar əslində bütün Java proqramçılarına statik üsullar kimi tanışdırlar, ona görə də bu, yeməyin dadını pozmayacaq kiçik bir şəkər olacaq.
Sənədlərin təsdiqi xidməti var. Kimsə sənəd yaradır və onu müzakirəyə verir, bu müddət ərzində redaktələr edilir və sonda sənəd razılaşdırılır. Təsdiq xidmətinin özü sənədlər haqqında heç nə bilmir: bu, sadəcə burada nəzərdən keçirməyəcəyimiz kiçik əlavə funksiyaları olan təsdiqləyicilərin söhbətidir.

Beləliklə, hər birində əvvəlcədən müəyyən edilmiş iştirakçılar dəsti olan söhbət otaqları (sənədlərə uyğun) var. Adi söhbətlərdə olduğu kimi, mesajlar mətn və fayllardan ibarətdir və cavab və ya yönləndirilə bilər:

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 və istifadəçi bağlantıları digər domenlərə keçidlərdir. Burada belə yaşayırıq:

typealias FileReference Long
typealias UserReference Long

İstifadəçi məlumatları Keycloak-da saxlanılır və REST vasitəsilə qəbul edilir. Eyni şey fayllara da aiddir: fayllar və onlar haqqında metainformasiya ayrıca fayl saxlama xidmətində yaşayır.

Bu xidmətlərə edilən bütün zənglər ağır istəklər. Bu o deməkdir ki, bu sorğuların daşınması üzrə əlavə məsrəf onların üçüncü tərəf xidməti tərəfindən işlənməsi üçün tələb olunan vaxtdan qat-qat çoxdur. Test skamyalarımızda bu cür xidmətlər üçün tipik zəng vaxtı 100 ms təşkil edir, ona görə də biz gələcəkdə bu nömrələrdən istifadə edəcəyik.

Son N mesajı bütün lazımi məlumatlarla qəbul etmək üçün sadə REST nəzarətçisi yaratmalıyıq. Yəni, hesab edirik ki, ön hissədə mesaj modeli demək olar ki, eynidir və bütün məlumatların göndərilməsi lazımdır. Front-end modeli arasındakı fərq ondan ibarətdir ki, fayl və istifadəçi əlaqə yaratmaq üçün bir az deşifrə olunmuş formada təqdim edilməlidir:

/** В таком виде отдаются ссылки на сущности для фронта */
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 aşağıdakıları həyata keçirməliyik:

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

Postfix UI, frontend üçün DTO modelləri, yəni REST vasitəsilə xidmət etməli olduğumuz şeylər deməkdir.

Burada təəccüblü ola bilər ki, biz heç bir söhbət identifikatorunu keçmirik və hətta ChatMessage/ChatMessageUI modelində belə yoxdur. Nümunələrin kodunu qarışdırmamaq üçün bunu qəsdən etdim (söhbətlər təcrid olunub, ona görə də bizdə yalnız bir olduğunu düşünə bilərik).

Fəlsəfi təxribatHəm ChatMessageUI sinfi, həm də ChatRestApi.getLast metodu, əslində sifarişli dəst olduğu halda, Siyahı məlumat növündən istifadə edir. JDK-da bunların hamısı pisdir, ona görə də interfeys səviyyəsində elementlərin sırasını elan etmək (əlavə və geri götürərkən sıranı qorumaq) işləməyəcək. Beləliklə, sifarişli Dəstin lazım olduğu hallarda List-dən istifadə etmək adi bir təcrübə halına gəldi (LinkedHashSet də var, lakin bu interfeys deyil).
Əhəmiyyətli məhdudiyyət: Güman edəcəyik ki, uzun cavab və ya köçürmə zəncirləri yoxdur. Yəni onlar mövcuddur, lakin onların uzunluğu üç mesajı keçmir. Bütün mesajlar zənciri ön hissəyə ötürülməlidir.

Xarici xidmətlərdən məlumat almaq üçün aşağıdakı API-lər mövcuddur:

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

Göründüyü kimi, xarici xidmətlər əvvəlcə toplu işlənməsini təmin edir və hər iki variantda: Set vasitəsilə (elementlərin sırasını qorumadan, unikal açarlarla) və Siyahı vasitəsilə (dublikatlar ola bilər - sifariş qorunub saxlanılır).

Sadə tətbiqlər

Sadəlövh icra

REST nəzarətçimizin ilk sadəlövh tətbiqi əksər hallarda bu kimi görünəcək:

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

Hər şey çox aydındır və bu böyük bir artıdır.

Biz toplu emaldan istifadə edirik və xarici xidmətdən məlumatları partiyalar şəklində alırıq. Bəs məhsuldarlığımıza nə baş verir?

Hər mesaj üçün müəllif sahəsində məlumat əldə etmək üçün UserRemoteApi-yə bir zəng və bütün əlavə edilmiş faylları əldə etmək üçün FileRemoteApi-yə bir zəng ediləcək. Deyəsən, belədir. Deyək ki, ChatMessage üçün forwardFrom və replyTo sahələri elə alınıb ki, bu, lazımsız zənglər tələb etmir. Lakin onları ChatMessageUI-yə çevirmək rekursiyaya gətirib çıxaracaq, yəni zəng sayğacları xeyli arta bilər. Daha əvvəl qeyd etdiyimiz kimi, fərz edək ki, bizdə çox yuva yoxdur və zəncir üç mesajla məhdudlaşır.

Nəticədə, hər mesaj üçün ikidən altıya qədər xarici xidmətlərə zəng və bütün mesaj paketi üçün bir JPA zəngi alacağıq. Zənglərin ümumi sayı 2*N+1 ilə 6*N+1 arasında dəyişəcək. Bu real vahidlərlə nə qədərdir? Tutaq ki, bir səhifəni göstərmək üçün 20 mesaj lazımdır. Onları əldə etmək üçün 4 saniyədən 10 saniyəyə qədər vaxt lazımdır. Dəhşətli! Mən onu 500 ms-də saxlamaq istərdim. Və onlar ön hissədə qüsursuz sürüşmə etməyi xəyal etdikləri üçün bu son nöqtə üçün performans tələbləri ikiqat artırıla bilər.

Pros:

  1. Kod qısa və özünü sənədləşdirir (bir dəstək komandasının arzusu).
  2. Kod sadədir, ona görə də ayağınıza atəş açmaq imkanları demək olar ki, yoxdur.
  3. Toplu emal yad bir şeyə bənzəmir və məntiqə üzvi şəkildə inteqrasiya olunur.
  4. Məntiq dəyişiklikləri etmək asan olacaq və yerli olacaq.

Minuslar:

Çox kiçik paketlərə görə dəhşətli performans.

Bu yanaşma sadə xidmətlərdə və ya prototiplərdə olduqca tez-tez görülə bilər. Dəyişikliklərin edilməsi sürəti vacibdirsə, sistemi çətinləşdirməyə dəyməz. Eyni zamanda, bizim çox sadə xidmətimiz üçün performans dəhşətlidir, ona görə də bu yanaşmanın tətbiq dairəsi çox dardır.

Sadə paralel emal

Bütün mesajları paralel olaraq emal etməyə başlaya bilərsiniz - bu, mesajların sayından asılı olaraq vaxtın xətti artımından xilas olmağa imkan verəcəkdir. Bu, xüsusilə yaxşı yol deyil, çünki bu, xarici xidmətdə böyük bir pik yüklə nəticələnəcək.

Paralel emalın həyata keçirilməsi çox sadədir:

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

Paralel mesaj emalından istifadə edərək, ideal olaraq 300-700 ms əldə edirik, bu sadəlövh tətbiqdən daha yaxşıdır, lakin hələ də kifayət qədər sürətli deyil.

Bu yanaşma ilə userRepository və fileRepository sorğuları sinxron şəkildə yerinə yetiriləcək ki, bu da çox səmərəli deyil. Bunu düzəltmək üçün zəng məntiqini xeyli dəyişməli olacaqsınız. Məsələn, CompletionStage (aka CompletableFuture) vasitəsilə:

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

Görünür ki, əvvəlcə sadə xəritələşdirmə kodu daha az başa düşülən hala gəldi. Bunun səbəbi, biz xarici xidmətlərə edilən zəngləri nəticələrin istifadə edildiyi yerdən ayırmalı olduq. Bu özlüyündə pis deyil. Ancaq zəngləri birləşdirmək o qədər də zərif görünmür və tipik reaktiv "əriştə" bənzəyir.

Koroutinlərdən istifadə etsəniz, hər şey daha layiqli görünəcək:

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

Harada:

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

Nəzəri olaraq, belə paralel emaldan istifadə edərək, 200-400 ms alacağıq, bu artıq gözləntilərimizə yaxındır.

Təəssüf ki, belə yaxşı paralelləşmə baş vermir və ödəniləcək qiymət olduqca qəddardır: eyni anda işləyən yalnız bir neçə istifadəçi ilə xidmətlər hər halda paralel olaraq işlənməyəcək bir çox sorğu ilə vurulacaq, buna görə də biz kədərli 4-lərimizə qayıdacaq.

Belə bir xidmətdən istifadə edərkən nəticəm 1300 mesajın işlənməsi üçün 1700–20 ms-dir. Bu, ilk tətbiqdən daha sürətlidir, lakin hələ də problemi həll etmir.

Paralel sorğuların alternativ istifadəsiÜçüncü tərəf xidmətləri toplu emal təmin etmirsə nə etməli? Məsələn, interfeys üsulları daxilində toplu emal tətbiqinin olmamasını gizlədə bilərsiniz:

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

Gələcək versiyalarda toplu emal görəcəyinizə ümid edirsinizsə, bu məntiqlidir.
Pros:

  1. Mesaja əsaslanan paralel emal prosesini asanlıqla həyata keçirin.
  2. Yaxşı ölçeklenebilirlik.

Eksiler:

  1. Paralel olaraq müxtəlif xidmətlərə sorğuları emal edərkən məlumatların alınmasını onun emalından ayırmaq ehtiyacı.
  2. Üçüncü tərəf xidmətlərinə artan yük.

Görünür ki, tətbiq dairəsi sadəlövh yanaşmanın əhatə dairəsi ilə təxminən eynidir. Başqalarının amansız istismarı səbəbindən xidmətinizin məhsuldarlığını bir neçə dəfə artırmaq istəyirsinizsə, paralel sorğu metodundan istifadə etməyin mənası var. Bizim nümunəmizdə məhsuldarlıq 2,5 dəfə artdı, lakin bu, açıq şəkildə kifayət deyil.

önbelleğe alma

Xarici xidmətlər üçün JPA ruhunda keşləmə edə bilərsiniz, yəni qəbul edilmiş obyektləri bir daha qəbul etməmək üçün (o cümlədən toplu emal zamanı) sessiya daxilində saxlaya bilərsiniz. Belə keşləri özünüz edə bilərsiniz, Spring-dən @Cacheable ilə istifadə edə bilərsiniz, üstəlik siz həmişə EhCache kimi hazır keşdən əl ilə istifadə edə bilərsiniz.

Ümumi problem, keşlərin yalnız hitləri olduqda faydalı olmasıdır. Bizim vəziyyətimizdə müəllif sahəsindəki hitlər çox güman ki, (tutaq ki, 50%), lakin fayllarda heç bir hit olmayacaq. Bu yanaşma bəzi təkmilləşdirmələri təmin edəcək, lakin bu, məhsuldarlığı kökündən dəyişməyəcək (və bizə bir irəliləyiş lazımdır).

Intersession (uzun) keşlər mürəkkəb etibarsızlaşdırma məntiqini tələb edir. Ümumiyyətlə, sessiyalararası keşlərdən istifadə edərək performans problemlərinin həllinə nə qədər gec başlasanız, bir o qədər yaxşıdır.

Pros:

  1. Kodu dəyişdirmədən keşləməni həyata keçirin.
  2. Məhsuldarlığın bir neçə dəfə artması (bəzi hallarda).

Eksiler:

  1. Yanlış istifadə edildikdə performansın azalması ehtimalı.
  2. Böyük yaddaş yükü, xüsusilə uzun keşlərlə.
  3. Mürəkkəb etibarsızlıq, səhvlər işləmə müddətində çoxalması çətin olan problemlərə gətirib çıxaracaq.

Çox vaxt keşlər yalnız dizayn problemlərini tez bir zamanda aradan qaldırmaq üçün istifadə olunur. Bu o demək deyil ki, onlardan istifadə edilməməlidir. Bununla belə, həmişə onlara ehtiyatla yanaşmalı və ilk növbədə nəticədə əldə olunan performansı qiymətləndirməlisiniz və yalnız bundan sonra qərar qəbul etməlisiniz.

Nümunəmizdə keşlər təxminən 25% performans artımını təmin edəcək. Eyni zamanda, önbelleklərin kifayət qədər mənfi cəhətləri var, ona görə də burada istifadə etməzdim.

Nəticələri

Beləliklə, biz toplu emaldan istifadə edən bir xidmətin sadəlövh tətbiqinə və onu sürətləndirməyin bir neçə sadə yoluna baxdıq.

Bütün bu üsulların əsas üstünlüyü sadəlikdir, ondan çox xoş nəticələr var.

Bu üsullarla bağlı ümumi problem, ilk növbədə paketlərin ölçüsü ilə əlaqəli zəif performansdır. Buna görə də, bu həllər sizə uyğun gəlmirsə, daha radikal üsulları nəzərdən keçirməyə dəyər.

Həll yollarını axtara biləcəyiniz iki əsas istiqamət var:

  • verilənlərlə asinxron iş (paradiqmanın dəyişməsini tələb edir, ona görə də bu məqalədə müzakirə olunmur);
  • sinxron emal saxlamaqla partiyaların genişləndirilməsi.

Partiyaların genişləndirilməsi xarici zənglərin sayını xeyli azaldacaq və eyni zamanda kodu sinxronlaşdıracaq. Məqalənin növbəti hissəsi bu mövzuya həsr olunacaq.

Mənbə: www.habr.com

Добавить комментарий