Problémy dávkového zpracování dotazů a jejich řešení (1. část)

Problémy dávkového zpracování dotazů a jejich řešení (1. část)Téměř všechny moderní softwarové produkty se skládají z několika služeb. Dlouhá doba odezvy meziservisních kanálů se často stává zdrojem problémů s výkonem. Standardním řešením tohoto druhu problému je sbalit více mezislužbových požadavků do jednoho balíčku, což se nazývá batching.

Pokud používáte dávkové zpracování, nemusíte být spokojeni s výsledky z hlediska výkonu nebo srozumitelnosti kódu. Tato metoda není pro volajícího tak snadná, jak si možná myslíte. Pro různé účely a v různých situacích se řešení mohou značně lišit. Na konkrétních příkladech ukážu klady a zápory několika přístupů.

Demonstrační projekt

Pro názornost se podívejme na příklad jedné ze služeb v aplikaci, na které právě pracuji.

Vysvětlení výběru platformy pro příkladyProblém špatného výkonu je poměrně obecný a neovlivňuje žádné konkrétní jazyky nebo platformy. Tento článek použije příklady kódu Spring + Kotlin k demonstraci problémů a řešení. Kotlin je stejně srozumitelný (nebo nesrozumitelný) pro vývojáře v Javě a C#, navíc je kód kompaktnější a srozumitelnější než v Javě. Aby to bylo srozumitelnější pro čistě Java vývojáře, vyhnu se černé magii Kotlin a použiji pouze bílou magii (v duchu Lomboku). Pár rozšiřujících metod bude, ale ty znají vlastně všichni Java programátoři jako statické metody, takže to bude malý cukr, který nezkazí chuť pokrmu.
K dispozici je služba schvalování dokumentů. Někdo vytvoří dokument a odešle ho k diskusi, během níž se provedou úpravy a nakonec se dokument odsouhlasí. Samotná schvalovací služba o dokumentech nic neví: je to jen chat schvalovatelů s malými doplňkovými funkcemi, které zde nebudeme uvažovat.

Existují tedy 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 lze na ně odpovědět nebo přeposlat:

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
)

Odkazy na soubory a uživatele jsou odkazy na jiné domény. Tady žijeme takto:

typealias FileReference Long
typealias UserReference Long

Uživatelská data jsou uložena v Keycloak a načítána přes REST. Totéž platí pro soubory: soubory a metainformace o nich žijí v samostatné službě ukládání souborů.

Všechna volání na tyto služby jsou těžké požadavky. To znamená, že režie přepravy těchto požadavků je mnohem větší než doba, kterou trvá jejich zpracování službou třetí strany. Na našich testovacích stolicích je typická doba hovoru pro takové služby 100 ms, takže tato čísla budeme používat i v budoucnu.

Potřebujeme vyrobit jednoduchý REST ovladač, abychom mohli přijímat posledních N zpráv se všemi potřebnými informacemi. To znamená, že se domníváme, že model zpráv ve frontendu je téměř stejný a všechna data je třeba odeslat. Rozdíl mezi front-endovým modelem je v tom, že soubor a uživatel musí být prezentovány v mírně dešifrované podobě, aby z nich byly odkazy:

/** В таком виде отдаются ссылки на сущности для фронта */
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
)

Potřebujeme implementovat následující:

interface ChatRestApi {
  fun getLast(nInt): List<ChatMessageUI>
}

Postfix uživatelského rozhraní znamená DTO modely pro frontend, tedy to, co bychom měli obsluhovat přes REST.

Zde může být překvapivé, že nepředáváme žádné ID chatu a dokonce ani model ChatMessage/ChatMessageUI ho nemá. Udělal jsem to záměrně, abych nezahltil kód příkladů (chaty jsou izolované, takže můžeme předpokládat, že máme jen jeden).

Filosofická odbočkaTřída ChatMessageUI i metoda ChatRestApi.getLast používají datový typ List, i když se ve skutečnosti jedná o uspořádanou sadu. To je v JDK špatné, takže deklarování pořadí prvků na úrovni rozhraní (zachování pořadí při přidávání a odebírání) nebude fungovat. Stalo se tedy běžnou praxí používat Seznam v případech, kdy je potřeba objednaná sada (existuje také LinkedHashSet, ale nejedná se o rozhraní).
Důležité omezení: Budeme předpokládat, že neexistují žádné dlouhé řetězce odpovědí nebo převodů. To znamená, že existují, ale jejich délka nepřesahuje tři zprávy. Celý řetězec zpráv musí být přenášen na frontend.

Pro příjem dat z externích služeb existují následující API:

interface ChatMessageRepository {
  fun findLast(nInt): List<ChatMessage>
}
data class FileHeadRemote(
  val id: FileReference,
  val name: String
)
interface FileRemoteApi {
  fun getHeadById(idFileReference): FileHeadRemote
  fun getHeadsByIds(idSet<FileReference>): Set<FileHeadRemote>
  fun getHeadsByIds(idList<FileReference>): List<FileHeadRemote>
  fun getHeadsByChat(): List<FileHeadRemote>
}
data class UserRemote(
  val id: UserReference,
  val name: String
)
interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote>
  fun getUsersByIds(idList<UserReference>): List<UserRemote>
}

Je vidět, že externí služby zpočátku zajišťují dávkové zpracování, a to v obou verzích: přes Set (bez zachování pořadí prvků, s unikátními klíči) a přes List (mohou existovat duplikáty - pořadí je zachováno).

Jednoduché implementace

Naivní implementace

První naivní implementace našeho REST ovladače bude ve většině případů vypadat nějak takto:

class ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ChatRestApi {
  override fun getLast(nInt) =
    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()
    )
}

Vše je velmi jasné, a to je velké plus.

Používáme dávkové zpracování a přijímáme data z externí služby dávkově. Ale co se stane s naší produktivitou?

Pro každou zprávu bude provedeno jedno volání UserRemoteApi pro získání dat v poli autora a jedno volání FileRemoteApi pro získání všech připojených souborů. Zdá se, že je to tak. Řekněme, že pole forwardFrom a replyTo pro ChatMessage jsou získávána takovým způsobem, že to nevyžaduje zbytečná volání. Ale jejich přeměna na ChatMessageUI povede k rekurzi, to znamená, že počítadla hovorů se mohou výrazně zvýšit. Jak jsme uvedli dříve, předpokládejme, že nemáme mnoho vnoření a řetězec je omezen na tři zprávy.

Ve výsledku tak získáme od dvou do šesti hovorů na externí služby na jednu zprávu a jedno volání JPA pro celý balíček zpráv. Celkový počet hovorů se bude lišit od 2*N+1 do 6*N+1. Kolik je to v reálných jednotkách? Řekněme, že k vykreslení stránky je potřeba 20 zpráv. Jejich příjem bude trvat od 4 s do 10 s. Hrozný! Chtěl bych to udržet do 500 ms. A protože snili o bezproblémovém posouvání ve frontendu, lze požadavky na výkon tohoto koncového bodu zdvojnásobit.

výhody:

  1. Kód je stručný a samodokumentující (sen podpůrného týmu).
  2. Kód je jednoduchý, takže nejsou téměř žádné možnosti střelit se do nohy.
  3. Dávkové zpracování nevypadá jako něco cizího a je organicky integrováno do logiky.
  4. Logické změny budou provedeny snadno a budou lokální.

Mínus:

Hrozný výkon kvůli velmi malým paketům.

Tento přístup lze poměrně často vidět u jednoduchých služeb nebo prototypů. Pokud je důležitá rychlost provádění změn, těžko se vyplatí systém komplikovat. Zároveň je pro naši velmi jednoduchou službu výkon hrozný, takže rozsah použitelnosti tohoto přístupu je velmi úzký.

Naivní paralelní zpracování

Všechny zprávy můžete začít zpracovávat paralelně – to vám umožní zbavit se lineárního nárůstu času v závislosti na počtu zpráv. Toto není zvláště dobrá cesta, protože bude mít za následek velké špičkové zatížení externí služby.

Implementace paralelního zpracování je velmi jednoduchá:

override fun getLast(nInt) =
  messageRepository.findLast(n).parallelStream()
    .map it.toFrontModel() }
    .collect(toList())

Při paralelním zpracování zpráv dostaneme ideálně 300–700 ms, což je mnohem lepší než u naivní implementace, ale stále to není dost rychlé.

S tímto přístupem budou požadavky na userRepository a fileRepository prováděny synchronně, což není příliš efektivní. Abyste to napravili, budete muset hodně změnit logiku volání. Například prostřednictvím 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())
  ) authorfiles ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

Je vidět, že původně jednoduchý mapovací kód se stal méně srozumitelným. Museli jsme totiž oddělit volání na externí služby od míst, kde se používají výsledky. To samo o sobě není špatné. Kombinace hovorů ale nevypadá příliš elegantně a připomíná typickou reaktivní „nudle“.

Pokud použijete coroutiny, vše bude vypadat decentněji:

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()
    )
  }

Kde:

fun <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

Teoreticky při takovém paralelním zpracování dostaneme 200–400 ms, což už je blízko k našemu očekávání.

Tak dobrá paralelizace bohužel neexistuje a cena za to je docela krutá: s několika uživateli současně pracujícími se na služby valí palba požadavků, které stejně nebudou paralelně zpracovávány, takže vrátí se 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 stále to neřeší problém.

Alternativní použití paralelních dotazůCo když služby třetích stran neposkytují dávkové zpracování? Můžete například skrýt nedostatek implementace dávkového zpracování uvnitř metod rozhraní:

interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  fun getUsersByIds(idList<UserReference>): List<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

To dává smysl, pokud doufáte, že v budoucích verzích uvidíte dávkové zpracování.
výhody:

  1. Snadno implementujte paralelní zpracování založené na zprávách.
  2. Dobrá škálovatelnost.

nevýhody:

  1. Nutnost oddělit získávání dat od jejich zpracování při paralelním zpracování požadavků na různé služby.
  2. Zvýšené zatížení služeb třetích stran.

Je vidět, že rozsah použitelnosti je přibližně stejný jako u naivního přístupu. Metodu paralelního požadavku má smysl používat, pokud chcete výkon své služby několikrát zvýšit kvůli nemilosrdnému vykořisťování ostatních. V našem příkladu se výkon zvýšil 2,5krát, ale to zjevně nestačí.

ukládání do mezipaměti

Můžete provádět ukládání do mezipaměti v duchu JPA pro externí služby, to znamená ukládat přijaté objekty v rámci relace, abyste je znovu nepřijímali (včetně během dávkového zpracování). Takové keše si můžete vytvořit sami, můžete použít Spring s jeho @Cacheable a navíc můžete vždy ručně použít hotovou keš jako EhCache.

Častým problémem by bylo, že keše jsou užitečné pouze v případě, že mají přístupy. V našem případě jsou zásahy do pole autora velmi pravděpodobné (řekněme 50 %), ale k žádným zásahům do souborů nedojde. Tento přístup poskytne určitá vylepšení, ale nezmění radikálně výkon (a my potřebujeme průlom).

Intersession (dlouhé) mezipaměti vyžadují složitou logiku zneplatnění. Obecně platí, že čím později se pustíte do řešení problémů s výkonem pomocí intersession cache, tím lépe.

výhody:

  1. Implementujte ukládání do mezipaměti bez změny kódu.
  2. Několikanásobné zvýšení produktivity (v některých případech).

nevýhody:

  1. Možnost snížení výkonu při nesprávném použití.
  2. Velká paměť, zejména u dlouhých mezipamětí.
  3. Komplexní zneplatnění, chyby, které povedou k těžko reprodukovatelným problémům za běhu.

Mezipaměti se velmi často používají pouze k rychlé opravě problémů s návrhem. To neznamená, že by se neměly používat. Vždy byste s nimi ale měli zacházet opatrně a nejprve zhodnotit výsledný výkonový zisk a teprve poté se rozhodnout.

V našem příkladu poskytnou mezipaměti zvýšení výkonu o přibližně 25 %. Přitom keší mají poměrně dost nevýhod, takže bych je zde nepoužil.

Výsledky

Podívali jsme se tedy na naivní implementaci služby, která využívá dávkové zpracování, a na několik jednoduchých způsobů, jak jej urychlit.

Hlavní výhodou všech těchto metod je jednoduchost, ze které plyne mnoho příjemných důsledků.

Častým problémem těchto metod je slabý výkon, především kvůli velikosti paketů. Pokud vám tedy tato řešení nevyhovují, pak stojí za zvážení radikálnějších metod.

Existují dva hlavní směry, ve kterých můžete hledat řešení:

  • asynchronní práce s daty (vyžaduje změnu paradigmatu, proto není v tomto článku diskutována);
  • zvětšení dávek při zachování synchronního zpracování.

Zvětšením dávek se výrazně sníží počet externích hovorů a zároveň zůstane kód synchronní. Tomuto tématu bude věnována další část článku.

Zdroj: www.habr.com

Přidat komentář