Peaaegu kõik tänapäevased tarkvaratooted koosnevad mitmest teenusest. Sageli muutub teenustevaheline suhtlemise aeg jõudluse probleemide allikaks. Tavaline lahendus selliste probleemide jaoks on mitme teenustevahelise päringu pakett, mida nimetatakse pakettide töötlemiseks (batching).
Kui kasutate pakettide töötlemist, ei pruugi te selle tulemusega olla rahul, olgu see jõudluse või koodi arusaadavuse seisukohalt. See meetod ei ole kutsujale nii lihtne, nagu võib arvata. Erinevate eesmärkide ja olukordade jaoks võivad lahendused tunduvalt erineda. Näitan konkreetsete näidete kaudu mitme lähenemise plussid ja miinused.
Demonstratsiooniprojekt
Selguse huvides vaatleme näidet ühest teenusest, mille kallal ma hetkel töötan.
Platvormi valiku selgitus näidete jaoksHalva jõudluse probleem on piisavalt üldine ning ei piirdu mingite konkreetsete keelte ja platvormidega. Selles artiklis kasutame ülesannete ja lahenduste demonstreerimiseks koodinäiteid Spring + Kotlin. Kotlin on Java ja C# arendajatele ühtviisi arusaadav (või arusaamatu), pealegi on kood kompaktsem ja mõistetavam kui Java-s. Arusaadavuse lihtsustamiseks puhtatele Java arendajatele püüan vältida Kotlin'i musta maagia ning kasutada ainult valget (Lomboki vaimus). Olemas on mõned extension meetodid, kuid need on tegelikult tutvustatud kõigile Java programmeerijatele kui static meetodid, seega on see väike magusus, mis ei riku roa maitset.
On dokumentide kooskõlastamise teenus. Keegi loob dokumendi ja toob selle arutusele, mille käigus tehakse parandusi ja lõpuks kooskõlastatakse dokument. Kooskõlastamise teenus ei tea dokumentidest midagi: see on lihtsalt kooskõlastajate vestlus koos väikeste lisafunktsioonidega, mida me siin arutama ei hakka.
Nii et igas vestluses (mis vastab dokumentidele) on kindel osalejate koosseis. Nagu tavavestlustes, sisaldavad sõnumid teksti ja faile ning võivad olla vastused (reply) ja edastamised (forward):
andmed klass VestlusSõnum(
// nullable так как появляется только после persist
val id: Pikk? = null,
/** Ссылка на автора */
val autor: KasutajaViide,
/** Сообщение */
val sõnum: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Nimekiri<FailiViide>? = null,
/** Если является ответом, то здесь будет оригинал */
val vastata: VestlusSõnum? = null,
/** Если является пересылкой, то здесь будет оригинал */
val edasikanda: VestlusSõnum? = null
)
Faili ja kasutaja lingid on lingid teistele domeenid. Meie loome selle nii:
tüüpi alias FailiViide = Pikk
tüüpi alias KasutajaViide = Pikk
Kasutajaandmed salvestatakse Keycloak'is ja saadakse REST'i kaudu. Sama kehtib ka failide kohta: failid ja nende metaandmed elavad eraldi failihaldusteenuses.
Kõik nende teenuste kutsed on raskeid päringuid. See tähendab, et nendes päringutes on transportimise kulud palju suuremad kui nende töötlemise aeg kolmandate osapoolte teenustes. Meie testkeskkondades on selliste teenuste tüüpiline reaktsiooniaeg 100 ms, seega kasutame edaspidi neid numbreid.
Me peame looma lihtsa REST-kontrolleri, et saada viimased N sõnumit koos kogu vajaliku teabega. Eeldame, et front-end'i sõnumimudel on peaaegu sama ja kõik andmed tuleb edastada. Front-end'i mudeli erinevus seisneb selles, et fail ja kasutaja tuleb esitada veidi dekodeeritud kujul, et muuta need linkideks:
/** В таком виде отдаются ссылки на сущности для фронта */
andmed klass Viidatud kasutajaliides(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val nimi: String
)
andmed klass Vestluseteate kasutajaliides(
val id: Pikk,
/** Ссылка на автора */
val autor: Viidatud kasutajaliides,
/** Сообщение */
val sõnum: String,
/** Ссылки на аттачи */
val files: Nimekiri<Viidatud kasutajaliides>,
/** Если являтся ответом, то здесь будет оригинал */
val vastata: Vestluseteate kasutajaliides? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val edasikanda: Vestluseteate kasutajaliides? = null
)
Me peame rakendama järgmist:
interface ChatRestApi {
lõbu getLast(n: Int): Nimekiri<Vestluseteate kasutajaliides>
}
Postfix UI tähendab DTO-mudeleid front-end'ile, see tähendab, et see, mida me peame REST'i kaudu edastama.
Siin võib tunduda üllatav, et me ei edasta mingit vestluse ID-d ega ole seda isegi mudelis ChatMessage/ChatMessageUI. Olen seda teinud meelega, et mitte ummistada näidisekoodi (vestlused on isoleeritud, seega võib eeldada, et meil on see üks).
Filosoofiline kõrvalepõigeNii klassis ChatMessageUI kui ka meetodis ChatRestApi.getLast kasutatakse andmetüüpi List, samas kui tegelikult on see järjestatud Set. JDK-s on sellega kõik kehvasti, seega ei õnnestu elementide järjekorra deklareerimine liidese tasemel (järjekorra säilitamine lisamisel ja väljavõtmisel). Seega on üldiseks praktikaks List'i kasutamine juhtudel, kui on vaja järjestatud Set'i (on olemas ka LinkedHashSet, kuid see ei ole liides).
Oluline piirang: eeldame, et pikki vastuste või edastuste ahelad ei esine. See tähendab, et need on olemas, kuid nende pikkus ei ületa kolme sõnumit. Front-end'is peab sõnumite ahel edastama kogu ulatuses.
Väliste teenuste andmete saamiseks on sellised API-d:
interface ChatMessageRepository {
lõbu findLast(n: Int): Nimekiri<VestlusSõnum>
}
andmed klass FileHeadRemote(
val id: FailiViide,
val nimi: String
)
interface FileRemoteApi {
lõbu getHeadById(id: FailiViide): FileHeadRemote
lõbu getHeadsByIds(id: Seada<FailiViide>): Seada<FileHeadRemote>
lõbu getHeadsByIds(id: Nimekiri<FailiViide>): Nimekiri<FileHeadRemote>
lõbu getHeadsByChat(): Nimekiri<FileHeadRemote>
}
andmed klass UserRemote(
val id: KasutajaViide,
val nimi: String
)
interface UserRemoteApi {
lõbu getUserById(id: KasutajaViide): UserRemote
lõbu getUsersByIds(id: Seada<KasutajaViide>): Seada<UserRemote>
lõbu getUsersByIds(id: Nimekiri<KasutajaViide>): Nimekiri<UserRemote>
}
On näha, et välisteenustes on algselt ette nähtud pakettide töötlemine, ja seda mõlemas variandis: Set'i kaudu (ilma elementide järjekorra säilitamiseta, unikaalsete võtmepaaridega) ja List'i kaudu (võivad olla ka dubleerimised — järjekord säilitatakse).
Lihtsad rakendused
Lihtne teostus
Meie REST-kontrolleri esimene naiivne rakendus näeb enamikus juhtudel välja umbes selline:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun VestlusSõnum.toFrontModel(): Vestluseteate kasutajaliides =
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 täiesti selge, ja see on suur pluss.
Kasutame partii töötlemist ja saame andmeid välist teenusest pakettidena. Aga mis toimub meie jõudlusega?
Iga sõnumi jaoks tehakse üks kutsumine UserRemoteApi'le autori väli saamiseks ja üks kutsumine FileRemoteApi'le kõigi manusdokumentide saamiseks. Tundub, et kõik. Oletame, et ChatMessage'i forwardFrom ja replyTo väljad saadakse nii, et see ei nõua lisakutsumisi. Kuid nende muutmine ChatMessageUI-ks põhjustab rekursiooni, mis tähendab, et kutsumise arvesti näitajad võivad oluliselt suureneda. Nagu oleme varem maininud, oletame, et meil ei ole suurt sügavat pesastamist ja kett on piiratud kolme sõnumiga.
Kokkuvõttes saame kaks kuni kuus väliste teenuste kutsumist ühe sõnumi peale ja ühe JPA-kutsumise kogu sõnumipaketi kohta. Kokkuvõttes muutub väljakutsete arv vahemikus 2*N+1 kuni 6*N+1. Kui palju see on reaalsetes ühikutes? Oletame, et lehe joonistamiseks on vaja 20 sõnumit. Nende saamiseks kulub 4 kuni 10 sekundit. Kohutav! Tahaksime mahtuda 500 ms. Ja kuna front-endis unistati sujuvast kerimisest, saab selle lõpp-punkti jõudluse nõuded kahekordistada.
Plussid:
- Kood on lühike ja isedokumenditav (toetuse unistus).
- Kood on lihtne, seega praktiliselt puuduvad võimalused end jalga tulistada.
- Partii töötlemine ei tundu midagi võõrast ja on loogikasse orgaaniliselt integreeritud.
- Loogika muudatused on kergesti teostatavad ja kohalikud.
Miinus:
Jõhkralt halb jõudlus, mis on seotud sellega, et paketid on väga väiksed.
Sellist lähenemist võib sageli näha lihtsates teenustes või prototüüpides. Kui muudatuste tegemise kiirus on oluline, siis ei tasu süsteemi keerulisemaks muuta. Samas on meie väga lihtsa teenuse jõudlus kohutav, nii et sellise lähenemise rakendamise raadius on väga kitsas.
Naivne paralleelne töötlemine
Saame käivitada kõikide sõnumite töötlemise paralleelselt - see aitab vabaneda lineaarsest ajakasvust sõltuvalt sõnumite arvust. See ei ole eriti hea tee, kuna see toob kaasa suure tipukoormuse välishteenusele.
Paralleelse töötlemise rakendamine on väga lihtne:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Kasutades paralleelset sõnumitöötlust, saame ideaaljuhul 300–700 ms, mis on palju parem kui naiivne rakendus, kuid siiski mitte piisavalt kiire.
Selle lähenemise korral täidetakse päringud userRepository ja fileRepository sünkroonselt, mis ei ole eriti efektiivne. Selle parandamiseks tuleb kutsuda loogikat piisavalt palju muuta. Näiteks läbi CompletionStage (aka CompletableFuture):
private fun VestlusSõnum.toFrontModel(): Vestluseteate kasutajaliides =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).toFrontReference()
}.thenCombine(
files?.let {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).map { it.toFrontReference() }
}
} ?: CompletableFuture.completedFuture(listOf())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = author,
message = message,
files = files,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}.get()!!
Nähtavasti on algselt lihtne kaardistamise kood vähem arusaadavaks muutunud. See tuleneb sellest, et pidime väliste teenuste kutsed eraldama tulemuste kasutamise kohast. See iseenesest ei ole halb. Kuid kutsede kombinatsioon näeb ebaühtlane välja ja meenutab tüüpilist reaktiivset "nuudlit".
Kui kasutada korutiine, näeb kõik paremini välja:
private fun VestlusSõnum.toFrontModel(): Vestluseteate kasutajaliides =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(files)
.map { it.toFrontReference() } } ?: listOf() }
).let { (autor, files) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
autor = autor,
message = message,
files = files,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Kus:
lõbu <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A to it[1] as B
}
Teoreetiliselt, kasutades sellist paralleelset töötlemist, saame 200–400 ms, mis on juba meie ootustele lähedal.
Kahjuks sellist head paralleelset töötlemist ei ole ja arve on üsna karm: kui mitu kasutajat töötab teenustes korraga, sademete hulgas, ei toimu ikkagi paralleelset töötlemist, nii et me jääme tagasi meie kurbade 4 s juurde.
Minu tulemus, kasutades sellist teenust, on 1300–1700 ms 20 sõnumi töötlemiseks. See on kiiremini kui esimeses rakenduses, kuid siiski ei lahenda probleemi.
Paralleelsete päringute alternatiivne rakendamineMis siis, kui välistes teenustes ei ole paindlikku töötlemist? Näiteks saab peita paindliku töötlemise puudumise liideste meetodite sisse:
interface UserRemoteApi {
lõbu getUserById(id: KasutajaViide): UserRemote
lõbu getUsersByIds(id: Seada<KasutajaViide>): Seada<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
lõbu getUsersByIds(id: Nimekiri<KasutajaViide>): Nimekiri<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
See on mõistlik, kui on lootust järgmistes versioonides paindlikku töötlemist näha.
Plussid:
- Lihtne paralleelse sõnumitöötluse rakendamine.
- Hea skaleeritavus.
Miinused:
- Vajadus eraldada andmete hankimine nende töötlemisest, kui paralleelselt tehakse päringuid erinevatele teenustele.
- Suurenenud koormus välistes teenustes.
Näha on, et kehtivusraamid on umbes samad kui naiivsel lähenemisel. Paralleelsete päringute meetodi kasutamine on mõttekas, kui soovite oma teenuse jõudlust mitu korda suurendada, piinates teiste ressursside kasutamist. Meie näites suurenes jõudlus 2,5 korda, kuid seda on selgelt liiga vähe.
Vahemälu
Võib teha vahemälu JPA stiilis välistes teenustes, st seansi raames hoida saadud objekte, et neid ei peaks uuesti saama (sealhulgas partiide töötlemisel). Selliseid vahemälusid saab teha ise, kasutada Springi @Cacheable'i, lisaks on alati võimalik kasutada valmis vahemälu nagu EhCache käsitsi.
Üldine probleem on see, et vahemäludest on kasu, kui on tabamusi. Meie juhul on autorivaldkonna tabamised üsna tõenäolised (öelda näiteks 50%), kuid failide tabamusi ei tule üldse. Mõningaid täiustusi see lähenemine küll toob, kuid see ei muuda jõudlust radikaalselt (ja meil on vajalik läbimurre).
Seansidevahelised (pikad) vahemälud nõuavad keerulist kehtetuks tunnistamise loogikat. Üldiselt, mida hiljem te jõuate selleni, et hakkate lahendama jõudlusprobleeme seansidevaheliste vahemäludega, seda parem.
Plussid:
- Vahemälude rakendamine ilma koodi muutmata.
- Jõudluse tõus mitu korda (mõnel juhul).
Miinused:
- Vale kasutamise korral võib jõudlust vähendada.
- Suur mäluhalduspüsi, eriti pikkade vahemälude puhul.
- Keeruline kehtetuks tunnistamine, vigade korral, mis võivad põhjustada raskesti jälgitavaid probleeme tööajal.
Eriti tihti kasutatakse vahemälusid lihtsalt selleks, et kiiresti lahendada projekteerimisprobleeme. See ei tähenda, et neid ei peaks kasutama. Siiski tuleb nendesse alati ettevaatlikult suhtuda ja kõigepealt hinnata saadud jõudluse tõusu, alles siis teha otsus.
Meie näites on vahemäludest jõudluse tõus umbes 25%. Sel juhul on vahemäludel üsna palju miinuseid, nii et ma ei soovitaks neid siin kasutada.
Summary
Nii et oleme käsitlenud naiivset teenuse rakendust, mis kasutab partiide töötlemist, ja mõningaid lihtsaid viise selle kiirusel parandamiseks.
Kõigi nende meetodite peamine eelis on lihtsus, millest tulenevad paljud meeldivad tagajärjed.
Üldine probleem nende meetodite puhul on halb jõudlus, mis on peamiselt seotud pakettide suurusega. Seetõttu, kui need lahendused teile ei sobi, tasub kaaluda radikaalsemaid meetodeid.
On kaks peamist suunda, milles lahendusi otsida:
- asünkroonne andmetöötlus (nõuab paradigmi vahetust, seetõttu ei käsitleta seda artiklis);
- pakettide suurendamine, säilitades sünkroonse töötlemise.
Pakettide suurendamine vähendab oluliselt väliseid kõnesid ja säilitab samal ajal koodi sünkroonsena. Sellele teemale pühendatakse artikli järgmine osa.
Allikas: habr.com
