Téměř všechny moderní softwarové produkty se skládají z několika služeb. Často se dlouhé doby odezvy mezi službami stávají zdrojem problémů s výkonem. Standardním řešením tohoto problému je zabalit několik meziservisních požadavků do jednoho balíčku, což se nazývá hromadné zpracování (batching).
Pokud používáte hromadné zpracování, nemusí vás výsledek z hlediska výkonu nebo srozumitelnosti kódu uspokojovat. Tato metoda není tak jednoduchá pro volající stranu, jak by se mohlo zdát. Pro různé cíle a v různých situacích se řešení výrazně liší. Na konkrétních příkladech ukážu výhody a nevýhody několika přístupů.
Demonstrační projekt
Pro názornost se podíváme na příklad jedné ze služeb v aplikaci, na které nyní pracuji.
Vysvětlení výběru platformy pro příkladyProblém špatného výkonu je dost obecný a netýká se žádných konkrétních jazyků a platforem. V tomto článku budou k demonstraci úloh a řešení použity příklady kódu v Springu + Kotlinu. Kotlin je pro vývojáře Javy a C# stejně srozumitelný (nebo nesrozumitelný) a navíc je kód kompaktnější a srozumitelnější než v Javě. Aby bylo pro běžné vývojáře Javy snazší pochopit, budu se vyhýbat temné magii Kotlinu a používat pouze tu bílou (ve stylu Lombok). Bude zde několik extension metod, ale ty jsou ve skutečnosti známé všem Java programátorům jako statické metody, takže to bude malý cukřík, který nezkazí chuť jídla.
Existuje služba pro schvalování dokumentů. Někdo vytvoří dokument a předloží ho k diskusi, během níž se provádějí úpravy, a nakonec se dokument schválí. Samotná služba schvalování nic neví o dokumentech: je to prostě chat schvalovatelů s několika dalšími funkcemi, které zde nebudeme zkoumat.
Takže existují chatovací místnosti (odpovídající dokumentům) s předdefinovanou sadou účastníků v každé z nich. Stejně jako v běžných chatech obsahují zprávy text a soubory a mohou být odpověďmi (reply) a přeposláním (forward):
data třída ChatZpráva(
// nullable так как появляется только после persist
val id: Dlouhý? = null,
/** Ссылка на автора */
val autor: UživatelskáReference,
/** Сообщение */
val zpráva: Řetězec,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val soubory: Seznam<OdkazNaSoubor>? = null,
/** Если является ответом, то здесь будет оригинал */
val odpovědětNa: ChatZpráva? = null,
/** Если является пересылкой, то здесь будет оригинал */
val přeposlatOd: ChatZpráva? = null
)
Odkazy na soubor a uživatele – to jsou odkazy na další domény. U nás to žije takto:
typový alias OdkazNaSoubor = Dlouhý
typový alias UživatelskáReference = Dlouhý
Data about users is stored in Keycloak and retrieved via REST. The same applies to files: files and their metadata reside in a separate file storage service.
All calls to these services are heavy requests. This means that the overhead for transporting these requests is much greater than the time spent processing them by the external service. In our test environments, the typical call time for such services is 100 ms, so we will use these figures moving forward.
We need to create a simple REST controller to retrieve the latest N messages with all necessary information. This means we assume that the message model on the frontend is almost the same and we need to pass all the data through. The difference for the frontend model is that the file and user need to be presented in a slightly decrypted form to make them links:
/** В таком виде отдаются ссылки на сущности для фронта */
data třída ReferenceUI(
/** Идентификатор для url */
val ref: Řetězec,
/** Видимое пользователю название ссылки */
val název: Řetězec
)
data třída ChatMessageUI(
val id: Dlouhý,
/** Ссылка на автора */
val autor: ReferenceUI,
/** Сообщение */
val zpráva: Řetězec,
/** Ссылки на аттачи */
val soubory: Seznam<ReferenceUI>,
/** Если являтся ответом, то здесь будет оригинал */
val odpovědětNa: ChatMessageUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val přeposlatOd: ChatMessageUI? = null
)
We need to implement the following:
rozhraní ChatRestApi {
zábava getLast(n: Int): Seznam<ChatMessageUI>
}
The postfix UI means DTO models for the frontend, i.e., what we need to send via REST.
It may seem surprising that we don’t pass any chat identifier, and even in the ChatMessage/ChatMessageUI model, it is absent. I did this intentionally to keep the example code uncluttered (the chats are isolated, so we can assume we have only one).
Philosophical digressionBoth in the ChatMessageUI class and in the ChatRestApi.getLast method, the data type List is used, whereas in reality, it is an ordered Set. This is poorly handled in JDK, so it won't be possible to declare the order of elements at the interface level (maintaining order during addition and retrieval). Thus, it has become common practice to use List in cases where an ordered Set is needed (there's also LinkedHashSet, but it's not an interface).
Important restriction: we will assume that long chains of responses or forwards do not occur. They exist, but their length does not exceed three messages. The message chain should be transmitted in full to the frontend.
To retrieve data from external services, there are the following APIs:
rozhraní ChatMessageRepository {
zábava findLast(n: Int): Seznam<ChatZpráva>
}
data třída FileHeadRemote(
val id: OdkazNaSoubor,
val název: Řetězec
)
rozhraní FileRemoteApi {
zábava getHeadById(id: OdkazNaSoubor): FileHeadRemote
zábava getHeadsByIds(id: Set<OdkazNaSoubor>): Set<FileHeadRemote>
zábava getHeadsByIds(id: Seznam<OdkazNaSoubor>): Seznam<FileHeadRemote>
zábava getHeadsByChat(): Seznam<FileHeadRemote>
}
data třída UserRemote(
val id: UživatelskáReference,
val název: Řetězec
)
rozhraní UserRemoteApi {
zábava getUserById(id: UživatelskáReference): UserRemote
zábava getUsersByIds(id: Set<UživatelskáReference>): Set<UserRemote>
zábava getUsersByIds(id: Seznam<UživatelskáReference>): Seznam<UserRemote>
}
It is evident that external services initially provide batch processing, both variants: via Set (without preserving the order of elements, with unique keys) and via List (duplicates may occur—order is preserved).
Simple implementations
Naivní implementace
První naivní implementace našeho REST kontroleru bude v mnoha případech vypadat takto:
třída ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.mapa { to.private fun() }
ChatMessageUI( ChatZpráva.private fun(): ChatMessageUI =
id = id ?:
vyhoď IllegalStateException( musí být přetrvávající""$this autor = userRepository.getUserById(author).),
toFrontReferencemessage = message,(),
files = files?.
nechtefileRepository.getHeadsByIds( { files ->
listOffiles)
.mapa { to.message = message,() }
} ?: forwardFrom = forwardFrom?.(),
replyTo = replyTo?.private fun(),
replyTo = replyTo?.private fun()
)
}
Všechno je naprosto jasné a to je velké plus.
Používáme dávkovou zpracování a získáváme data z externího servisu po dávkách. Ale co se stane s výkonem?
Pro každou zprávu bude provedený jeden hovor na UserRemoteApi pro získání dat pro pole author a jeden hovor na FileRemoteApi pro získání všech přiložených souborů. Vypadá to na to, že vše. Předpokládejme, že pole forwardFrom a replyTo pro ChatMessage se získávají tak, že to nevyžaduje zbytečné volání. Ale převod na ChatMessageUI povede k rekurzi, což znamená, že ukazatele volání mohou výrazně vzrůst. Jak jsme již poznamenali, předpokládejme, že velké zanoření nemáme a řetězec je omezen na tři zprávy.
Nakonec dostaneme od dvou do šesti volání externích služeb na jednu zprávu a jeden JPA hovor na celý balík zpráv. Celkový počet volání se bude pohybovat mezi 2*N+1 a 6*N+1. Kolik to bude v reálných jednotkách? Předpokládejme, že pro vykreslení stránky potřebujeme 20 zpráv. Abychom je získali, bude potřeba od 4 s do 10 s. Hrozné! Chtěli bychom se vejít do 500 ms. A jelikož ve frontendu snili o bezprošíkové scrollování, požadavky na výkon tohoto koncového bodu lze zdvojnásobit.
Plusy:
- Kód je krátký a sebedokumentující (sen podpory).
- Kód je jednoduchý, takže možnosti, jak si střelit do nohy, jsou téměř nulové.
- Dávkové zpracování nevypadá jako něco cizího a organicky se vtěluje do logiky.
- Změny logiky budou prováděny snadno a budou místní.
Minus:
Hrozný výkon spojený s tím, že dávky jsou velmi malé.
Takový přístup je dost často vidět v jednoduchých službách nebo prototypech. Pokud je důležitá rychlost změn, pravděpodobně stojí za to nekomplikovat systém. Zároveň pro náš velmi jednoduchý servis je výkon hrozný, takže rámce použitelnosti takového přístupu jsou velmi úzké.
Naivní paralelní zpracování
Můžeme spustit zpracování všech zpráv paralelně - to zbaví rostoucího času v závislosti na počtu zpráv. To není příliš dobrý způsob, protože to povede k velkému špičkovému zatížení externího servisu.
Implementace paralelního zpracování je velmi jednoduchá:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { to.private fun() }
.collect(toList())
Při použití paralelního zpracování zpráv získáme ideálně 300–700 ms, což je mnohem lepší než při naivní implementaci, ale stále nedostatečně rychlé.
Při tomto přístupu budou dotazy na userRepository a fileRepository vykonávány synchronně, což není příliš efektivní. Abychom to napravili, budeme muset výrazně změnit logiku volání. Například pomocí CompletionStage (alias CompletableFuture):
ChatMessageUI( ChatZpráva.private fun(): ChatMessageUI =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).message = message,()
}.thenCombine(
files?.fileRepository.getHeadsByIds( {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).mapa { to.message = message,() }
}
} ?: CompletableFuture.completedFuture(forwardFrom = forwardFrom?.())
) { author, files ->
id = id ?:
vyhoď IllegalStateException( musí být přetrvávající""$this autor = userRepository.getUserById(author).),
author = author,
files = files?.
files = files,
replyTo = replyTo?.private fun(),
replyTo = replyTo?.private fun()
)
}.get()!!
Je vidět, že původně jednoduchý kód mapování se stal méně srozumitelným. Je to způsobeno tím, že jsme museli oddělit volání externích služeb od místa použití výsledků. Samo o sobě to není špatné. Ale kombinace volání vypadá poněkud neohrabaně a připomíná typickou reaktivní "nudli večere".
Pokud použijeme korutiny, vše bude vypadat lépe:
ChatMessageUI( ChatZpráva.private fun(): ChatMessageUI =
připojit(
{ userRepository.getUserById(author).message = message,() },
{ files?.fileRepository.getHeadsByIds( { fileRepository.getHeadsByIds(soubory)
.mapa { to.message = message,() } } ?: forwardFrom = forwardFrom?.() }
).fileRepository.getHeadsByIds( { (autor, soubory) ->
id = id ?:
vyhoď IllegalStateException( musí být přetrvávající""$this autor = userRepository.getUserById(author).),
autor = autor,
files = files?.
soubory = soubory,
replyTo = replyTo?.private fun(),
replyTo = replyTo?.private fun()
)
}
Kde:
zábava <A, B> připojit(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.fileRepository.getHeadsByIds( {
to[0] as A to to[1] as B
}
Teoreticky, při použití takového paralelního zpracování, získáme 200–400 ms, což už je blízko našim očekáváním.
Bohužel takové dobré paralelizace není dosaženo, a navíc riziko je poměrně vysoké: při současné práci několika uživatelů na službách se na ně sesype lavina požadavků, které stejně nebudou zpracovávány paralelně, takže se vrátíme k našim smutným 4 s.
Můj výsledek při použití takové služby je 1300–1700 ms na zpracování 20 zpráv. To je rychlejší než v první implementaci, ale přesto to problém neřeší.
Alternativní použití paralelních dotazůCo když ve třetích službách není zajištěna dávková zpracování? Například je možné skrýt absenci implementace dávkového zpracování uvnitř metod rozhraní:
rozhraní UserRemoteApi {
zábava getUserById(id: UživatelskáReference): UserRemote
zábava getUsersByIds(id: Set<UživatelskáReference>): Set<UserRemote> =
id.parallelStream()
.map { getUserById(to) }.collect(toSet())
zábava getUsersByIds(id: Seznam<UživatelskáReference>): Seznam<UserRemote> =
id.parallelStream()
.map { getUserById(to) }.collect(toList())
}
Má to smysl, pokud je naděje na zavedení dávkového zpracování v následujících verzích.
Plusy:
- Snadná implementace paralelního zpracování zpráv.
- Dobrá škálovatelnost.
Nevýhody:
- Nutnost oddělení získávání dat od jejich zpracování při paralelním zpracování dotazů k různým službám.
- Zvýšené zatížení na třetí strany služeb.
Je zřejmé, že rámce a použitelnost jsou přibližně stejné jako u naivního přístupu. Použití metody paralelních požadavků dává smysl, pokud chcete několikrát zvýšit výkon svého služby na úkor neúprosného využívání cizí infrastruktury. V našem příkladu se výkon zvýšil o 2,5krát, ale to je zjevně nedostatečné.
Cacheování
Cacheování může být implementováno ve stylu JPA pro externí služby, to znamená, že během sezení uchováváme získané objekty, abychom je nemuseli získávat znovu (včetně při dávkovém zpracování). Můžete si vytvořit takové cache sami, můžete použít Spring s jeho @Cacheable, a vždy je možné ručně použít hotovou cache, jako je EhCache.
Obecný problém bude souviset s tím, že z cache je užitek jen tehdy, pokud existují zásahy. V našem případě jsou velmi pravděpodobné zásahy podle pole author (dejme tomu 50 %), zatímco zásahy podle souborů nebudou vůbec. Tento přístup přinese určité zlepšení, ale výkon radikálně nezmění (a my potřebujeme průlom).
Mezisessionní (dlouhé) cache vyžadují složitou logiku invalidace. Obecně platí, že čím později se dostanete k tomu, že budete řešit problémy s výkonem pomocí mezisessionních cache, tím lépe.
Plusy:
- Implementace cacheování bez změny kódu.
- Zisk výkonu několikrát (v některých případech).
Nevýhody:
- Možnost snížení výkonu při nesprávném použití.
- Velké paměťové overheady, zejména u dlouhých cache.
- Složitá invalidace, jejíž chyby budou vést k těžko reprodukovatelným problémům za běhu.
Velmi často se cache používají pouze k rychlému vyřešení problémů s návrhem. Neznamená to však, že by se neměly používat. Je však vždy dobré k nim přistupovat opatrně a nejprve zhodnotit získaný výkon, a teprve poté se rozhodnout.
V našem příkladu bude z cache zisk výkonu přibližně 25 %. Přitom má cache řadu nevýhod, takže bych je zde nepoužil.
Závěry
Podívali jsme se tedy na naivní implementaci služby využívající dávkové zpracování a na několik jednoduchých způsobů, jak ji urychlit.
Hlavní výhodou všech těchto metod je jednoduchost, z níž vyplývá mnoho příjemných důsledků.
Obecný problém těchto metod spočívá v nízkém výkonu, který je především spojen s velikostí paketů. Pokud vám tedy tato řešení nevyhovují, měli byste se zamyslet nad radikálnějšími metodami.
Existují dva hlavní směry, kterými lze hledat řešení:
- asynchronní práce s daty (vyžaduje změnu paradigmat, proto se v tomto článku nezmiňuje);
- zvětšení balíků při zachování synchronního zpracování.
Zvětšení balíků výrazně sníží počet externích volání a zároveň udrží kód synchronní. Tato téma bude věnována následující části článku.
Zdroj: habr.com
