Praktikisht të gjithë produktet moderne softuerike përbëhen nga disa shërbime. Shpesh, koha e madhe e përgjigjes mes kanaleve të shërbimeve bëhet burim probleme me performancën. Zgjidhja standarde e këtij lloj problemi është grumbullimi i disa kërkesave ndërshërbimore në një paketë, e cila quhet përpunim në grup (batching).
Nëse po përdorni përpunim në grup, mund të mos jeni të kënaqur me rezultatin e saj në aspektin e performancës ose qartësisë së kodit. Ky metodë nuk është aq e thjeshtë për anën thirrëse sa mund të mendoni. Për qëllime të ndryshme dhe në situata të ndryshme, zgjidhjet mund të ndryshojnë ndjeshëm. Në disa shembuj konkretë, do të tregoj pro dhe kundër disa qasjeve.
Projekti demonstrues
Për qartësi, le të marrim si shembull një nga shërbimet në aplikacionin me të cilin po punoj tani.
Shpjegimi për zgjedhjen e platformës për shembujProblemi i performancës së dobët është mjaft i zakonshëm dhe nuk lidhet me ndonjë gjuhë ose platformë të veçantë. Në këtë artikull, për të demonstruar detyrat dhe zgjidhjet, do të përdoren shembuj kodi në Spring + Kotlin. Kotlin është po aq e kuptueshme (ose e paqartë) për zhvilluesit e Java dhe C#, përveç kësaj, kodi rezulton më i kompakt dhe më i kuptueshëm se ai në Java. Për të lehtësuar kuptimin për zhvilluesit e pastër të Java, do të shmang ndonjë magji të zezë të Kotlin dhe do të përdor vetëm atë të bardhë (në frymën e Lombok). Do të ketë pak metoda extension, por ato në të vërtetë janë të njohura për të gjithë programuesit e Java si metoda statike, kështu që kjo do të jetë një pak ëmbëltuese që nuk do ta prishë shijen e pjatës.
Ka një shërbim miratimi dokumentesh. Disa krijojnë një dokument dhe e nxjerrin atë për diskutim, gjatë procesit bëhen rregullime, dhe në fund dokumenti miratohet. Shërbimi vetë i miratimit nuk di asgjë për dokumentet: është thjesht një bisedë miratuese me disa funksione shtesë të vogla që nuk do të shqyrtojmë këtu.
Pra, ka dhoma bisedash (që përkojnë me dokumentet) me një grup të përcaktuar pjesëmarrësish në secilën prej tyre. Sikurse në bisedat e zakonshme, mesazhet përmbajnë tekst dhe skedarë dhe mund të jenë përgjigje (reply) dhe përcjellje (forward):
klasa e dhënave BisedaMesazhi(
// nullable так как появляется только после persist
val id: I gjatë? = null,
/** Ссылка на автора */
val autori: ReferencaPërdorues,
/** Сообщение */
val mesazhi: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Lista<ReferencaSkedhe>? = null,
/** Если является ответом, то здесь будет оригинал */
val përgjigjuTek: BisedaMesazhi? = null,
/** Если является пересылкой, то здесь будет оригинал */
val përparaNga: BisedaMesazhi? = null
)
Referencat në skedarin dhe përdoruesin janë referenca në të tjerë domenet. Këtu jetojmë kështu:
të tipizoni ReferencaSkedhe = I gjatë
të tipizoni ReferencaPërdorues = I gjatë
Të dhënat për përdoruesit ruhen në Keycloak dhe merren përmes REST. E njëjta gjë vlen për skedarët: skedarët dhe meta-informata për ta jetojnë në një shërbim të veçantë të ruajtjes së skedarëve.
Të gjitha thirrjet e këtyre shërbimeve janë kërkesa të rënda. Kjo do të thotë se kostot operative për transportin e këtyre kërkesave janë shumë më të mëdha se koha e procesimit nga shërbimi i jashtëm. Në stendat tona të testimit, koha tipike e thirrjes për këto shërbime është 100 ms, kështu që më pas do të përdorim këto të dhëna.
Na nevojitet një kontrollues i thjeshtë REST për të marrë N mesazhe të fundit me të gjitha informatat e nevojshme. Kjo do të thotë se supozojmë se modeli i mesazheve në frontend është pothuajse identik dhe duhet të dërgojmë të gjitha të dhënat. Ndryshimi i modelit për frontend është se skedari dhe përdoruesi duhet të paraqiten në një formë disi të dekriptuar, për t'i bërë ato lidhje:
/** В таком виде отдаются ссылки на сущности для фронта */
klasa e dhënave ReferencaUI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val emri: String
)
klasa e dhënave ChatMesazhUI(
val id: I gjatë,
/** Ссылка на автора */
val autori: ReferencaUI,
/** Сообщение */
val mesazhi: String,
/** Ссылки на аттачи */
val files: Lista<ReferencaUI>,
/** Если являтся ответом, то здесь будет оригинал */
val përgjigjuTek: ChatMesazhUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val përparaNga: ChatMesazhUI? = null
)
Na nevojitet të realizojmë të siguiente:
interface ChatRestApi {
fun merrSete(n: Int): Lista<ChatMesazhUI>
}
Postfix UI do të thotë modele DTO për frontend, domethënë ato që duhet të dërgojmë përmes REST.
Këtu mund të duket befasuese se nuk po dërgojmë asnjë identifikues të bisedës dhe as në modelin ChatMessage/ChatMessageUI nuk e ka atë. E kam bërë këtë qëllimisht për të mos ngarkuar kodin e shembujve (bisedat janë të izoluar, kështu që mund të mendojmë se kemi vetëm një).
Një devijim filozofikSi në klasën ChatMessageUI, ashtu edhe në metodën ChatRestApi.getLast, përdoret tipi i të dhënave List, ndonëse në të vërtetë është një Set i renditur. Në JDK është keq me këtë, prandaj nuk do të arrijmë të deklarojmë rendin e elementeve në nivelin e ndërfaqes (ruajtja e rendit gjatë shtimit dhe nxjerrjes). Prandaj, praktika e zakonshme është përdorimi i List në ato raste kur nevojitet një Set i renditur (ka edhe LinkedHashSet, por kjo nuk është ndërfaqe).
Një kufizim i rëndësishëm: do të mendojmë se nuk ka zinxhirë të gjatë përgjigjesh ose përcjelljesh. Domethënë, ata ekzistojnë, por gjatësia e tyre nuk kalon tri mesazhe. Në frontend, zinxhiri i mesazheve duhet të dërgohet i plotë.
Për marrjen e të dhënave nga shërbime të jashtme ekzistojnë këto API:
interface ChatMessageRepository {
fun findLast(n: Int): Lista<BisedaMesazhi>
}
klasa e dhënave FileHeadRemote(
val id: ReferencaSkedhe,
val emri: String
)
interface FileRemoteApi {
fun getHeadById(id: ReferencaSkedhe): FileHeadRemote
fun getHeadsByIds(id: Caktoni<ReferencaSkedhe>): Caktoni<FileHeadRemote>
fun getHeadsByIds(id: Lista<ReferencaSkedhe>): Lista<FileHeadRemote>
fun getHeadsByChat(): Lista<FileHeadRemote>
}
klasa e dhënave UserRemote(
val id: ReferencaPërdorues,
val emri: String
)
interface UserRemoteApi {
fun getUserById(id: ReferencaPërdorues): UserRemote
fun getUsersByIds(id: Caktoni<ReferencaPërdorues>): Caktoni<UserRemote>
fun getUsersByIds(id: Lista<ReferencaPërdorues>): Lista<UserRemote>
}
E dukshme është se në shërbimet e jashtme fillimisht parashikohet përpunimi në grup, duke pasur parasysh të dy variantet: përmes Set (pa ruajtjen e rendit të elementeve, me çelësa unikë) dhe përmes List (mund të ketë dhe duplicime - rendi ruhet).
Implementime të thjeshta
Implementimi naive
Implementimi i parë naive i kontrollorit tonë REST do të duket në shumicën e rasteve kështu:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun merrSete(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun BisedaMesazhi.toFrontModel(): ChatMesazhUI =
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()
)
}
E gjitha është e qartë, dhe kjo është një avantazh i madh.
Ne përdorim përpunimin në grupe dhe marrim të dhënat nga një shërbim të jashtëm në grupe. Por çfarë ndodh me performancën tonë?
Për çdo mesazh do të bëhet një thirrje UserRemoteApi për të marrë të dhënat mbi fushën author dhe një thirrje FileRemoteApi për të marrë të gjitha skedarët e bashkëngjitur. Duket se është e gjitha. Le të supozojmë se fushat forwardFrom dhe replyTo për ChatMessage merren në një mënyrë që nuk kërkon thirrje të tepërta. Por transformimi i tyre në ChatMessageUI do të çojë në rekurzivitet, do të thotë që numri i thirrjeve mund të rritet ndjeshëm. Siç e theksuam më parë, le të supozojmë se ne nuk kemi thellësi të madhe dhe zinxhiri është i kufizuar në tre mesazhe.
Në fund, do të kemi nga dy deri në gjashtë thirrje shërbimesh të jashtme për një mesazh dhe një thirrje JPA për të gjithë paketën e mesazheve. Numri total i thirrjeve do të variatojë nga 2*N+1 deri në 6*N+1. Sa është kjo në njësitë reale? Le të supozojmë se për të vizatuar faqen nevojiten 20 mesazhe. Për t'i marrë ato, do të duhen nga 4 sekonda deri në 10 sekonda. Horrible! Do të dëshironim të përfshimim në 500 ms. Dhe pasi në frontend kishim për të bërë një skroll pa ndërprerje, kërkesat për performancën e këtij endpointi mund të dyfishohen.
Avantazhet:
- Kodi është i shkurtër dhe vetë-dokumentues (ëndrra e mbështetjes).
- Kodi është i thjeshtë, kështu që mundësitë për të dështuar janë pothuajse zero.
- Përpunimi në grupe nuk duket si diçka e huaj dhe është organikisht i inkorporuar në logjikë.
- Ndryshimet në logjikë do të jenë të lehta për tu bërë dhe do të jenë lokale.
Disavantazhi:
Performanca e tmerrshme, e lidhur me faktin se grupet e dhënave janë shumë të vogla.
Ky qasje shpesh mund të shihet në shërbime të thjeshta ose në prototipa. Nëse shpejtësia e ndërrimeve është e rëndësishme, nuk ka shumë kuptim të komplikohet sistemi. Në të njëjtën kohë, për shërbimin tonë shumë të thjeshtë, performanca rezulton të jetë e tmerrshme, kështu që kufijtë e aplikueshmërisë së një qasjeje të tillë janë shumë të ngushta.
Përpunimi naiv paralel
Mund të nisni përpunimin e të gjitha mesazheve paralelisht — kjo do të lejojë të eliminohet rritja lineare e kohës në varësi të numrit të mesazheve. Ky nuk është një rrugë e veçantë, sepse do të çojë në një ngarkesë të madhe pikore në shërbimin e jashtëm.
Implementimi i përpunimit paralel është shumë i thjeshtë:
override fun merrSete(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Duke përdorur përpunimin paralel të mesazheve, do të arrijmë 300–700 mc në ideal, që është shumë më mirë sesa në zbatimin naiv, por ende jo mjaftueshëm i shpejtë.
Me këtë qasje, kërkesat për userRepository dhe fileRepository do të ekzekutohen sinkronisht, gjë që nuk është shumë efektive. Për ta rregulluar këtë, do të duhet të ndryshojmë mjaft logjikën e thirrjeve. Për shembull, përmes CompletionStage (ose CompletableFuture):
private fun BisedaMesazhi.toFrontModel(): ChatMesazhUI =
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()!!
Kemi vënë re se kodi fillestar i hartimit është bërë më pak i qartë. Kjo është për shkak se na duhej të ndanim thirrjet për shërbimet jashtme nga përdorimi i rezultateve. Vetë kjo nuk është e keqe. Por kombinimi i thirrjeve duket jo shumë elegant dhe kujton një 'merak' tipik reaktiv.
Nëse përdorim korutina, gjithçka do të duket më mirë:
private fun BisedaMesazhi.toFrontModel(): ChatMesazhUI =
bashkohu(
{ 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()
)
}
Ku:
fun <A, B> bashkohu(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A to it[1] as B
}
Teorikisht, duke përdorur këtë përpunim paralel, do të arrijmë 200–400 mc, që është tashmë afër pritjeve tona.
Fatkeqësisht, një përparim kaq i mirë në paralelizëm nuk ndodh, dhe çmimi është mjaft i rëndë: nëse disa përdorues punojnë njëkohësisht, shërbimet do të përballen me një mori kërkesash, të cilat sërish nuk do të përpunohen paralelisht, kështu që ne do të kthehemi te 4 s. tonë të trishtuar.
Rezultati im duke përdorur një shërbim të tillë është 1300–1700 ms për përpunimin e 20 mesazheve. Kjo është më e shpejtë se në realizimin e parë, por megjithatë nuk zgjidh problemin.
Përdorimi alternativ i kërkesave paralelÇfarë nëse shërbimet e jashtme nuk parashikojnë përpunim në grup? Për shembull, mund të fshehim mungesën e implementimit të përpunimit në grup brenda metodave të ndërfaqeve:
interface UserRemoteApi {
fun getUserById(id: ReferencaPërdorues): UserRemote
fun getUsersByIds(id: Caktoni<ReferencaPërdorues>): Caktoni<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
fun getUsersByIds(id: Lista<ReferencaPërdorues>): Lista<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Kjo ka kuptim nëse ka shpresë për shfaqjen e përpunimit në grup në versionet e ardhshme.
Avantazhet:
- Implementim i lehtë i përpunimit paralel përmes mesazheve.
- Shkallëzim i mirë.
Disavantazhet:
- Nevoja për të ndarë marrjen e të dhënave nga përpunimi i tyre gjatë përpunimit paralel të kërkesave ndaj shërbimeve të ndryshme.
- E rënduar më e madhe mbi shërbimet e jashtme.
Duket se kufijtë e përdorimit janë përafërsisht të njëjtë me ata të qasjes naive. Të përdorësh metodën e kërkesave paralel ka shumë kuptim nëse dëshiron ta rritësh disa herë efikasitetin e shërbimit tënd përmes një shfrytëzimi të paqenë të burimeve të të tjerëve. Në shembullin tonë, efikasiteti u rrit me 2.5 herë, por kjo saktësisht nuk është e mjaftueshme.
Kešimi
Mund të bësh keşi në stilin e JPA për shërbimet e jashtme, domethënë të ruash objektet e marra brenda sesionit, për t'i marrë ato sërish (përfshirë gjatë përpunimit në grupe). Mund të krijosh të tillë keşi vetë, mund të përdorësh Spring me @Cacheable të tij, plus gjithmonë mund të përdorësh një keš të gatshëm si EhCache manualisht.
Problemi kryesor do të lidhet me faktin se kešat kanë dobi vetëm nëse ka goditje. Në rastin tonë, goditjet sipas fushës author janë shumë të mundshme (supozoni 50%), ndërsa goditjet sipas skedarëve nuk do të ekzistojnë fare. Disa përmirësime ky qasje do të sjellë, por nuk do të ndryshojë radikalefikasitetin (dhe na nevojitet një shpërthim).
Kešat ndër-sesionale (të gjata) kërkojnë logjikë të komplikuar invalidimi. Në përgjithësi, sa më vonë të kalosh në atë që do të zgjidhësh problemet e efikasitetit me anë të kešave ndër-sesionale, aq më mirë.
Avantazhet:
- Implementimi i kešimit pa ndryshuar kodin.
- Rritja e efikasitetit disa herë (në disa raste).
Disavantazhet:
- Mundësia e uljes së efikasitetit në përdorim të gabuar.
- Shpenzime të mëdha memorike, veçanërisht me keša të gjata.
- Invalidim i komplikuar, gabimet në të cilin do të çojnë në probleme të vështira për t'u reprodukuar në kohën e ekzekutimit.
Shpesh, kešat përdoren vetëm për të ndrequr shpejt problemet e projektit. Kjo nuk do të thotë se nuk duhet t'i përdorim. Megjithatë, gjithmonë duhet t'u qasemi atyre me kujdes dhe fillimisht të vlerësojmë rritjen e efikasitetit të marrë, e më pas të marrim vendim.
Në shembullin tonë, nga kešat do të ketë rritje efikasiteti në rreth 25%. Megjithatë, ka shumë disavantazhe të kešave, kështu që nuk do të doja t'i përdorja këtu.
Përfundime
Kështu, ne shqyrtuam një implementim naive të shërbimit që përdor përpunimin në grupe, dhe disa mënyra të thjeshta për ta përshpejtuar atë.
Avantazhi kryesor i të gjithë këtyre metodave është thjeshtësia, nga e cila ka shumë ndarje të këndshme.
Një problem i zakonshëm i këtyre metodave është performanca e dobët, e cila lidhet kryesisht me madhësinë e paketave. Prandaj, nëse këto zgjidhje nuk ju përshtaten, është e udhës të shqyrtoni metoda më radikale.
Ka dy drejtime kryesore ku mund të kërkoni zgjidhje:
- punimi asinkron me të dhënat (kërkon ndryshim paradigme, prandaj nuk do të shqyrtohet në këtë artikull);
- përmirësimi i paketave duke ruajtur përpunimin sinkron.
Përmirësimi i paketave do t'i lejojë të reduktojë ndjeshëm numrin e thirrjeve të jashtme dhe në të njëjtën kohë të ruajë kodin sinkron. Kjo temë do t'i kushtohet pjesës së ardhshme të artikullit.
Burimi: habr.com
