Hast alle moderne software produkten besteane út ferskate tsjinsten. Faak wurde lange antwurdtiden fan interservicekanalen in boarne fan prestaasjesproblemen. De standert oplossing foar dit soarte fan probleem is te pakken meardere interservice fersiken yn ien pakket, dat hjit batching.
As jo batchferwurking brûke, binne jo miskien net bliid mei de resultaten yn termen fan prestaasjes as dúdlikens fan koade. Dizze metoade is net sa maklik foar de beller as jo miskien tinke. Foar ferskate doelen en yn ferskate situaasjes kinne oplossings sterk ferskille. Mei help fan spesifike foarbylden sil ik de foar- en neidielen fan ferskate oanpak sjen litte.
Demonstraasje projekt
Litte wy foar dúdlikens nei in foarbyld sjen fan ien fan 'e tsjinsten yn' e applikaasje wêr't ik op it stuit oan wurkje.
Taljochting fan platfoarm seleksje foar foarbyldenIt probleem fan minne prestaasjes is frij algemien en hat gjin ynfloed op spesifike talen of platfoarms. Dit artikel sil Spring + Kotlin-koadefoarbylden brûke om problemen en oplossingen te demonstrearjen. Kotlin is like begryplik (as ûnbegryplik) foar Java- en C #-ûntwikkelders, boppedat is de koade kompakter en begrypliker as yn Java. Om it makliker te meitsjen foar pure Java-ûntwikkelders, sil ik de swarte magy fan Kotlin foarkomme en allinich de wite magy brûke (yn 'e geast fan Lombok). D'r sille in pear útwreidingsmetoaden wêze, mar se binne eins bekend foar alle Java-programmeurs as statyske metoaden, dus dit sil in lytse sûker wêze dy't de smaak fan it skûtel net bedjerre sil.
D'r is in tsjinst foar goedkarring fan dokuminten. Immen makket in dokumint en stelt it yn foar diskusje, wêrby't bewurkings wurde makke, en úteinlik wurdt it dokumint ôfpraat. De goedkarringstsjinst sels wit neat fan dokuminten: it is gewoan in petear fan goedkarders mei lytse ekstra funksjes dy't wy hjir net sille beskôgje.
Dat, d'r binne petearkeamers (oerienkomme mei dokuminten) mei in foarôf definieare set fan dielnimmers yn elk fan har. Lykas yn gewoane petearen, befetsje berjochten tekst en bestannen en kinne antwurden of trochstjoere wêze:
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
)
Keppelings nei in bestân en brûker binne keppelings nei oare domeinenHjir is hoe't it foar ús wurket:
typealias FileReference = Long
typealias UserReference = Long
Brûkersgegevens wurde opslein yn Keycloak en ophelle fia REST. Itselde jildt foar bestannen: bestannen en meta-ynformaasje oer har libje yn in aparte tsjinst foar bestannen opslach.
Alle oproppen nei dizze tsjinsten binne swiere fersiken. Dit betsjut dat de overhead fan it ferfieren fan dizze oanfragen folle grutter is dan de tiid dy't it duorret foar't se wurde ferwurke troch in tsjinst fan tredden. Op ús testbanken is de typyske oproptiid foar sokke tsjinsten 100 ms, dus wy sille dizze nûmers yn 'e takomst brûke.
Wy moatte in ienfâldige REST-controller meitsje om de lêste N-berjochten te ûntfangen mei alle nedige ynformaasje. Dat is, wy leauwe dat it berjochtmodel yn 'e frontend hast itselde is en alle gegevens moatte wurde ferstjoerd. It ferskil tusken it front-end-model is dat it bestân en de brûker moatte wurde presintearre yn in wat dekodearre foarm om se keppelings te meitsjen:
/** В таком виде отдаются ссылки на сущности для фронта */
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
)
Wy moatte it folgjende útfiere:
interface ChatRestApi {
fun getLast(n: Int): List<ChatMessageUI>
}
De UI-postfix betsjut DTO-modellen foar de frontend, dat is wat wy moatte tsjinje fia REST.
Wat hjir ferrassend kin wêze, is dat wy gjin petear-ID trochjaan en sels it ChatMessage/ChatMessageUI-model hat gjin ien. Ik die dit mei opsetsin om de koade fan 'e foarbylden net te rommeljen (de petearen binne isolearre, dus kinne wy oannimme dat wy mar ien hawwe).
Filosofyske digressionSawol de ChatMessageUI-klasse as de ChatRestApi.getLast-metoade brûke it gegevenstype List as it feitlik in bestelde Set is. Dit is min yn 'e JDK, dus it ferklearjen fan de folchoarder fan eleminten op it ynterfacenivo (bewarje de folchoarder by it tafoegjen en fuortheljen) sil net wurkje. Sa is it gewoane praktyk wurden om in List te brûken yn gefallen dêr't in bestelde Set nedich is (d'r is ek in LinkedHashSet, mar dit is gjin ynterface).
Wichtige beheining: Wy sille oannimme dat d'r gjin lange keatlingen fan antwurden of oerstappen binne. Dat is, se besteane, mar har lingte is net mear as trije berjochten. De hiele keatling fan berjochten moat wurde oerbrocht nei de frontend.
Om gegevens te ûntfangen fan eksterne tsjinsten binne d'r de folgjende API's:
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>
}
It kin sjoen wurde dat eksterne tsjinsten yn earste ynstânsje soargje foar batchferwurking, en yn beide ferzjes: fia Set (sûnder it behâld fan de folchoarder fan eleminten, mei unike kaaien) en fia List (d'r kinne duplikaten wêze - de folchoarder wurdt bewarre).
Ienfâldige ymplemintaasjes
Naive ymplemintaasje
De earste naïve ymplemintaasje fan ús REST-controller sil der yn de measte gefallen sa útsjen:
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()
)
}
Alles is hiel dúdlik, en dit is in grutte plus.
Wy brûke batchferwurking en ûntfange gegevens fan in eksterne tsjinst yn batches. Mar wat bart der mei ús produktiviteit?
Foar elk berjocht sil ien oprop oan UserRemoteApi dien wurde om gegevens op it auteursfjild te krijen en ien oprop nei FileRemoteApi om alle taheakke bestannen te krijen. It liket derop dat it sa is. Litte wy sizze dat de forwardFrom- en replyTo-fjilden foar ChatMessage wurde krigen op sa'n manier dat dit gjin ûnnedige oproppen fereasket. Mar se omsette yn ChatMessageUI sil liede ta rekursje, dat is, oproptellers kinne signifikant tanimme. Lykas wy earder opmurken, lit ús oannimme dat wy net in protte nêst hawwe en de ketting is beheind ta trije berjochten.
As gefolch krije wy twa oant seis oproppen nei eksterne tsjinsten per berjocht en ien JPA-oprop foar it heule pakket berjochten. It totale oantal oproppen sil fariearje fan 2*N+1 oant 6*N+1. Hoefolle is dit yn echte ienheden? Litte wy sizze dat it 20 berjochten duorret om in side wer te jaan. Om se te ûntfangen sil it fan 4 s oant 10 s duorje. Freeslik! Ik wol it binnen 500 ms hâlde. En om't se dreamden fan it meitsjen fan naadloos rôljen yn 'e frontend, kinne de prestaasjeseasken foar dit einpunt wurde ferdûbele.
Pros:
- De koade is bondich en selsdokumintearjend (de dream fan in stipeteam).
- De koade is ienfâldich, dus der binne hast gjin mooglikheden om josels yn 'e foet te sjitten.
- Batchferwurking liket net op iets frjemds en is organysk yntegrearre yn 'e logika.
- Logyske feroarings sille maklik wurde makke en sille lokaal wêze.
Min:
Ferskriklike prestaasjes fanwege heul lytse pakketten.
Dizze oanpak kin frij faak sjoen wurde yn ienfâldige tsjinsten as yn prototypen. As de snelheid fan it meitsjen fan feroaringen wichtich is, is it amper wurdich om it systeem te komplisearjen. Tagelyk, foar ús heul ienfâldige tsjinst is de prestaasjes ferskriklik, dus it berik fan tapasberens fan dizze oanpak is heul smel.
Naïve parallelle ferwurking
Jo kinne begjinne mei it ferwurkjen fan alle berjochten parallel - dit sil tastean jo te ûntdwaan fan de lineêre ferheging fan de tiid ôfhinklik fan it oantal berjochten. Dit is net in bysûnder goed paad omdat it sil resultearje yn in grutte pyk load op de eksterne tsjinst.
It útfieren fan parallelle ferwurking is heul ienfâldich:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Troch parallelle berjochtferwurking te brûken, krije wy ideaal 300–700 ms, wat folle better is as mei in naïve ymplemintaasje, mar dochs net fluch genôch.
Mei dizze oanpak sille fersiken nei userRepository en fileRepository synchroon wurde útfierd, wat net heul effisjint is. Om dit te reparearjen, moatte jo de oproplogika in protte feroarje. Bygelyks fia 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()!!
It kin sjoen wurde dat de yn earste ynstânsje ienfâldige mappingkoade minder begryplik wurden is. Dit is om't wy de oproppen nei eksterne tsjinsten moatte skiede fan wêr't de resultaten wurde brûkt. Dit op himsels is net min. Mar it kombinearjen fan petearen sjocht net heul elegant en liket op in typyske reaktive "noodle".
As jo coroutines brûke, sil alles fatsoenliker útsjen:
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()
)
}
Wêr:
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
}
Teoretysk sille wy, mei sa'n parallelle ferwurking, 200-400 ms krije, wat al tichtby ús ferwachtingen is.
Spitigernôch bestiet sa'n goede parallelisaasje net, en de priis om te beteljen is frij wreed: mei mar in pear brûkers dy't tagelyk wurkje, sil in barrage fan oanfragen op 'e tsjinsten falle, dy't yn elk gefal net parallel wurde ferwurke, dus wy sil weromkomme nei ús tryste 4 s.
Myn resultaat by it brûken fan sa'n tsjinst is 1300–1700 ms foar it ferwurkjen fan 20 berjochten. Dit is flugger as yn 'e earste ymplemintaasje, mar lost it probleem noch net op.
Alternatyf gebrûk fan parallelle queriesWat as tsjinsten fan tredden gjin batchferwurking leverje? Jo kinne bygelyks it gebrek oan ymplemintaasje fan batchferwurking yn ynterfacemetoaden ferbergje:
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())
}
Dit makket sin as jo hoopje batchferwurking te sjen yn takomstige ferzjes.
Pros:
- Implementearje maklik berjocht-basearre parallelle ferwurking.
- Goede skalberens.
Cons:
- De needsaak om gegevenswinning te skieden fan har ferwurking by it ferwurkjen fan fersiken nei ferskate tsjinsten parallel.
- Ferhege lading op tsjinsten fan tredden.
It is te sjen dat de tapasberens likernôch itselde is as dy fan de naïve oanpak. It makket sin om de metoade foar parallelle oanfraach te brûken as jo de prestaasjes fan jo tsjinst meardere kearen wolle ferheegje fanwegen de genedeleaze eksploitaasje fan oaren. Yn ús foarbyld, de prestaasjes tanommen mei 2,5 kear, mar dit is dúdlik net genôch.
caching
Jo kinne caching dwaan yn 'e geast fan JPA foar eksterne tsjinsten, dat is, ûntfongen objekten opslaan binnen in sesje om se net wer te ûntfangen (ynklusyf by batchferwurking). Jo kinne sels sokke caches meitsje, jo kinne Spring brûke mei syn @Cacheable, plus jo kinne altyd in klearmakke cache lykas EhCache manuell brûke.
In mienskiplik probleem soe wêze dat caches allinnich nuttich binne as se hits hawwe. Yn ús gefal binne hits op it auteursfjild heul wierskynlik (litte wy sizze, 50%), mar d'r sille hielendal gjin hits wêze op bestannen. Dizze oanpak sil wat ferbetterings leverje, mar it sil de prestaasjes net radikale feroarje (en wy hawwe in trochbraak nedich).
Intersession (lange) caches fereaskje komplekse ûnjildingslogika. Yn 't algemien, hoe letter jo komme by it oplossen fan prestaasjesproblemen mei intersession-caches, hoe better.
Pros:
- Implementearje caching sûnder koade te feroarjen.
- Ferhege produktiviteit ferskate kearen (yn guon gefallen).
Cons:
- Mooglikheid fan fermindere prestaasjes as ferkeard brûkt.
- Grutte ûnthâld overhead, benammen mei lange caches.
- Komplekse ûnjildigens, flaters wêryn sil liede ta dreech te reprodusearjen problemen yn runtime.
Hiel faak wurde caches allinich brûkt om ûntwerpproblemen fluch op te lossen. Dit betsjut net dat se net moatte wurde brûkt. Jo moatte se lykwols altyd mei foarsichtigens behannelje en earst de resultearjende prestaasjeswinst evaluearje, en pas dan in beslút nimme.
Yn ús foarbyld sille caches in prestaasjesferheging leverje fan sawat 25%. Tagelyk hawwe caches nochal in soad neidielen, dus ik soe se hjir net brûke.
Resultaten
Dat, wy seagen nei in naïve ymplemintaasje fan in tsjinst dy't batchferwurking brûkt, en wat ienfâldige manieren om it te rapperjen.
It wichtichste foardiel fan al dizze metoaden is ienfâld, wêrfan d'r in protte noflike gefolgen binne.
In mienskiplik probleem mei dizze metoaden is minne prestaasjes, benammen troch de grutte fan 'e pakketten. Dêrom, as dizze oplossingen jo net passe, dan is it wurdich om mear radikale metoaden te beskôgjen.
D'r binne twa haadrjochtingen wêryn jo nei oplossingen kinne sykje:
- asynchrone wurk mei gegevens (fereasket in paradigma ferskowing, dus wurdt net besprutsen yn dit artikel);
- fergrutting fan batches mei behâld fan syngroane ferwurking.
Fergrutting fan batches sil it oantal eksterne petearen sterk ferminderje en tagelyk de koade syngroan hâlde. It folgjende diel fan it artikel sil wijd wurde oan dit ûnderwerp.
Boarne: www.habr.com
