Težave s paketnim obdelovanjem zahtevkov in njihova rešitev (del 1)

Težave s paketnim obdelovanjem zahtevkov in njihova rešitev (del 1)Večina sodobnih programski produktov se sestavlja iz več storitev. Pogosto dolgi odzivni časi medservisnih kanalov postanejo vir težav pri zmogljivosti. Standardno rešitev za tovrstne težave predstavlja pakiranje več medservisnih zahtevkov v en paket, kar imenujemo obdelava paketov (batching).

Če uporabljate obdelavo paketov, morda ne boste zadovoljni z njenim rezultatom v smislu zmogljivosti ali razumljivosti kode. Ta metoda ni tako preprosta za kličoče strani, kot morda mislite. Za različne namene in v različnih situacijah se rešitve lahko zelo razlikujejo. Na konkretnih primerih bom pokazal prednosti in slabosti več pristopov.

Demonstracijski projekt

Za boljšo ilustracijo si oglejmo primer ene od storitev v aplikaciji, na kateri trenutno delam.

Pojasnilo pri izbiri platforme za primereTežava slabe zmogljivosti je precej pogosta in ne nanaša se na določene jezike ali platforme. V tem članku bomo za prikaz nalog in rešitev uporabili primere kode v Spring + Kotlin. Kotlin je razumljiv (ali nerazumljiv) tako Java kot C# razvijalcem, poleg tega pa je koda bolj kompaktna in razumljiva kot v Java. Da bi olajšal razumevanje za čiste Java razvijalce, se bom izogibal črni magiji Kotlina in uporabljal le belo (v slogu Lombok). Malo bo tudi razširitvenih metod, vendar so te v resnici znane vsem Java programerjem kot static metode, tako da bo to majhna sladkost, ki ne bo pokvarila okusa jedi.
Obstaja storitev za usklajevanje dokumentov. Nekdo ustvari dokument in ga predstavi v razpravo, v kateri se izvajajo popravki, in na koncu se dokument uskladi. Sama storitev usklajevanja nič ne ve o dokumentih: to je le klepet usklajevalcev z manjšimi dodatnimi funkcijami, ki jih tukaj ne bomo obravnavali.

Torej, obstajajo klepetalnice (ki ustrezajo dokumentom) s predpisanim naborom udeležencev v vsaki od njih. Kot v običajnih klepetih, sporočila vsebujejo besedilo in datoteke ter lahko vsebujejo odgovore (reply) in posredovanja (forward):

podatki razreda SporočiloČata(
  // nullable так как появляется только после persist
  val id: Dolgo? = null,
  /** Ссылка на автора */
  val avtor: ReferenciaUporabnika,
  /** Сообщение */
  val sporočilo: Niz,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val datoteke: Seznam<ReferenciaDatoteke>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val odgovoriNa: SporočiloČata? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val posredujOd: SporočiloČata? = null
)

Povezave na datoteko in uporabnika so povezave na druge domena. Pri nas to deluje tako:

tip ReferenciaDatoteke = Dolgo
tip ReferenciaUporabnika = Dolgo

Podaci o korisnicima pohranjuju se u Keycloak i dobijaju se putem REST-a. To se isto odnosi i na fajlove: fajlovi i meta informacije o njima nalaze se u zasebnoj usluzi za skladištenje fajlova.

Svi pozivi ovih usluga su teški zahtevi. To znači da su troškovi transporta ovih zahteva mnogo veći od vremena obrade od strane spoljnih usluga. Na našim testnim platformama tipično vreme poziva takvih usluga je 100 ms, tako da ćemo u daljem tekstu koristiti te cifre.

Potrebno je napraviti jednostavan REST kontroler za dobijanje poslednjih N poruka sa svom potrebnom informacijom. Odnosno, pretpostavljamo da je na frontendu model poruka gotovo identičan i potrebno je preneti sve podatke. Razlika u modelu za frontend je to što je fajl i korisnik potrebno predstaviti u malo dešifrovanom obliku, kako bismo ih napravili linkovima:

/** В таком виде отдаются ссылки на сущности для фронта */
podatki razreda ReferenceUI(
  /** Идентификатор для url */
  val ref: Niz,
  /** Видимое пользователю название ссылки */
  val ime: Niz
)
podatki razreda ChatMessageUI(
  val id: Dolgo,
  /** Ссылка на автора */
  val avtor: ReferenceUI,
  /** Сообщение */
  val sporočilo: Niz,
  /** Ссылки на аттачи */
  val datoteke: Seznam<ReferenceUI>,
  /** Если являтся ответом, то здесь будет оригинал */
  val odgovoriNa: ChatMessageUI? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val posredujOd: ChatMessageUI? = null
)

Potrebno je implementirati sledeće:

vmesnik ChatRestApi {
  zabavno getLast(n: Int): Seznam<ChatMessageUI>
}

Postfix UI označava DTO modele za frontend, to jest ono što treba da vratimo putem REST-a.

Ovde može delovati iznenađujuće to što ne prenosimo nikakav identifikator chata, a čak ni u modelu ChatMessage/ChatMessageUI ga nema. Uradio sam to namerno, kako ne bih opterećivao kod primera (čata su izolovani, tako da možemo smatrati da ih imamo samo jedan).

Filozofsko odstupanjeI u klasi ChatMessageUI i u metodi ChatRestApi.getLast koristi se tip podataka List, dok je zapravo to uređeni Set. U JDK-u je sa tim sve loše, tako da ne možemo deklarisati redosled elemenata na nivou interfejsa (očuvanje reda prilikom dodavanja i vađenja). Tako da je opšta praksa korišćenje List u slučajevima kada je potreban uređeni Set (postoji i LinkedHashSet, ali to nije interfejs).
Važno ograničenje: pretpostavićemo da dugačkih lanaca odgovora ili prosledjivanja nema. Odnosno, postoje, ali njihova dužina ne prelazi tri poruke. Na frontendu, lanac poruka treba se preneti u celini.

Za dobijanje podataka iz spoljašnjih usluga postoje ovakvi API-ji:

vmesnik ChatMessageRepository {
  zabavno findLast(n: Int): Seznam<SporočiloČata>
}
podatki razreda FileHeadRemote(
  val id: ReferenciaDatoteke,
  val ime: Niz
)
vmesnik FileRemoteApi {
  zabavno getHeadById(id: ReferenciaDatoteke): FileHeadRemote
  zabavno getHeadsByIds(id: Nastavi<ReferenciaDatoteke>): Nastavi<FileHeadRemote>
  zabavno getHeadsByIds(id: Seznam<ReferenciaDatoteke>): Seznam<FileHeadRemote>
  zabavno getHeadsByChat(): Seznam<FileHeadRemote>
}
podatki razreda UserRemote(
  val id: ReferenciaUporabnika,
  val ime: Niz
)
vmesnik UserRemoteApi {
  zabavno getUserById(id: ReferenciaUporabnika): UserRemote
  zabavno getUsersByIds(id: Nastavi<ReferenciaUporabnika>): Nastavi<UserRemote>
  zabavno getUsersByIds(id: Seznam<ReferenciaUporabnika>): Seznam<UserRemote>
}

Vidljivo je da su u spoljašnjim uslugama prvobitno predviđena paketna obrada, i to u oba slučaja: putem Set (bez očuvanja reda elemenata, sa jedinstvenim ključevima) i putem List (mogu biti i duplikati — red se čuva).

Jednostavne implementacije

Naivna implementacija

Prva naivna implementacija našeg REST kontrolera najčešće će izgledati ovako:

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 SporočiloČata.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()
    )
}

Sve je veoma jasno, što je veliki plus.

Koristimo batch obradu i dobijamo podatke iz spoljne usluge u paketima. Ali šta se dešava sa performansama?

Za svaku poruku će biti izvršen jedan poziv UserRemoteApi za dobijanje podataka o autoru i jedan poziv FileRemoteApi za dobijanje svih priloženih fajlova. Čini se da je to sve. Pretpostavimo da se polja forwardFrom i replyTo za ChatMessage dobijaju tako da to ne zahteva dodatne pozive. Međutim, pretvaranje njih u ChatMessageUI će izazvati rekurziju, što može značajno povećati broj poziva. Kao što smo prethodno napomenuli, pretpostavimo da nemamo veliku dubinu ugnježdenosti i da je lanac ograničen na tri poruke.

U konačnici, dobićemo od dva do šest poziva spoljnih usluga za jednu poruku i jedan JPA poziv za ceo paket poruka. Ukupan broj poziva variraće od 2*N+1 do 6*N+1. Koliko je to u stvarnim jedinicama? Pretpostavimo da je za prikaz stranice potrebno 20 poruka. Da bismo ih dobili, biće potrebno od 4 do 10 sekundi. Užasno! Voleli bismo da stignemo unutar 500 ms. I pošto su na frontendu želeli da naprave besprekornu rolnu, zahtevi za performansama ovog endpoint-a mogli bi se udvostručiti.

Prednosti:

  1. Kod je kratak i samodokumentovan (san podrške).
  2. Kod je jednostavan, tako da almost nema mogućnosti da se upucamo u nogu.
  3. Batch obrada se ne čini strana i organski se uklapa u logiku.
  4. Izmene logike će biti lake i lokalne.

Mane:

Užasne performanse, vezane za to što su paketi veoma mali.

Ovakav pristup se često može videti u jednostavnim servisima ili prototipima. Ako je brzina izmene važna, verovatno ne bi trebalo komplikovati sistem. U isto vreme, za naš veoma jednostavan servis performanse su užasne, tako da su granice primene ovog pristupa veoma uske.

Naivna paralelna obrada

Može se pokrenuti obrada svih poruka paralelno — to će omogućiti eliminaciju linearno rastućeg vremena u zavisnosti od broja poruka. To nije posebno dobar put, jer će dovesti do velikog vršnog opterećenja na spoljnoj usluzi.

Uvesti paralelno procesiranje je vrlo jednostavno:

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

Koristeći paralelno procesiranje poruka, u najboljem slučaju ćemo dobiti 300–700 ms, što je znatno bolje od naivne implementacije, ali još uvijek nije dovoljno brzo.

Sa ovim pristupom, zahtevi ka userRepository i fileRepository će se izvršavati sinhrono, što nije baš efikasno. Da bismo to popravili, moraćemo značajno da promenimo logiku poziva, na primer, koristeći CompletionStage (ili CompletableFuture):

private fun SporočiloČata.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()!!

Vidljivo je da je prvobitno jednostavan kod mapiranja postao manje razumljiv. To je rezultat potrebe da se odvoje pozivi spoljašnjih servisa od mesta korišćenja rezultata. To samo po sebi nije loše. Međutim, kombinovanje poziva izgleda prilično nepraktično i podseća na tipičnu reaktivnu "nudlu".

Ako se koriste korutine, sve će izgledati bolje:

private fun SporočiloČata.toFrontModel(): ChatMessageUI =
  pridružite se(
    { userRepository.getUserById(author).toFrontReference() },
    { files?.let { fileRepository.getHeadsByIds(datoteke)
      .map { it.toFrontReference() } } ?: listOf() }
  ).let { (avtor, datoteke) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      avtor = avtor,
      message = message,
      datoteke = datoteke,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

Kje:

zabavno <A, B> pridružite se(a: () -> A, b: () -> B) =
  runBlocking(IO) {
    awaitAll(async { a() }, async { b() })
  }.let {
    it[0] as A to it[1] as B
  }

Teoretski, korišćenjem takvog paralelnog procesiranja dobićemo 200–400 ms, što je već blizu naših očekivanja.

Nažalost, takvo dobro paralelizovanje ne postoji, a cena je prilično visoka: kada samo nekoliko korisnika istovremeno koristi servise, doći će do valjka zahteva koji se i dalje neće obraditi paralelno, tako da ćemo se vratiti našem tužnom rezultatu od 4 s.

Moj rezultat korišćenja takvog servisa je 1300–1700 ms za obradu 20 poruka. To je brže nego u prvoj implementaciji, ali i dalje ne rešava problem.

Alternativna primena paralelnih zahtevaŠta ako u spoljašnjim servisima nije predviđena paketna obrada? Na primer, može se sakriti nedostatak implementacije paketne obrade unutar metoda interfejsa:

vmesnik UserRemoteApi {
  zabavno getUserById(id: ReferenciaUporabnika): UserRemote
  zabavno getUsersByIds(id: Nastavi<ReferenciaUporabnika>): Nastavi<UserRemote> =
    id.parallelStream()
      .map { getUserById(it) }.collect(toSet())
  zabavno getUsersByIds(id: Seznam<ReferenciaUporabnika>): Seznam<UserRemote> =
    id.parallelStream()
      .map { getUserById(it) }.collect(toList())
}

To ima smisla ako postoji nada za uvođenje paketne obrade u sledećim verzijama.
Prednosti:

  1. Lako uvođenje paralelnog procesiranja po porukama.
  2. Dobra skalabilnost.

Mane:

  1. Potreba za odvajanjem preuzimanja podataka od njihove obrade prilikom paralelnog procesiranja zahteva ka različitim servisima.
  2. Povećano opterećenje na spoljašnje servise.

Opaziti je da su okviri primenljivosti otprilike isti kao kod naivnog pristupa. Korišćenje metode paralelnih zahteva ima smisla ako želite nekoliko puta povećati performanse vaše usluge iskorišćavanjem resursa drugih. U našem primeru, performanse su se povećale za 2,5 puta, ali to je očigledno nedovoljno.

Keširanje

Može se implementirati keširanje u duhu JPA za spoljne servise, tj. u okviru sesije čuvati dobijene objekte, kako ih ne biste ponovo dobijali (uključujući grupnu obradu). Takve keševe možete napraviti sami, možete koristiti Spring sa svojim @Cacheable, a uvek možete koristiti i gotov keš poput EhCache ručno.

Opšti problem će biti povezan s tim što keševi donose korist samo ako ima promašaja. U našem slučaju, vrlo su verovatni promašaji po polju author (recimo, 50%), dok promašaja po fajlovima neće biti. Ovaj pristup će doneti neka poboljšanja, ali neće drastično promeniti performanse (a nama je potreban proboj).

Međusesijski (dugi) keševi zahtevaju složenu logiku invalidacije. Uopšteno, što duže odlažete rešavanje problema performansi uz pomoć međusesijskih keševa, to je bolje.

Prednosti:

  1. Implementacija keširanja bez promene koda.
  2. Povećanje performansi nekoliko puta (u nekim slučajevima).

Mane:

  1. Mogućnost smanjenja performansi pri pogrešnom korišćenju.
  2. Visoki memory overhead, posebno sa dugim keševima.
  3. Složená invalidacija, greške u kojoj će dovesti do teško reprodukovivih problema u runtime-u.

Veoma često se keševi koriste samo da bi se brzo zakrpile probleme dizajniranja. To ne znači da ih ne treba koristiti. Međutim, uvek ih treba koristiti s oprezom, a prvo proceniti dobijeno povećanje performansi, a zatim doneti odluku.

U našem primeru, keševi će doneti povećanje performansi od oko 25%. Međutim, mana keševa je prilično mnogo, tako da ih ovde ne bih koristio.

Zaključki

Dakle, razmotrili smo naivnu implementaciju servisa koji koristi grupnu obradu i nekoliko jednostavnih načina za njen ubrzanje.

Glavna prednost svih ovih metoda je jednostavnost, iz koje dolazi mnogo prijatnih posledica.

Zajednički problem ovih metoda je loša izvedba, koja je u prvom redu povezana s veličinom paketa. Stoga, ako vam ova rješenja ne odgovaraju, vrijedi razmotriti radikalnije metode.

Postoje dva glavna pravca u kojima možete potražiti rješenja:

  • asinkrono rukovanje podacima (što zahtijeva promjenu paradigme, pa se u ovom članku ne razmatra);
  • povećanje veličine paketa uz zadržavanje sinkrone obrade.

Povećanje veličine paketa značajno će smanjiti broj vanjskih poziva, a pritom zadržati kod sinkronim. Ova tema bit će obrađena u sljedećem dijelu članka.

Izvor: habr.com

Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki 🔥 Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki | ProHoster