Problémy dávkového spracovania dotazov a ich riešenia (1. časť)

Problémy dávkového spracovania dotazov a ich riešenia (1. časť)Takmer všetky moderné softvérové ​​produkty pozostávajú z niekoľkých služieb. Dlhé časy odozvy medziservisných kanálov sa často stávajú zdrojom problémov s výkonom. Štandardným riešením tohto druhu problému je zbaliť viacero medziservisných požiadaviek do jedného balíka, čo sa nazýva dávkovanie.

Ak používate dávkové spracovanie, nemusíte byť spokojní s výsledkami z hľadiska výkonu alebo prehľadnosti kódu. Táto metóda nie je pre volajúceho taká jednoduchá, ako by ste si mohli myslieť. Na rôzne účely a v rôznych situáciách sa riešenia môžu značne líšiť. Na konkrétnych príkladoch ukážem klady a zápory viacerých prístupov.

Ukážkový projekt

Pre názornosť sa pozrime na príklad jednej zo služieb v aplikácii, na ktorej práve pracujem.

Vysvetlenie výberu platformy pre príkladyProblém slabého výkonu je pomerne všeobecný a neovplyvňuje žiadne konkrétne jazyky ani platformy. Tento článok použije príklady kódu Spring + Kotlin na demonštráciu problémov a riešení. Kotlin je rovnako zrozumiteľný (alebo nepochopiteľný) pre vývojárov v Jave a C#, navyše kód je kompaktnejší a zrozumiteľnejší ako v Jave. Na uľahčenie pochopenia pre čisto Java vývojárov sa vyhnem čiernej mágii Kotlin a použijem len bielu mágiu (v duchu Lomboku). Bude existovať niekoľko metód rozšírenia, ale v skutočnosti sú všetkým programátorom Java známe ako statické metódy, takže to bude malý cukor, ktorý nepokazí chuť jedla.
K dispozícii je služba schvaľovania dokumentov. Niekto vytvorí dokument a odošle ho na diskusiu, počas ktorej sa vykonajú úpravy a nakoniec sa dokument odsúhlasí. Samotná schvaľovacia služba nevie nič o dokumentoch: je to len chat schvaľovateľov s malými doplnkovými funkciami, ktoré tu nebudeme brať do úvahy.

Takže existujú chatovacie miestnosti (zodpovedajúce dokumentom) s preddefinovanou množinou účastníkov v každej z nich. Rovnako ako v bežných rozhovoroch, správy obsahujú text a súbory a možno na ne odpovedať alebo poslať ďalej:

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 súbory a používateľov sú odkazy na iné domény. Tu žijeme takto:

typealias FileReference Long
typealias UserReference Long

Užívateľské dáta sú uložené v Keycloak a načítané cez REST. To isté platí pre súbory: súbory a metainformácie o nich sú uložené v samostatnej službe na ukladanie súborov.

Všetky hovory na tieto služby sú ťažké požiadavky. To znamená, že réžia prepravy týchto požiadaviek je oveľa väčšia ako čas potrebný na ich spracovanie službou tretej strany. Na našich testovacích stoloch je typická doba hovoru pre takéto služby 100 ms, takže tieto čísla budeme používať aj v budúcnosti.

Na príjem posledných N správ so všetkými potrebnými informáciami musíme vyrobiť jednoduchý REST ovládač. To znamená, že veríme, že model správy vo frontende je takmer rovnaký a všetky dáta je potrebné odoslať. Rozdiel medzi modelom front-end je v tom, že súbor a používateľ musia byť prezentovaní v mierne dešifrovanej forme, aby sa z nich vytvorili 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
)

Musíme implementovať nasledovné:

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

Postfix UI znamená DTO modely pre frontend, teda to, čo by sme mali obsluhovať cez REST.

Čo tu môže byť prekvapujúce je, že neposielame žiadne ID chatu a dokonca ani model ChatMessage/ChatMessageUI ho nemá. Urobil som to zámerne, aby som nezahltil kód príkladov (chaty sú izolované, takže môžeme predpokladať, že máme len jeden).

Filozofická odbočkaTrieda ChatMessageUI aj metóda ChatRestApi.getLast používajú typ údajov List, aj keď v skutočnosti ide o usporiadanú množinu. To je v JDK zlé, takže deklarovanie poradia prvkov na úrovni rozhrania (zachovanie poradia pri pridávaní a odstraňovaní) nebude fungovať. Takže sa stalo bežnou praxou používať zoznam v prípadoch, keď je potrebná objednaná sada (existuje aj LinkedHashSet, ale toto nie je rozhranie).
Dôležité obmedzenie: Budeme predpokladať, že neexistujú žiadne dlhé reťazce odpovedí alebo prenosov. To znamená, že existujú, ale ich dĺžka nepresahuje tri správy. Celý reťazec správ sa musí preniesť na frontend.

Na prijímanie údajov z externých služieb existujú nasledujúce rozhrania 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 vidieť, že externé služby spočiatku zabezpečujú dávkové spracovanie a to v oboch verziách: cez Set (bez zachovania poradia prvkov, s jedinečnými kľúčmi) a cez Zoznam (môžu existovať duplikáty - poradie je zachované).

Jednoduché implementácie

Naivná implementácia

Prvá naivná implementácia nášho REST ovládača bude vo väčšine prípadov vyzerať asi 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šetko je veľmi jasné, a to je veľké plus.

Používame dávkové spracovanie a prijímame dáta z externej služby v dávkach. Čo sa však stane s našou produktivitou?

Pre každú správu sa uskutoční jedno volanie do UserRemoteApi na získanie údajov v poli autora a jedno volanie do FileRemoteApi na získanie všetkých pripojených súborov. Zdá sa, že je to tak. Povedzme, že polia forwardFrom a replyTo pre ChatMessage sa získajú takým spôsobom, že to nevyžaduje zbytočné hovory. Ich premena na ChatMessageUI však povedie k rekurzii, to znamená, že počítadlá hovorov sa môžu výrazne zvýšiť. Ako sme už uviedli, predpokladajme, že nemáme veľa vnorení a reťazec je obmedzený na tri správy.

Vo výsledku tak získame od dvoch do šiestich hovorov na externé služby na správu a jeden hovor JPA na celý balík správ. Celkový počet hovorov sa bude meniť od 2*N+1 do 6*N+1. Koľko je to v skutočných jednotkách? Povedzme, že na vykreslenie stránky je potrebných 20 správ. Ich príjem bude trvať od 4 s do 10 s. Strašné! Chcel by som to dodržať do 500 ms. A keďže snívali o bezproblémovom rolovaní vo frontende, požiadavky na výkon tohto koncového bodu sa môžu zdvojnásobiť.

Pros:

  1. Kód je stručný a dokumentuje sám seba (sen podporného tímu).
  2. Kód je jednoduchý, takže nie sú takmer žiadne príležitosti streliť si do nohy.
  3. Dávkové spracovanie nevyzerá ako niečo cudzie a je organicky integrované do logiky.
  4. Logické zmeny sa budú vykonávať jednoducho a budú lokálne.

mínus:

Hrozný výkon kvôli veľmi malým paketom.

Tento prístup možno pomerne často vidieť v jednoduchých službách alebo v prototypoch. Ak je dôležitá rýchlosť vykonávania zmien, sotva sa oplatí skomplikovať systém. Zároveň je pre našu veľmi jednoduchú službu výkon hrozný, takže rozsah použiteľnosti tohto prístupu je veľmi úzky.

Naivné paralelné spracovanie

Môžete začať spracovávať všetky správy paralelne - to vám umožní zbaviť sa lineárneho nárastu času v závislosti od počtu správ. Toto nie je obzvlášť dobrá cesta, pretože bude mať za následok veľké špičkové zaťaženie externej služby.

Implementácia paralelného spracovania je veľmi jednoduchá:

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

Použitím paralelného spracovania správ dostaneme ideálne 300–700 ms, čo je oveľa lepšie ako pri naivnej implementácii, no stále nie dostatočne rýchle.

S týmto prístupom sa požiadavky na userRepository a fileRepository budú vykonávať synchrónne, čo nie je príliš efektívne. Aby ste to napravili, budete musieť dosť zmeniť logiku hovoru. Napríklad cez 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 vidieť, že pôvodne jednoduchý mapovací kód sa stal menej zrozumiteľným. Museli sme totiž oddeliť hovory na externé služby od toho, kde sa používajú výsledky. To samo o sebe nie je zlé. Kombinovanie hovorov však nevyzerá veľmi elegantne a pripomína typické reaktívne „rezance“.

Ak použijete korutíny, všetko bude vyzerať decentnejšie:

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 pri takomto paralelnom spracovaní dostaneme 200–400 ms, čo je už blízko k našim očakávaniam.

Žiaľ, takáto dobrá paralelizácia neexistuje a cena, ktorú treba zaplatiť, je dosť krutá: keďže súčasne pracuje len niekoľko používateľov, na služby dopadne záplava požiadaviek, ktoré sa aj tak nebudú paralelne spracovávať, takže vráti sa k našim smutným 4 s.

Môj výsledok pri použití takejto služby je 1300–1700 ms na spracovanie 20 správ. Je to rýchlejšie ako pri prvej implementácii, ale stále to nerieši problém.

Alternatívne použitie paralelných dopytovČo ak služby tretích strán neposkytujú dávkové spracovanie? Môžete napríklad skryť nedostatok implementácie dávkového spracovania v rámci metód rozhrania:

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áva zmysel, ak dúfate, že v budúcich verziách uvidíte dávkové spracovanie.
Pros:

  1. Jednoducho implementujte paralelné spracovanie založené na správach.
  2. Dobrá škálovateľnosť.

Nevýhody:

  1. Potreba oddeliť získavanie údajov od ich spracovania pri paralelnom spracovaní požiadaviek na rôzne služby.
  2. Zvýšené zaťaženie služieb tretích strán.

Je vidieť, že rozsah pôsobnosti je približne rovnaký ako rozsah naivného prístupu. Metódu paralelnej požiadavky má zmysel použiť, ak chcete niekoľkonásobne zvýšiť výkon svojej služby kvôli nemilosrdnému vykorisťovaniu ostatných. V našom príklade sa výkon zvýšil 2,5-krát, ale to zjavne nestačí.

ukladanie do vyrovnávacej pamäte

Môžete vykonať ukladanie do vyrovnávacej pamäte v duchu JPA pre externé služby, to znamená ukladať prijaté objekty v rámci relácie, aby ste ich už znova neprijímali (vrátane dávkového spracovania). Takéto kešky si môžete vyrobiť sami, môžete použiť Spring s jeho @Cacheable a navyše môžete vždy manuálne použiť hotovú kešku ako EhCache.

Bežným problémom by bolo, že vyrovnávacie pamäte sú užitočné iba vtedy, ak majú zásahy. V našom prípade sú zásahy do poľa autora veľmi pravdepodobné (povedzme 50 %), ale v súboroch nebudú žiadne zásahy. Tento prístup prinesie určité vylepšenia, ale nezmení radikálne výkon (a potrebujeme prielom).

Intersession (dlhé) cache vyžadujú komplexnú logiku zneplatnenia. Vo všeobecnosti platí, že čím neskôr sa pustíte do riešenia problémov s výkonom pomocou intersession cache, tým lepšie.

Pros:

  1. Implementujte ukladanie do vyrovnávacej pamäte bez zmeny kódu.
  2. Niekoľkonásobne zvýšená produktivita (v niektorých prípadoch).

Nevýhody:

  1. Možnosť zníženia výkonu pri nesprávnom použití.
  2. Veľká réžia pamäte, najmä s dlhými vyrovnávacími pamäťami.
  3. Komplexné zneplatnenie, chyby, ktoré povedú k ťažko reprodukovateľným problémom za behu.

Vyrovnávacie pamäte sa veľmi často používajú iba na rýchlu opravu problémov s dizajnom. To neznamená, že by sa nemali používať. Vždy by ste s nimi však mali zaobchádzať opatrne a najskôr zhodnotiť výsledný výkonový zisk a až potom sa rozhodnúť.

V našom príklade budú vyrovnávacie pamäte poskytovať zvýšenie výkonu približne o 25 %. Zároveň majú kešky dosť veľa nevýhod, preto by som ich tu nepoužil.

Výsledky

Pozreli sme sa teda na naivnú implementáciu služby, ktorá využíva dávkové spracovanie, a niekoľko jednoduchých spôsobov, ako ho urýchliť.

Hlavnou výhodou všetkých týchto metód je jednoduchosť, z ktorej je veľa príjemných dôsledkov.

Bežným problémom týchto metód je slabý výkon, predovšetkým kvôli veľkosti paketov. Preto, ak vám tieto riešenia nevyhovujú, potom stojí za zváženie radikálnejších metód.

Existujú dva hlavné smery, v ktorých môžete hľadať riešenia:

  • asynchrónna práca s údajmi (vyžaduje zmenu paradigmy, preto sa o nej v tomto článku nehovorí);
  • zväčšenie dávok pri zachovaní synchrónneho spracovania.

Zväčšenie dávok výrazne zníži počet externých hovorov a zároveň zachová synchrónny kód. Tejto téme bude venovaná ďalšia časť článku.

Zdroj: hab.com

Pridať komentár