Pothuajse të gjitha produktet moderne softuerike përbëhen nga shërbime të shumëfishta. Kohët e ngadalta të reagimit midis shërbimeve shpesh bëhen burim problemesh të performancës. Një zgjidhje standarde për këtë problem është paketimi i kërkesave të shumëfishta ndërshërbimesh në një paketë të vetme, një proces i njohur si grumbullim.
Nëse përdorni përpunim në grup, mund të jeni të pakënaqur me performancën ose qartësinë e kodit. Kjo metodë nuk është aq e drejtpërdrejtë për thirrësin sa mund të mendoni. Zgjidhjet për qëllime dhe situata të ndryshme mund të ndryshojnë shumë. Duke përdorur shembuj specifikë, do të ilustroj pro dhe kundrat e disa qasjeve.
Projekt demonstrues
Për ta ilustruar këtë, le të shohim një shembull të njërit prej shërbimeve në aplikacionin në të cilin po punoj aktualisht.
Shpjegim i zgjedhjes së platformës për shembujProblemi i performancës së dobët është mjaft i përgjithshëm dhe nuk ka të bëjë me ndonjë gjuhë ose platformë specifike. Ky artikull do të përdorë shembuj kodi Spring dhe Kotlin për të demonstruar sfidat dhe zgjidhjet. Kotlin është po aq i kuptueshëm (ose i pakuptueshëm) për zhvilluesit e Java dhe C#, dhe kodi që rezulton është më kompakt dhe i kuptueshëm se Java. Për ta bërë më të lehtë për zhvilluesit e pastër të Java-s ta kuptojnë, do të shmang magjinë e zezë të Kotlin dhe do të përdor vetëm magjinë e bardhë (në frymën e Lombokut). Do të ketë disa metoda zgjerimi, por këto 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ëlsues i vogël që nuk do ta prishë pjatën.
Ekziston një shërbim miratimi dokumentesh. Dikush krijon një dokument dhe e paraqet atë për diskutim, gjatë të cilit bëhen ndryshime dhe në fund dokumenti miratohet. Vetë shërbimi i miratimit nuk di asgjë për dokumentet: është thjesht një dhomë bisede për miratuesit me disa veçori shtesë që nuk do t'i diskutojmë këtu.
Pra, ekzistojnë dhoma chati (që korrespondojnë me dokumentet) me një grup të paracaktuar pjesëmarrësish në secilën prej tyre. Ashtu si në chatet e rregullta, mesazhet përmbajnë tekst dhe skedarë dhe mund të jenë përgjigje ose të ridrejtuara:
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
)
Lidhjet për një skedar dhe përdorues janë lidhje për skedarë të tjerë. domenetJa se si funksionon për ne:
typealias FileReference = Long
typealias UserReference = Long
Të dhënat e përdoruesit ruhen në Keycloak dhe merren nëpërmjet REST. E njëjta gjë vlen edhe për skedarët: skedarët dhe meta të dhënat e tyre ndodhen në një shërbim të veçantë ruajtjeje skedarësh.
Të gjitha thirrjet për këto shërbime janë kërkesa të vështiraKjo do të thotë që kostoja e transportimit të këtyre kërkesave është shumë më e madhe se koha që duhet për t'i përpunuar ato nga shërbimi i palës së tretë. Në platformat tona të testimit, koha tipike e thirrjes për shërbime të tilla është 100 ms, kështu që do t'i përdorim këto shifra që nga tani e tutje.
Na duhet të krijojmë një kontrollues të thjeshtë REST për të marrë N mesazhet e fundit me të gjithë informacionin e nevojshëm. Me fjalë të tjera, supozojmë se modeli i mesazhit të frontend është pothuajse i njëjtë dhe të gjitha të dhënat duhet të dërgohen. Dallimi në modelin e frontend është se skedari dhe përdoruesi duhet të përfaqësohen në një formë paksa të deshifruar për t'i bërë ato të lidhura:
/** В таком виде отдаются ссылки на сущности для фронта */
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
)
Ne duhet të zbatojmë sa vijon:
interface ChatRestApi {
fun getLast(n: Int): List<ChatMessageUI>
}
Postfiksi i ndërfaqes së përdoruesit qëndron për modelet DTO për frontend-in, domethënë, atë që duhet të kthejmë nëpërmjet REST.
Ajo që mund të jetë e habitshme këtu është se ne nuk kalojmë asnjë ID bisede, dhe nuk ka asnjë në modelin ChatMessage/ChatMessageUI. E bëra këtë qëllimisht për ta mbajtur kodin shembullor të thjeshtë (bisedat janë të izoluara, kështu që mund ta konsiderojmë veten sikur kemi vetëm një).
Digresion filozofikSi klasa ChatMessageUI ashtu edhe metoda ChatRestApi.getLast përdorin llojin e të dhënave List, por në fakt ato janë një Set i renditur. JDK nuk e mbështet këtë, kështu që deklarimi i renditjes së elementeve në nivelin e ndërfaqes (ruajtja e rendit gjatë futjes dhe rikthimit) nuk është i mundur. Prandaj, është praktikë e zakonshme të përdoret List kur nevojitet një Set i renditur (LinkedHashSet është gjithashtu i disponueshëm, por nuk është një ndërfaqe).
Kufizim i rëndësishëm: Le të supozojmë se zinxhirët e gjatë të përgjigjeve ose të dërgimeve të reja nuk ekzistojnë. Domethënë, ato ekzistojnë, por gjatësia e tyre nuk i kalon tre mesazhe. I gjithë zinxhiri i mesazheve duhet të transmetohet në frontend.
Për të marrë të dhëna nga shërbimet e jashtme, ekzistojnë API-të e mëposhtme:
interface ChatMessageRepository {
fun findLast(n: Int): List<ChatMessage>
}
data class FileHeadRemote(
val id: FileReference,
val name: String
)
interface FileRemoteApi {
fun getHeadById(id: FileReference): FileHeadRemote
fun getHeadsByIds(id: Set<FileReference>): Set<FileHeadRemote>
fun getHeadsByIds(id: List<FileReference>): List<FileHeadRemote>
fun getHeadsByChat(): List<FileHeadRemote>
}
data class UserRemote(
val id: UserReference,
val name: String
)
interface UserRemoteApi {
fun getUserById(id: UserReference): UserRemote
fun getUsersByIds(id: Set<UserReference>): Set<UserRemote>
fun getUsersByIds(id: List<UserReference>): List<UserRemote>
}
Është e qartë se shërbimet e jashtme fillimisht ofrojnë përpunim në grup, dhe në të dy variantet: nëpërmjet Set (pa ruajtur rendin e elementeve, me çelësa unikë) dhe nëpërmjet List (mund të ketë kopje - rendi ruhet).
Implementime të thjeshta
Zbatim naiv
Implementimi i parë naiv i kontrolluesit tonë REST do të duket diçka e tillë në shumicën e rasteve:
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 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()
)
}
Gjithçka është jashtëzakonisht e qartë, dhe ky është një plus i madh.
Ne përdorim përpunimin në grupe dhe marrim të dhëna nga një shërbim i jashtëm në grupe. Por çfarë po ndodh me performancën?
Për çdo mesazh, do të bëhet një thirrje UserRemoteApi për të marrë fushën e autorit dhe një thirrje FileRemoteApi për të marrë të gjithë skedarët e bashkangjitur. Duket se kaq. Le të supozojmë se fushat forwardFrom dhe replyTo për ChatMessage gjenerohen në një mënyrë të tillë që kjo nuk kërkon thirrje shtesë. Megjithatë, konvertimi i tyre në ChatMessageUI do të çojë në rekursion, që do të thotë se numëruesit e thirrjeve mund të rriten ndjeshëm. Siç e theksuam më parë, le të supozojmë se nuk kemi shumë ndërthurje dhe fija është e kufizuar në tre mesazhe.
Si rezultat, do të përfundojmë me dy deri në gjashtë thirrje shërbimi të jashtëm për mesazh dhe një thirrje JPA për të gjithë grupin e mesazheve. Numri total i thirrjeve do të ndryshojë nga 2*N+1 në 6*N+1. Sa është kjo në njësi reale? Le të themi se një faqe ka nevojë për 20 mesazhe për t'u paraqitur. Marrja e tyre do të zgjasë midis 4 dhe 10 sekondave. Tmerruese! Do të donim ta mbanim nën 500 ms. Dhe meqenëse ekipi i frontend donte të arrinte lëvizje pa probleme, kërkesat e performancës për këtë pikë fundore mund të dyfishohen.
Pro:
- Kodi është i shkurtër dhe vetëdokumentues (ëndrra e një personi mbështetës).
- Kodi është i thjeshtë, kështu që nuk ka pothuajse asnjë mundësi për të qëlluar veten në këmbë.
- Përpunimi në seri nuk duket i huaj dhe përshtatet pa probleme në logjikë.
- Ndryshimet në logjikë do të jenë të lehta për t'u bërë dhe do të jenë lokale.
Minus:
Performancë e tmerrshme për shkak se paketat janë shumë të vogla.
Kjo qasje është mjaft e zakonshme në shërbime ose prototipe të thjeshta. Nëse shpejtësia e ndryshimit është e rëndësishme, vështirë se ia vlen ta ndërlikosh sistemin. Megjithatë, për shërbimin tonë shumë të thjeshtë, performanca është e tmerrshme, kështu që zbatueshmëria e kësaj qasjeje është shumë e kufizuar.
Përpunim paralel naiv
Mund ta ekzekutoni të gjithë përpunimin e mesazheve paralelisht—kjo do të eliminojë rritjen lineare të kohës së përpunimit në varësi të numrit të mesazheve. Kjo nuk është një qasje veçanërisht e mirë, pasi do të çojë në një ngarkesë të konsiderueshme maksimale në shërbimin e jashtëm.
Implementimi i përpunimit paralel është shumë i thjeshtë:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Duke përdorur përpunimin paralel të mesazheve, në mënyrë ideale marrim 300-700 ms, që është shumë më mirë se implementimi naiv, por prapë jo mjaftueshëm i shpejtë.
Me këtë qasje, kërkesat drejtuar userRepository dhe fileRepository do të ekzekutohen në mënyrë sinkrone, gjë që është joefikase. Për ta rregulluar këtë, logjika e thirrjes do të duhet të modifikohet ndjeshëm. Për shembull, nëpërmjet CompletionStage (i njohur edhe si CompletableFuture):
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())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = author,
message = message,
files = files,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}.get()!!
Mund të shihni se kodi fillimisht i thjeshtë i hartëzimit është bërë më pak i qartë. Kjo për shkak se na është dashur të ndajmë thirrjet drejt shërbimeve të jashtme nga vendi ku përdoren rezultatet. Kjo në vetvete nuk është keq. Por kombinimi i thirrjeve nuk duket shumë elegant dhe i ngjan "makaronave" tipike reaktive.
Nëse përdorni korutina, gjithçka do të duket më mirë:
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()
)
}
Ku:
fun <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, marrim 200-400 ms, që është tashmë afër pritjeve tona.
Fatkeqësisht, një paralelizim kaq i mirë nuk ekziston, dhe çmimi është mjaft i lartë: me vetëm disa përdorues që punojnë njëkohësisht, shërbimet do të përmbyten me kërkesa që nuk do të përpunohen paralelisht gjithsesi, kështu që do të kthehemi te 4 sekondat tona të trishtueshme.
Rezultati im duke përdorur këtë shërbim është 1300–1700 ms për përpunimin e 20 mesazheve. Kjo është më e shpejtë se implementimi i parë, por prapë nuk e zgjidh problemin.
Përdorimi alternativ i pyetjeve paralelePo sikur shërbimet e palëve të treta të mos e mbështesin përpunimin në grup? Për shembull, mund ta fshihni mungesën e implementimit të përpunimit në grup brenda metodave të ndërfaqes:
interface UserRemoteApi {
fun getUserById(id: UserReference): UserRemote
fun getUsersByIds(id: Set<UserReference>): Set<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
fun getUsersByIds(id: List<UserReference>): List<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Kjo ka kuptim nëse ka shpresë që përpunimi në grup të shfaqet në versionet e ardhshme.
Pro:
- Implementim i lehtë i përpunimit paralel të drejtuar nga mesazhet.
- Shkallëzueshmëri e mirë.
Cons:
- Nevoja për të ndarë mbledhjen e të dhënave nga përpunimi i tyre gjatë përpunimit paralel të kërkesave për shërbime të ndryshme.
- Ngarkesë e shtuar në shërbimet e palëve të treta.
Është e qartë se fusha e zbatueshmërisë është përafërsisht e njëjtë me atë të qasjes naive. Përdorimi i pyetjeve paralele ka kuptim nëse doni të rrisni performancën e shërbimit tuaj disa herë duke shfrytëzuar pa mëshirë të të tjerëve. Në shembullin tonë, performanca u rrit me 2,5 herë, por kjo nuk është e mjaftueshme.
caching
Mund të implementoni ruajtjen në memorje në stilin JPA për shërbime të jashtme, duke ruajtur objektet e rikuperuara brenda një sesioni për të shmangur rimarrjen e tyre (përfshirë edhe gjatë përpunimit në grup). Mund t'i implementoni vetë memorje të tilla në memorje, duke përdorur Spring me @Cacheable-in e tij, ose gjithmonë mund të përdorni manualisht një memorje të gatshme në memorje si EhCache.
Problemi i përgjithshëm është se memorjet e përkohshme janë të dobishme vetëm nëse ka rezultate. Në rastin tonë, rezultatet në fushën e autorit kanë shumë të ngjarë (le të themi 50%), por nuk do të ketë asnjë rezultat në skedarë. Kjo qasje do të ofrojë disa përmirësime, por nuk do ta përmirësojë rrënjësisht performancën (dhe ne kemi nevojë për një përparim).
Memorjet e gjata (cache) ndërmjet seancave kërkojnë logjikë komplekse të invalidizimit. Në përgjithësi, sa më gjatë të prisni për t'iu drejtuar memorjeve të gjata ndërmjet seancave për të zgjidhur problemet e performancës, aq më mirë.
Pro:
- Implementoni ruajtjen në memorje pa ndryshuar kodin.
- Rritja e produktivitetit disa herë (në disa raste).
Cons:
- Mundësia e përkeqësimit të performancës nëse nuk përdoret siç duhet.
- Mbingarkesë e madhe memorieje, veçanërisht me memorje të gjata në memorie.
- Pavlefshmëri komplekse, gabimet në të cilat do të çojnë në probleme të vështira për t'u riprodhuar gjatë kohës së ekzekutimit.
Memorjet e përkohshme shpesh përdoren thjesht si një zgjidhje e shpejtë për problemet e dizajnit. Kjo nuk do të thotë që ato nuk duhet të përdoren. Megjithatë, ia vlen gjithmonë t'u qasemi atyre me kujdes dhe të vlerësojmë përfitimet që rezultojnë në performancë përpara se të marrim një vendim.
Në shembullin tonë, memorjet e përkohshme do të ofrojnë një rritje të performancës prej rreth 25%. Megjithatë, memorjet e përkohshme kanë disa disavantazhe, kështu që nuk do t'i përdorja këtu.
Rezultatet e
Pra, kemi parë një implementim naiv të një shërbimi që përdor përpunimin në grup, dhe disa mënyra të thjeshta për ta shpejtuar atë.
Avantazhi kryesor i të gjitha këtyre metodave është thjeshtësia e tyre, e cila ka shumë pasoja të këndshme.
Një problem i zakonshëm me këto metoda është performanca e dobët, kryesisht për shkak të madhësisë së paketës. Prandaj, nëse këto zgjidhje nuk funksionojnë për ju, duhet të merrni në konsideratë qasje më radikale.
Ekzistojnë dy drejtime kryesore në të cilat mund të kërkohen zgjidhje:
- punë asinkrone me të dhëna (kërkon një ndryshim paradigme, kështu që nuk diskutohet në këtë artikull);
- Zmadhimi i serive duke ruajtur përpunimin sinkron.
Grumbullimi i grupeve do të zvogëlojë ndjeshëm numrin e thirrjeve të jashtme, duke e mbajtur kodin sinkron. Kjo temë do të trajtohet në seksionin tjetër të artikullit.
Burimi: www.habr.com
