Pakettpäringu töötlemise probleemid ja nende lahendused (1. osa)

Pakettpäringu töötlemise probleemid ja nende lahendused (1. osa)Peaaegu kõik kaasaegsed tarkvaratooted koosnevad mitmest teenusest. Sageli muutuvad teenustevaheliste kanalite pikad reageerimisajad jõudlusprobleemide allikaks. Seda tüüpi probleemi standardlahendus on mitme teenustevahelise taotluse pakkimine ühte paketti, mida nimetatakse pakkimiseks.

Kui kasutate paketttöötlust, ei pruugi te tulemustega rahul olla jõudluse või koodi selguse osas. See meetod ei ole helistajale nii lihtne, kui arvate. Erinevatel eesmärkidel ja erinevates olukordades võivad lahendused olla väga erinevad. Toon konkreetsete näidete abil mitme lähenemisviisi plusse ja miinuseid.

Demonstratsiooniprojekt

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

Platvormi valiku selgitus näidete jaoksKehva jõudluse probleem on üsna üldine ega mõjuta konkreetseid keeli ega platvorme. See artikkel kasutab probleemide ja lahenduste demonstreerimiseks Spring + Kotlini koodinäiteid. Kotlin on ühtviisi arusaadav (või arusaamatu) nii Java kui ka C# arendajatele, lisaks on kood kompaktsem ja arusaadavam kui Javas. Puhtalt Java arendajatele arusaadavaks muutmiseks väldin Kotlini musta maagiat ja kasutan ainult valget maagiat (Lomboki vaimus). Laiendusmeetodeid on küll vähe, kuid need on tegelikult kõigile Java programmeerijatele tuttavad kui staatilised meetodid, nii et see on väike suhkur, mis roa maitset ei riku.
Olemas on dokumentide kinnitamise teenus. Keegi koostab dokumendi ja esitab selle aruteluks, mille käigus tehakse muudatusi ja lõpuks lepitakse dokumendis kokku. Kinnitusteenistus ise ei tea dokumentidest midagi: see on lihtsalt kinnitajate vestlus väikeste lisafunktsioonidega, mida me siin ei käsitle.

Seega on olemas (dokumentidele vastavad) jututoad, millest igaühes on eelnevalt määratud osalejate hulk. Nagu tavalistes vestlustes, sisaldavad sõnumid teksti ja faile ning need võivad olla vastused või edasisaatmised:

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
)

Faili- ja kasutajalingid on lingid teistele domeenidele. Siin me elame nii:

typealias FileReference Long
typealias UserReference Long

Kasutajaandmed salvestatakse Keycloaki ja hangitakse REST-i kaudu. Sama kehtib ka failide kohta: failid ja metainfo nende kohta elavad eraldi failisalvestusteenuses.

Kõik kõned nendele teenustele on rasked taotlused. See tähendab, et nende päringute edastamise üldkulud on palju suuremad kui aeg, mis kulub nende töötlemiseks kolmanda osapoole teenusel. Meie katsestendil on selliste teenuste tüüpiline kõneaeg 100 ms, seega kasutame neid numbreid ka edaspidi.

Peame tegema lihtsa REST-kontrolleri, et saada vastu viimast N sõnumit koos kogu vajaliku teabega. See tähendab, et me usume, et kasutajaliidese sõnumimudel on peaaegu sama ja kõik andmed tuleb saata. Esiotsa mudeli erinevus seisneb selles, et fail ja kasutaja tuleb linkimiseks esitada veidi dekrüpteeritud kujul:

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

Peame rakendama järgmist:

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

UI postfix tähendab DTO-mudeleid esiserva jaoks, st seda, mida peaksime REST-i kaudu teenindama.

Üllatav võib siin olla see, et me ei edasta ühtegi vestlus ID-d ja isegi ChatMessage/ChatMessageUI mudelil pole seda. Tegin seda tahtlikult, et mitte segada näidete koodi (vestlused on isoleeritud, seega võime eeldada, et meil on ainult üks).

Filosoofiline kõrvalepõigeNii ChatMessageUI klass kui ka meetod ChatRestApi.getLast kasutavad andmetüüpi Loend, kuigi tegelikult on tegemist järjestatud komplektiga. See on JDK-s halb, nii et elementide järjekorra deklareerimine liidese tasemel (järjekorra säilitamine lisamisel ja eemaldamisel) ei toimi. Nii on saanud tavaks kasutada Listi juhtudel, kui on vaja tellitud Seti (on ka LinkedHashSet, aga see pole liides).
Oluline piirang: Eeldame, et pikki vastuste või edastuste ahelaid pole. See tähendab, et need on olemas, kuid nende pikkus ei ületa kolme sõnumit. Kogu sõnumite ahel tuleb edastada kasutajaliidesele.

Andmete saamiseks välistelt teenustelt on olemas järgmised API-d:

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

On näha, et välisteenused pakuvad algselt paketttöötlust ja seda mõlemas versioonis: Seti kaudu (elementide järjekorda säilitamata, ainulaadsete võtmetega) ja loendi kaudu (võib esineda duplikaate - järjekord säilib).

Lihtsad teostused

Naiivne teostus

Meie REST-kontrolleri esimene naiivne rakendus näeb enamikul juhtudel välja umbes selline:

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

Kõik on väga selge ja see on suur pluss.

Kasutame paketttöötlust ja võtame välisteenuselt vastu andmeid partiidena. Aga mis saab meie tootlikkusest?

Iga sõnumi puhul tehakse üks kõne UserRemoteApile, et saada andmeid autorivälja kohta, ja üks kõne FileRemoteApile, et saada kõik manustatud failid. Tundub, et see on kõik. Oletame, et ChatMessage'i väljad forwardFrom ja replyTo on saadud nii, et see ei nõua tarbetuid kõnesid. Kuid nende muutmine ChatMessageUI-ks toob kaasa rekursiooni, see tähendab, et kõneloendurid võivad märkimisväärselt suureneda. Nagu varem märkisime, oletame, et meil ei ole palju pesastusi ja kett on piiratud kolme sõnumiga.

Selle tulemusena saame ühe sõnumi kohta kaks kuni kuus kõnet välisteenustele ja ühe JPA kõne kogu sõnumite paketi kohta. Kõnede koguarv varieerub vahemikus 2*N+1 kuni 6*N+1. Kui palju see reaalsetes ühikutes on? Oletame, et lehe renderdamiseks kulub 20 sõnumit. Nende kättesaamine võtab aega 4 s kuni 10 s. Kohutav! Soovin hoida seda 500 ms piires. Ja kuna nad unistasid sujuva kerimise tegemisest eesmises, saab selle lõpp-punkti jõudlusnõudeid kahekordistada.

plussid:

  1. Kood on lühike ja ennast dokumenteeriv (tugimeeskonna unistus).
  2. Kood on lihtne, nii et peaaegu puuduvad võimalused endale jalga tulistada.
  3. Paketttöötlus ei näe välja nagu midagi võõrast ja on loogikasse orgaaniliselt integreeritud.
  4. Loogikamuudatusi tehakse lihtsalt ja need on kohalikud.

Miinus:

Jube jõudlus väga väikeste pakettide tõttu.

Seda lähenemist võib üsna sageli näha lihtsates teenustes või prototüüpides. Kui oluline on muudatuste tegemise kiirus, siis vaevalt tasub süsteemi keeruliseks ajada. Samal ajal on meie väga lihtsa teenuse jõudlus kohutav, seega on selle lähenemisviisi rakendusala väga kitsas.

Naiivne paralleeltöötlus

Saate alustada kõigi sõnumite paralleelset töötlemist – see võimaldab teil vabaneda lineaarsest ajapikenemisest sõltuvalt sõnumite arvust. See ei ole eriti hea tee, kuna see toob kaasa välisteenuse suure tippkoormuse.

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

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

Paralleelset sõnumitöötlust kasutades saame ideaalis 300–700 ms, mis on palju parem kui naiivse teostuse korral, kuid siiski mitte piisavalt kiire.

Selle lähenemisviisi korral täidetakse päringuid userRepositoryle ja fileRepositoryle sünkroonselt, mis pole eriti tõhus. Selle parandamiseks peate kõneloogikat üsna palju muutma. Näiteks CompletionStage'i (teise nimega CompletableFuture) kaudu:

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

On näha, et algselt lihtne kaardistuskood on muutunud vähem arusaadavaks. Seda seetõttu, et pidime eraldama välisteenustele tehtud kõned tulemuste kasutamisest. See iseenesest pole halb. Kuid kõnede kombineerimine ei tundu eriti elegantne ja sarnaneb tüüpilise reaktiivse "nuudliga".

Kui kasutate korutiine, näeb kõik korralikum välja:

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

Kui:

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

Teoreetiliselt saame sellist paralleeltöötlust kasutades 200–400 ms, mis on juba lähedane meie ootustele.

Kahjuks sellist head paralleelsust ei eksisteeri ja hind, mida maksta, on üsna julm: kui korraga töötab vaid paar kasutajat, langeb teenustele suur hulk päringuid, mida niikuinii paralleelselt ei töödelda, nii et naaseb meie kurbade 4 s.

Minu tulemus sellise teenuse kasutamisel on 1300–1700 ms 20 sõnumi töötlemiseks. See on kiirem kui esimeses rakenduses, kuid siiski ei lahenda probleemi.

Paralleelpäringute alternatiivsed kasutusvõimalusedMis siis, kui kolmanda osapoole teenused ei paku paketttöötlust? Näiteks saate liidese meetodite sees peita paketttöötluse rakendamise puudumise:

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

See on mõttekas, kui loodate tulevastes versioonides näha paketttöötlust.
plussid:

  1. Rakendage lihtsalt sõnumipõhist paralleeltöötlust.
  2. Hea mastaapsus.

miinuseid:

  1. Vajadus eraldada andmete kogumine nende töötlemisest, kui töödeldakse paralleelselt erinevatele teenustele suunatud päringuid.
  2. Suurem koormus kolmandate osapoolte teenustele.

On näha, et rakendusala on ligikaudu sama, mis naiivsel lähenemisel. Paralleeltaotluse meetodit on mõttekas kasutada, kui soovite oma teenuse toimivust teiste halastamatu ärakasutamise tõttu mitu korda suurendada. Meie näites kasvas jõudlus 2,5 korda, kuid sellest ei piisa selgelt.

vahemällu salvestamine

Väliste teenuste jaoks saate JPA vaimus vahemällu salvestada, st salvestada seansi jooksul vastuvõetud objekte, et mitte neid uuesti vastu võtta (sealhulgas paketttöötluse ajal). Saate selliseid vahemälu ise teha, võite kasutada Springit selle @Cacheable-ga, lisaks saate alati käsitsi kasutada valmis vahemälu, näiteks EhCache.

Levinud probleem on see, et vahemälud on kasulikud ainult siis, kui neil on tabamusi. Meie puhul on tabamused autoriväljal väga tõenäolised (oletame, et 50%), kuid failidele ei tule üldse tabamusi. See lähenemisviis pakub mõningaid täiustusi, kuid see ei muuda jõudlust radikaalselt (ja me vajame läbimurret).

Sessioonidevahelised (pikad) vahemälud nõuavad keerulist kehtetuks tunnistamise loogikat. Üldiselt, mida hiljem jõuate jõudlusprobleemide lahendamiseni seanssidevahelise vahemälu abil, seda parem.

plussid:

  1. Rakendage vahemälu ilma koodi muutmata.
  2. Suurenenud tootlikkus mitu korda (mõnel juhul).

miinuseid:

  1. Ebaõige kasutamise korral on võimalik jõudluse vähenemine.
  2. Suur mälumaht, eriti pikkade vahemäludega.
  3. Keeruline kehtetuks tunnistamine, mille vead põhjustavad käitusajal raskesti reprodutseeritavaid probleeme.

Väga sageli kasutatakse vahemälu ainult disainiprobleemide kiireks parandamiseks. See ei tähenda, et neid ei tohiks kasutada. Siiski peaksite neisse alati suhtuma ettevaatlikult ja esmalt hindama sellest tulenevat jõudluse kasvu ning alles seejärel tegema otsuse.

Meie näites suurendavad vahemälud jõudlust umbes 25%. Samas on vahemäludel päris palju miinuseid, nii et ma neid siin ei kasutaks.

Tulemused

Niisiis, vaatasime paketttöötlust kasutava teenuse naiivset rakendamist ja mõningaid lihtsaid viise selle kiirendamiseks.

Kõigi nende meetodite peamine eelis on lihtsus, millest on palju meeldivaid tagajärgi.

Nende meetodite tavaline probleem on halb jõudlus, mis on peamiselt tingitud pakettide suurusest. Seega, kui need lahendused sulle ei sobi, siis tasub kaaluda radikaalsemaid meetodeid.

Lahendusi saate otsida kahes peamises suunas:

  • asünkroonne töö andmetega (nõuab paradigma nihet, seega selles artiklis seda ei käsitleta);
  • partiide suurendamine, säilitades samal ajal sünkroonse töötlemise.

Partiide suurendamine vähendab oluliselt väliskõnede arvu ja hoiab samal ajal koodi sünkroonsena. Artikli järgmine osa on pühendatud sellele teemale.

Allikas: www.habr.com

Lisa kommentaar