Problemi grupne obrade upita i njihova rješenja (1. dio)

Problemi grupne obrade upita i njihova rješenja (1. dio)Gotovo svi moderni softverski proizvodi se sastoje od nekoliko servisa. Često dugo vremena odziva međuservisnih kanala postaju izvor problema u performansama. Standardno rješenje za ovu vrstu problema je pakiranje više međuservisnih zahtjeva u jedan paket, što se naziva batching.

Ako koristite grupnu obradu, možda nećete biti zadovoljni rezultatima u smislu performansi ili jasnoće koda. Ova metoda nije tako laka za pozivaoca kao što mislite. Za različite namjene iu različitim situacijama rješenja se mogu jako razlikovati. Koristeći konkretne primjere, pokazat ću prednosti i nedostatke nekoliko pristupa.

Demonstracioni projekat

Radi jasnoće, pogledajmo primjer jedne od usluga u aplikaciji na kojoj trenutno radim.

Objašnjenje odabira platforme za primjereProblem loših performansi je prilično općenit i ne utječe na određene jezike ili platforme. Ovaj članak će koristiti Spring + Kotlin primjere koda za demonstraciju problema i rješenja. Kotlin je podjednako razumljiv (ili nerazumljiv) Java i C# programerima, osim toga, kod je kompaktniji i razumljiviji nego u Javi. Da bi bilo lakše razumjeti čistim Java programerima, izbjeći ću crnu magiju Kotlina i koristiti samo bijelu magiju (u duhu Lomboka). Biće nekoliko metoda proširenja, ali one su zapravo poznate svim Java programerima kao statičke metode, tako da će ovo biti mali šećer koji neće pokvariti ukus jela.
Postoji usluga odobravanja dokumenata. Neko kreira dokument i predaje ga na raspravu, tokom koje se vrši uređivanje i na kraju se dokument usaglašava. Sama služba za odobravanje ne zna ništa o dokumentima: to je samo razgovor odobravatelja s malim dodatnim funkcijama koje ovdje nećemo razmatrati.

Dakle, postoje sobe za ćaskanje (koje odgovaraju dokumentima) sa unapred definisanim skupom učesnika u svakoj od njih. Kao iu redovnim razgovorima, poruke sadrže tekst i datoteke i mogu biti odgovori ili prosljeđivanje:

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
)

Veze datoteka i korisnika su veze ka drugim domenima. Evo mi zivimo ovako:

typealias FileReference Long
typealias UserReference Long

Korisnički podaci se pohranjuju u Keycloak i preuzimaju putem REST-a. Isto važi i za fajlove: fajlovi i metainformacije o njima žive u zasebnoj usluzi skladištenja datoteka.

Svi pozivi ovim servisima su teški zahtjevi. To znači da su troškovi transporta ovih zahtjeva mnogo veći od vremena koje je potrebno da ih obradi usluga treće strane. Na našim testnim stolovima, tipično vrijeme poziva za takve usluge je 100 ms, tako da ćemo ubuduće koristiti ove brojeve.

Moramo napraviti jednostavan REST kontroler za primanje zadnjih N poruka sa svim potrebnim informacijama. Odnosno, vjerujemo da je model poruke u frontendu gotovo isti i da je potrebno poslati sve podatke. Razlika između front-end modela je u tome što fajl i korisnik moraju biti predstavljeni u blago dešifrovanom obliku kako bi bili povezani:

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

Moramo implementirati sljedeće:

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

UI postfix znači DTO modele za frontend, odnosno ono što treba da opslužujemo preko REST-a.

Ono što ovdje može biti iznenađujuće je da mi ne prosljeđujemo nikakav ID za ćaskanje, a čak ga i ChatMessage/ChatMessageUI model nema. To sam uradio namjerno kako ne bih zatrpao kod primjera (četovi su izolirani, tako da možemo pretpostaviti da imamo samo jedan).

Filozofska digresijaI klasa ChatMessageUI i metoda ChatRestApi.getLast koriste tip podataka List kada je u stvari uređen skup. Ovo je loše u JDK-u, tako da deklarisanje redosleda elemenata na nivou interfejsa (očuvanje redosleda prilikom dodavanja i uklanjanja) neće raditi. Tako je postala uobičajena praksa da se koristi lista u slučajevima kada je potreban uređeni set (postoji i LinkedHashSet, ali ovo nije interfejs).
Važno ograničenje: Pretpostavljamo da nema dugih lanaca odgovora ili transfera. Odnosno, postoje, ali njihova dužina ne prelazi tri poruke. Cijeli lanac poruka mora se prenijeti na frontend.

Za primanje podataka od vanjskih servisa postoje sljedeći API-ji:

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

Vidi se da eksterni servisi inicijalno obezbeđuju batch obradu, i to u obe verzije: preko Seta (bez očuvanja redosleda elemenata, sa jedinstvenim ključevima) i preko Liste (možda ima duplikata - redosled se čuva).

Jednostavne implementacije

Naivna implementacija

Prva naivna implementacija našeg REST kontrolera će izgledati otprilike ovako u većini slučajeva:

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

Sve je vrlo jasno, a ovo je veliki plus.

Koristimo grupnu obradu i primamo podatke od eksterne usluge u serijama. Ali šta se dešava sa našom produktivnošću?

Za svaku poruku, obavit će se jedan poziv UserRemoteApi za dobivanje podataka o autorskom polju i jedan poziv FileRemoteApi za preuzimanje svih priloženih datoteka. Izgleda da je to to. Recimo da su polja forwardFrom i replyTo za ChatMessage dobijena na način da to ne zahtijeva nepotrebne pozive. Ali njihovo pretvaranje u ChatMessageUI će dovesti do rekurzije, to jest, brojači poziva mogu se značajno povećati. Kao što smo ranije napomenuli, pretpostavimo da nemamo puno gniježđenja i da je lanac ograničen na tri poruke.

Kao rezultat, dobićemo od dva do šest poziva prema eksternim servisima po poruci i jedan JPA poziv za cijeli paket poruka. Ukupan broj poziva će varirati od 2*N+1 do 6*N+1. Koliko je to u stvarnim jedinicama? Recimo da je potrebno 20 poruka da se prikaže stranica. Da biste ih primili, potrebno je od 4 s do 10 s. Užasno! Želio bih ga zadržati unutar 500 ms. A budući da su sanjali da naprave besprijekorno pomicanje u frontendu, zahtjevi performansi za ovu krajnju tačku mogu se udvostručiti.

Pros:

  1. Kod je sažet i samodokumentirajući (san tima za podršku).
  2. Šifra je jednostavna, tako da gotovo da nema mogućnosti da sebi pucate u nogu.
  3. Batch obrada ne izgleda kao nešto strano i organski je integrirana u logiku.
  4. Logičke promjene će se napraviti lako i bit će lokalne.

Oduzeti:

Užasne performanse zbog vrlo malih paketa.

Ovaj pristup se prilično često može vidjeti u jednostavnim uslugama ili u prototipovima. Ako je brzina unošenja izmjena važna, teško da se isplati komplikovati sistem. Istovremeno, za našu vrlo jednostavnu uslugu performanse su užasne, tako da je obim primjenjivosti ovog pristupa vrlo uzak.

Naivna paralelna obrada

Možete započeti obradu svih poruka paralelno - to će vam omogućiti da se riješite linearnog povećanja vremena u zavisnosti od broja poruka. Ovo nije posebno dobar put jer će rezultirati velikim vršnim opterećenjem eksterne usluge.

Implementacija paralelne obrade je vrlo jednostavna:

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

Koristeći paralelnu obradu poruka, u idealnom slučaju dobijamo 300–700 ms, što je mnogo bolje nego s naivnom implementacijom, ali još uvijek nije dovoljno brzo.

Sa ovim pristupom, zahtjevi za userRepository i fileRepository će se izvršavati sinhrono, što nije baš efikasno. Da biste ovo popravili, morat ćete dosta promijeniti logiku poziva. Na primjer, putem CompletionStage (aka 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()!!

Može se vidjeti da je prvobitno jednostavan kod za mapiranje postao manje razumljiv. To je zato što smo morali razdvojiti pozive vanjskim servisima od onih gdje se koriste rezultati. Ovo samo po sebi nije loše. Ali kombiniranje poziva ne izgleda baš elegantno i nalikuje tipičnom reaktivnom „rezancu“.

Ako koristite korutine, sve će izgledati pristojnije:

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

Gde:

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

Teoretski, koristeći takvu paralelnu obradu, dobićemo 200–400 ms, što je već blizu našim očekivanjima.

Nažalost, tako dobra paralelizacija ne postoji, a cijena koju treba platiti je prilično surova: sa samo nekoliko korisnika koji rade u isto vrijeme, na servise će pasti hrpa zahtjeva, koji se ionako neće paralelno obrađivati, pa mi vratit će se u naša tužna 4 s.

Moj rezultat pri korištenju takvog servisa je 1300–1700 ms za obradu 20 poruka. Ovo je brže nego u prvoj implementaciji, ali još uvijek ne rješava problem.

Alternativne upotrebe paralelnih upitaŠta ako usluge treće strane ne pružaju grupnu obradu? Na primjer, možete sakriti nedostatak implementacije paketne obrade unutar metoda interfejsa:

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

Ovo ima smisla ako se nadate da ćete vidjeti grupnu obradu u budućim verzijama.
Pros:

  1. Lako implementirajte paralelnu obradu zasnovanu na porukama.
  2. Dobra skalabilnost.

Cons:

  1. Potreba da se odvoji prikupljanje podataka od njihove obrade prilikom paralelne obrade zahtjeva različitim servisima.
  2. Povećano opterećenje usluga trećih strana.

Može se vidjeti da je obim primjenjivosti približno isti kao i kod naivnog pristupa. Ima smisla koristiti metodu paralelnog zahtjeva ako želite nekoliko puta povećati performanse svoje usluge zbog nemilosrdnog iskorištavanja drugih. U našem primjeru performanse su porasle 2,5 puta, ali to očito nije dovoljno.

keširanje

Možete raditi keširanje u duhu JPA za eksterne usluge, to jest, pohranjivati ​​primljene objekte unutar sesije kako ih ne biste ponovo primili (uključujući i tokom grupne obrade). Možete sami napraviti takve keš memorije, možete koristiti Spring sa svojim @Cacheable, plus uvijek možete koristiti gotovu keš memoriju kao što je EhCache ručno.

Uobičajeni problem bi bio da su kešovi korisni samo ako imaju pogotke. U našem slučaju, pogoci u polju autora su vrlo vjerovatni (recimo, 50%), ali neće biti nikakvih pogodaka u fajlovima. Ovaj pristup će pružiti neka poboljšanja, ali neće radikalno promijeniti performanse (a potreban nam je iskorak).

Intersession (duge) keš memorije zahtijevaju složenu logiku poništavanja. Općenito, što kasnije prijeđete na rješavanje problema performansi korištenjem kešova međusesije, to bolje.

Pros:

  1. Implementirajte keširanje bez promjene koda.
  2. Povećana produktivnost nekoliko puta (u nekim slučajevima).

Cons:

  1. Mogućnost smanjenja performansi ako se koristi nepravilno.
  2. Velika količina memorije, posebno kod dugih keš memorija.
  3. Složeno poništavanje, greške u kojima će dovesti do teško reproduciranih problema u vremenu izvođenja.

Vrlo često se kešovi koriste samo za brzo otklanjanje problema u dizajnu. To ne znači da ih ne treba koristiti. Međutim, uvijek se prema njima treba odnositi s oprezom i prvo procijeniti rezultatski dobitak u performansama, pa tek onda donijeti odluku.

U našem primjeru, kešovi će omogućiti povećanje performansi od oko 25%. U isto vrijeme, kešovi imaju dosta nedostataka, pa ih ovdje ne bih koristio.

Ishodi

Dakle, pogledali smo naivnu implementaciju usluge koja koristi grupnu obradu i neke jednostavne načine da je ubrzamo.

Glavna prednost svih ovih metoda je jednostavnost, iz koje proizlaze mnoge ugodne posljedice.

Uobičajeni problem kod ovih metoda je loša izvedba, prvenstveno zbog veličine paketa. Stoga, ako vam ova rješenja ne odgovaraju, onda je vrijedno razmotriti radikalnije metode.

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

  • asinhroni rad sa podacima (zahteva promenu paradigme, pa se o tome ne govori u ovom članku);
  • povećanje serija uz održavanje sinhrone obrade.

Povećanje paketa će uvelike smanjiti broj eksternih poziva i istovremeno zadržati sinhroni kod. Sljedeći dio članka bit će posvećen ovoj temi.

izvor: www.habr.com

Dodajte komentar