Quasi tutti i prudutti di u software mudernu sò custituiti da parechji servizii. Spessu, i tempi di risposta longu di i canali interservizi diventanu una fonte di prublemi di rendiment. A suluzione standard à stu tipu di prublema hè di imballà parechje richieste interservizi in un pacchettu, chì hè chjamatu batching.
Sè vo aduprate u prucessu di batch, pudete micca esse felice cù i risultati in quantu à u rendiment o a chiarità di codice. Stu metudu ùn hè micca cusì faciule per u chjamante cum'è pudete pensà. Per diversi scopi è in diverse situazioni, i suluzioni ponu varià assai. Utilizendu esempi specifichi, vi mustraraghju i pro è i contra di parechji approcci.
Prughjettu di dimostrazione
Per a chiarezza, fighjemu un esempiu di unu di i servizii in l'applicazione chì aghju travagliatu.
Spiegazione di a selezzione di a piattaforma per esempiU prublema di scarsa prestazione hè abbastanza generale è ùn tocca à alcuna lingua o piattaforma specifica. Questu articulu hà da utilizà esempi di codice Spring + Kotlin per dimustrà prublemi è suluzione. Kotlin hè ugualmente comprensibile (o incomprensibile) per i sviluppatori Java è C#, in più, u codice hè più compactu è cumprendi più cà in Java. Per fà più faciule per capiscenu per i sviluppatori Java puri, eviteraghju a magia negra di Kotlin è solu aduprà a magia bianca (in u spiritu di Lombok). Ci saranu uni pochi di metudi di estensione, ma sò in realtà familiarizati per tutti i programatori Java cum'è metudi statichi, cusì serà un picculu zuccaru chì ùn sguassate micca u gustu di u platu.
Ci hè un serviziu di appruvazioni di documenti. Qualchissia crea un documentu è u sottumette per discussione, durante u quale l'edizioni sò fatte, è in fine u documentu hè accunsentutu. U serviziu di appruvazioni stessu ùn sapi nunda di documenti: hè solu una chat di appruvazioni cù picculi funzioni supplementari chì ùn avemu micca cunsideratu quì.
Dunque, ci sò chat room (currispondenu à i ducumenti) cù un inseme predefinitu di participanti in ognunu di elli. Cum'è in i chats regulari, i missaghji cuntenenu testu è fugliali è ponu esse risposti o rinviati:
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
)
File è ligami d'utilizatori sò ligami à altri domini. Quì campemu cusì:
typealias FileReference = Long
typealias UserReference = Long
I dati di l'utilizatori sò guardati in Keycloak è recuperati via REST. U stessu passa per i schedarii: i schedarii è i metainformazioni nantu à elli campanu in un serviziu di almacenamentu separatu.
Tutte e chjama à questi servizii sò richieste pesanti. Questu significa chì l'overhead di u trasportu di sti dumande hè assai più grande di u tempu chì ci vole per esse trattatu da un serviziu di terzu. In i nostri banchi di prova, u tempu tipicu di chjama per tali servizii hè 100 ms, cusì useremu questi numeri in u futuru.
Avemu bisognu di fà un semplice controller REST per riceve l'ultimi N missaghji cù tutte l'infurmazioni necessarii. Vale à dì, avemu cridutu chì u mudellu di missaghju in u frontend hè quasi u listessu è tutti i dati deve esse mandatu. A diffarenza trà u mudellu front-end hè chì u schedariu è l'utilizatori anu da esse presentati in una forma ligeramente decifrata per fà ligami:
/** В таком виде отдаются ссылки на сущности для фронта */
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
)
Avemu bisognu di implementà i seguenti:
interface ChatRestApi {
fun getLast(n: Int): List<ChatMessageUI>
}
U postfix UI significa mudelli DTO per u frontend, vale à dì ciò chì duvemu serve via REST.
Ciò chì pò esse surprisante quì hè chì ùn avemu micca passatu alcunu ID di chat è ancu u mudellu ChatMessage / ChatMessageUI ùn hà micca unu. Aghju fattu questu intenzionalmente per ùn impastà u codice di l'esempii (i chats sò isolati, cusì pudemu suppone chì avemu solu unu).
Digressione filosoficaTramindui a classa ChatMessageUI è u mètudu ChatRestApi.getLast aduprà u tipu di dati Lista quandu in fattu hè un Set urdinatu. Questu hè male in u JDK, cusì dichjarà l'ordine di l'elementi à u livellu di l'interfaccia (priservà l'ordine quandu aghjunghje è sguassà) ùn viaghja micca. Cusì hè diventatu una pratica cumuni per utilizà una Lista in i casi induve un Set urdinatu hè necessariu (ci hè ancu un LinkedHashSet, ma questu ùn hè micca una interfaccia).
Limitazione impurtante: Assumiremu chì ùn ci sò micca catene longu di risposte o trasferimenti. Vale à dì, esistenu, ma a so lunghezza ùn trapassa trè missaghji. L'intera catena di missaghji deve esse trasmessa à u frontend.
Per riceve dati da i servizii esterni, ci sò e seguenti API:
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>
}
Pò esse vistu chì i servizii esterni inizialmente furnisce u prucessu di batch, è in i dui versioni: à traversu Set (senza priservà l'ordine di elementi, cù chjavi unichi) è attraversu Lista (ci pò esse duplicati - l'ordine hè cunsirvatu).
Implementazioni simplici
Implementazione ingenua
A prima implementazione ingenua di u nostru controller REST parerà cusì cusì in a maiò parte di i casi:
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()
)
}
Tuttu hè assai chjaru, è questu hè un grande plus.
Avemu aduprà u prucessu di batch è riceve dati da un serviziu esternu in batch. Ma chì succede à a nostra produtividade?
Per ogni missaghju, una chjama à UserRemoteApi serà fatta per uttene dati nantu à u campu di l'autore è una chjama à FileRemoteApi per uttene tutti i fugliali attaccati. Chì pare esse. Diciamu chì i campi forwardFrom è replyTo per ChatMessage sò ottenuti in tale manera chì questu ùn hè micca bisognu di chjama inutili. Ma trasfurmà in ChatMessageUI portarà à a recursione, vale à dì, i contatori di chjama ponu aumentà significativamente. Comu avemu nutatu prima, supponemu chì ùn avemu micca assai nidificazione è a catena hè limitata à trè missaghji.
In u risultatu, riceveremu da duie à sei chjamate à servizii esterni per missaghju è una chjama JPA per tuttu u pacchettu di missaghji. U numeru tutale di chjamate varierà da 2 * N + 1 à 6 * N + 1. Quantu hè questu in unità reali? Diciamu chì ci vole 20 missaghji per rende una pagina. Per riceveli, ci vole da 4 s à 10 s. Terribile! Mi piacerebbe mantene in 500 ms. E siccomu sunnianu di fà un scrolling senza saldatura in u frontend, i requisiti di prestazione per questu endpoint ponu esse radduppiati.
Pros:
- U codice hè cuncisu è autodocumentatu (sognu di un squadra di supportu).
- U codice hè simplice, cusì ùn ci hè quasi nisuna opportunità per sparà in u pede.
- U prucessu di batch ùn pare micca qualcosa di straneru è hè integratu organicamente in a logica.
- I cambiamenti logici seranu fatti facilmente è seranu lucali.
Minus:
Prestazione terribile per via di pacchetti assai chjuchi.
Stu approcciu pò esse vistu abbastanza spessu in servizii simplici o in prototipi. Se a rapidità di fà cambiamenti hè impurtante, ùn vale a pena cumplicà u sistema. À u listessu tempu, per u nostru serviziu assai simplice u rendiment hè terribili, cusì u scopu di l'applicabilità di questu approcciu hè assai strettu.
Trattamentu parallelu ingenu
Pudete principià a trasfurmazioni di tutti i missaghji in parallelu - questu vi permetterà di sguassà l'aumentu lineale in u tempu secondu u numeru di missaghji. Questu ùn hè micca un percorsu particularmente bonu perchè resultarà in una grande carica di punta nantu à u serviziu esternu.
Implementà u prucessu parallelu hè assai simplice:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Utilizendu u prucessu di messagiu parallelu, avemu 300-700 ms idealmente, chì hè assai megliu cà cù una implementazione ingenua, ma ancu micca abbastanza veloce.
Cù questu approcciu, e dumande à l'UserRepository è u fileRepository seranu eseguite in modu sincronu, chì ùn hè micca assai efficace. Per riparà questu, avete da cambià assai a logica di a chjama. Per esempiu, via CompletionStage (aka 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()!!
Pò esse vistu chì u codice di mapping inizialmente simplice hè diventatu menu comprensibile. Questu hè perchè avemu avutu a separà e chjama à i servizii esterni da induve i risultati sò usati. Questu in sè stessu ùn hè micca male. Ma cumminendu e chjama ùn pare micca assai eleganti è s'assumiglia à un tipicu "noodle" reattivu.
Sè vo aduprate coroutines, tuttu sarà più decentu:
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()
)
}
Dove:
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
}
Teoricamente, aduprendu tali prucessione parallele, uttene 200-400 ms, chì hè digià vicinu à e nostre aspettative.
Sfurtunatamente, una tale bona parallelizazione ùn esiste micca, è u prezzu à pagà hè abbastanza crudele: cù solu uni pochi d'utilizatori chì travaglianu à u stessu tempu, un barrage di dumande cascarà nantu à i servizii, chì ùn saranu micca trattati in parallelu in ogni modu, cusì avemu tornerà à i nostri tristi 4 s.
U mo risultatu quandu si usa un tali serviziu hè 1300-1700 ms per processà 20 missaghji. Questu hè più veloce chì in a prima implementazione, ma ancu ùn risolve u prublema.
Usi alternativu di e dumande paralleleE s'è i servizii di terzu ùn furnisce micca un prucessu batch? Per esempiu, pudete ammuccià a mancanza di implementazione di trasfurmazioni batch in i metudi di l'interfaccia:
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())
}
Questu hè sensu s'è vo sperate di vede u prucessu di batch in versioni future.
Pros:
- Implementa facilmente u trattamentu parallelu basatu in messagi.
- Bona scalabilità.
Cons:
- A necessità di separà l'acquistu di dati da u so trasfurmazioni quandu si tratta di richieste à diversi servizii in parallelu.
- Aumentu di a carica nantu à i servizii di terze parti.
Pò esse vistu chì u scopu di l'applicabilità hè apprussimatamente uguale à quellu di l'approcciu ingenu. Hè sensu di utilizà u metudu di dumanda parallela se vulete aumentà a prestazione di u vostru serviziu parechje volte per via di a sfruttamentu senza pietà di l'altri. In u nostru esempiu, u rendiment hà aumentatu da 2,5 volte, ma questu hè chjaramente micca abbastanza.
caching
Pudete fà caching in u spiritu di JPA per i servizii esterni, vale à dì, almacenà l'uggetti ricevuti in una sessione per ùn riceve micca di novu (cumpresu durante u processu batch). Pudete fà tali cache voi stessu, pudete aduprà Spring cù u so @Cacheable, in più pudete sempre aduprà una cache pronta cum'è EhCache manualmente.
Un prublema cumuni seria chì i cache sò utili solu s'ellu anu successu. In u nostru casu, i colpi nantu à u campu di l'autore sò assai prubabile (per esempiu, 50%), ma ùn ci sarà micca successu in i schedari. Stu approcciu darà qualchì migliuramentu, ma ùn cambierà micca radicalmente u rendiment (è avemu bisognu di un avanzu).
I cache di intersessione (longu) necessitanu una logica cumplessa di invalidazione. In generale, più tardi si scende à risolve i prublemi di rendiment usendu cache di intersessione, u megliu.
Pros:
- Implementa caching senza cambià u codice.
- A produtividade aumentata parechje volte (in certi casi).
Cons:
- Possibilità di prestazioni ridotte s'ellu hè adupratu in modu incorrectu.
- Grande memoria overhead, soprattuttu cù cache longu.
- Invalidazione cumplessa, errori in quale portanu à prublemi difficiuli di ripruduce in runtime.
Moltu spessu, i cache sò usati solu per patch rapidamente i prublemi di disignu. Questu ùn significa micca chì ùn deve micca esse usatu. In ogni casu, duvete sempre trattà cun prudenza è prima evaluà u guadagnu di rendiment risultatu, è solu dopu piglià una decisione.
In u nostru esempiu, i caches furnisceranu un aumentu di rendiment di circa 25%. À u listessu tempu, i caches anu assai disadvantages, perchè ùn l'aghju micca aduprà quì.
Risultati
Dunque, avemu vistu una implementazione ingenua di un serviziu chì usa u processu batch, è qualchi modi simplici per accelerà.
U vantaghju principali di tutti sti metudi hè a simplicità, da quale ci sò assai cunsiquenzi piacevuli.
Un prublema cumuni cù questi metudi hè un rendimentu poviru, principalmente per via di a dimensione di i pacchetti. Dunque, se queste suluzioni ùn vi cunvene micca, allora vale a pena cunsiderà i metudi più radicali.
Ci hè dui direzzione principali in quale pudete circà suluzioni:
- travagliu asincronu cù dati (esige un cambiamentu di paradigma, cusì ùn hè micca discututu in questu articulu);
- allargamentu di batch mantenendu u prucessu sincronu.
L'allargamentu di lotti riducerà assai u numeru di chjamati esterni è à u stessu tempu mantene u codice sincronu. A prossima parte di l'articulu serà dedicata à stu tema.
Source: www.habr.com