Probleemid päringute paketipõhise töötlemise ja nende lahendamisega (osa 1)

Probleemid päringute paketipõhise töötlemise ja nende lahendamisega (osa 1)Peaaegu kõik kaasaegsed tarkvaratooted koosnevad mitmest teenusest. Tihti muutuvad stabiilised vastuseajad teenustevahelistes kanalites jõudlusprobleemide allikaks. Tüüpiline lahendus selliste probleemide jaoks on mitme teenustevahelise päringu pakendamine ühte pakkesse, mida nimetatakse partii töötlemiseks (batching).

Kui kasutate partii töötlemist, võib selle tulemus jõudluse või koodi arusaadavuse osas teid mitte rahuldada. See meetod ei ole kutsuva poole jaoks nii lihtne, nagu esialgu arvata võiks. Erinevates olukordades ja eesmärkides võivad lahendused oluliselt erineda. Konkreetselt näidates demonstreerin ma mitme lähenemise plusse ja miinuseid.

Demonstreerimisprojekt

Selgituseks vaatame ühe teenuse näidet rakenduses, millega ma praegu töötan.

Selgitus platvormi valikule näidete jaoksKehva jõudluse probleem on piisavalt levinud ja ei piirduda mõne konkreetse keele või platvormiga. Käesolevas artiklis kasutatakse ülesannete ja lahenduste demonstreerimiseks koodi näiteid Spring + Kotlinist. Kotlin on sama arusaadav (või arusaamatu) Java- ja C#-arendajatele ning kood on kompaktsem ja arusaadavam kui Java. Et hõlbustada mõistmist puhtatele Java-arendajatele, väldin Kotlin'i musta maagia kasutamist ja kasutan ainult valget (Lomboki vaimus). Samuti on natuke laienduse meetodeid, kuid need on tegelikult kõigile Java-programmeerijatele tuttavad kui staatilised meetodid, seega on see väike magusus, mis ei riku roa maitset.
On olemas dokumentide kooskõlastamise teenus. Keegi loob dokumendi ja esitab selle arutamiseks, mille käigus tehakse muudatusi, ja lõpuks koostatakse dokument. Kooskõlastamisteenus ei tea dokumentidest midagi: see on lihtsalt kooskõlastajate vestlus koos väikeste lisafunktsioonidega, mida me siin arutama ei hakka.

Seega on olemas vestlusruumid (mis vastavad dokumentidele) kindla kaaslastesetiga igas neist. Nagu tavavestlustes, sisaldavad sõnumid tekste ja faile ning võivad olla vastused (reply) ja edastused (forward):

andmed klass VestlusSõnum(
  // nullable так как появляется только после persist
  val id: Pikk? = null,
  /** Ссылка на автора */
  val autor: KasutajaViide,
  /** Сообщение */
  val sõnum: String,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val files: Loend<Failiviide>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val vastata: VestlusSõnum? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val edastaja: VestlusSõnum? = null
)

Faili ja kasutaja lingid on lingid teistele. domeenideleMeil on see järgmine:

tüüpalias Failiviide Pikk
tüüpalias KasutajaViide Pikk

Kasutajate andmed salvestatakse Keycloakis ja saadakse RESTi kaudu. Sama kehtib failide kohta: failid ja nende metainformatsioon elavad eraldi failide salvestamisteenuses.

Kõik nende teenuste kõned on raskeid päringud.See tähendab, et nende päringute transpordikulud on palju suuremad kui nende töötlemise aeg kõrvalteenusest. Meie testi seintel on tüüpiline aeg selliste teenuste kõnede jaoks 100 ms, seega kasutame edaspidi neid numbreid.

Peame looma lihtsa REST-kontrolleri, et saada viimased N sõnumit kogu vajaliku teabega. Eeldame, et frontendis on sõnumite mudel peaaegu sama ja kõik andmed tuleb edastada. Erinevus frontendi mudelis seisneb selles, et fail ja kasutaja tuleb esitada natuke dekodeeritud kujul, et teha neist lingid:

/** В таком виде отдаются ссылки на сущности для фронта */
andmed klass Viidatud UI(
  /** Идентификатор для url */
  val viidatud: String,
  /** Видимое пользователю название ссылки */
  val nimi: String
)
andmed klass Vestlusmessenger UI(
  val id: Pikk,
  /** Ссылка на автора */
  val autor: Viidatud UI,
  /** Сообщение */
  val sõnum: String,
  /** Ссылки на аттачи */
  val files: Loend<Viidatud UI>,
  /** Если являтся ответом, то здесь будет оригинал */
  val vastata: Vestlusmessenger UI? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val edastaja: Vestlusmessenger UI? = null
)

Peame rakendama järgmist:

liides ChatRestApi {
  lõbu. getLast(nInt): Loend<Vestlusmessenger UI>
}

Postfix UI tähendab DTO-mudeleid frontendi jaoks, see tähendab, et seda, mida peame RESTi kaudu edastama.

Siin võib tunduda üllatavana, et me ei edasta mingit vestluse identifikaatorit, isegi mudelis ChatMessage/ChatMessageUI ei ole seda. Tehtud on see teadlikult, et mitte koormata näidete koodi (vestlused on isoleeritud, seega võib arvata, et meil on seda üldse ainult üks).

Filosoofiline kõrvalhüpeN nii ChatMessageUI klassis kui ka ChatRestApi.getLast meetodis kasutatakse andmetüüpi List, samas kui tegelikult on see järjestatud Set. JDK-s on sellega kõik kehvasti, seega ei ole võimalik deklareerida elementide järjekorda liidese tasemel (järjekorra säilitamine lisamisel ja väljavõtmisel). Seetõttu on tavaks kasutada List-i nendes juhtudel, kui on vajalik järjestatud Set (olemas on ka LinkedHashSet, kuid see ei ole liides).
Oluline piirang: Eeldame, et pikad vastuste või edastuste ahelad ei esine. Nimelt need võivad esineda, kuid nende pikkus ei ületa kolme sõnumit. Frontendis tuleb sõnumite ahel edastada tervikuna.

Väliste teenuste andmete saamiseks on järgmised API-d:

liides ChatMessageRepository {
  lõbu. findLast(nInt): Loend<VestlusSõnum>
}
andmed klass FileHeadRemote(
  val id: Failiviide,
  val nimi: String
)
liides FileRemoteApi {
  lõbu. getHeadById(idFailiviide): FileHeadRemote
  lõbu. getHeadsByIds(idSet<Failiviide>: Set<FileHeadRemote>
  lõbu. getHeadsByIds(idLoend<Failiviide>: Loend<FileHeadRemote>
  lõbu. getHeadsByChat(): Loend<FileHeadRemote>
}
andmed klass UserRemote(
  val id: KasutajaViide,
  val nimi: String
)
liides UserRemoteApi {
  lõbu. getUserById(idKasutajaViide): UserRemote
  lõbu. getUsersByIds(idSet<KasutajaViide>: Set<UserRemote>
  lõbu. getUsersByIds(idLoend<KasutajaViide>: Loend<UserRemote>
}

On näha, et väliste teenuste jaoks on algselt ettenähtud partii töötlemine, ja seejuures mõlemas variandis: Set'i kaudu (ilma elementide järjekorra säilitamiseta, unikaalsete võtmetega) ja List'i kaudu (võivad olla ka dubleerimised - järjekord säilib).

Lihtsad rakendused

Lihtne rakendus

Esimene naivistlik rakendus meie REST-kontrollerist näeb enamikul juhtudel välja umbes nii:

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 VestlusSõnum.toFrontModel(): Vestlusmessenger UI =
    ChatMessageUI(
      id = id ?: viska 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()
    )
}

Kõik on täiesti selge ja see on suur pluss.

Me kasutame partii töötlemist ja saame andmeid välisteenuseid kaudu partiitena. Aga mis on meie jõudluse seis?

Iga sõnumi puhul tehakse üks UserRemoteApi väljakutse andmete saamiseks autori välja kohta ja üks FileRemoteApi väljakutse kõigi manustatud failide jaoks. Tundub, et see on kõik. Oletame, et forwardFrom ja replyTo väljad ChatMessage'i puhul saadakse nii, et see ei nõua liigseid väljakutseid. Küll aga toob nende muutmine ChatMessageUI-ks kaasa rekursiooni, st väljakutsete arvu näitajad võivad tõsiselt suureneda. Nagu varem mainisime, oletame, et meil ei ole suurt sügavat hierarhiat ja ahel on piiratud kolme sõnumiga.

Lõpptulemusena saame kahe kuni kuue välisteenuse väljakutse ühe sõnumi kohta ja ühe JPA-väljakutse kogu sõnumipartii jaoks. Kokkuvõttes varieerub väljakutsete arv vahemikus 2*N+1 kuni 6*N+1. Kui palju see reaalses maailmas on? Oletame, et lehe renderdamiseks on vajalik 20 sõnumit. Nende saamiseks kulub 4 kuni 10 sekundit. Kohutav! Tahaksime mahutada 500 ms sisse. Ja kuna esiküljel soovitakse luua sujuvat kerimist, saab selle lõpp-punkti jõudlusnõuded kahekordistada.

Plussid:

  1. Kood on lühike ja isedokumendistuv (toetuse unistus).
  2. Kood on lihtne, seega on võimalused endale jalga tulistada peaaegu olematud.
  3. Partii töötlemine ei tundu olevat midagi võõrast ja on orgaaniliselt sisse kirjutatud loogikasse.
  4. Loogika muudatused toimuvad lihtsalt ja need on kohalikud.

Miinus:

Kohutav jõudlus, mis on seotud väikeste partiitide saamisega.

Sellist lähenemist võib sageli näha lihtsates teenustes või prototüüpides. Kui muudatuste tegemise kiirus on oluline, siis ei ole mõtet süsteemi keerulisemaks muuta. Samas on meie väga lihtsa teenuse jõudlus kohutav, seega on sellise lähenemise rakendamise piirid väga kitsad.

Loomulik paralleelne töötlemine

Saame käivitada kõigi sõnumite töötlemise paralleelselt - see vabastab meid ajalisest kasvust sõltuvalt sõnumite arvust. See ei ole eriti hea tee, kuna see toob kaasa suure tipukoormuse välisteenusele.

Paralleelse töötlemise rakendamine on väga lihtne:

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

Kasutades sõnumite paralleelset töötlemist, saame ideaaljuhul 300–700 ms, mis on palju parem kui naiivses teostuses, aga siiski ei ole see piisavalt kiire.

Selle lähenemise puhul teostatakse päringud userRepository ja fileRepository vääratult, mis ei ole eriti efektiivne. Selle parandamiseks tuleb logikat märkimisväärselt muuta. Näiteks CompletionStage (aka CompletableFuture) kaudu:

private fun VestlusSõnum.toFrontModel(): Vestlusmessenger UI =
  CompletableFuture.supplyAsync {
    userRepository.getUserById(author).toFrontReference()
  }.thenCombine(
    files?.let {
      CompletableFuture.supplyAsync {
        fileRepository.getHeadsByIds(files).map it.toFrontReference() }
      }
    } ?: CompletableFuture.completedFuture(listOf())
  ) authorfiles ->
    ChatMessageUI(
      id = id ?: viska IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

On näha, et algselt lihtne mappimise kood on muutunud vähem arusaadavaks. See tuleneb sellest, et pidime eraldama väliste teenuste kutseid tulemustest. See iseenesest ei ole halb. Kuid väljakutsete kombineerimine ei tundu eriti elegantne ja meenutab tüüpilist reaktiivset 'nuudlit'.

Kui kasutame koruutine, näeb kõik viisakamat välja:

private fun VestlusSõnum.toFrontModel(): Vestlusmessenger UI =
  liitu(
    userRepository.getUserById(author).toFrontReference() },
    files?.let fileRepository.getHeadsByIds(files)
      .map it.toFrontReference() } } ?: listOf() }
  ).let (author, files) ->
    ChatMessageUI(
      id = id ?: viska IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

Kus:

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

Teoreetiliselt, kasutades sellist paralleelset töötlemist, saame 200–400 ms, mis on juba lähedal meie ootustele.

Kahjuks pole sellist head vastandamist ja ka hind on üsna karm: kui mitu kasutajat teenuste kaudu samaaegselt töötavad, tuleb massiivne päringute voog, mida ikkagi ei töödelda paralleelselt, nii et naaseme meie kurbade 4 sekundi juurde.

Minu tulemus selle teenuse kasutamisel on 1300–1700 ms 20 sõnumi töötlemiseks. See on kiiremini kui esimeses teostuses, kuid probleem ei kao siiski.

Alternatiivne paralleelsete päringute rakendamineMis siis, kui kolmandates teenustes ei ole ette nähtud partii töötlemist? Näiteks võime peita partii töötlemise rakenduse puudumise liideste meetodite sisse:

liides UserRemoteApi {
  lõbu. getUserById(idKasutajaViide): UserRemote
  lõbu. getUsersByIds(idSet<KasutajaViide>: Set<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  lõbu. getUsersByIds(idLoend<KasutajaViide>: Loend<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

See on mõttekas, kui loota, et partii töötlemine ilmub järgmistes versioonides.
Plussid:

  1. Lihtne paralleelse töötlemise rakendamine sõnumite osas.
  2. Hea skaleeritavus.

Miinused:

  1. Paralleelse töötlemise korral on vajalik andmete hankimise eraldamine nende töötlemisest erinevate teenuste päringutes.
  2. Suurenenud koormus välistele teenustele.

On näha, et rakendamise piirid on enam-vähem samad kui naiivsel lähenemisel. Paralleelsete päringute meetodi kasutamine on mõttekas, kui soovite mitmekordselt suurendada oma teenuse jõudlust teiste teenuste ülimat ärakasutamist. Meie näites suurenes jõudlus 2,5 korda, kuid see pole selgelt piisav.

Vahemälu

Võib rakendada JPA-st inspireeritud vahemälu väliste teenuste jaoks, ehk hoida seansi raames saadud objekte, et neid ei peaks uuesti hankima (sealhulgas partii töötlemise puhul). Selliseid vahemälusid saab teha ise, kasutada Springi @Cacheable ja alati võib kasutada ka olemasolevat vahemälu nagu EhCache käsitsi.

Üldine probleem on seotud sellega, et vahemälust on kasu ainult siis, kui on põrkumisi. Meie juhul on autorivälja põrkumise tõenäosus üsna suur (öeldes 50%), kuid failide põrkumisi ei toimu üldse. See lähenemine toob mõningaid parandusi, kuid ei muuda jõudlust radikaalselt (ja me vajame läbimurret).

Seansidevahelised (pikad) vahemälud nõuavad keerulist kehtetuks tunnistamise loogikat. Üldiselt, mida hiljem tegelete jõudlusprobleemide lahendamisega seansidevaheliste vahemäludega, seda parem.

Plussid:

  1. Vahemälu rakendamine ilma koodi muutmata.
  2. Jõudluse kasv mitu korda (teatud juhtudel).

Miinused:

  1. Võimalus jõudluse languseks vale kasutamise korral.
  2. Suured mälu ülepead, eriti pikkade vahemälude korral.
  3. Keeruline kehtetuks tunnistamine, mille vead võivad viia raskesti taastatavate probleemideni käitamise ajal.

Väga sageli kasutatakse vahemälusid lihtsalt selleks, et kiiresti lappida projekteerimisprobleeme. See ei tähenda, et neid ei tohiks kasutada. Siiski tuleks alati suhtuda neisse ettevaatlikult ja kõigepealt hinnata saadud jõudluse kasvu, enne kui otsustate.

Meie näitena on vahemäludest jõudlus kasvanud umbes 25%. Samas on vahemäludel üsna palju miinuseid, seega ma ei soovitaks neid siin kasutada.

Kokkuvõte

Nii oleme vaadanud naiivset rakendust, mis kasutab partii töötlemist, ja mõningaid lihtsaid viise selle kiirendamiseks.

Kohalike meetodite peamine eelise on lihtsus, millest tuleneb palju meeldivaid tagajärgi.

Nende meetodite üldine probleem on halb jõudlus, mis on peamiselt seotud partii suurusega. Seega, kui need lahendused ei sobi, siis tasuks kaaluda radikaalsemaid meetodeid.

On kaks peamist suunda, kus saab lahendusi otsida:

  • asünkroonne töö andmetega (nõuab paradigmade muutmist, seega ei käsitleta seda artiklis);
  • partiisuuruse suurendamine sünkroonse töötlemise säilitamisel.

Partiisuuruse suurendamine vähendab väliseid kõnesid ja samas säilitab sünkroonse koodi. Sellele teemale pühendatakse järgmine artikli osa.

Allikas: habr.com

Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster