Quasi tutti i moderni prodotti software sono composti da diversi servizi. Spesso, un lungo tempo di risposta nei canali tra i servizi diventa fonte di problemi di prestazioni. La soluzione standard a questo tipo di problemi è raggruppare più richieste inter-servizio in un unico pacchetto, un processo noto come elaborazione batch.
Se utilizzi l'elaborazione batch, potresti non essere soddisfatto del suo risultato in termini di prestazioni o chiarezza del codice. Questo metodo non è così semplice per la parte chiamante come si potrebbe pensare. Le soluzioni possono variare notevolmente a seconda degli obiettivi e delle situazioni. Mostrerò i pro e i contro di diversi approcci attraverso esempi specifici.
Progetto dimostrativo
Per chiarezza, consideriamo l'esempio di uno dei servizi nell'applicazione su cui sto attualmente lavorando.
Spiegazione della scelta della piattaforma per gli esempiIl problema delle scarse prestazioni è relativamente comune e non riguarda specificamente linguaggi o piattaforme particolari. In questo articolo, per dimostrare le sfide e le soluzioni, useremo esempi di codice in Spring + Kotlin. Kotlin è comprensibile (o incomprensibile) per i programmatori Java e C#, inoltre il codice risulta più compatto e chiaro rispetto a Java. Per facilitare la comprensione per i programmatori Java puri, eviterò la magia nera di Kotlin e utilizzerò solo quella bianca (in stile Lombok). Ci saranno alcuni metodi di estensione, ma in realtà sono familiari a tutti i programmatori Java come metodi statici, quindi sarà un piccolo zucchero che non rovinerà il gusto del piatto.
Esiste un servizio di approvazione dei documenti. Qualcuno crea un documento e lo porta in discussione, durante la quale vengono apportate modifiche, e alla fine il documento viene approvato. Il servizio di approvazione non ha conoscenza di specifici documenti: è semplicemente una chat per gli approvatori con alcune funzioni aggiuntive che non tratteremo qui.
Quindi, ci sono stanze di chat (corrispondenti ai documenti) con un insieme predefinito di partecipanti in ciascuna di esse. Come in una normale chat, i messaggi contengono testo e file e possono essere risposte (reply) e inoltri (forward):
classe data MessaggioChat(
// nullable так как появляется только после persist
val id: Lungo? = null,
/** Ссылка на автора */
val autore: RiferimentoUtente,
/** Сообщение */
val messaggio: Stringa,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val file: Elenco<RiferimentoFile>? = null,
/** Если является ответом, то здесь будет оригинал */
val rispondiA: MessaggioChat? = null,
/** Если является пересылкой, то здесь будет оригинал */
val inoltratoDa: MessaggioChat? = null
)
I collegamenti a file e utenti sono riferimenti ad altri. dominiIn questo modo funziona:
typealias RiferimentoFile = Lungo
typealias RiferimentoUtente = Lungo
I dati sugli utenti sono memorizzati in Keycloak e vengono recuperati tramite REST. Lo stesso vale per i file: file e metadati su di essi risiedono in un servizio di archiviazione file separato.
Tutte le chiamate a questi servizi sono richieste pesanti.Questo significa che l'overhead per il trasporto di queste richieste è molto maggiore del tempo necessario per la loro elaborazione da parte di un altro servizio. Nei nostri banchi di prova, il tempo medio di chiamata per tali servizi è di 100 ms, quindi utilizzeremo queste cifre in seguito.
Dobbiamo creare un semplice controller REST per ottenere gli ultimi N messaggi con tutte le informazioni necessarie. Dobbiamo considerare che nel frontend il modello dei messaggi è quasi identico e dobbiamo trasferire tutti i dati. La differenza del modello per il frontend è che file e utenti devono essere presentati in una forma leggermente decifrata, in modo da trasformarli in collegamenti:
/** В таком виде отдаются ссылки на сущности для фронта */
classe data Interfaccia di Riferimento(
/** Идентификатор для url */
val riferimento: Stringa,
/** Видимое пользователю название ссылки */
val nome: Stringa
)
classe data InterfacciaMessaggioChat(
val id: Lungo,
/** Ссылка на автора */
val autore: Interfaccia di Riferimento,
/** Сообщение */
val messaggio: Stringa,
/** Ссылки на аттачи */
val file: Elenco<Interfaccia di Riferimento>,
/** Если являтся ответом, то здесь будет оригинал */
val rispondiA: InterfacciaMessaggioChat? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val inoltratoDa: InterfacciaMessaggioChat? = null
)
Dobbiamo implementare quanto segue:
interfaccia ChatRestApi {
divertimento getLast(n: Int): Elenco<InterfacciaMessaggioChat>
}
Postfix UI significa modelli DTO per il frontend, vale a dire ciò che dobbiamo restituire tramite REST.
Qui potrebbe sembrare sorprendente che non stiamo passando alcun identificativo della chat e nemmeno nel modello ChatMessage/ChatMessageUI è presente. L'ho fatto intenzionalmente per non appesantire il codice degli esempi (le chat sono isolate, quindi possiamo considerare che abbiamo una sola chat).
Digressione filosoficaSia nella classe ChatMessageUI che nel metodo ChatRestApi.getLast viene utilizzato il tipo di dati List, mentre in realtà si tratta di un Set ordinato. In JDK la situazione è complicata, quindi non riesco a dichiarare l'ordine degli elementi a livello di interfaccia (mantenendo l'ordine durante l'aggiunta e l'estrazione). Pertanto, è diventata prassi comune utilizzare List nei casi in cui sarebbe necessario un Set ordinato (c'è anche LinkedHashSet, ma non è un'interfaccia).
Importante limitazione: supponiamo che non ci siano catene di risposte o inoltri lunghi. Esistono, ma la loro lunghezza non supera i tre messaggi. Nel frontend, la catena di messaggi deve essere trasferita nella sua interezza.
Per ottenere dati da servizi esterni, ci sono questi API:
interfaccia ChatMessageRepository {
divertimento findLast(n: Int): Elenco<MessaggioChat>
}
classe data FileHeadRemote(
val id: RiferimentoFile,
val nome: Stringa
)
interfaccia FileRemoteApi {
divertimento getHeadById(id: RiferimentoFile): FileHeadRemote
divertimento getHeadsByIds(id: Set<RiferimentoFile>: Set<FileHeadRemote>
divertimento getHeadsByIds(id: Elenco<RiferimentoFile>: Elenco<FileHeadRemote>
divertimento getHeadsByChat(): Elenco<FileHeadRemote>
}
classe data UserRemote(
val id: RiferimentoUtente,
val nome: Stringa
)
interfaccia UserRemoteApi {
divertimento getUserById(id: RiferimentoUtente): UserRemote
divertimento getUsersByIds(id: Set<RiferimentoUtente>: Set<UserRemote>
divertimento getUsersByIds(id: Elenco<RiferimentoUtente>: Elenco<UserRemote>
}
Si nota che nei servizi esterni è inizialmente prevista l'elaborazione batch, sia nel caso Set (senza mantenimento dell'ordine degli elementi, con chiavi uniche) che nel caso List (possono esserci duplicati - l'ordine è mantenuto).
Implementazioni semplici
Implementazione naive
La prima implementazione ingenua del nostro REST controller apparirà nella maggior parte dei casi in questo modo:
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 MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
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()
)
}
Tutto è chiaro, e questo è un grande punto a favore.
Utilizziamo l'elaborazione batch e otteniamo dati da un servizio esterno in pacchetti. Ma che dire delle nostre performance?
Per ogni messaggio verrà effettuata una chiamata a UserRemoteApi per ottenere dati dal campo autore e una chiamata a FileRemoteApi per ottenere tutti i file allegati. Sembrerebbe tutto. Supponiamo che i campi forwardFrom e replyTo per ChatMessage vengano ottenuti senza necessitare di chiamate extra. Tuttavia, la conversione in ChatMessageUI porterà a una ricorsione, il che significa che i contatori delle chiamate potrebbero aumentare notevolmente. Come abbiamo notato in precedenza, supponiamo che non abbiamo una grande annidamento e che il chain sia limitato a tre messaggi.
In definitiva, otterremo da due a sei chiamate ai servizi esterni per ogni messaggio e una chiamata JPA per l'intero pacchetto di messaggi. Il numero totale di chiamate varierà da 2*N+1 a 6*N+1. Quanti sono in unità reali? Supponiamo che per disegnare la pagina siano necessari 20 messaggi. Per ottenerli, ci vorranno tra 4 e 10 secondi. Terribile! Vorremmo tenerci sotto i 500 ms. E poiché nel frontend desideravamo implementare uno scroll fluido, i requisiti di performance per questo endpoint potrebbero addirittura raddoppiare.
Vantaggi:
- Il codice è conciso e auto-documentato (il sogno del supporto).
- Il codice è semplice, quindi le possibilità di farsi male sono quasi nulle.
- L'elaborazione batch non appare come qualcosa di estraneo ed è organicamente integrata nella logica.
- Le modifiche alla logica saranno facili da apportare e saranno locali.
Svantaggio:
Prestazioni terribili, dovute al fatto che i pacchetti sono molto piccoli.
Questo approccio è abbastanza comune nei servizi semplici o nei prototipi. Se la velocità di modifica è importante, è difficile complicare il sistema. Allo stesso tempo, per il nostro servizio molto semplice, le performance risultano terribili, quindi le possibilità di applicabilità di questo approccio sono molto ristrette.
Elaborazione parallela ingenua
Possiamo avviare l'elaborazione di tutti i messaggi in parallelo, eliminando così la crescita lineare del tempo a seconda del numero di messaggi. Questo non è particolarmente un buon percorso, poiché porterà a un carico di picco elevato sul servizio esterno.
Implementare l'elaborazione parallela è molto semplice:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Utilizzando l'elaborazione parallela dei messaggi, otterremo idealmente tra 300 e 700 ms, il che è notevolmente meglio rispetto all'implementazione ingenua, ma è ancora troppo lento.
Con questo approccio, le richieste a userRepository e fileRepository saranno eseguite in modo sincrono, il che non è molto efficiente. Per correggere questo, sarà necessario modificare uno strato in modo significativo la logica delle chiamate. Ad esempio, utilizzando CompletionStage (alias CompletableFuture):
private fun MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
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()!!
È evidente che il codice di mapping inizialmente semplice è diventato meno comprensibile. Ciò è dovuto al fatto che siamo stati costretti a separare le chiamate ai servizi esterni dal luogo in cui utilizziamo i risultati. Di per sé, non è un male. Tuttavia, la combinazione delle chiamate non appare particolarmente elegante e ricorda la tipica 'noodles' reattiva.
Se utilizziamo le coroutine, tutto apparirà più presentabile:
private fun MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(file)
.map { it.toFrontReference() } } ?: listOf() }
).let { (autore, file) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
autore = autore,
message = message,
file = file,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Dove:
divertimento <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] come A to it[1] come B
}
Teoricamente, utilizzando un'elaborazione parallela del genere, otterremo tra 200 e 400 ms, che è già vicino alle nostre aspettative.
Sfortunatamente, una tale buona parallelizzazione non esiste, e il costo è piuttosto severo: con il lavoro simultaneo di pochi utenti, il servizio sarà sopraffatto da un'ondata di richieste che non saranno comunque elaborate in parallelo, quindi torneremo ai nostri tristi 4 s.
Il mio risultato utilizzando tale servizio è di 1300–1700 ms per elaborare 20 messaggi. È più veloce rispetto alla prima implementazione, ma non risolve comunque il problema.
Applicazione alternativa delle richieste paralleleCosa succede se i servizi esterni non prevedono l'elaborazione batch? Ad esempio, possiamo nascondere l'assenza dell'implementazione dell'elaborazione batch all'interno dei metodi delle interfacce:
interfaccia UserRemoteApi {
divertimento getUserById(id: RiferimentoUtente): UserRemote
divertimento getUsersByIds(id: Set<RiferimentoUtente>: Set<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
divertimento getUsersByIds(id: Elenco<RiferimentoUtente>: Elenco<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Ha senso se si ha la speranza che in future versioni venga implementata l'elaborazione batch.
Vantaggi:
- Facile implementazione dell'elaborazione parallela dei messaggi.
- Buona scalabilità.
Contro:
- Necessità di separare l'ottenimento dei dati dalla loro elaborazione durante l'elaborazione parallela delle richieste ai diversi servizi.
- Carico aumentato sui servizi esterni.
È evidente che i limiti di applicabilità sono più o meno gli stessi di quelli dell'approccio ingenuo. Utilizzare il metodo delle richieste parallele ha senso se si desidera aumentare di diversi fattori le prestazioni del proprio servizio sfruttando senza pietà i servizi altrui. Nel nostro esempio, le prestazioni sono aumentate di 2,5 volte, ma ciò è evidentemente insufficiente.
Caching
È possibile implementare una cache nello spirito di JPA per i servizi esterni, ovvero mantenere gli oggetti ottenuti durante la sessione, in modo da non doverli recuperare di nuovo (anche durante l'elaborazione batch). È possibile creare tali cache autonomamente, utilizzare Spring con il suo @Cacheable, oppure si può sempre utilizzare una cache pronta come EhCache manualmente.
Il problema comune sarà che le cache sono utili solo se ci sono dei colpi. Nel nostro caso, è molto probabile che ci siano colpi sul campo author (supponiamo, il 50%), mentre non ci saranno colpi sui file. Alcuni miglioramenti questo approccio apporterà, ma non cambierà radicalmente le performance (e noi abbiamo bisogno di una svolta).
Le cache intersessioni (lunghe) richiedono logiche di invalidazione complesse. In generale, più tardi si scende nel risolvere problemi di performance utilizzando cache intersessioni, meglio è.
Vantaggi:
- Implementazione della caching senza modificare il codice.
- Incremento delle performance di diversi fattori (in alcuni casi).
Contro:
- Possibilità di riduzione delle performance se utilizzato in modo errato.
- Grandi sovraccarichi di memoria, specialmente con cache lunghe.
- Invalidazione complessa, i cui errori porteranno a problemi difficili da riprodurre durante il runtime.
Molto spesso le cache vengono utilizzate solo per risolvere rapidamente problemi di progettazione. Ciò non significa che non debbano essere utilizzate. Tuttavia, è sempre opportuno trattarle con cautela e prima valutare il guadagno di performance ottenuto, per poi prendere decisioni.
Nel nostro esempio avremo un incremento delle performance delle cache intorno al 25%. Tuttavia, ci sono molti svantaggi, quindi non le utilizzerei qui.
Risultati
Quindi, abbiamo esaminato un'implementazione naive di un servizio che utilizza l'elaborazione batch e alcuni metodi semplici per accelerarla.
Il principale pregio di tutti questi metodi è la semplicità, da cui derivano molte conseguenze piacevoli.
Un problema comune di questi metodi è la scarsa performance, soprattutto legata alle dimensioni dei pacchetti. Quindi, se queste soluzioni non ti soddisfano, è opportuno considerare metodi più radicali.
Ci sono due principali direzioni in cui cercare soluzioni:
- lavoro asincrono con i dati (richiede un cambio di paradigma, quindi non verrà trattato in questo articolo);
- aggregazione dei pacchetti mantenendo l'elaborazione sincrona.
L'aggregazione dei pacchetti ridurrà drasticamente il numero di chiamate esterne e allo stesso tempo manterrà il codice sincrono. Questo argomento sarà trattato nella prossima parte dell'articolo.
Fonte: habr.com
