A kötegelt lekérdezés feldolgozás problémái és megoldásaik (1. rész)

A kötegelt lekérdezés feldolgozás problémái és megoldásaik (1. rész)Szinte minden modern szoftvertermék több szolgáltatásból áll. A szolgálatközi csatornák hosszú válaszideje gyakran teljesítményproblémák forrásává válik. Az ilyen jellegű probléma szokásos megoldása több szolgáltatásközi kérés egyetlen csomagba történő becsomagolása, amelyet kötegelésnek neveznek.

Ha kötegelt feldolgozást használ, előfordulhat, hogy nem lesz elégedett az eredménnyel a teljesítmény vagy a kód tisztasága tekintetében. Ez a módszer nem olyan egyszerű a hívó számára, mint gondolná. Különböző célokra és különböző helyzetekben a megoldások nagyon eltérőek lehetnek. Konkrét példákon keresztül bemutatom több megközelítés előnyeit és hátrányait.

Bemutató projekt

Az egyértelműség kedvéért nézzünk meg egy példát az egyik szolgáltatásra az alkalmazásban, amelyen jelenleg dolgozom.

A platform kiválasztásának magyarázata példákraA gyenge teljesítmény problémája meglehetősen általános, és nem érint egyetlen nyelvet vagy platformot sem. Ez a cikk Spring + Kotlin kódpéldákat használ a problémák és megoldások bemutatására. A Kotlin ugyanúgy érthető (vagy érthetetlen) a Java és C# fejlesztők számára, ráadásul a kód kompaktabb és érthetőbb, mint a Java-ban. Hogy a tiszta Java fejlesztők könnyebben megértsék, kerülöm Kotlin fekete mágiáját, és csak a fehér mágiát használom (a Lombok szellemében). Lesz néhány bővítési mód, de ezeket tulajdonképpen minden Java programozó ismeri, mint statikus metódusokat, így ez egy kis cukor lesz, ami nem rontja el az étel ízét.
Van egy dokumentum-jóváhagyási szolgáltatás. Valaki létrehoz egy dokumentumot, és benyújtja megvitatásra, amely során szerkesztésre kerül sor, és végül a dokumentumban megállapodnak. Maga a jóváhagyási szolgálat semmit sem tud a dokumentumokról: ez csak a jóváhagyók beszélgetése kis kiegészítő funkciókkal, amelyeket itt nem veszünk figyelembe.

Tehát vannak csevegőszobák (amelyek a dokumentumoknak felelnek meg), mindegyikben előre meghatározott résztvevőkkel. A szokásos csevegésekhez hasonlóan az üzenetek szöveget és fájlokat tartalmaznak, és lehetnek válaszok vagy továbbítások:

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
)

A fájl- és felhasználói hivatkozások más domainekre mutató hivatkozások. Itt így élünk:

typealias FileReference Long
typealias UserReference Long

A felhasználói adatokat a Keycloak tárolja, és a REST-en keresztül kéri le. Ugyanez vonatkozik a fájlokra is: a fájlok és a velük kapcsolatos metainformációk külön fájltároló szolgáltatásban élnek.

Minden hívás ezekre a szolgáltatásokra súlyos kéréseket. Ez azt jelenti, hogy a kérelmek továbbításának többletköltsége sokkal nagyobb, mint az az idő, amelybe a harmadik féltől származó szolgáltatás feldolgozása szükséges. Tesztpadjainkon az ilyen szolgáltatások jellemző hívási ideje 100 ms, így a jövőben is ezeket a számokat fogjuk használni.

Egy egyszerű REST vezérlőt kell készítenünk, hogy megkapjuk az utolsó N üzenetet az összes szükséges információval. Vagyis úgy gondoljuk, hogy a frontend üzenetmodellje majdnem ugyanaz, és minden adatot el kell küldeni. A különbség a front-end modell között az, hogy a fájlt és a felhasználót enyhén dekódolt formában kell bemutatni ahhoz, hogy linkek legyenek:

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

A következőket kell megvalósítanunk:

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

Az UI postfix DTO modelleket jelent a frontend számára, vagyis azt, amit a REST-en keresztül kell kiszolgálnunk.

Ami itt meglepő lehet, az az, hogy nem adunk át semmilyen csevegési azonosítót, és még a ChatMessage/ChatMessageUI modellnek sincs ilyen. Szándékosan tettem ezt, nehogy összezavarjam a példák kódját (a csevegések elszigeteltek, így feltételezhetjük, hogy csak egy van).

Filozófiai kitérőMind a ChatMessageUI osztály, mind a ChatRestApi.getLast metódus a Lista adattípust használja, bár valójában rendezett halmazról van szó. Ez rossz a JDK-ban, így az elemek sorrendjének interfész szinten történő deklarálása (a sorrend megőrzése hozzáadáskor és eltávolításkor) nem fog működni. Így általánossá vált a List használata olyan esetekben, amikor rendezett Setre van szükség (van LinkedHashSet is, de ez nem interfész).
Fontos korlátozás: Feltételezzük, hogy nincsenek hosszú válasz- vagy átviteli láncok. Azaz léteznek, de hosszuk nem haladja meg a három üzenetet. A teljes üzenetláncot továbbítani kell a frontendnek.

A külső szolgáltatásoktól származó adatok fogadásához a következő API-k állnak rendelkezésre:

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

Látható, hogy a külső szolgáltatások kezdetben kötegelt feldolgozást biztosítanak, és mindkét verzióban: Set-en keresztül (az elemek sorrendjének megőrzése nélkül, egyedi kulcsokkal) és Listán keresztül (esetleg duplikátumok lehetnek - a sorrend megmarad).

Egyszerű megvalósítások

Naiv megvalósítás

A REST vezérlőnk első naiv megvalósítása 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(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()
    )
}

Minden nagyon világos, és ez nagy előny.

Kötegelt feldolgozást használunk, és kötegelt formában fogadjuk az adatokat egy külső szolgáltatástól. De mi történik a termelékenységünkkel?

Minden egyes üzenethez egy hívás történik a UserRemoteApi felé, hogy megkapja a szerző mező adatait, és egy hívás a FileRemoteApi felé az összes csatolt fájl lekéréséhez. Úgy tűnik, ez az. Tegyük fel, hogy a ChatMessage forwardFrom és replyTo mezőit úgy kapjuk meg, hogy ehhez nincs szükség felesleges hívásokra. De ChatMessageUI-vá alakítása rekurzióhoz vezet, vagyis a hívásszámlálók jelentősen megnövekedhetnek. Amint azt korábban megjegyeztük, tegyük fel, hogy nincs sok beágyazásunk, és a lánc három üzenetre korlátozódik.

Ennek eredményeként üzenetenként két-hat hívást kapunk külső szolgáltatásokhoz, és egy JPA-hívást a teljes üzenetcsomaghoz. A hívások teljes száma 2*N+1 és 6*N+1 között változik. Mennyi ez valós mértékegységben? Tegyük fel, hogy egy oldal megjelenítéséhez 20 üzenet szükséges. A kézhezvételük 4 másodperctől 10 másodpercig tart. Szörnyű! 500 ms-on belül szeretném tartani. És mivel arról álmodoztak, hogy zökkenőmentes görgetést készítsenek a frontendben, ennek a végpontnak a teljesítménykövetelményei megduplázhatók.

Előnyök:

  1. A kód tömör és öndokumentáló (a támogató csapat álma).
  2. A kód egyszerű, így szinte nincs lehetőség lábon lőni.
  3. A kötegelt feldolgozás nem tűnik valami idegennek, és szervesen integrálódik a logikába.
  4. A logikai változtatások egyszerűen végrehajthatók, és helyiek lesznek.

kevesebb:

Szörnyű teljesítmény a nagyon kis csomagok miatt.

Ez a megközelítés meglehetősen gyakran megfigyelhető egyszerű szolgáltatásokban vagy prototípusokban. Ha fontos a változtatások gyorsasága, aligha érdemes bonyolítani a rendszert. Ugyanakkor nagyon egyszerű szolgáltatásunknak a teljesítménye borzasztó, így ennek a megközelítésnek az alkalmazhatósági köre igen szűk.

Naiv párhuzamos feldolgozás

Megkezdheti az összes üzenet párhuzamos feldolgozását - ez lehetővé teszi, hogy megszabaduljon az üzenetek számától függő lineáris időnövekedéstől. Ez nem túl jó út, mert nagy csúcsterhelést fog eredményezni a külső szolgáltatáson.

A párhuzamos feldolgozás megvalósítása nagyon egyszerű:

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

Párhuzamos üzenetfeldolgozással ideális esetben 300-700 ms-ot kapunk, ami sokkal jobb, mint egy naiv implementációval, de mégsem elég gyors.

Ezzel a megközelítéssel a userRepository és a fileRepository kérések szinkronban lesznek végrehajtva, ami nem túl hatékony. Ennek kijavításához elég sokat kell változtatnia a híváslogikán. Például a CompletionStage (más néven CompletableFuture) segítségével:

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

Látható, hogy a kezdetben egyszerű leképezési kód kevésbé érthetővé vált. Ennek az az oka, hogy el kellett különítenünk a külső szolgáltatásokhoz intézett hívásokat az eredmények felhasználásától. Ez önmagában nem rossz. De a hívások kombinálása nem tűnik túl elegánsnak, és egy tipikus reaktív „tésztához” hasonlít.

Ha korutinokat használ, minden jobban fog kinézni:

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

Hol:

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

Elméletileg ilyen párhuzamos feldolgozást alkalmazva 200–400 ms-t kapunk, ami már megközelíti a várakozásainkat.

Sajnos ilyen jó párhuzamosítás nem létezik, és az ára is elég kegyetlen: ha csak néhány felhasználó dolgozik egyszerre, kérések özöne hullik a szolgáltatásokra, amelyeket úgysem párhuzamosan dolgozunk fel, ezért mi visszatér a szomorú 4 s.

Az eredményem egy ilyen szolgáltatás használatakor 1300-1700 ms 20 üzenet feldolgozására. Ez gyorsabb, mint az első megvalósításnál, de még mindig nem oldja meg a problémát.

A párhuzamos lekérdezések alternatív felhasználási módjaiMi a teendő, ha a harmadik féltől származó szolgáltatások nem biztosítanak kötegelt feldolgozást? Például elrejtheti a kötegelt feldolgozás megvalósításának hiányát az interfész metódusaiban:

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

Ennek van értelme, ha azt reméli, hogy a kötegelt feldolgozást látni fogja a jövőbeli verziókban.
Előnyök:

  1. Könnyen megvalósítható üzenetalapú párhuzamos feldolgozás.
  2. Jó skálázhatóság.

Hátrányok:

  1. Az adatgyűjtés és a feldolgozás elkülönítésének szükségessége a különböző szolgáltatásokhoz intézett kérések párhuzamos feldolgozásakor.
  2. Megnövekedett terhelés a harmadik féltől származó szolgáltatásokra.

Látható, hogy az alkalmazhatóság köre megközelítőleg megegyezik a naiv megközelítésével. A párhuzamos kérés módszerének akkor van értelme, ha többszörösére szeretné növelni szolgáltatása teljesítményét mások kíméletlen kihasználása miatt. Példánkban a teljesítmény 2,5-szeresére nőtt, de ez nyilvánvalóan nem elég.

gyorsítótárazás

A külső szolgáltatásokhoz a JPA szellemében gyorsítótárazást végezhet, azaz a fogadott objektumokat egy munkameneten belül tárolhatja, hogy ne kapja meg őket újra (beleértve a kötegelt feldolgozás során is). Készíthetsz ilyen gyorsítótárakat magad is, használhatod a Spring-et a @Cacheable-vel, plusz mindig használhatsz egy kész cache-t, mint például az EhCache manuálisan.

Gyakori probléma az lenne, hogy a gyorsítótárak csak akkor hasznosak, ha vannak találatai. Esetünkben a szerző mezőben való találat nagyon valószínű (mondjuk 50%), de fájlokon egyáltalán nem lesz találat. Ez a megközelítés némi javulást fog eredményezni, de nem változtat gyökeresen a teljesítményen (és áttörésre van szükségünk).

Az intersession (hosszú) gyorsítótárak összetett érvénytelenítési logikát igényelnek. Általánosságban elmondható, hogy minél később kezdi meg a teljesítményproblémák megoldását az intersession cache használatával, annál jobb.

Előnyök:

  1. A gyorsítótárazás végrehajtása a kód megváltoztatása nélkül.
  2. Többszöri termelékenységnövekedés (bizonyos esetekben).

Hátrányok:

  1. A teljesítmény csökkenésének lehetősége helytelen használat esetén.
  2. Nagy memória, különösen hosszú gyorsítótárak esetén.
  3. Összetett érvénytelenítés, amelynek hibái nehezen reprodukálható problémákhoz vezetnek futás közben.

Nagyon gyakran a gyorsítótárakat csak a tervezési problémák gyors javítására használják. Ez nem jelenti azt, hogy nem szabad használni. Azonban mindig óvatosan kell kezelni őket, és először értékelni kell az ebből eredő teljesítménynövekedést, és csak azután dönteni.

Példánkban a gyorsítótárak körülbelül 25%-os teljesítménynövekedést biztosítanak. Ugyanakkor a gyorsítótárnak elég sok hátránya van, ezért itt nem használnám őket.

Eredményei

Tehát megvizsgáltuk egy szolgáltatás naiv megvalósítását, amely kötegelt feldolgozást használ, és néhány egyszerű módszert annak felgyorsítására.

Mindezen módszerek fő előnye az egyszerűség, aminek számos kellemes következménye van.

Ezeknél a módszereknél gyakori probléma a gyenge teljesítmény, elsősorban a csomagok mérete miatt. Ezért, ha ezek a megoldások nem felelnek meg Önnek, akkor érdemes radikálisabb módszereket fontolóra venni.

Két fő irányban lehet megoldást keresni:

  • adatokkal végzett aszinkron munka (paradigmaváltást igényel, ezért ebben a cikkben nem tárgyaljuk);
  • tételek bővítése a szinkron feldolgozás fenntartása mellett.

A kötegek növelése nagymértékben csökkenti a külső hívások számát, és egyúttal szinkronban tartja a kódot. A cikk következő része ennek a témának lesz szentelve.

Forrás: will.com

Hozzászólás