Næsten alle moderne softwareprodukter består af flere tjenester. Oftest bliver den lange svartid på tværs af tjenestekanaler en kilde til problemer med ydeevnen. Den standard løsning på sådanne problemer er at pakke flere tværtjenesteforespørgsler i ét batch, hvilket kaldes batchbehandling.
Hvis du bruger batchbehandling, kan du være utilfreds med dens resultater med hensyn til ydeevne eller kodeforståelse. Denne metode er ikke så simpel for den kaldende part, som man måske skulle tro. Løsningerne kan variere betydeligt afhængigt af forskellige mål og situationer. Jeg vil vise fordele og ulemper ved flere tilgange med konkrete eksempler.
Demonstrationsprojekt
For klarhedens skyld lad os se på et eksempel på en af tjenesterne i den applikation, jeg arbejder på lige nu.
Forklaring af valg af platform til eksemplerProblemet med dårlig ydeevne er ret almindeligt og vedrører ikke specifikke sprog og platforme. I denne artikel vil jeg demonstrere opgaver og løsninger ved at bruge kodeeksempler i Spring + Kotlin. Kotlin er lige så forståeligt (eller uforståeligt) for Java- og C#-udviklere, og derudover er koden mere kompakt og forståelig end Java. For at lette forståelsen for rene Java-udviklere vil jeg undgå mørk magi i Kotlin og kun bruge hvid (i ånden af Lombok). Der vil være nogle extension-metoder, men de er faktisk velkendte for alle Java-programmører som static-metoder, så det vil være en lille sødme, der ikke vil ødelægge retten.
Der er en tjeneste til godkendelse af dokumenter. Nogen opretter et dokument og bringer det til drøftelse, hvor der foretages ændringer, og i sidste ende godkendes dokumentet. Selve godkendelsestjenesten ved ingenting om dokumenterne: det er blot en chat for godkendere med et lille sæt ekstra funktioner, som vi ikke vil behandle her.
Så der er chatrum (som svarer til dokumenter) med en forudbestemt gruppe af deltagere i hver. Som i almindelige chats indeholder beskeder tekst og filer og kan være svar (reply) og videresendelser (forward):
data klasse ChatMeddelelse(
// nullable так как появляется только после persist
val id: Lang? = null,
/** Ссылка на автора */
val forfatter: BrugerReference,
/** Сообщение */
val besked: Streng,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val filer: Liste<FilReference>? = null,
/** Если является ответом, то здесь будет оригинал */
val svarTil: ChatMeddelelse? = null,
/** Если является пересылкой, то здесь будет оригинал */
val videreFra: ChatMeddelelse? = null
)
Links til filen og brugeren – det er links til andre domains. Hos os lever det sådan her:
typealias FilReference = Lang
typealias BrugerReference = Lang
Brugerdata opbevares i Keycloak og hentes via REST. Det samme gælder filer: filer og metadata om dem lever i en separat filopbevaringsservice.
Alle opkald til disse services er tunge forespørgsler. Det betyder, at omkostningerne ved transport af disse forespørgsler er meget højere end behandlingen af dem af en ekstern service. På vores testmiljøer er den typiske behandlingstid for sådanne services 100 ms, så vi vil bruge disse tal fremover.
Vi skal lave en simpel REST-controller for at hente de seneste N beskeder med al relevant information. Det vil sige, at vi antager, at frontend-modellen for beskeder er næsten den samme, og vi skal sende alle data videre. Forskellen i modellen for frontend er, at filen og brugeren skal præsenteres i en lidt dekodet form for at gøre dem til links:
/** В таком виде отдаются ссылки на сущности для фронта */
data klasse ReferenceUI(
/** Идентификатор для url */
val ref: Streng,
/** Видимое пользователю название ссылки */
val navn: Streng
)
data klasse ChatMessageUI(
val id: Lang,
/** Ссылка на автора */
val forfatter: ReferenceUI,
/** Сообщение */
val besked: Streng,
/** Ссылки на аттачи */
val filer: Liste<ReferenceUI>,
/** Если являтся ответом, то здесь будет оригинал */
val svarTil: ChatMessageUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val videreFra: ChatMessageUI? = null
)
Vi skal implementere følgende:
interface ChatRestApi {
sjov fåSidste(n: Int): Liste<ChatMessageUI>
}
Postfix UI betyder DTO-modeller til frontend, det vil sige det, vi skal returnere via REST.
Her kan det virke overraskende, at vi ikke sender nogen chat-id, og selv i modellen ChatMessage/ChatMessageUI er det ikke til stede. Jeg gjorde dette bevidst for ikke at overbelaste kodeeksemplerne (chats er isolerede, så det kan antages, at vi kun har én).
Filosofisk bemærkningBåde i klassen ChatMessageUI og i metoden ChatRestApi.getLast anvendes datatypen List, selvom det i virkeligheden er et ordnet Set. I JDK er dette problematisk, så det er ikke muligt at deklarere rækkefølgen af elementer på interface-niveau (bevaring af rækkefølge ved tilføjelse og udtagning). Så det er blevet en almindelig praksis at bruge List i de tilfælde, hvor der er behov for et ordnet Set (der er også LinkedHashSet, men det er ikke et interface).
Vigtig begrænsning: lad os antage, at der ikke findes lange kæder af svar eller videresendelser. Det vil sige, at der findes sådanne, men deres længde ikke overstiger tre beskeder. I frontend skal kæden af beskeder overføres i sin helhed.
For at hente data fra eksterne services anvendes følgende API'er:
interface ChatMessageRepository {
sjov findLast(n: Int): Liste<ChatMeddelelse>
}
data klasse FileHeadRemote(
val id: FilReference,
val navn: Streng
)
interface FileRemoteApi {
sjov getHeadById(id: FilReference): FileHeadRemote
sjov getHeadsByIds(id: Sæt<FilReference>): Sæt<FileHeadRemote>
sjov getHeadsByIds(id: Liste<FilReference>): Liste<FileHeadRemote>
sjov getHeadsByChat(): Liste<FileHeadRemote>
}
data klasse UserRemote(
val id: BrugerReference,
val navn: Streng
)
interface UserRemoteApi {
sjov getUserById(id: BrugerReference): UserRemote
sjov getUsersByIds(id: Sæt<BrugerReference>): Sæt<UserRemote>
sjov getUsersByIds(id: Liste<BrugerReference>): Liste<UserRemote>
}
Det er tydeligt, at de eksterne services oprindeligt er designet til batchbehandling, både gennem Set (uden bevarelse af rækkefølge, med unikke nøgler) og gennem List (kan indeholde dubletter – rækkefølge bevares).
Enkle implementeringer
Naiv implementering
Den første naive implementation af vores REST-controller vil i de fleste tilfælde se nogenlunde sådan her ud:
klasse ChatRestController(
privat val messageRepository: ChatMessageRepository,
privat val userRepository: UserRemoteApi,
privat val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun fåSidste(n: Int) =
messageRepository.findLast(n)
.kortlægning { det.tilFrontModel() }
privat fun ChatMeddelelse.tilFrontModel(): ChatMessageUI =
ChatMessageUI(
id = id ?: throw IllegalStateException("$dette skal være vedholdt"),
forfatter = userRepository.getUserById(forfatter).tilFrontReference(),
besked = besked,
filer = filer?.lad { files ->
fileRepository.getHeadsByIds(files)
.kortlægning { det.tilFrontReference() }
} ?: listeAf(),
videregivetFra = videregivetFra?.tilFrontModel(),
svarTil = svarTil?.tilFrontModel()
)
}
Alt er klart og tydeligt, hvilket er en stor fordel.
Vi bruger batchbehandling og henter data fra en ekstern tjeneste i pakker. Men hvad med vores ydeevne?
Der vil blive lavet et kald til UserRemoteApi for at hente data om feltet author, og et kald til FileRemoteApi for at hente alle vedhæftede filer. Det ser ud til at være alt. Lad os antage, at felterne forwardFrom og replyTo for ChatMessage hentes sådan, at det ikke kræver ekstra kald. Men konverteringen til ChatMessageUI vil føre til rekursion, hvilket betyder, at antallet af kald kan stige markant. Som vi tidligere nævnte, lad os antage, at der ikke er mange niveauer af indlejring, og kæden er begrænset til tre beskeder.
Resultatet vil være mellem to og seks kald til eksterne tjenester for én besked og ét JPA-kald for hele batchen af beskeder. Det samlede antal kald vil variere fra 2*N+1 til 6*N+1. Hvor meget er det i reelle enheder? Antag, at der er brug for 20 beskeder for at tegne siden. For at få dem, vil det tage mellem 4 sekunder og 10 sekunder. Forfærdeligt! Man ville gerne kunne holde det under 500 ms. Og eftersom frontend-teamet drømte om at lave sømløs scrolling, kan kravene til ydeevne for denne endpoint blive dobbelte.
Fordele:
- Koden er kort og selv-dokumenterende (supportteamets drøm).
- Koden er simpel, så der er næsten ingen risiko for at skyde sig selv i foden.
- Batchbehandling virker ikke fremmed og er naturligt integreret i logikken.
- Ændringer i logikken vil være lette at implementere og vil være lokale.
Minus:
Forfærdelig ydeevne, fordi pakkerne er meget små.
Denne tilgang ses ofte i enkle tjenester eller prototyper. Hvis hastighed i ændringer er vigtig, er det næppe værd at komplicere systemet. Samtidig er ydeevnen for vores meget enkle tjeneste forfærdelig, så anvendelsesområdet for en sådan tilgang er meget begrænset.
Naiv parallel behandling
Man kan køre behandlingen af alle beskeder parallelt - det vil eliminere den lineære vækst i tid i forhold til antallet af beskeder. Det er ikke en særlig god vej, da det vil føre til en stor spidsbelastning på den eksterne tjeneste.
Det er meget enkelt at implementere parallell behandling:
override fun fåSidste(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { det.tilFrontModel() }
.collect(toList())
Ved at bruge parallell behandling af beskeder kan vi i ideelle tilfælde opnå 300–700 ms, hvilket er meget bedre end ved en naiv implementering, men stadig ikke hurtigt nok.
Med denne tilgang vil anmodningerne til userRepository og fileRepository blive udført synkront, hvilket ikke er særlig effektivt. For at løse dette vil vi være nødt til at ændre logikken for kald betydeligt. For eksempel ved hjælp af CompletionStage (også kendt som CompletableFuture):
privat fun ChatMeddelelse.tilFrontModel(): ChatMessageUI =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).tilFrontReference()
}.thenCombine(
files?.lad {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).kortlægning { det.tilFrontReference() }
}
} ?: CompletableFuture.completedFuture(listeAf())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$dette skal være vedholdt"),
author = author,
besked = besked,
files = files,
videregivetFra = videregivetFra?.tilFrontModel(),
svarTil = svarTil?.tilFrontModel()
)
}.get()!!
Det er tydeligt, at den oprindeligt enkle mappingskode er blevet mindre forståelig. Dette skyldes, at vi har været nødt til at adskille kald til eksterne tjenester fra brugen af resultaterne. I sig selv er dette ikke dårligt. Men kombinationen af kald ser ikke særlig elegant ud og minder om typisk reaktiv "spaghetti."
Hvis vi bruger korutiner, vil alt se pænere ud:
privat fun ChatMeddelelse.tilFrontModel(): ChatMessageUI =
sammenføje(
{ userRepository.getUserById(author).tilFrontReference() },
{ files?.lad { fileRepository.getHeadsByIds(filer)
.kortlægning { det.tilFrontReference() } } ?: listeAf() }
).lad { (forfatter, filer) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$dette skal være vedholdt"),
forfatter = forfatter,
besked = besked,
filer = filer,
videregivetFra = videregivetFra?.tilFrontModel(),
svarTil = svarTil?.tilFrontModel()
)
}
Hvor:
sjov <A, B> sammenføje(en: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { en() }, async { b() })
}.lad {
det[0] as A til det[1] as B
}
Teoretisk set, ved at bruge sådan parallell behandling, kan vi opnå 200–400 ms, hvilket allerede er tættere på vores forventninger.
Desværre er sådan god parallelisering sjældent, og prisen er ret hård: når et par brugere arbejder samtidig, vil der opstå en bølge af anmodninger til tjenesterne, som stadig ikke vil blive behandlet parallelt, så vi vil vende tilbage til vores triste 4 s.
Mit resultat ved at bruge en sådan tjeneste er 1300–1700 ms til behandling af 20 beskeder. Det er hurtigere end den første implementering, men løser stadig ikke problemet.
Alternativ brug af parallelle anmodningerHvad hvis de eksterne tjenester ikke understøtter batchbehandling? For eksempel kan vi skjule manglen på batchbehandling inden i metoderne til grænsefladerne:
interface UserRemoteApi {
sjov getUserById(id: BrugerReference): UserRemote
sjov getUsersByIds(id: Sæt<BrugerReference>): Sæt<UserRemote> =
id.parallelStream()
.map { getUserById(det) }.collect(toSet())
sjov getUsersByIds(id: Liste<BrugerReference>): Liste<UserRemote> =
id.parallelStream()
.map { getUserById(det) }.collect(toList())
}
Dette giver mening, hvis der er håb om, at batchbehandling vil komme i de kommende versioner.
Fordele:
- Let implementering af parallell behandling af beskeder.
- God skalerbarhed.
Ulemper:
- Behovet for at adskille dataindsamling fra deres behandling ved parallell behandling af anmodninger til forskellige tjenester.
- Øget belastning på de eksterne tjenester.
Det er tydeligt, at anvendelsesområderne er omtrent de samme som ved den naive tilgang. At bruge metoden med parallelle forespørgsler giver mening, hvis du ønsker at øge ydeevnen af din service mange gange ved hårdt at udnytte andres ressourcer. I vores eksempel blev ydeevnen øget med 2,5 gange, men det er tydeligvis ikke nok.
Caching
Det er muligt at lave caching i stil med JPA for eksterne tjenester, det vil sige at gemme de modtagne objekter inden for sessionen, så de ikke behøver at blive hentet igen (også under batchbehandling). Man kan lave sådanne caches selv, eller man kan bruge Spring med sin @Cacheable, plus man kan altid bruge en færdiglavet cache som EhCache manuelt.
Det overordnede problem vil være, at caches kun er nyttige, når der er hits. I vores tilfælde er der sandsynligvis hits på feltet author (lad os sige 50 %), men der vil ikke være hits på filerne overhovedet. Denne tilgang vil give nogle forbedringer, men vil ikke radikalt ændre ydeevnen (og vi har brug for et gennembrud).
Mellemsession (lange) caches kræver kompleks invalidationslogik. Generelt gælder det, at jo senere du går over til at løse ydeevneproblemer ved hjælp af mellemsessionscaches, jo bedre.
Fordele:
- Implementering af caching uden at ændre koden.
- Ydeevneforøgelse flere gange (i nogle tilfælde).
Ulemper:
- Mulighed for at reducere ydeevnen ved forkert brug.
- Store hukommelsesomkostninger, især med lange caches.
- Kompleks invalidation, hvor fejl vil føre til vanskeligt reproducérbare problemer i runtime.
Meget ofte bruges caches kun til hurtigt at lappe designproblemer. Det betyder ikke, at de ikke skal bruges. Men man bør altid være forsigtig med dem og først vurdere den opnåede ydeevneforøgelse, før man træffer en beslutning.
I vores eksempel vil caches give en ydeevneforøgelse på omkring 25 %. Der er dog flere ulemper ved caches, så jeg ville ikke bruge dem her.
Resumé
Så vi har set på den naive implementering af tjenesten, der bruger batchbehandling, og nogle enkle måder at accelerere den på.
Den største fordel ved alle disse metoder er enkelheden, som giver mange behagelige konsekvenser.
Et almindeligt problem med disse metoder er den dårlige ydeevne, der først og fremmest er forbundet med pakke størrelsen. Så hvis disse løsninger ikke passer til dig, er det værd at overveje mere radikale metoder.
Der er to hovedretninger, hvor man kan lede efter løsninger:
- asynkront arbejde med data (kræver skift af paradigme, så dette emne behandles ikke i denne artikel);
- større pakker ved at bevare synkron behandling.
Større pakker vil markant reducere antallet af eksterne opkald og samtidig bevare koden synkron. Dette emne vil blive dækket i næste del af artiklen.
Kilde: habr.com
