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

Problemi skupne obrade upita i njihova rješenja (1. dio)Gotovo svi moderni softverski proizvodi sastoje se od nekoliko usluga. Često dugo vrijeme odziva međuservisnih kanala postaje izvor problema s izvedbom. Standardno rješenje za ovu vrstu problema je pakiranje više interservisnih zahtjeva u jedan paket, što se naziva paketiranje.

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

Demonstracijski projekt

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

Objašnjenje odabira platforme za primjereProblem loše izvedbe prilično je općenit i ne utječe na specifične jezike ili platforme. Ovaj će članak koristiti primjere Spring + Kotlin koda za demonstraciju problema i rješenja. Kotlin je jednako razumljiv (ili nerazumljiv) Java i C# programerima, osim toga, kod je kompaktniji i razumljiviji nego u Javi. Kako bi bilo lakše razumjeti čistim Java programerima, izbjegavat ću crnu magiju Kotlina i koristiti samo bijelu magiju (u duhu Lomboka). Bit će nekoliko metoda proširenja, ali one su zapravo poznate svim Java programerima kao statične metode, tako da će ovo biti mali šećer koji neće pokvariti okus jela.
Postoji usluga odobravanja dokumenata. Netko izradi dokument i preda ga na raspravu, tijekom koje se rade izmjene i na kraju se dokument usuglaš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 chat sobe (koje odgovaraju dokumentima) s unaprijed definiranim skupom sudionika u svakoj od njih. Kao i u običnim chatovima, poruke sadrže tekst i datoteke i mogu biti odgovori ili proslijeđene:

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
)

Datotečne i korisničke veze poveznice su na druge domene. Ovdje živimo ovako:

typealias FileReference Long
typealias UserReference Long

Korisnički podaci pohranjuju se u Keycloak i dohvaćaju putem REST-a. Isto vrijedi i za datoteke: datoteke i metapodaci o njima žive u zasebnoj usluzi za pohranu datoteka.

Svi pozivi ovim službama su teške zahtjeve. To znači da su režijski troškovi prijenosa tih zahtjeva puno 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, pa ćemo te brojeve koristiti u budućnosti.

Moramo napraviti jednostavan REST kontroler za primanje zadnjih N poruka sa svim potrebnim informacijama. Odnosno, vjerujemo da je model poruke u sučelju gotovo isti i da se svi podaci moraju poslati. Razlika između front-end modela je u tome što datoteka i korisnik moraju biti predstavljeni u blago dešifriranom obliku kako bi se povezivali:

/** В таком виде отдаются ссылки на сущности для фронта */
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 označava DTO modele za frontend, odnosno ono što bismo trebali servirati preko REST-a.

Ono što bi moglo biti iznenađujuće ovdje je da ne prosljeđujemo nikakav ID chata, a čak ga ni model ChatMessage/ChatMessageUI nema. Ovo sam napravio namjerno kako ne bih zatrpao kod primjera (razgovori su izolirani, pa možemo pretpostaviti da imamo samo jedan).

Filozofska digresijaI klasa ChatMessageUI i metoda ChatRestApi.getLast koriste tip podataka List iako se zapravo radi o uređenom skupu. To je loše u JDK-u, tako da deklariranje redoslijeda elemenata na razini sučelja (očuvanje redoslijeda prilikom dodavanja i uklanjanja) neće raditi. Stoga je postala uobičajena praksa koristiti popis u slučajevima kada je potreban uređeni skup (postoji i LinkedHashSet, ali to nije sučelje).
Važno ograničenje: Pretpostavit ćemo da nema dugih lanaca odgovora ili prijenosa. Odnosno, postoje, ali njihova duljina ne prelazi tri poruke. Cijeli lanac poruka mora se prenijeti na sučelje.

Za primanje podataka od vanjskih usluga 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>
}

Vidljivo je da vanjski servisi inicijalno omogućuju skupnu obradu, i to u obje verzije: kroz Set (bez očuvanja redoslijeda elemenata, s jedinstvenim ključevima) i kroz List (mogu postojati duplikati - redoslijed se čuva).

Jednostavne implementacije

Naivna implementacija

Prva naivna implementacija našeg REST kontrolera izgledat će 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 to je veliki plus.

Koristimo skupnu obradu i primamo podatke od vanjskog servisa u serijama. Ali što se događa s našom produktivnošću?

Za svaku poruku izvršit će se jedan poziv UserRemoteApi-ju za dobivanje podataka o polju autora i jedan poziv FileRemoteApi-ju za dobivanje svih priloženih datoteka. Čini se da je to to. Recimo da su polja forwardFrom i replyTo za ChatMessage dobivena na takav način da to ne zahtijeva nepotrebne pozive. Ali njihovo pretvaranje u ChatMessageUI dovest će do rekurzije, odnosno brojači poziva mogu se značajno povećati. Kao što smo ranije primijetili, pretpostavimo da nemamo puno gniježđenja i da je lanac ograničen na tri poruke.

Kao rezultat, dobit ćemo od dva do šest poziva prema vanjskim servisima po poruci i jedan JPA poziv za cijeli paket poruka. Ukupan broj poziva varirat će od 2*N+1 do 6*N+1. Koliko je to u stvarnim jedinicama? Recimo da je potrebno 20 poruka za prikaz stranice. Za njihovo primanje bit će potrebno od 4 s do 10 s. Strašno! Htio bih ga zadržati unutar 500 ms. A budući da su sanjali o besprijekornom pomicanju u sučelju, zahtjevi za izvedbom za ovu krajnju točku mogu se udvostručiti.

Pros:

  1. Kod je koncizan i samodokumentiran (san tima za podršku).
  2. Kod je jednostavan, tako da gotovo da nema prilike da si pucate u nogu.
  3. Skupna 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.

Minus:

Užasne performanse zbog vrlo malih paketa.

Ovaj pristup može se vidjeti prilično često u jednostavnim uslugama ili u prototipovima. Ako je važna brzina unošenja promjena, teško da se isplati komplicirati sustav. U isto vrijeme, za našu vrlo jednostavnu uslugu performanse su užasne, pa je opseg primjenjivosti ovog pristupa vrlo uzak.

Naivna paralelna obrada

Možete početi obrađivati ​​sve poruke paralelno - to će vam omogućiti da se riješite linearnog povećanja vremena ovisno o broju poruka. Ovo nije osobito dobar put jer će rezultirati velikim vršnim opterećenjem vanjske usluge.

Implementacija paralelne obrade je vrlo jednostavna:

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

Korištenjem paralelne obrade poruka dobivamo idealno 300–700 ms, što je puno bolje nego s naivnom implementacijom, ali još uvijek nedovoljno brzo.

S ovim pristupom, zahtjevi za userRepository i fileRepository će se izvršavati sinkrono, što nije vrlo učinkovito. Da biste to 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 odvojiti pozive vanjskim servisima od mjesta 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()
    )
  }

Gdje:

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

Teoretski, takvom paralelnom obradom dobit ćemo 200–400 ms, što je već blizu naših očekivanja.

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

Moj rezultat pri korištenju takve usluge 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Što ako usluge trećih strana ne pružaju skupnu obradu? Na primjer, možete sakriti nedostatak implementacije skupne obrade unutar metoda sučelja:

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 skupnu obradu u budućim verzijama.
Pros:

  1. Jednostavna implementacija paralelne obrade temeljene na porukama.
  2. Dobra skalabilnost.

Cons:

  1. Potreba za odvajanjem prikupljanja podataka od njihove obrade pri paralelnoj obradi zahtjeva različitim uslugama.
  2. Povećano opterećenje usluga trećih strana.

Vidi se da je opseg 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.

predmemoriranje

Možete napraviti predmemoriju u duhu JPA za vanjske usluge, odnosno pohraniti primljene objekte unutar sesije kako ih ne biste ponovno primili (uključujući i tijekom skupne obrade). Takve predmemorije možete napraviti sami, možete koristiti Spring sa svojim @Cacheable, plus uvijek možete ručno koristiti gotovu predmemoriju kao što je EhCache.

Čest problem bi bio da su predmemorije korisne samo ako imaju pogodaka. U našem slučaju, pogoci u polju autora su vrlo vjerojatni (recimo, 50%), ali neće biti pogotka na datotekama. Ovaj će pristup omogućiti određena poboljšanja, ali neće radikalno promijeniti izvedbu (i potreban nam je proboj).

Intersession (duge) predmemorije zahtijevaju složenu logiku poništenja. Općenito, što kasnije počnete rješavati probleme performansi korištenjem intersessijskih predmemorija, to bolje.

Pros:

  1. Implementirajte predmemoriranje bez mijenjanja koda.
  2. Povećana produktivnost nekoliko puta (u nekim slučajevima).

Cons:

  1. Mogućnost smanjene učinkovitosti ako se nepravilno koristi.
  2. Velika količina memorije, posebno s dugim predmemorijama.
  3. Složeno poništavanje, pogreške u kojima će dovesti do problema koje je teško reproducirati tijekom izvođenja.

Vrlo često se predmemorije koriste samo za brzo rješavanje problema dizajna. To ne znači da ih ne treba koristiti. Međutim, prema njima uvijek treba postupati s oprezom i prvo procijeniti rezultirajući dobitak performansi, a tek onda donijeti odluku.

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

Rezultati

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

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

Čest problem s ovim metodama je slaba izvedba, prvenstveno zbog veličine paketa. Stoga, ako vam ova rješenja ne odgovaraju, vrijedi razmisliti o radikalnijim metodama.

Dva su glavna smjera u kojima možete tražiti rješenja:

  • asinkroni rad s podacima (zahtijeva promjenu paradigme, pa se ne raspravlja u ovom članku);
  • povećanje serija uz održavanje sinkrone obrade.

Povećanje paketa uvelike će smanjiti broj vanjskih poziva iu isto vrijeme zadržati sinkroničnost koda. Sljedeći dio članka bit će posvećen ovoj temi.

Izvor: www.habr.com

Dodajte komentar