A modern software product typically consists of several services. Often, a long response time between service channels becomes a source of performance issues. A standard solution to such problems is to package multiple inter-service requests into one batch, a method known as batching.
If you're using batching, you might be dissatisfied with its performance or code clarity. This method is not as straightforward for the caller as one might think. Solutions can vary significantly for different purposes and situations. I will illustrate the advantages and disadvantages of several approaches using specific examples.
Demonstration Project
For clarity, let's look at an example of one of the services in the application I'm currently working on.
Explanation for platform choice in examplesThe issue of poor performance is quite common and is not limited to specific languages or platforms. In this article, we will use code examples in Spring + Kotlin to demonstrate tasks and solutions. Kotlin is equally understandable (or difficult) for Java and C# developers, and moreover, the code tends to be more compact and comprehensible than in Java. To facilitate understanding for pure Java developers, I will avoid Kotlin's black magic and only use its white magic (in the spirit of Lombok). There will be a few extension methods, but they are actually familiar to all Java programmers as static methods, so it will be a little sugar that does not spoil the taste of the dish.
There is a document approval service. Someone creates a document and presents it for discussion, during which edits are made, and ultimately, the document is approved. The approval service knows nothing about the documents: it is simply a chat for approvers with a few additional functions that we will not discuss here.
So, there are chat rooms (corresponding to documents) with a predefined set of participants in each of them. As in regular chats, messages contain text and files and can be replies and forwards:
adat osztály CsevegésÜzenet(
// nullable так как появляется только после persist
val azonosító: Hosszú? = null,
/** Ссылка на автора */
val szerző: FelhasználóHivatkozás,
/** Сообщение */
val üzenet: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val fájlok: Lista<FájlHivatkozás>? = null,
/** Если является ответом, то здесь будет оригинал */
val válaszRa: CsevegésÜzenet? = null,
/** Если является пересылкой, то здесь будет оригинал */
val továbbítvaTőle: CsevegésÜzenet? = null
)
Links to files and users are links to others domainok. It lives like this:
típusalias FájlHivatkozás = Hosszú
típusalias FelhasználóHivatkozás = Hosszú
A felhasználói adatok a Keycloak-ban tárolódnak, és a REST-en keresztül érhetők el. Ugyanez vonatkozik a fájlokra is: a fájlok és azok metaadatai egy külön fájl tároló szolgáltatásban élnek.
Minden ezen szolgáltatások hívása nehéz kérés. Ez azt jelenti, hogy a kérés szállításának költségei sokkal magasabbak, mint a harmadik féltől származó szolgáltatás általi feldolgozásuk ideje. A tesztkörnyezetünkben az ilyen szolgáltatások hívásának jellemző ideje 100 ms, ezért a továbbiakban ezeket a számokat fogjuk használni.
Szükségünk van egy egyszerű REST-vezérlőre, amely az utolsó N üzenetet adja meg az összes szükséges információval. Tehát úgy tekintjük, hogy a frontend üzenetmodellje szinte ugyanolyan, és minden adatot át kell küldeni. A frontend modell eltérése az, hogy a fájlokat és a felhasználókat kissé dekódolt formában kell bemutatni, hogy hivatkozásokat hozzunk létre:
/** В таком виде отдаются ссылки на сущности для фронта */
adat osztály ReferenciaUI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val név: String
)
adat osztály CsevegőÜzenetUI(
val azonosító: Hosszú,
/** Ссылка на автора */
val szerző: ReferenciaUI,
/** Сообщение */
val üzenet: String,
/** Ссылки на аттачи */
val fájlok: Lista<ReferenciaUI>.
/** Если являтся ответом, то здесь будет оригинал */
val válaszRa: CsevegőÜzenetUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val továbbítvaTőle: CsevegőÜzenetUI? = null
)
A következőket kell megvalósítanunk:
interface ChatRestApi {
. Új getLast(n: Int): Lista<CsevegőÜzenetUI>
}
A postfix UI DTO-modelleket jelenti a frontend számára, azaz azt, amit a REST-en keresztül kell átadnunk.
Itt meglepőnek tűnhet, hogy nem adunk át semmilyen chat azonosítót, és még a ChatMessage/ChatMessageUI modellben sincs, ezt szándékosan tettem, hogy ne zsúfoljam tele a példák kódját (a chatek izoláltak, így tekinthetjük, hogy van belőle egyetlen példányunk).
Filozófiai kitérőA ChatMessageUI osztályban és a ChatRestApi.getLast metódusban a List típusú adatokat használunk, miközben valójában rendezett Set-re van szükség. A JDK-val ez sok problémát okoz, ezért az elemek sorrendjének deklarálása az interfész szintjén (sorrend megőrzése a hozzáadás és kinyerés során) nem kivitelezhető. Így általános gyakorlat lett, hogy a List-t használják olyan esetekben, amikor rendezett Set szükséges (a LinkedHashSet is van, de ez nem interfész).
Fontos korlátozás: tekintsük úgy, hogy hosszú válaszláncok vagy átküldések nem fordulnak elő. Vannak, de a hosszúságuk nem haladja meg a három üzenetet. A frontend üzenetláncot teljes egészében kell átadni.
Az adatok külső szolgáltatásokból való lekérésére az alábbi API-k állnak rendelkezésre:
interface ChatMessageRepository {
. Új findLast(n: Int): Lista<CsevegésÜzenet>
}
adat osztály FileHeadRemote(
val azonosító: FájlHivatkozás,
val név: String
)
interface FileRemoteApi {
. Új getHeadById(id: FájlHivatkozás): FileHeadRemote
. Új getHeadsByIds(id: Beállít<FájlHivatkozás>): Beállít<FileHeadRemote>
. Új getHeadsByIds(id: Lista<FájlHivatkozás>): Lista<FileHeadRemote>
. Új getHeadsByChat(): Lista<FileHeadRemote>
}
adat osztály UserRemote(
val azonosító: FelhasználóHivatkozás,
val név: String
)
interface UserRemoteApi {
. Új getUserById(id: FelhasználóHivatkozás): UserRemote
. Új getUsersByIds(id: Beállít<FelhasználóHivatkozás>): Beállít<UserRemote>
. Új getUsersByIds(id: Lista<FelhasználóHivatkozás>): Lista<UserRemote>
}
Látható, hogy a külső szolgáltatásokban kezdetben elképzelték a kötegelt feldolgozást, mindkét variációval: Set-en keresztül (elemek sorrendjének megőrzése nélkül, egyedi kulcsokkal) és List-en keresztül (megjelenhetnek és duplikációk - a sorrend megőrződik).
Egyszerű megvalósítások
Naiv megvalósítás
Első naiv megvalósítása a REST-vezérlőnknek a legtöbb esetben így fog kinézni:
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 CsevegésÜzenet.toFrontModel(): CsevegőÜzenetUI =
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()
)
}
Minden maximálisan világos, és ez nagy előny.
Csoportos feldolgozást alkalmazunk, és adatokat szerzünk be külső szolgáltatásoktól csoportokban. De mi a helyzet a teljesítményünkkel?
Minden üzenet esetében egy UserRemoteApi hívást végzünk az author mező adatainak megszerzésére, és egy FileRemoteApi hívást az összes csatolt fájl beszerzésére. Úgy tűnik, ez minden. Tegyük fel, hogy a ChatMessage forradalmi és replyTo mezők értékei úgy jönnek elő, hogy ez nem igényel további hívásokat. Viszont ezek ChatMessageUI-vá alakítása rekurzióhoz vezethet, ami azt jelenti, hogy a hívások számlálóinak mutatói jelentősen megnövekedhetnek. Ahogy korábban említettük, feltételezhetjük, hogy mély beágyazódások nincsenek, és a lánc három üzenetre korlátozódik.
Végül két és hat külső szolgáltatás hívást kapunk egy üzenetre, és egy JPA-hívást az összes üzenetcsoportra. A hívások összes száma 2*N+1 és 6*N+1 között változhat. Ez hány valós egység? Tegyük fel, hogy a lap megjelenítéséhez 20 üzenetre van szükség. Ezek megszerzéséhez 4-10 másodpercre lesz szükség. Szörnyű! Szeretnénk 500 ms alatt végezni. Mivel a frontendben átalakításra vágytak, a teljesítményigényeket ennek az endpointnak meg kell duplázni.
Előnyök:
- A kód tömör és önmagát dokumentáló (a támogató csapat álma).
- A kód egyszerű, így a lábon lövési lehetőségek majdnem megsemmisülnek.
- A csoportos feldolgozás nem tűnik idegennek, és szervesen beilleszkedik a logikába.
- A logikai változtatások könnyen, helyben végrehajthatók.
Hátrány:
Szörnyű teljesítmény, amiatt, hogy a csomagok nagyon kicsik.
Ez a megközelítés gyakran látható egyszerű szolgáltatásokban vagy prototípusokban. Ha a változtatások sebessége fontos, valószínűleg nem érdemes bonyolítani a rendszert. Ugyanakkor a mi nagyon egyszerű szolgáltatásunk esetében a teljesítmény borzalmas, tehát az ilyen megközelítések alkalmazhatóságának keretei nagyon szűkek.
Naiv párhuzamos feldolgozás
Minden üzenet párhuzamos feldolgozása elindítható - ez lehetővé teszi a feldolgozási idő lineáris növekedésének eltávolítását az üzenetek számának függvényében. Ez nem különösebben jó út, mert nagy csúcs terhelést idéz elő a külső szolgáltatáson.
A párhuzamos feldolgozás bevezetése nagyon egyszerű:
override fun getLast(n: Int) =
messageRepository.findLast(n).párhuzamosÁram()
.térképez { it.toFrontModel() }
.gyűjt(toList())
Párhuzamos üzenetfeldolgozással ideálisan 300–700 ms körüli sebességet érhetünk el, ami sokkal jobb, mint a naiv megvalósítás, de még mindig nem elég gyors.
Ezzel a megközelítéssel a userRepository és a fileRepository lekérdezései szinkron módon fognak futni, ami nem túl hatékony. Ennek javításához a hívási logikát jelentősen meg kell változtatni. Például CompletionStage (más néven CompletableFuture) használatával:
private fun CsevegésÜzenet.toFrontModel(): CsevegőÜzenetUI =
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()!!
Látható, hogy az eredetileg egyszerű leképezési kód nehezebben érthetővé vált. Ennek az az oka, hogy el kellett választanunk a külső szolgáltatások hívását az eredmények felhasználásának helyétől. Ez önmagában nem rossz. De a hívások kombinálása nem túl elegáns, és emlékeztet a tipikus reaktív "tésztára".
Ha korutinákat használunk, minden szebb lesz:
private fun CsevegésÜzenet.toFrontModel(): CsevegőÜzenetUI =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(files)
.map { it.toFrontReference() } } ?: listOf() }
).let { (szerző, fájlok) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
szerző = szerző,
message = message,
fájlok = fájlok,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Ahol:
. Új <A, B> join(a: () -> A, b: () -> B) =
futtatásBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A to it[1] as B
}
Elméletileg, ha ilyen párhuzamos feldolgozást használunk, 200–400 ms körüli sebességet érhetünk el, ami már közel áll a várakozásainkhoz.
Sajnos, ilyen jó párhuzamosítás nem létezik, és az ár, amit fizetünk, meglehetősen súlyos: ha csak néhány felhasználó egyszerre használja a szolgáltatásokat, akkor a sok kérdés, amit a rendszer nem tud párhuzamosan feldolgozni, újra visszatér a szomorú 4 ms-hoz.
Az én eredményem egy ilyen szolgáltatás használatával 1300–1700 ms a 20 üzenet feldolgozásához. Ez gyorsabb, mint az első megvalósítás, de még mindig nem oldja meg a problémát.
Alternatív párhuzamos lekérdezések alkalmazásMi van, ha a külső szolgáltatások nem támogatják a csoportos feldolgozást? Például a csoportos feldolgozás hiányát az interfészek módszereiben lehet elrejteni:
interface UserRemoteApi {
. Új getUserById(id: FelhasználóHivatkozás): UserRemote
. Új getUsersByIds(id: Beállít<FelhasználóHivatkozás>): Beállít<UserRemote> =
id.parallelStream()
.térképez { getUserById(it) }.collect(toSet())
. Új getUsersByIds(id: Lista<FelhasználóHivatkozás>): Lista<UserRemote> =
id.parallelStream()
.térképez { getUserById(it) }.gyűjt(toList())
}
Ennek van értelme, ha van remény arra, hogy a következő verziókban megjelenik a csoportos feldolgozás.
Előnyök:
- Egyszerű párhuzamos üzenetfeldolgozás bevezetése.
- Jó skálázhatóság.
Hátrányok:
- Az adatok megszerzésének elkülönítése a párhuzamos lekérdezések feldolgozása során különböző szolgáltatásokhoz.
- Továbbá megnövekedett terhelés a külső szolgáltatásokra.
Látható, hogy az alkalmazási keretek körülbelül olyanok, mint a naiv megközelítés esetében. A párhuzamos kérések alkalmazása értelmes, ha azt szeretné, hogy a szolgáltatása teljesítménye többszörösére nőjön, mások erőforrásainak kíméletlen kihasználásával. Példánkban a teljesítmény 2,5-szeresére nőtt, de ez nyilvánvalóan nem elegendő.
Gyorsítótárazás
Gyorsítótárazást végezhetünk JPA stílusban külső szolgáltatások számára, vagyis az ülések keretein belül tárolhatjuk a megszerzett objektumokat, hogy ne kelljen őket újra lekérdezni (a csoportos feldolgozás során is). Ilyen gyorsítótárakat készíthetünk magunk is, használhatjuk a Spring @Cacheable annotációját, emellett mindig igénybe vehetünk kész gyorsítótárakat, például az EhCache-t.
A közös probléma az, hogy a gyorsítótárak csak akkor hasznosak, ha vannak találatok. Az esetünkben valószínűleg lesznek találatok az author mezőn (például 50%), de a fájloknál egyáltalán nem lesznek. Ez a megközelítés némi javulást eredményezhet, de radikálisan nem változtatja meg a teljesítményt (mivel áttörésre van szükségünk).
A közötti (hosszú) gyorsítótárak összetett érvénytelenítési logikát igényelnek. Általában minél később kényszerül a teljesítményproblémák megoldására közötti gyorsítótárakkal, annál jobb.
Előnyök:
- Gyorsítótárazás bevezetése kódváltoztatás nélkül.
- Több mintéh képesség több szoros esetén.
Hátrányok:
- A teljesítmény csökkenésének lehetősége helytelen használat esetén.
- Nagy memória túlterhelés, különösen a hosszú gyorsítótárak esetén.
- Összetett érvénytelenítés, amelynek hibái nehezen reprodukálható problémákhoz vezethetnek a futási időben.
Nagyon gyakran a gyorsítótárakat csak arra használják, hogy gyorsan megoldják a tervezési problémákat. Ez nem jelenti azt, hogy ne lenne szükség rájuk. Mindazonáltal mindig érdemes óvatosan kezelni őket, és először értékelni a nyert teljesítménynövekedést, majd csak ezután dönteni.
A példánkban a gyorsítótárak teljesítménynövekedése körülbelül 25% lesz. Eközben a gyorsítótáraknak meglehetősen sok hátrányuk van, ezért nem használnám őket itt.
Összegzés
Tehát megvizsgáltuk a szolgáltatás egy naiv implementációját, amely csoportos feldolgozást használ, és néhány egyszerű módját annak felgyorsítására.
Ezeknek a módszereknek a legfőbb előnye a egyszerűség, amely számos kedvező következménnyel jár.
Ezeknek a módszereknek a közös problémája a csomagok méretével kapcsolatos gyenge teljesítmény. Ezért, ha ezek a megoldások nem megfelelőek, érdemes radikálisabb módszereket fontolóra venni.
Két fő irány van, ahol keresni lehet a megoldásokat:
- aszimmetrikus adatkezelés (paradigma váltást igényel, ezért ebben a cikkben nem kerül tárgyalásra);
- a csomagok nagyobbításával szinkron feldolgozás mellett.
A csomagok nagyobbítása jelentősen csökkenti a külső hívások számát és közben megőrzi a szinkron kódot. E témára a cikk következő része figyel majd.
Forrás: habr.com
