Gotovo svi moderni softverski proizvodi se sastoje od više usluga. Često dugi periodi odziva između uslužnih kanala postaju izvor problema s performansama. Standardno rješenje takvih problema je pakiranje više međuslužnih poziva u jedan paket, što nazivamo batch obradom.
Ako koristite batch obradu, možda nećete biti zadovoljni njenim rezultatom u pogledu performansi ili razumljivosti koda. Ova metoda nije tako jednostavna za pozivatelja kao što se može činiti. Rješenja mogu drastično varirati za različite ciljeve i u različitim situacijama. Na konkretnim primjerima ću pokazati prednosti i nedostatke nekoliko pristupa.
Demonstarcijski projekt
Za ilustraciju razmotrit ćemo primjer jedne od usluga u aplikaciji na kojoj trenutno radim.
Objašnjenje odabira platforme za primjereProblem loše izvedbe je dovoljno općenit i ne odnosi se na određene jezike i platforme. U ovom članku za demonstraciju problema i rješenja koristit ćemo primjere koda na Spring + Kotlin. Kotlin je jednako razumljiv (ili nerazumljiv) Java i C# programerima, a osim toga, kod je kompaktiniji i razumljiviji nego u Javi. Kako bi se olakšalo razumijevanje čistim Java programerima, izbjegavat ću crnu magiju KOTLIN-a i koristiti samo bijelu (u duhu Lombok-a). Bit će malo extension metoda, ali su u stvarnosti poznate svim Java programerima kao static metode, pa će to biti mali šećer koji neće pokvariti okus jela.
Postoji usluga odobravanja dokumenata. Netko stvara dokument i donosi ga na raspravu, tijekom koje se unose izmjene, a na kraju se dokument odobrava. Sama usluga odobravanja ne zna ništa o dokumentima: to je samo chat za odobravanje s malim dodatnim funkcijama koje ovdje nećemo razmatrati.
Dakle, postoje chat sobe (koje odgovaraju dokumentima) s unaprijed definiranim skupom sudionika u svakoj od njih. Kao u običnim chatovima, poruke sadrže tekst i datoteke te mogu biti odgovori (reply) ili proslijeđene (forward):
data klasa ChatPoruka(
// nullable так как появляется только после persist
val id: Dug? = null,
/** Ссылка на автора */
val autor: ReferencaKorisnika,
/** Сообщение */
val poruka: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Popis<ReferencaDatoteke>? = null,
/** Если является ответом, то здесь будет оригинал */
val replyTo: ChatPoruka? = null,
/** Если является пересылкой, то здесь будет оригинал */
val forwardFrom: ChatPoruka? = null
)
Linkovi na datoteku i korisnika – su linkovi na druge domene. Kod nas to živi ovako:
tip ReferencaDatoteke = Dug
tip ReferencaKorisnika = Dug
Podaci o korisnicima pohranjuju se u Keycloak i dobivaju putem REST-a. Isto vrijedi i za datoteke: datoteke i meta-informacije o njima nalaze se u zasebnoj usluzi za pohranu datoteka.
Svi pozivi tih usluga su teški zahtjevi. To znači da su troškovi transporta ovih zahtjeva mnogo veći od vremena obrade od strane vanjske usluge. Na našim testnim okruženjima tipično vrijeme poziva takvih usluga iznosi 100 ms, tako da ćemo kasnije koristiti te brojke.
Trebamo napraviti jednostavan REST kontroler za dobivanje posljednjih N poruka sa svim potrebnim informacijama. To jest, pretpostavljamo da je model poruka na frontend-u gotovo isti i da trebamo proslijediti sve podatke. Razlika u modelu za frontend je ta da datoteku i korisnika treba prikazati u malo dešifriranom obliku, kako bismo ih učinili vezama:
/** В таком виде отдаются ссылки на сущности для фронта */
data klasa Referentni UI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val ime: String
)
data klasa ChatPorukaUI(
val id: Dug,
/** Ссылка на автора */
val autor: Referentni UI,
/** Сообщение */
val poruka: String,
/** Ссылки на аттачи */
val files: Popis<Referentni UI>,
/** Если являтся ответом, то здесь будет оригинал */
val replyTo: ChatPorukaUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val forwardFrom: ChatPorukaUI? = null
)
Trebamo implementirati sljedeće:
sučelje ChatRestApi {
fun getLast(n: Int): Popis<ChatPorukaUI>
}
Postfix UI označava DTO modele za frontend, to jest ono što trebamo poslati putem REST-a.
Ovdje može izgledati iznenađujuće da ne prenosimo nikakav identifikator chata i čak ga nema ni u modelima ChatMessage/ChatMessageUI. Učinio sam to namjerno kako ne bih opterećivao primjer koda (chatovi su izolirani, pa možemo smatrati da imamo samo jedan).
Filozofsko odmakI u klasi ChatMessageUI i u metodi ChatRestApi.getLast koristi se tip podataka List, dok je zapravo to uređeni Set. U JDK-u nije dobro s tim, pa ne možemo deklarirati redoslijed elemenata na razini sučelja (zapamćivanje reda prilikom dodavanja i uzimanja). Tako da je opća praksa korištenje List-a u slučajevima kada je potreban uređeni Set (ima još i LinkedHashSet, ali to nije sučelje).
Važno ograničenje: pretpostavit ćemo da dugačkih lanaca odgovora ili prosljeđivanja nema. To jest, oni postoje, ali njihova dužina ne prelazi tri poruke. Na frontend-u bi lanac poruka trebao biti proslijeđen u cijelosti.
Za dobivanje podataka iz vanjskih usluga postoje takvi API-ji:
sučelje ChatMessageRepository {
fun findLast(n: Int): Popis<ChatPoruka>
}
data klasa FileHeadRemote(
val id: ReferencaDatoteke,
val ime: String
)
sučelje FileRemoteApi {
fun getHeadById(id: ReferencaDatoteke): FileHeadRemote
fun getHeadsByIds(id: Set<ReferencaDatoteke>): Set<FileHeadRemote>
fun getHeadsByIds(id: Popis<ReferencaDatoteke>): Popis<FileHeadRemote>
fun getHeadsByChat(): Popis<FileHeadRemote>
}
data klasa UserRemote(
val id: ReferencaKorisnika,
val ime: String
)
sučelje UserRemoteApi {
fun getUserById(id: ReferencaKorisnika): UserRemote
fun getUsersByIds(id: Set<ReferencaKorisnika>): Set<UserRemote>
fun getUsersByIds(id: Popis<ReferencaKorisnika>): Popis<UserRemote>
}
Vidljivo je da su u vanjskim uslugama izvorno predviđeni paketni procesi, i to u oba slučaja: putem Set-a (bez pamćenja redoslijeda elemenata, s jedinstvenim ključevima) i putem List-a (mogu postojati i duplici - redoslijed se pamti).
Jednostavne implementacije
Naivna implementacija
Prva naivna implementacija našeg REST kontrolera većinom će izgledati ovako:
class ChatRestController(
privatna val messageRepository: ChatMessageRepository,
privatna val userRepository: UserRemoteApi,
privatna val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { toFrontModel.privatna fun() }
ChatMessageUI( ChatPoruka.privatna fun(): ChatPorukaUI =
id = id ?:
IllegalStateException( throw mora biti pohranjen""$this author = userRepository.getUserById(author).),
toFrontReferencemessage = message,(),
files = files?.
nekafileRepository.getHeadsByIds( { files ->
popisfiles)
.map { toFrontModel.message = message,() }
} ?: forwardFrom = forwardFrom?.(),
replyTo = replyTo?.privatna fun(),
replyTo = replyTo?.privatna fun()
)
}
Sve je potpuno jasno, i to je velika prednost.
Koristimo obradu u paketima i datoteke preuzimamo iz vanjske usluge u paketima. No što se događa s našom izvedbom?
Za svaku poruku bit će napravljeni jedan poziv UserRemoteApi za dobivanje podataka o polju author i jedan poziv FileRemoteApi za preuzimanje svih priključenih datoteka. Čini se da je to sve. Pretpostavimo da se polja forwardFrom i replyTo za ChatMessage dobivaju tako da to ne zahtijeva dodatne pozive. No, pretvaranje u ChatMessageUI rezultirat će rekurzijom, što znači da će pokazatelji broja poziva znatno porasti. Kao što smo ranije istaknuli, pretpostavimo da nećemo imati veliku ugniježđenost i da je lanac ograničen na tri poruke.
Na kraju ćemo dobiti od dva do šest poziva vanjskih usluga za jednu poruku i jedan JPA poziv za cijeli paket poruka. Ukupan broj poziva varirat će od 2*N+1 do 6*N+1. Koliko je to u stvarnim jedinicama? Pretpostavimo da za iscrtavanje stranice treba 20 poruka. Da bismo ih dobili, trebat će nam od 4 sekunde do 10 sekundi. Strašno! Željeli bismo stati unutar 500 ms. A budući da su na frontendu željeli realizirati besprijekorno pomicanje, zahtjevi za performansama ovog endpointa mogu se udvostručiti.
Prednosti:
- Kod je kratak i samodokumentirajući (sanjaju podršku).
- Kod je jednostavan, pa su mogućnosti da si sami zakompliciramo situaciju gotovo nepostojeće.
- Obrada u paketima ne izgleda kao nešto strano i organski je uklopljena u logiku.
- Promjene logike će se lako uvesti i bit će lokalne.
Nedostatak:
Užasna izvedba, povezano s time što su paketi vrlo mali.
Ovakav pristup često se može vidjeti u jednostavnim uslugama ili prototipima. Ako je brzina promjena važna, vjerojatno ne treba komplicirati sustav. U isto vrijeme, za našu vrlo jednostavnu uslugu izvedba je strašna, pa su granice primjenjivosti takvog pristupa vrlo uske.
Naivna paralelna obrada
Može se pokrenuti obrada svih poruka paralelno - to će omogućiti eliminaciju linearne povezanosti vremena s brojem poruka. Ovo nije osobito dobar put, jer će dovesti do velike vršne opterećenosti na vanjsku uslugu.
Implementacija paralelnog procesuiranja je vrlo jednostavna:
override fun getLast(n: Int) =
messageRepository.findLast(n).paralelnaStruja()
.mapirati { toFrontModel.privatna fun() }
.skupljati(toList())
Koristeći paralelno procesuiranje poruka, dobijamo 300–700 ms u idealnim uslovima, što je značajno bolje nego kod naivne implementacije, ali još uvek nije dovoljno brzo.
Sa ovakvim pristupom, zahtevi ka userRepository i fileRepository će se izvršavati sinkrono, što nije efikasno. Da bismo to ispravili, potrebno je značajno promeniti logiku poziva. Na primer, pomoću CompletionStage (aka CompletableFuture):
ChatMessageUI( ChatPoruka.privatna fun(): ChatPorukaUI =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).message = message,()
}.thenCombine(
files?.fileRepository.getHeadsByIds( {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).map { toFrontModel.message = message,() }
}
} ?: CompletableFuture.completedFuture(forwardFrom = forwardFrom?.())
) { author, files ->
id = id ?:
IllegalStateException( throw mora biti pohranjen""$this author = userRepository.getUserById(author).),
author = author,
files = files?.
files = files,
replyTo = replyTo?.privatna fun(),
replyTo = replyTo?.privatna fun()
)
}.get()!!
Vidljivo je da je izvorno jednostavan kod mapiranja postao manje razumljiv. To je rezultat potrebe da odvojimo pozive spoljašnjih servisa od mesta korišćenja rezultata. Samo po sebi, to nije loše. Ali kombinovanje poziva izgleda manje elegantno i podseća na tipičnu reaktivnu 'nudlu'.
Ako koristimo korutine, sve će izgledati pristojnije:
ChatMessageUI( ChatPoruka.privatna fun(): ChatPorukaUI =
join(
{ userRepository.getUserById(author).message = message,() },
{ files?.fileRepository.getHeadsByIds( { fileRepository.getHeadsByIds(files)
.map { toFrontModel.message = message,() } } ?: forwardFrom = forwardFrom?.() }
).fileRepository.getHeadsByIds( { (autor, datoteke) ->
id = id ?:
IllegalStateException( throw mora biti pohranjen""$this author = userRepository.getUserById(author).),
autor = autor,
files = files?.
datoteke = datoteke,
replyTo = replyTo?.privatna fun(),
replyTo = replyTo?.privatna fun()
)
}
Gdje:
fun <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll((196 tisuća redaka koda, 11 autora, 7 commitera, 11 neodgovorenih problema); { a() }, (196 tisuća redaka koda, 11 autora, 7 commitera, 11 neodgovorenih problema); { b() })
}.fileRepository.getHeadsByIds( {
toFrontModel[0] as A to toFrontModel[1] as B
}
Teoretski, koristeći ovakvo paralelno procesiranje, dobijamo 200–400 ms, što je već blizu našim očekivanjima.
Nažalost, takvo dobro raspodeljivanje ne postoji, a i cena je prilično visoka: pri istovremenom radu samo nekoliko korisnika, servisi će doživeti nagli skok zahteva, koji se i dalje ne će obrađivati paralelno, tako da ćemo se vratiti na tužnih 4 s.
Moj rezultat kada koristim ovakav servis je 1300–1700 ms za obradu 20 poruka. To je brže nego u prvoj implementaciji, ali ipak ne rešava problem.
Alternativna primena paralelnih zahtevaŠta ako spoljni servisi ne podržavaju obradu u paketima? Na primer, možemo sakriti nedostatak implementacije obrade u paketima unutar metoda interfejsa:
sučelje UserRemoteApi {
fun getUserById(id: ReferencaKorisnika): UserRemote
fun getUsersByIds(id: Set<ReferencaKorisnika>): Set<UserRemote> =
id.parallelStream()
.mapirati { getUserById(toFrontModel) }.collect(toSet())
fun getUsersByIds(id: Popis<ReferencaKorisnika>): Popis<UserRemote> =
id.parallelStream()
.mapirati { getUserById(toFrontModel) }.skupljati(toList())
}
To ima smisla ako postoji nada za pojavu obrade u paketima u narednim verzijama.
Prednosti:
- Laka implementacija paralelnog procesuiranja poruka.
- Dobra skalabilnost.
Mane:
- Potrebna odvojenost između dobijanja podataka i njihove obrade prilikom paralelnog procesiranja zahteva ka različitim servisima.
- Povećano opterećenje na spoljnim servisima.
Čini se da su okviri primjenjivosti otprilike isti kao kod naivnog pristupa. Korištenje metode paralelnih upita ima smisla ako želite višestruko povećati performanse svoje usluge iskorištavanjem resursa drugih. U našem primjeru performanse su povećane 2,5 puta, ali to je očigledno nedovoljno.
Keširanje
Može se napraviti keširanje u duhu JPA za vanjske usluge, to jest, unutar sesije čuvati primljene objekte kako ne biste morali ponovno dobijati iste (uključujući i pri paketnoj obradi). Takve cache-ove možete izraditi sami, možete koristiti Spring sa njegovim @Cacheable, a također uvijek možete koristiti gotov keš poput EhCache-a ručno.
Opći problem će biti vezan uz to da keševi imaju smisla samo ako postoji pogodaka. U našem slučaju vrlo su vjerojatni pogodci po polju author (recimo, 50 %), dok pogodaka po datotekama neće biti uopće. Neka poboljšanja će ovaj pristup donijeti, ali radikalno neće promijeniti performanse (a nama je potreban proboj).
Međusesijski (dugi) keševi zahtijevaju složenu logiku invalida. Uglavnom, što duže budete čekali da se suočite s problemima performansi koristeći međusesijske keševe, to bolje.
Prednosti:
- Implementacija keširanja bez izmjene koda.
- Povećanje performansi nekoliko puta (u nekim slučajevima).
Mane:
- Mogućnost smanjenja performansi pri pogrešnom korištenju.
- Veliki memorijski troškovi, posebno s dugim keševima.
- Složena invalidacija, pogreške u kojoj mogu dovesti do teško reproducibilnih problema u runtime-u.
Većinom se keševi koriste samo za brzo rješavanje problema dizajna. To ne znači da ih ne treba koristiti. Međutim, uvijek ih treba koristiti s oprezom i prvo procijeniti postignuto povećanje performansi, a zatim donijeti odluku.
U našem primjeru keševi će donijeti povećanje performansi od oko 25 %. U isto vrijeme, nedostaci keširanja su prilično veliki, tako da ih ovdje ne bih koristio.
Zaključci
Dakle, razmotrili smo naivnu implementaciju usluge koja koristi paketnu obradu i nekoliko jednostavnih načina za njezino ubrzanje.
Glavna prednost svih ovih metoda je jednostavnost, iz koje proizlazi mnogo ugodnih posljedica.
Opći problem ovih metoda je loša izvedba, prvenstveno uzrokovana veličinom paketa. Stoga, ako vam ova rješenja ne odgovaraju, vrijedi razmotriti radikalnije metode.
Postoje dva glavna smjera u kojima možete potražiti rješenja:
- asinkrono rukovanje podacima (što zahtijeva promjenu paradigme, pa se u ovom članku ne razmatra);
- uvođenje većih paketa uz zadržavanje sinkrone obrade.
Uvođenje većih paketa značajno će smanjiti broj vanjskih poziva, dok će kod ostati sinkroniziran. Ova tema bit će obrađena u sljedećem dijelu članka.
Izvor: habr.com
