Problemi paketne obdelave poizvedb in njihove rešitve (1. del)

Problemi paketne obdelave poizvedb in njihove rešitve (1. del)Skoraj vsi sodobni programski izdelki so sestavljeni iz več storitev. Dolgi odzivni časi medstoritvenih kanalov pogosto postanejo vir težav z zmogljivostjo. Standardna rešitev za tovrstne težave je pakiranje več zahtev med storitvami v en paket, kar se imenuje paketiranje.

Če uporabljate paketno obdelavo, morda ne boste zadovoljni z rezultati v smislu zmogljivosti ali jasnosti kode. Ta metoda za klicatelja ni tako enostavna, kot si mislite. Za različne namene in v različnih situacijah se lahko rešitve zelo razlikujejo. Na konkretnih primerih bom prikazal prednosti in slabosti več pristopov.

Demonstracijski projekt

Zaradi jasnosti si poglejmo primer ene od storitev v aplikaciji, na kateri trenutno delam.

Razlaga izbire platforme za primereProblem slabega delovanja je precej splošen in ne zadeva nobenih posebnih jezikov ali platform. Ta članek bo uporabil primere kode Spring + Kotlin za prikaz težav in rešitev. Kotlin je enako razumljiv (ali nerazumljiv) razvijalcem Jave in C#, poleg tega je koda bolj kompaktna in razumljiva kot v Javi. Da bi stvari lažje razumeli čisti razvijalci Jave, se bom izogibal črni magiji Kotlina in uporabil samo belo magijo (v duhu Lomboka). Nekaj ​​bo razširitvenih metod, ki pa jih pravzaprav poznajo vsi programerji Java kot statične metode, tako da bo to mali sladkor, ki ne bo pokvaril okusa jedi.
Obstaja storitev odobritve dokumentov. Nekdo ustvari dokument in ga predloži v razpravo, med katero potekajo popravki in na koncu je dokument dogovorjen. Sama odobritvena služba ne ve ničesar o dokumentih: je le klepet odobriteljev z majhnimi dodatnimi funkcijami, ki jih tukaj ne bomo obravnavali.

Torej obstajajo klepetalnice (ki ustrezajo dokumentom) z vnaprej določenim naborom udeležencev v vsaki od njih. Kot v običajnih klepetih sporočila vsebujejo besedilo in datoteke ter so lahko odgovori ali posredovana:

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
)

Povezave do datotek in uporabnikov so povezave do drugih domen. Tukaj živimo takole:

typealias FileReference Long
typealias UserReference Long

Uporabniški podatki so shranjeni v Keycloak in prejeti prek REST. Enako velja za datoteke: datoteke in metainformacije o njih živijo v ločeni storitvi za shranjevanje datotek.

Vsi klici na te storitve so težke zahteve. To pomeni, da so stroški prenosa teh zahtev veliko večji od časa, ki je potreben, da jih obdela storitev tretje osebe. Na naših preskusnih mizah je tipičen čas klica za takšne storitve 100 ms, zato bomo te številke uporabljali v prihodnje.

Narediti moramo preprost krmilnik REST, da prejme zadnjih N sporočil z vsemi potrebnimi informacijami. To pomeni, da verjamemo, da je v sprednjem delu model sporočila skoraj enak in da je treba vse podatke poslati. Razlika med sprednjim modelom je v tem, da je treba datoteko in uporabnika predstaviti v nekoliko dešifrirani obliki, da lahko ustvarite povezave:

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

Izvesti moramo naslednje:

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

Postfix UI pomeni DTO modele za frontend, torej tisto, kar moramo streči preko REST-a.

Tukaj je morda presenetljivo, da ne posredujemo nobenega identifikatorja klepeta in niti v modelu ChatMessage/ChatMessageUI ga ni. To sem naredil namenoma, da ne bi obremenil kode primerov (klepeti so izolirani, zato lahko domnevamo, da imamo samo enega).

Filozofska digresijaTako razred ChatMessageUI kot metoda ChatRestApi.getLast uporabljata podatkovni tip List, čeprav gre v resnici za urejen nabor. V JDK je vse to slabo, zato deklariranje vrstnega reda elementov na ravni vmesnika (ohranjanje vrstnega reda pri dodajanju in pridobivanju) ne bo delovalo. Zato je postala običajna praksa uporaba seznama v primerih, ko je potreben urejen nabor (obstaja tudi LinkedHashSet, vendar to ni vmesnik).
Pomembna omejitev: Predvidevamo, da ni dolgih verig odgovorov ali prenosov. To pomeni, da obstajajo, vendar njihova dolžina ne presega treh sporočil. Celotno verigo sporočil je treba prenesti na sprednji del.

Za prejemanje podatkov iz zunanjih storitev obstajajo naslednji 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>
}

Vidimo, da zunanje storitve na začetku poskrbijo za paketno obdelavo in to v obeh variantah: preko Set (brez ohranjanja vrstnega reda elementov, z unikatnimi ključi) in preko List (lahko pride do dvojnikov - vrstni red se ohrani).

Enostavne izvedbe

Naivna izvedba

Prva naivna izvedba našega krmilnika REST bo v večini primerov videti nekako takole:

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

Vse je zelo jasno in to je velik plus.

Uporabljamo paketno obdelavo in prejemamo podatke od zunanje storitve v paketih. Toda kaj se dogaja z našo produktivnostjo?

Za vsako sporočilo bo izveden en klic UserRemoteApi za pridobitev podatkov o polju avtorja in en klic FileRemoteApi za pridobitev vseh priloženih datotek. Zdi se, da je to to. Recimo, da sta polji forwardFrom in replyTo za ChatMessage pridobljeni tako, da to ne zahteva nepotrebnih klicev. Toda če jih spremenite v ChatMessageUI, bo prišlo do rekurzije, to je, da se lahko števci klicev znatno povečajo. Kot smo že omenili, predpostavimo, da nimamo veliko gnezdenja in je veriga omejena na tri sporočila.

Posledično bomo dobili od dva do šest klicev na zunanje storitve na sporočilo in en klic JPA za celoten paket sporočil. Skupno število klicev se bo gibalo od 2*N+1 do 6*N+1. Koliko je to v realnih enotah? Recimo, da je za upodabljanje strani potrebnih 20 sporočil. Da jih dobite, boste potrebovali od 4 s do 10 s. Grozno! Rad bi ga obdržal znotraj 500 ms. In ker so sanjali o brezhibnem pomikanju na sprednjem delu, se lahko zahteve glede zmogljivosti za to končno točko podvojijo.

Profesionalci:

  1. Koda je jedrnata in samodokumentirana (sanje ekipe za podporo).
  2. Koda je preprosta, zato skoraj ni možnosti, da bi se ustrelili v nogo.
  3. Paketna obdelava ni videti kot nekaj tujega in je organsko integrirana v logiko.
  4. Logične spremembe bodo enostavne in lokalne.

Minus:

Grozna zmogljivost zaradi zelo majhnih paketov.

Ta pristop je pogosto viden v preprostih storitvah ali v prototipih. Če je pomembna hitrost izvajanja sprememb, se kompliciranje sistema komajda splača. Hkrati pa je za našo zelo preprosto storitev zmogljivost grozljiva, zato je obseg uporabnosti tega pristopa zelo ozek.

Naivna vzporedna obdelava

Vsa sporočila lahko začnete obdelovati vzporedno - to vam bo omogočilo, da se znebite linearnega povečanja časa glede na število sporočil. To ni posebej dobra pot, ker bo povzročila veliko konično obremenitev zunanje storitve.

Izvajanje vzporedne obdelave je zelo preprosto:

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

Z uporabo vzporedne obdelave sporočil dobimo idealno 300–700 ms, kar je veliko bolje kot pri naivni izvedbi, a še vedno premalo hitro.

S tem pristopom se bodo zahteve za userRepository in fileRepository izvajale sinhrono, kar ni zelo učinkovito. Če želite to popraviti, boste morali precej spremeniti logiko klicev. Na primer prek 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()!!

Vidimo, da je prvotno preprosta koda za preslikavo postala manj razumljiva. To je zato, ker smo morali ločiti klice v zunanje storitve od uporabe rezultatov. To samo po sebi ni slabo. Toda združevanje klicev ne izgleda posebej elegantno in spominja na tipične reaktivne "rezance".

Če uporabljate korutine, bo vse videti bolj spodobno:

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

Kje:

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

Teoretično bomo s tako vzporedno obdelavo dobili 200–400 ms, kar je že blizu naših pričakovanj.

Na žalost se tako dobra paralelizacija ne zgodi in cena, ki jo je treba plačati, je precej kruta: z le nekaj uporabniki, ki delajo hkrati, bodo storitve zadane s plazom zahtev, ki tako ali tako ne bodo obdelane vzporedno, zato se bo vrnil v naše žalostne 4-ke.

Moj rezultat pri uporabi takšne storitve je 1300–1700 ms za obdelavo 20 sporočil. To je hitreje kot pri prvi izvedbi, vendar še vedno ne reši težave.

Alternativne uporabe vzporednih poizvedbKaj pa, če storitve tretjih oseb ne zagotavljajo paketne obdelave? Na primer, lahko skrijete pomanjkanje izvajanja paketne obdelave znotraj metod vmesnika:

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

To je smiselno, če upate, da boste v prihodnjih različicah videli paketno obdelavo.
Profesionalci:

  1. Enostavna implementacija vzporedne obdelave na podlagi sporočil.
  2. Dobra razširljivost.

Cons:

  1. Potreba po ločevanju pridobivanja podatkov od njihove obdelave pri vzporedni obdelavi zahtev do različnih storitev.
  2. Povečana obremenitev storitev tretjih oseb.

Vidimo, da je obseg uporabnosti približno enak kot pri naivnem pristopu. Metodo vzporedne zahteve je smiselno uporabiti, če želite zaradi neusmiljenega izkoriščanja drugih večkrat povečati zmogljivost svoje storitve. V našem primeru se je produktivnost povečala za 2,5-krat, vendar to očitno ni dovolj.

Predpomnjenje

Predpomnjenje lahko izvedete v duhu JPA za zunanje storitve, to je, da shranite prejete objekte znotraj seje, da jih ne boste ponovno prejeli (tudi med paketno obdelavo). Takšne predpomnilnike lahko naredite sami, lahko uporabite Spring z njegovim @Cacheable, poleg tega pa lahko vedno ročno uporabite že pripravljen predpomnilnik, kot je EhCache.

Pogosta težava bi bila, da so predpomnilniki uporabni le, če imajo zadetke. V našem primeru so zadetki v polju avtorja zelo verjetni (recimo 50%), v datotekah pa zadetkov sploh ne bo. Ta pristop bo zagotovil nekaj izboljšav, vendar ne bo radikalno spremenil produktivnosti (in potrebujemo preboj).

Medsejni (dolgi) predpomnilniki zahtevajo zapleteno logiko razveljavitve. Na splošno velja, da pozneje ko se lotite reševanja težav z zmogljivostjo z uporabo predpomnilnikov med sejami, tem bolje.

Profesionalci:

  1. Izvedite predpomnjenje brez spreminjanja kode.
  2. Povečana produktivnost večkrat (v nekaterih primerih).

Cons:

  1. Možnost zmanjšanja učinkovitosti ob nepravilni uporabi.
  2. Velike količine pomnilnika, zlasti pri dolgih predpomnilnikih.
  3. Kompleksna razveljavitev, katere napake bodo vodile do težko ponovljivih težav v času izvajanja.

Zelo pogosto se predpomnilniki uporabljajo samo za hitro odpravljanje težav pri oblikovanju. To ne pomeni, da jih ne bi smeli uporabljati. Vendar pa morate z njimi vedno ravnati previdno in najprej oceniti posledično povečanje zmogljivosti in se šele nato odločiti.

V našem primeru bodo predpomnilniki zagotovili približno 25-odstotno povečanje zmogljivosti. Hkrati pa imajo predpomnilniki kar nekaj slabosti, zato jih tukaj ne bi uporabljal.

Rezultati

Ogledali smo si torej naivno izvedbo storitve, ki uporablja paketno obdelavo, in več preprostih načinov za njeno pospešitev.

Glavna prednost vseh teh metod je preprostost, iz katere je veliko prijetnih posledic.

Pogosta težava s temi metodami je slabo delovanje, predvsem povezano z velikostjo paketov. Če vam torej te rešitve ne ustrezajo, je vredno razmisliti o bolj radikalnih metodah.

Obstajata dve glavni smeri, v katerih lahko iščete rešitve:

  • asinhrono delo s podatki (zahteva spremembo paradigme, zato v tem članku ni obravnavana);
  • povečanje serij ob ohranjanju sinhrone obdelave.

Povečanje paketov bo močno zmanjšalo število zunanjih klicev in hkrati ohranilo sinhronost kode. Naslednji del članka bo posvečen tej temi.

Vir: www.habr.com

Dodaj komentar