Bijna alle moderne softwareproducten bestaan uit verschillende services. Vaak wordt de lange responstijd van interservicekanalen een bron van prestatieproblemen. De standaardoplossing voor dit soort problemen is het verpakken van verschillende interserviceverzoeken in één pakket, wat batchverwerking (batching) wordt genoemd.
Als je batchverwerking gebruikt, ben je misschien niet tevreden met het resultaat qua prestaties of duidelijkheid van de code. Deze methode is niet zo eenvoudig voor de aanroepende kant als je zou denken. Voor verschillende doelen en in verschillende situaties kunnen de oplossingen sterk verschillen. Aan de hand van specifieke voorbeelden zal ik de voor- en nadelen van verschillende benaderingen tonen.
Demoproject
Ter illustratie bekijken we een voorbeeld van een van de services in de applicatie waar ik momenteel aan werk.
Uitleg over de keuze van platform voor de voorbeeldenHet probleem van slechte prestaties is vrij algemeen en betreft niet specifieke talen of platforms. In dit artikel worden voorbeelden van code in Spring + Kotlin gebruikt om taken en oplossingen te demonstreren. Kotlin is even begrijpelijk (of onbegrijpelijk) voor Java- en C#-ontwikkelaars. Bovendien is de code compacter en begrijpelijker dan in Java. Om het begrip voor pure Java-ontwikkelaars te vergemakkelijken, zal ik de zwarte magie van Kotlin vermijden en alleen de witte gebruiken (in de geest van Lombok). Er zullen een paar extension-methoden zijn, maar die zijn feitelijk bekend bij elke Java-programmeur als static-methoden, dus dit zal een kleine zoetigheid zijn die de smaak van het gerecht niet verpest.
Er is een service voor documentgoedkeuring. Iemand maakt een document en brengt het ter discussie, waarbij wijzigingen worden aangebracht tijdens het proces, en uiteindelijk wordt het document goedgekeurd. De goedkeuringsservice weet niets over de documenten: het is gewoon een chat voor goedkeurders met een paar extra functies die we hier niet zullen bespreken.
Dus, er zijn chatruimtes (die overeenkomen met documenten) met een vooraf gedefinieerde set deelnemers in elk van hen. Zoals in gewone chats, kunnen de berichten tekst en bestanden bevatten en kunnen ze antwoorden (reply) en doorsturen (forward) zijn:
gegevensklasse ChatBericht(
// nullable так как появляется только после persist
val id: Lang? = null,
/** Ссылка на автора */
val auteur: GebruikersReferentie,
/** Сообщение */
val bericht: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val bestanden: Lijst<BestandsReferentie>? = null,
/** Если является ответом, то здесь будет оригинал */
val antwoordAan: ChatBericht? = null,
/** Если является пересылкой, то здесь будет оригинал */
val doorsturenVan: ChatBericht? = null
)
Links naar het bestand en de gebruiker zijn links naar andere domeinen. Zo leeft het bij ons:
typealias BestandsReferentie = Lang
typealias GebruikersReferentie = Lang
Gegevens van gebruikers worden opgeslagen in Keycloak en verkregen via REST. Hetzelfde geldt voor bestanden: bestanden en metadata erover worden bewaard in een aparte opslagdienst.
Alle aanroepen naar deze diensten zijn zware verzoeken. Dit betekent dat de overhead voor het transport van deze verzoeken veel groter is dan de tijd die het kost om ze door een externe dienst te verwerken. In onze testomgevingen is de typische aanroepstijd voor dergelijke diensten 100 ms, dus we zullen deze cijfers verder gebruiken.
We moeten een eenvoudige REST-controller maken om de laatste N berichten met alle benodigde informatie te verkrijgen. Dit betekent dat we aannemen dat het berichtenmodel aan de front-end bijna gelijk is en dat we alle gegevens moeten doorsturen. Het verschil in het model voor de front-end is dat het bestand en de gebruiker in een iets gedecodeerde vorm moeten worden gepresenteerd, zodat ze als links kunnen fungeren:
/** В таком виде отдаются ссылки на сущности для фронта */
gegevensklasse Referentie UI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val naam: String
)
gegevensklasse Chat Bericht UI(
val id: Lang,
/** Ссылка на автора */
val auteur: Referentie UI,
/** Сообщение */
val bericht: String,
/** Ссылки на аттачи */
val bestanden: Lijst<Referentie UI>,
/** Если являтся ответом, то здесь будет оригинал */
val antwoordAan: Chat Bericht UI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val doorsturenVan: Chat Bericht UI? = null
)
We moeten het volgende implementeren:
interface ChatRestApi {
fun getLast(n: Int): Lijst<Chat Bericht UI>
}
Postfix UI betekent DTO-modellen voor de front-end, dat wil zeggen datgene wat we via REST moeten teruggeven.
Het kan verrassend lijken dat we geen chat-id doorgeven en dat deze zelfs niet voorkomt in het model ChatMessage/ChatMessageUI. Ik heb dit opzettelijk gedaan om de codevoorbeelden niet te verrommelen (chats zijn geïsoleerd, dus we kunnen ervan uitgaan dat we er eigenlijk maar één hebben).
Filosofische overwegingZowel in de klasse ChatMessageUI als in de methode ChatRestApi.getLast wordt het datatype List gebruikt, terwijl het in werkelijkheid een geordende Set is. In de JDK is dit een probleem, dus het is niet mogelijk om de volgorde van elementen op het niveau van de interface te declareren (het behoud van volgorde bij toevoegen en extraheren). Daarom is het gebruik van List in gevallen waar een geordende Set nodig is, een algemene praktijk geworden (er is ook LinkedHashSet, maar dit is geen interface).
Belangrijke beperking: we gaan ervan uit dat er geen lange ketens van antwoorden of doorsturen zijn. Dat wil zeggen, ze zijn er, maar de lengte overschrijdt niet drie berichten. In de front-end moet de keten van berichten als geheel worden doorgestuurd.
Voor het verkrijgen van gegevens uit externe diensten zijn er dergelijke API's:
interface Chatberichtrepository {
fun vindLaatste(n: Int): Lijst<ChatBericht>
}
gegevensklasse BestandHoofdAfstandsbedieningen(
val id: BestandsReferentie,
val naam: String
)
interface BestandAfstandsbedieningApi {
fun haalHoofdOpPerId(id: BestandsReferentie): BestandHoofdAfstandsbedieningen
fun haalHoofdenOpPerIds(id: Stel in<BestandsReferentie>): Stel in<BestandHoofdAfstandsbedieningen>
fun haalHoofdenOpPerIds(id: Lijst<BestandsReferentie>): Lijst<BestandHoofdAfstandsbedieningen>
fun haalHoofdenOpPerChat(): Lijst<BestandHoofdAfstandsbedieningen>
}
gegevensklasse GebruikerAfstandsbediening(
val id: GebruikersReferentie,
val naam: String
)
interface GebruikerAfstandsbedieningApi {
fun haalGebruikerOpPerId(id: GebruikersReferentie): GebruikerAfstandsbediening
fun haalGebruikersOpPerIds(id: Stel in<GebruikersReferentie>): Stel in<GebruikerAfstandsbediening>
fun haalGebruikersOpPerIds(id: Lijst<GebruikersReferentie>): Lijst<GebruikerAfstandsbediening>
}
Het is duidelijk dat in externe diensten aanvankelijk batchverwerking is voorzien, zowel in beide varianten: via Set (zonder behoud van volgorde, met unieke sleutels) als via List (dubbelingen zijn mogelijk — volgorde wordt behouden).
Eenvoudige implementaties
Naïeve implementatie
De eerste naïeve implementatie van onze REST-controller zal er in de meeste gevallen ongeveer zo uitzien:
klasse ChatRestController(
private val messageRepository: Chatberichtrepository,
private val userRepository: GebruikerAfstandsbedieningApi,
private val fileRepository: BestandAfstandsbedieningApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun ChatBericht.toFrontModel(): Chat Bericht UI =
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()
)
}
Alles is duidelijk, en dat is een groot pluspunt.
We gebruiken batchverwerking en halen gegevens uit een externe service in batches. Maar wat gebeurt er met de prestaties?
Voor elk bericht zal er één aanroep van UserRemoteApi worden gedaan om gegevens op te halen voor het veld author en één aanroep van FileRemoteApi om alle bijgevoegde bestanden op te halen. Dat lijkt alles te zijn. Laten we aannemen dat de velden forwardFrom en replyTo voor ChatMessage zo worden verkregen dat dit geen extra aanroepen vereist. Maar de omzetting naar ChatMessageUI resulteert in recursie, wat betekent dat de tellers van aanroepen aanzienlijk kunnen toenemen. Zoals eerder vermeld, laten we aannemen dat we geen grote diepte hebben en dat de keten beperkt is tot drie berichten.
Uiteindelijk krijgen we tussen de twee en zes aanroepen van externe services voor één bericht en één JPA-aanroep voor het hele pakket berichten. Het totale aantal aanroepen varieert van 2*N+1 tot 6*N+1. Hoeveel is dat in echte eenheden? Stel dat er 20 berichten nodig zijn voor het weergeven van de pagina. Om deze te verkrijgen, zijn 4 tot 10 seconden nodig. Verschrikkelijk! We willen binnen de 500 ms blijven. Aangezien de frontend graag een naadloze scroll-ervaring wil creëren, kunnen de prestatie-eisen voor deze endpoint verdubbeld worden.
Voordelen:
- De code is kort en zelfdocumenterend (een droom voor de support).
- De code is eenvoudig, dus de kansen om jezelf in de voet te schieten zijn bijna nihil.
- Batchverwerking lijkt niets vervreemdends en past organisch in de logica.
- Wijzigingen in de logica kunnen gemakkelijk en lokaal worden aangebracht.
Nadeel:
Verschrikkelijke prestaties, gerelateerd aan het feit dat de pakketten erg klein zijn.
Deze benadering is vrij vaak te zien in eenvoudige services of prototypes. Als de snelheid van wijzigingen belangrijk is, is het niet verstandig om het systeem te compliceren. Tegelijkertijd zijn de prestaties voor onze zeer eenvoudige service verschrikkelijk, dus de toepasbaarheid van deze benadering is zeer beperkt.
Naïeve parallelle verwerking
Het is mogelijk om de verwerking van alle berichten parallel uit te voeren - dit zal de lineaire toename van de tijd in verband met het aantal berichten wegnemen. Dit is echter niet bijzonder een goede optie, omdat het zal leiden tot een grote piekbelasting op de externe service.
Parallel processing is very easy to implement:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
By using parallel processing of messages, we ideally achieve 300–700 ms, which is much better than with a naive implementation, but still not fast enough.
With this approach, requests to userRepository and fileRepository will be executed synchronously, which is not very efficient. To fix this, we would need to significantly change the logic of the calls. For example, using CompletionStage (aka CompletableFuture):
private fun ChatBericht.toFrontModel(): Chat Bericht UI =
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()!!
It is evident that the initially simple mapping code has become less clear. This is because we had to separate the calls to external services from the usage of the results. This in itself is not bad. But the combination of calls does not look particularly elegant and resembles typical reactive 'spaghetti'.
If we use coroutines, everything will look much nicer:
private fun ChatBericht.toFrontModel(): Chat Bericht UI =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds bestanden
.map { it.toFrontReference() } } ?: listOf() }
).let { (auteur, bestanden) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
auteur = auteur,
message = message,
bestanden = bestanden,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Waarbij:
fun <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A — is leeg, en kiest daardoor indirect it[1] as B
}
Theoretically, using such parallel processing, we get 200–400 ms, which is already close to our expectations.
Unfortunately, such good parallelization is rare, and the trade-off is quite severe: with just a few users simultaneously working on the services, a flood of requests will crash in, and they still will not be processed in parallel, so we will return to our sad 4 seconds.
My result when using such a service is 1300–1700 ms to process 20 messages. This is faster than in the first implementation, but it still does not solve the problem.
Alternative application of parallel requestsWhat if batch processing is not provided in external services? For example, we can hide the absence of batch processing within the methods of the interfaces:
interface GebruikerAfstandsbedieningApi {
fun haalGebruikerOpPerId(id: GebruikersReferentie): GebruikerAfstandsbediening
fun haalGebruikersOpPerIds(id: Stel in<GebruikersReferentie>): Stel in<GebruikerAfstandsbediening> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
fun haalGebruikersOpPerIds(id: Lijst<GebruikersReferentie>): Lijst<GebruikerAfstandsbediening> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
This makes sense if there is hope for batch processing in future versions.
Voordelen:
- Easy implementation of parallel processing by messages.
- Good scalability.
Nadelen:
- The need to separate data retrieval from processing during parallel requests to different services.
- Increased load on external services.
Het is duidelijk dat de toepassingsomvang ongeveer hetzelfde is als die van de naïeve benadering. Het maakt zin om de methode van parallelle verzoeken te gebruiken als je de prestaties van je dienst meerdere keren wilt verhogen door onverbiddelijk gebruik te maken van externe bronnen. In ons voorbeeld zijn de prestaties met 2,5 keer verhoogd, maar dat is duidelijk onvoldoende.
Caching
Caching in de geest van JPA voor externe diensten is mogelijk, dat wil zeggen, binnen een sessie opgeslagen objecten kunnen worden bewaard zodat ze niet opnieuw moeten worden opgehaald (ook niet tijdens batchverwerking). Je kunt zulke caches zelf maken, je kunt Spring gebruiken met zijn @Cacheable, plus je kunt altijd kant-en-klare caches zoals EhCache handmatig gebruiken.
Het algemene probleem is dat caches alleen nuttig zijn als er hits zijn. In ons geval is het redelijk waarschijnlijk dat er hits zijn op het veld author (laten we zeggen 50%), maar er zullen helemaal geen hits zijn op bestanden. Deze benadering zal bepaalde verbeteringen opleveren, maar zal de prestaties niet radicaal veranderen (en dat is wat we nodig hebben).
Tussen-sessies (lange) caches vereisen complexe invalidatie logica. Over het algemeen, hoe later je naar tussen-sessies caches terugvalt om prestatieproblemen op te lossen, hoe beter.
Voordelen:
- Implementatie van caching zonder codewijziging.
- Prestatiewinst met meerdere keren (in sommige gevallen).
Nadelen:
- Mogelijkheid tot prestatievermindering bij onjuiste toepassing.
- Grote geheugenkosten, vooral met lange caches.
- Complexe invalidatie, waarbij fouten leiden tot moeilijk reproduceerbare runtime-problemen.
Caches worden vaak alleen gebruikt om snel ontwerpproblemen op te lossen. Dit betekent niet dat ze niet moeten worden gebruikt. Het is echter altijd wijs om voorzichtig met hen om te gaan en eerst de behaalde prestatieverbetering te evalueren voordat je een beslissing neemt.
In ons voorbeeld zal de prestatiewinst van caches rond de 25% zijn. Daarbij hebben caches behoorlijk veel nadelen, dus ik zou ze hier niet gebruiken.
Conclusies
We hebben de naïeve implementatie van een dienst die batchverwerking gebruikt bekeken en enkele eenvoudige manieren om deze te versnellen.
Het belangrijkste voordeel van al deze methoden is de eenvoud, waaruit veel aangename gevolgen voortkomen.
Een veelvoorkomend probleem van deze methoden is de slechte prestaties, die voornamelijk verband houden met de pakketgrootte. Dus als deze oplossingen voor jou niet geschikt zijn, is het de moeite waard om radicalere methoden te overwegen.
Er zijn twee hoofdrichtingen waarin we naar oplossingen kunnen zoeken:
- asynchrone verwerking van gegevens (vereist een paradigmawisseling, daarom wordt dit in dit artikel niet behandeld);
- het vergroten van pakketten terwijl de synchronisatie behouden blijft.
Het vergroten van pakketten kan het aantal externe aanroepen sterk verminderen en tegelijkertijd de code synchroon houden. Dit onderwerp zal in het volgende deel van het artikel worden behandeld.
Bron: habr.com
