Practicamente toate produsele software moderne constau din mai multe servicii. Adesea, un timp de răspuns mare între canalele de servicii devine o sursă de probleme de performanță. Soluția standard pentru acest tip de probleme este împachetarea mai multor cereri inter-servicii într-un singur pachet, numită procesare în loturi (batching).
Dacă utilizați procesarea în loturi, s-ar putea să nu fiți mulțumit de rezultatul său în ceea ce privește performanța sau claritatea codului. Această metodă nu este la fel de simplă pentru apelant precum s-ar putea crede. Pentru scopuri diferite și în diverse situații, soluțiile pot varia considerabil. În exemple concrete voi arăta avantajele și dezavantajele mai multor abordări.
Proiect demonstrativ
Pentru a ilustra, să luăm un exemplu dintr-unul dintre serviciile aplicației la care lucrez acum.
Explicație privind alegerea platformei pentru exempleProblema performanței slabe este destul de comună și nu se referă la anumite limbaje sau platforme. În acest articol, pentru a demonstra sarcinile și soluțiile, vor fi folosite exemple de cod pe Spring + Kotlin. Kotlin este la fel de clar (sau neclar) pentru dezvoltatorii Java și C#, iar codul rezultat este mai compact și mai ușor de înțeles decât cel pe Java. Pentru a facilita înțelegerea pentru dezvoltatorii Java puri, voi evita magia neagră a Kotlin și voi folosi doar magia albă (în spiritul Lombok). Vor fi puține metode de extensie, dar acestea sunt de fapt cunoscute tuturor programatorilor Java ca metode static, așa că acestea vor fi un pic de zahăr care nu va strica gustul preparatului.
Există un serviciu de aprobat documente. Cineva creează un document și îl supune discuției, în timpul căreia se fac modificări, iar în cele din urmă documentul este aprobat. Serviciul de aprobat nu știe nimic despre documente: este pur și simplu un chat pentru aprobat cu câteva funcții suplimentare, pe care nu le vom analiza aici.
Așadar, există camere de chat (corespunzătoare documentelor) cu un set predefinit de participanți în fiecare dintre ele. Așa cum se întâmplă în chaturile obișnuite, mesajele conțin text și fișiere și pot fi răspunsuri (reply) și redirecționări (forward):
dată clasă ChatMessage(
// nullable так как появляется только после persist
val id: Lung? = null,
/** Ссылка на автора */
val autor: UserReference,
/** Сообщение */
val mesaj: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val fișiere: Listă<FileReference>>? = null,
/** Если является ответом, то здесь будет оригинал */
val replyTo: ChatMessage? = null,
/** Если является пересылкой, то здесь будет оригинал */
val forwardFrom: ChatMessage? = null
)
Linkurile către fișiere și utilizatori sunt linkuri către altele domenii. La noi aceasta funcționează astfel:
tipar de alias FileReference = Lung
tipar de alias UserReference = Lung
Datele utilizatorilor sunt stocate în Keycloak și obținute prin REST. Același lucru este valabil și pentru fișiere: fișierele și meta-informațiile lor există într-un serviciu de stocare a fișierelor separată.
Toate apelurile acestor servicii sunt solicitări grele. Acest lucru înseamnă că cheltuielile de transport pentru aceste solicitări sunt mult mai mari decât timpul de procesare de către serviciul extern. În standurile noastre de testare, timpul tipic pentru apelarea acestor servicii este de 100 ms, așa că în continuare vom folosi aceste cifre.
Trebuie să realizăm un controler REST simplu pentru a obține ultimele N mesaje cu toate informațiile necesare. Asta înseamnă că presupunem că în frontend modelul mesajelor este aproape identic și trebuie să trimitem toate datele. Diferența modelului pentru frontend este că fișierul și utilizatorul trebuie să fie prezentate într-o formă puțin decriptată, pentru a le face linkuri:
/** В таком виде отдаются ссылки на сущности для фронта */
dată clasă Interfață de Utilizator pentru Referință(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val nume: String
)
dată clasă Interfață de Utilizator pentru Mesaje de Chat(
val id: Lung,
/** Ссылка на автора */
val autor: Interfață de Utilizator pentru Referință,
/** Сообщение */
val mesaj: String,
/** Ссылки на аттачи */
val fișiere: Listă<Interfață de Utilizator pentru Referință>
/** Если являтся ответом, то здесь будет оригинал */
val replyTo: Interfață de Utilizator pentru Mesaje de Chat? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val forwardFrom: Interfață de Utilizator pentru Mesaje de Chat? = null
)
Trebuie să implementăm următoarele:
interfață ChatRestApi {
distracția getLast(n: Int): Listă<Interfață de Utilizator pentru Mesaje de Chat>
}
Suffixul UI se referă la modele DTO pentru frontend, adică ceea ce trebuie să livrăm prin REST.
Aici ar putea părea surprinzător faptul că nu transmitem niciun identificator al chat-ului și nici în modelul ChatMessage/ChatMessageUI nu este. Am făcut acest lucru intenționat, pentru a nu aglomera codul exemplelor (chaturile sunt izolate, așa că putem considera că avem de fapt unul singur).
O deviere filozoficăAtât în clasa ChatMessageUI, cât și în metoda ChatRestApi.getLast se folosește tipul de date List, în timp ce de fapt acesta este un Set ordonat. În JDK, situația nu este grozavă, așa că nu va fi posibil să declarăm ordinea elementelor la nivel de interfață (menținerea ordinii la adăugare și extragere). Așa că practica obișnuită a devenit utilizarea List atunci când este necesar un Set ordonat (există și LinkedHashSet, dar acesta nu este o interfață).
O limitare importantă: vom considera că nu există lanțuri lungi de răspunsuri sau redirecționări. Asta înseamnă că ele există, dar lungimea lor nu depășește trei mesaje. În frontend, lanțul de mesaje trebuie transmis în întregime.
Pentru a obține date din servicii externe, există următoarele API-uri:
interfață ChatMessageRepository {
distracția findLast(n: Int): Listă<ChatMessage>
}
dată clasă FileHeadRemote(
val id: FileReference,
val nume: String
)
interfață FileRemoteApi {
distracția getHeadById(id: FileReference): FileHeadRemote
distracția getHeadsByIds(id: Set<FileReference>): Set<FileHeadRemote>
distracția getHeadsByIds(id: Listă<FileReference>): Listă<FileHeadRemote>
distracția getHeadsByChat(): Listă<FileHeadRemote>
}
dată clasă UserRemote(
val id: UserReference,
val nume: String
)
interfață UserRemoteApi {
distracția getUserById(id: UserReference): UserRemote
distracția getUsersByIds(id: Set<UserReference>): Set<UserRemote>
distracția getUsersByIds(id: Listă<UserReference>): Listă<UserRemote>
}
Se observă că în serviciile externe este prevăzută inițial procesarea pe loturi, atât în ambele variante: prin Set (fără menținerea ordinii elementelor, cu chei unice) și prin List (pot exista duplicate - ordinea este menținută).
Implementări simple
O implementare naivă
Prima implementare naivă a controller-ului nostru REST va arăta, în cele mai multe cazuri, cam așa:
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(): Interfață de Utilizator pentru Mesaje de Chat =
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()
)
}
Totul este extrem de clar și acest lucru este un mare avantaj.
Folosim procesarea în loturi și obținem date dintr-un serviciu extern în loturi. Dar ce se întâmplă cu performanța?
Pentru fiecare mesaj, se va face un apel UserRemoteApi pentru a obține datele despre câmpul author și un apel FileRemoteApi pentru a obține toate fișierele atașate. Așadar, pare simplu. Să presupunem că câmpurile forwardFrom și replyTo pentru ChatMessage sunt obținute astfel încât să nu necesite apeluri suplimentare. Dar transformarea lor în ChatMessageUI va duce la recursivitate, adică statisticile apelurilor pot crește semnificativ. Așa cum am menționat anterior, să presupunem că nu avem o adâncime mare și că șirul este limitat la trei mesaje.
În cele din urmă, vom obține de la două la șase apeluri către servicii externe pentru un singur mesaj și un apel JPA pentru întreaga serie de mesaje. Numărul total de apeluri va varia de la 2*N+1 la 6*N+1. Câte unități reprezintă acest lucru în realitate? Să presupunem că pentru redarea paginii avem nevoie de 20 de mesaje. Pentru a le obține, va fi nevoie de între 4 și 10 secunde. Groaznic! Ne-am dori să ne încadrăm în 500 ms. Și, având în vedere că în front-end se visa un scroll continuu, cerințele pentru performanța acestui endpoint pot fi dublate.
Pro:
- Codul este concis și auto-documentat (visul suportului tehnic).
- Codul este simplu, deci posibilitățile de a te împușca în picior sunt aproape inexistente.
- Procesarea în loturi nu pare ceva străin și se integrează organic în logică.
- Modificările logice vor fi ușor de realizat și vor fi locale.
Minus:
Performanță groaznică, provocată de faptul că pachetele sunt foarte mici.
Această abordare poate fi văzută adesea în servicii simple sau în prototipuri. Dacă viteza de implementare a modificărilor este importantă, este puțin probabil să merite să complicăm sistemul. În același timp, pentru serviciul nostru foarte simplu, performanța este teribilă, astfel încât domeniul de aplicabilitate al unei astfel de abordări este foarte restrâns.
Procesarea paralelă naivă
Se pot procesa toate mesajele în paralel - acest lucru va elimina creșterea liniară a timpului în funcție de numărul de mesaje. Aceasta nu este o soluție foarte bună, deoarece va duce la o povară de vârf mare asupra serviciului extern.
Implementarea procesării paralele este foarte simplă:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Folosind procesarea paralelă a mesajelor, vom obține ideal între 300–700 μs, ceea ce este mult mai bine decât o implementare naivă, dar tot nu este suficient de rapid.
Cu o astfel de abordare, cererile către userRepository și fileRepository vor fi executate sincronic, ceea ce nu este foarte eficient. Pentru a remedia acest lucru, va trebui să schimbăm semnificativ logica apelurilor. De exemplu, folosind CompletionStage (cunoscuta CompletableFuture):
private fun ChatMessage.toFrontModel(): Interfață de Utilizator pentru Mesaje de Chat =
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()!!
Se observă că codul inițial simplu de mapping a devenit mai puțin clar. Acest lucru se datorează faptului că a trebuit să separăm apelurile către servicii externe de locul în care utilizăm rezultatele. În sine, acest lucru nu este rău. Dar combinarea apelurilor arată oarecum neglijent și amintește de o tipică "pastă" reactivă.
Dacă folosim corutine, totul va arăta mai bine:
private fun ChatMessage.toFrontModel(): Interfață de Utilizator pentru Mesaje de Chat =
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()
)
}
Unde:
distracția <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A to it[1] as B
}
Teoretic, folosind o astfel de procesare paralelă, am putea obține între 200–400 μs, ceea ce este deja aproape de așteptările noastre.
Din păcate, o astfel de bună paralelare nu există, iar prețul este destul de ridicat: la utilizarea simultană a doar câtorva utilizatori, serviciile vor fi copleșite de un val de cereri, care oricum nu vor fi procesate paralel, așa că ne vom întoarce la cei triști 4 ms.
Rezultatul meu utilizând un astfel de serviciu este de 1300–1700 ms pentru procesarea a 20 de mesaje. Este mai rapid decât în prima implementare, dar totuși nu rezolvă problema.
O aplicare alternativă a cererilor paraleleCe se întâmplă dacă serviciile externe nu prevăd procesarea pe loturi? De exemplu, putem ascunde lipsa implementării procesării pe loturi în interiorul metodelor interfețelor:
interfață UserRemoteApi {
distracția getUserById(id: UserReference): UserRemote
distracția getUsersByIds(id: Set<UserReference>): Set<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
distracția getUsersByIds(id: Listă<UserReference>): Listă<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Acest lucru are sens dacă există speranța că procesarea pe loturi va apărea în versiunile viitoare.
Pro:
- Implementare ușoară a procesării paralele pe mesaje.
- Scalabilitate bună.
Dezavantaje:
- Necesitatea separării obținerii datelor de procesarea acestora în timpul procesării paralele a cererilor către diferite servicii.
- Creșterea încărcării pe serviciile externe.
Se observă că domeniile de aplicare sunt aproximativ aceleași ca și în abordarea naivă. Utilizarea metodei de cereri paralele are sens dacă doriți să creșteți de câteva ori performanța serviciului dumneavoastră prin exploatarea necruțătoare a resurselor externe. În exemplul nostru, performanța a crescut de 2,5 ori, dar acest lucru este evident insuficient.
Cache
Se poate realiza caching în stilul JPA pentru servicii externe, adică în cadrul sesiunii să se păstreze obiectele obținute pentru a nu le obține din nou (inclusiv în procesarea în loturi). Se pot face astfel de cache-uri manual, se poate folosi Spring cu @Cacheable, și întotdeauna se poate utiliza un cache gata, cum ar fi EhCache, manual.
Problema generală va fi că de la cache-uri există beneficii doar dacă există hit-uri. În cazul nostru, este foarte probabil să avem hit-uri pe câmpul author (să spunem, 50%), dar nu vor exista hit-uri pe fișiere deloc. Această abordare va oferi unele îmbunătățiri, dar nu va schimba radical performanța (iar noi avem nevoie de o rupere).
Cache-urile inter-sesiune (lungi) necesită o logică complexă de invalidare. În general, cu cât ajungeți mai târziu la soluționarea problemelor de performanță cu ajutorul cache-urilor inter-sesiune, cu atât mai bine.
Pro:
- Implementarea cache-ului fără a schimba codul.
- Creșterea performanței de câteva ori (în unele cazuri).
Dezavantaje:
- Possibilitatea de a reduce performanța în cazul unei utilizări greșite.
- Cheltuieli ridicate de memorie, în special cu cache-uri lungi.
- Invalidare complexă, erorile în care vor duce la probleme greu de reprodus în timpul execuției.
Foarte adesea, cache-urile sunt folosite doar pentru a acoperi rapid problemele de proiectare. Acest lucru nu înseamnă că nu ar trebui folosite. Totuși, întotdeauna ar trebui să le abordați cu precauție și mai întâi să evaluați creșterea de performanță obținută, iar apoi să luați o decizie.
În exemplul nostru, de la cache-uri va fi o creștere a performanței de aproximativ 25%. Cu toate acestea, există destul de multe dezavantaje ale cache-urilor, așa că nu aș recomanda să le folosesc aici.
Concluzii
Așadar, am analizat implementarea naivă a unui serviciu care utilizează procesarea în loturi și câteva moduri simple de a o accelera.
Principala calitate a tuturor acestor metode este simplitatea, din care decurg multe consecințe plăcute.
O problemă comună a acestor metode este performanța slabă, legată în principal de dimensiunea pachetelor. Așadar, dacă aceste soluții nu vă sunt potrivite, ar trebui să luați în considerare metode mai radicale.
Există două direcții principale în care puteți căuta soluții:
- lucrul asincron cu datele (care necesită o schimbare de paradigmă, așa că nu va fi discutat în acest articol);
- îmbunătățirea dimensiunii pachetelor păstrând procesarea sincronă.
Îmbunătățirea dimensiunii pachetelor va reduce semnificativ numărul apelurilor externe și va permite, în același timp, păstrarea codului sincron. Această temă va fi dedicată următoarei părți a articolului.
Sursa: habr.com
