Gati çdo produkt modern software përbëhet nga disa shërbime. Shpesh, koha e madhe e përgjigjes midis kanaleve të shërbimit bëhet burim problemesh me performancën. Çështja standarde për të tillë probleme është grumbullimi i disa kërkesave midis shërbimeve në një paketë, të njohur si përpunimi i paketave (batching).
Nëse përdorni përpunimin e paketave, rezultati mund të mos ju kënaqë sa i përket performancës ose qartësisë së kodit. Ky metod nuk është aq i thjeshtë për palën që thërret, sa mund të mendohet. Zgjidhjet për qëllime të ndryshme dhe në situata të ndryshme mund të ndryshojnë ndjeshëm. Përmes shembujve konkretë, do të ilustroj përparësitë dhe disavantazhet e disa qasjeve.
Projekt demonstrues
Për ilustrim, le të shqyrtojmë një shembull të një shërbimi në aplikacionin me të cilin po punoj tani.
Shpjegim mbi zgjedhjen e platformës për shembujProblemi i performancës së dobët është mjaft i zakonshëm dhe nuk ka të bëjë me disa gjuhë ose platforma specifike. Në këtë artikull, për të demonstruar detyrat dhe zgjidhjet do të përdoren shembuj kodi në Spring + Kotlin. Kotlin është po aq i kuptueshëm (ose i paqartë) për zhvilluesit e Java-s dhe C#, për më tepër, kodi rezulton më kompakt dhe i kuptueshëm se ai i Java-s. Për ta lehtësuar kuptimin për zhvilluesit e pastër të Java-s, do të shmang grimin e errët të Kotlin dhe do të përdor vetëm të bardhën (në frymën e Lombok). Do të ketë pak metoda zgjerimi, por ato në të vërtetë janë të njohura për të gjithë programuesit e Java-s si metoda statike, kështu që kjo do të jetë një ëmbëlsirë e vogël që nuk do ta prishë shijen e pjatës.
Ka një shërbim miratimi dokumentesh. Disa krijojnë një dokument dhe e nxjerrin për diskutim, proces në të cilin bëhen rregullime, dhe në fund dokumenti miratohet. Shërbimi i miratimit vetë nuk di asgjë për dokumentet: është thjesht një chat miratimi me disa funksione shtesë që nuk do të shqyrtojmë këtu.
Pra, ka dhoma chat-i (që korrespondohen me dokumentet) me një grup të përcaktuar pjesëmarrësish në secilën prej tyre. Si në chat-et e zakonshme, mesazhet përmbajnë tekst dhe skedarë dhe mund të jenë përgjigje (reply) dhe përcjellje (forward):
data klasa ChatMessage(
// nullable так как появляется только после persist
vlera id: e gjatë? = null,
/** Ссылка на автора */
vlera autori: UserReference,
/** Сообщение */
vlera mesazhi: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
vlera skedarët: Listë<FileReference>? = null,
/** Если является ответом, то здесь будет оригинал */
vlera pergjigjeTek: ChatMessage? = null,
/** Если является пересылкой, то здесь будет оригинал */
vlera dergoNga: ChatMessage? = null
)
Lidhjet në skedar dhe përdorues janë lidhje në të tjera domenet. Kjo jeton kështu:
tipoalias FileReference = e gjatë
tipoalias UserReference = e 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-informacioni mbi ata 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ëndë. Kjo nënkupton se shpenzimet për transportin e këtyre kërkesave janë shumë më të larta se koha e përpunimit nga shërbimi i jashtëm. Në instalimet tona testuese, koha tipike e thirrjes për këto shërbime është 100 ms, kështu që në vijim do të përdorim këto numra.
Na nevojitet një kontrollues i thjeshtë REST për të marrë mesazhet e fundit N me të gjitha informacionet e nevojshme. Pra, assume se në frontend modeli i mesazheve është pothuajse i njëjtë dhe duhet të dërgojmë të gjitha të dhënat. Dallimi i modelit për frontend është se skedari dhe përdoruesi duhet të paraqiten në një formë pak më të deshifruar, për t'i bërë ato lidhje:
/** В таком виде отдаются ссылки на сущности для фронта */
data klasa ReferencaUI(
/** Идентификатор для url */
vlera referencë: String,
/** Видимое пользователю название ссылки */
vlera emri: String
)
data klasa ChatMesazhUI(
vlera id: e gjatë,
/** Ссылка на автора */
vlera autori: ReferencaUI,
/** Сообщение */
vlera mesazhi: String,
/** Ссылки на аттачи */
vlera skedarët: Listë<ReferencaUI>,
/** Если являтся ответом, то здесь будет оригинал */
vlera pergjigjeTek: ChatMesazhUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
vlera dergoNga: ChatMesazhUI? = null
)
Na nevojitet të realizojmë këtë:
interface ChatRestApi {
argëtim merrDhe(n: Int): Listë<ChatMesazhUI>
}
Postfix UI do të thotë modele DTO për frontend, pra, ajo që duhet të dorëzojmë përmes REST.
Këtu mund të duket befasuese se ne nuk e kalojmë ndonjë identifikues chat-i dhe as në modelin ChatMessage / ChatMessageUI nuk e kemi. E kam bërë këtë qëllimisht, për të mos ngarkuar kodin e shembujve (chat-et janë të izoluar, kështu që mund të mendojmë se kemi vetëm një).
Një shkëputje filozofikeSi në klasën ChatMessageUI, ashtu edhe në metodën ChatRestApi.getLast përdoret lloji i të dhënave List, kur në fakt është një Set i renditur. Në JDK ky është një problem, kështu që nuk do të jetë e mundur të deklarohet rendi i elementeve në nivelin e interfesës (ruajtja e rendit gjatë shtimit dhe nxjerrjes). Kështu, praktika e zakonshme është të përdorim List në ato raste kur na nevojitet një Set i renditur (ka edhe LinkedHashSet, por ky nuk është një interface).
Një kufizim i rëndësishëm: do ta marrim parasysh se nuk ka zinxhirë të gjatë përgjigjesh ose përcjelljesh. Pra, ata janë, por gjatësia e tyre nuk kalon tre mesazhe. Në frontend, zinxhiri i mesazheve duhet të kalojë si një tërësi.
Për të marrë të dhënat nga shërbimet e jashtme ka këto API:
interface ChatMessageRepository {
argëtim findLast(n: Int): Listë<ChatMessage>
}
data klasa FileHeadRemote(
vlera id: FileReference,
vlera emri: String
)
interface FileRemoteApi {
argëtim getHeadById(id: FileReference): FileHeadRemote
argëtim getHeadsByIds(id: Set<FileReference>): Set<FileHeadRemote>
argëtim getHeadsByIds(id: Listë<FileReference>): Listë<FileHeadRemote>
argëtim getHeadsByChat(): Listë<FileHeadRemote>
}
data klasa UserRemote(
vlera id: UserReference,
vlera emri: String
)
interface UserRemoteApi {
argëtim getUserById(id: UserReference): UserRemote
argëtim getUsersByIds(id: Set<UserReference>): Set<UserRemote>
argëtim getUsersByIds(id: Listë<UserReference>): Listë<UserRemote>
}
Është e qartë se shërbimet e jashtme parashikojnë fillimisht përpunimin e paketave, sidomos në të dy variantet: përmes Set (pa ruajtjen e rendit të elementeve, me çelësa unikë) dhe përmes List (mund të ketë dhe kopje - rendi ruhet).
Implementime të thjeshta
Implementimi naiv
Implementimi i parë naive i kontrolluerit tonë REST do të duket kryesisht kështu:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun merrDhe(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun ChatMessage.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ë mjaft e qartë, dhe kjo është një përfitim i madh.
Ne përdorim përpunim grupor dhe marrim të dhënat nga një shërbim i 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 për fushën author dhe një thirrje FileRemoteApi për të marrë të gjitha skedarët e bashkangjitur. Duket sikur ë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, shndërrimi i tyre në ChatMessageUI do të sjellë rekurzivitet, që do të thotë se numri i thirrjeve mund të rritet ndjeshëm. Siç e thamë më parë, le të supozojmë se nuk ka shumë thellësi dhe zinxhiri është i kufizuar në tri mesazhe.
Në përfundim, do të marrim nga dy në gjashtë thirrje për shërbime të jashtme për një mesazh dhe një thirrje JPA për të gjithë paketën e mesazheve. Numri total i thirrjeve do të variojë nga 2*N+1 deri në 6*N+1. Sa është këtë në njësi reale? Le të supozojmë se për vizatimin e faqes duhen 20 mesazhe. Për t’i marrë ato, do të nevojiten nga 4 deri në 10 sekonda. E tmerrshme! Do të doja të dija si të ulesha në 500 ms. Dhe, pasi në front do të donin të bënin skroll të pandërprerë, kërkesat për performancën e këtij endpointi mund të dyfishohen.
Përparësitë:
- Kodi është i shkurtër dhe vetë-dokumentues (Ëndrrat e mbështetjes).
- Kodi është i thjeshtë, kështu që mundësitë për të goditur veten në këmbë janë pothuajse zero.
- Përpunimi grupor nuk duket si diçka e huaj dhe është integruar natyrshëm në logjikë.
- Ndryshimet e logjikës do të bëhen lehtësisht dhe do të jenë lokale.
Minus:
Performanca e tmerrshme, e lidhur me faktin se paketat po bëhen shumë të vogla.
Ky qasje mund të shihet shpesh në shërbime të thjeshta ose në prototipa. Nëse shpejtësia e ndryshimeve është e rëndësishme, nuk ka kuptim të komplikohet sistemi. Megjithatë, për shërbimin tonë shumë të thjeshtë, performanca rezulton të jetë e tmerrshme, kështu që kufijtë e aplikueshmërisë për këtë qasje janë shumë të ngushtë.
Procesim naiv paralel
Mund të fillojmë përpunimin e të gjitha mesazheve paralelisht - kjo do të ndihmojë për të shmangur rritjen lineare të kohës në varësi të numrit të mesazheve. Kjo nuk është një rrugë e mirë, për shkak se do të shkaktojë një ngarkesë të madhe në shërbimin e jashtëm.
Të implementosh përpunimin paralel është shumë e lehtë:
override fun merrDhe(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Duke përdorur përpunimin paralel të mesazheve, do të arrijmë 300–700 ms në kushte ideale, që është shumë më mirë se realizimi naiv, por ende jo mjaft shpejt.
Me këtë qasje, kërkesat për userRepository dhe fileRepository do të ekzekutohen në mënyrë sinkrone, që nuk është shumë efektive. Për ta rregulluar këtë, do të duhet të ndryshohet mjaft logjika e thirrjeve. Për shembull, përmes CompletionStage (d.m.th., CompletableFuture):
private fun ChatMessage.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()!!
E shohim se kodi i thjeshtë i mapimit fillestar u bë më pak i qartë. Kjo ndodh sepse na u desh të ndanim thirrjet e shërbimeve të jashtme nga vendi i përdorimit të rezultateve. Kjo vetë nuk është e keqe. Por, kombinimi i thirrjeve duket jo shumë elegant dhe i ngjan "makaroneve" tipike reaktive.
Nëse përdorim korutina, gjithçka do të duket më e hijshme:
private fun ChatMessage.toFrontModel(): ChatMesazhUI =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(fajlat)
.map { it.toFrontReference() } } ?: listOf() }
).let { (autori, fajlat) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
autori = autori,
message = message,
fajlat = fajlat,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Ku:
argëtim <A, B> join(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 një përpunim të tillë paralel, do të arrijmë 200–400 ms, që është tashmë afër pritshmërive tona.
Fatkeqësisht, një ndarje kaq të mirë nuk ndodh, dhe çmimi është mjaft i ashpër: nëse disa përdorues punojnë njëherësh, për shërbimet do të ndodhin një mori kërkesash, që përsëri nuk do të përpunohen paralelisht, kështu që do të kthehemi në ato 4 sekonda të trishtueshme.
Rezultati im duke përdorur një shërbim të tillë është 1300–1700 ms për të përpunuar 20 mesazhe. Kjo është më shpejt se në realizimin e parë, por prapëseprapë nuk e zgjidh problemin.
Përdorimi alternativ i thirrjeve paralel.Çfarë nëse shërbimet e jashtme nuk ofrojnë përpunim në grupe? Për shembull, mund të fshehim mungesën e implementimit të përpunimit në grup brenda metodave të interface-t:
interface UserRemoteApi {
argëtim getUserById(id: UserReference): UserRemote
argëtim getUsersByIds(id: Set<UserReference>): Set<UserRemote>=
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
argëtim getUsersByIds(id: Listë<UserReference>): Listë<UserRemote>=
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Kjo ka kuptim, nëse ka shpresë për shfaqjen e përpunimit në grupe në versionet e ardhshme.
Përparësitë:
- Implementimi i lehtë i përpunimit paralel për mesazhet.
- Mundësi e mirë për shkallëzim.
Disavantazhet:
- Nevoja për të ndarë marrjen e të dhënave nga përpunimi i tyre gjatë përpunimit paralel të kërkesave për shërbime të ndryshme.
- Ngarkesë e rritur në shërbimet e jashtme.
E shohim se kufijtë e aplikueshmërisë janë përafërsisht të njëjtë si ato të qasjes naive. Të përdorësh metodën e thirrjeve paralel ka kuptim, nëse dëshiron të rrisësh në disa herë performancën e shërbimit tënd duke shfrytëzuar pa mëshirë shërbimet e të tjerëve. Në shembullin tonë, performanca u rrit 2.5 herë, por kjo është qartë e pamjaftueshme.
Kešimi
E mundshme të bëhet caching në stilin JPA për shërbimet e jashtme, domethënë gjatë seancës mund të ruajmë objektet e marra, për të mos i marrë përsëri (në përfshirje të përpunimeve me grupe). Mund të krijoni këto cache vetë, mund të përdorni Spring me @Cacheable, plus gjithmonë mund të përdorni një cache të gatshme si EhCache manualisht.
Problemi i përgjithshëm do të lidhet me faktin se caching ka dobi vetëm nëse ka goditje. Në rastin tonë, është shumë e mundshme që goditjet të ndodhin në fushën author (të themi, 50%), ndërsa nuk do të ketë goditje fare për skedarët. Disa përmirësime ky qasje do të ofrojë, por nuk do ta ndryshojë radikalisht performancën (dhe ne kemi nevojë për një përshpejtim).
Cache-t ndërseancore (të gjata) kërkojnë logjikë të komplikuar të invalidimit. Në përgjithësi, sa më vonë të arrini në pikën ku do të zgjidhni problemet e performancës me anë të cache-ve ndërseancore, aq më mirë.
Përparësitë:
- Zbatimi i caching pa ndryshuar kodin.
- Rritja e performancës disa herë (në disa raste).
Disavantazhet:
- Mundësia e uljes së performancës në rast përdorimi të gabuar.
- Ngarkesa e madhe e memories, veçanërisht me cache të gjata.
- Invalidimi i komplikuar, gabimet në të cilin do të çojnë në probleme të vështira për t'u riprodhuar në kohë ekzekutimi.
Shpesh cache-t përdoren vetëm për të riparuar shpejt problemet e dizajnit. Kjo nuk do të thotë që nuk duhet t'i përdorni. Megjithatë, gjithmonë duhet t'i qaseni atyre me kujdes dhe së pari të vlerësoni përfitimin që merrni nga performanca, dhe pastaj të merrni një vendim.
Në shembullin tonë, cache-t do të japin një përfitim në performancë rreth 25%. Megjithatë, ka shumë disavantazhe të cache-ve, kështu që nuk do të doja t'i përdorja këtu.
Përfundimet
Pra, shqyrtuam implementimin naive të shërbimit që përdor përpunimin me grupe dhe disa mënyra të thjeshta për ta përshpejtuar atë.
Avantazhi kryesor i këtyre metodave është thjeshtësia, nga e cila ka shumë ndjekje të këndshme.
Problemi i zakonshëm i këtyre metodave është performanca e dobët, e cila lidhet kryesisht me madhësinë e grupeve. Prandaj, nëse këto zgjidhje nuk ju përshtaten, duhet të shqyrtoni metoda më radikale.
Ka dy drejtime kryesore ku mund të kërkoni zgjidhje:
- punimi asinkron me të dhënat (kërkon ndryshimin e paradigmes, prandaj nuk shqyrtohet në këtë artikull);
- rritjen e grupeve duke ruajtur përpunimin sinkron.
Rritja e grupeve do të 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 tjetër të artikullit.
Burimi: habr.com
