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áltatások közötti lassú válaszidők gyakran teljesítményproblémák forrásává válnak. Ennek a problémának a standard megoldása több szolgáltatásközi kérés egyetlen csomagba csomagolása, ezt a folyamatot kötegelt feldolgozásnak nevezik.

Ha kötegelt feldolgozást használsz, elégedetlen lehetsz a teljesítményével vagy a kód érthetőségével. Ez a módszer nem olyan egyszerű a hívó számára, mint gondolnád. A különböző célokra és helyzetekre vonatkozó megoldások nagymértékben eltérhetnek. Konkrét példákon keresztül bemutatom a különböző megközelítések előnyeit és hátrányait.

Demonstrációs projekt

Ennek szemléltetésére nézzünk egy példát az alkalmazásom egyik szolgáltatására, amin jelenleg dolgozom.

A platformválasztás magyarázata példákkalA gyenge teljesítmény problémája meglehetősen általános, és nem vonatkozik semmilyen konkrét nyelvre vagy platformra. Ez a cikk Spring és Kotlin kódpéldákkal mutatja be a kihívásokat és a megoldásokat. A Kotlin ugyanolyan érthető (vagy érthetetlen) a Java és C# fejlesztők számára, és az így létrejövő kód tömörebb és érthetőbb, mint a Java. Hogy a tisztán Java fejlesztők könnyebben megérthessék, elkerülöm Kotlin fekete mágiáját, és csak fehér mágiát használok (Lombok szellemében). Lesz néhány kiterjesztési metódus, de ezek valójában minden Java programozó számára ismerősek statikus metódusként, így ez egy kis édesség lesz, ami nem rontja el az ételt.
Létezik egy dokumentum-jóváhagyási szolgáltatás. Valaki létrehoz egy dokumentumot, és benyújtja megbeszélésre, amelynek során szerkesztéseket végeznek rajta, és végül a dokumentumot jóváhagyják. Maga a jóváhagyási szolgáltatás semmit sem tud a dokumentumokról: egyszerűen egy csevegőszoba a jóváhagyók számára, néhány további funkcióval, amelyeket itt nem fogunk megvitatni.

Tehát vannak csevegőszobák (dokumentumoknak megfelelően), amelyekben előre meghatározott résztvevők vannak. 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
)

Egy fájlra és felhasználóra mutató hivatkozások egymásra mutató hivatkozások. domainekNálunk így működik:

typealias FileReference Long
typealias UserReference Long

A felhasználói adatokat a Keycloak tárolja és REST protokollon keresztül kéri le. Ugyanez vonatkozik a fájlokra is: a fájlok és metaadataik egy külön fájltároló szolgáltatásban találhatók.

Minden hívás ezekre a szolgáltatásokra nehéz kérésekEz azt jelenti, hogy ezen kérések továbbításának többletterhelése sokkal nagyobb, mint amennyi időbe telik a harmadik féltől származó szolgáltatás feldolgozása. Tesztelési környezeteinken az ilyen szolgáltatások tipikus hívási ideje 100 ms, ezért mostantól ezeket az adatokat fogjuk használni.

Létre kell hoznunk egy egyszerű REST vezérlőt, amely az utolsó N üzenetet minden szükséges információval együtt lekéri. Más szóval, feltételezzük, hogy a frontend üzenetmodell majdnem ugyanaz, és az összes adatot el kell küldeni. A frontend modellben az a különbség, hogy a fájlt és a felhasználót kissé visszafejtett formában kell reprezentálni, hogy összekapcsolhatók 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 a frontend DTO modelljeit jelöli, azaz azt, amit a REST-en keresztül vissza kell adnunk.

Ami itt meglepő lehet, az az, hogy nem adunk át semmilyen chat ID-t, és a ChatMessage/ChatMessageUI modellben sincs ilyen. Ezt szándékosan tettem, hogy a példakód egyszerű maradjon (a chat-ek elszigeteltek, így úgy tekinthetünk magunkra, mintha csak eggyel rendelkeznénk).

Filozófiai kitérőMind a ChatMessageUI osztály, mind a ChatRestApi.getLast metódus a List adattípust használja, de valójában egy rendezett Set-et alkotnak. A JDK ezt nem támogatja, így az elemek sorrendjének deklarálása az interfész szintjén (a sorrend megőrzése a beszúrás és a lekérés során) nem lehetséges. Ezért bevett gyakorlat a List használata, ha rendezett Set-re van szükség (a LinkedHashSet is elérhető, de az nem egy interfész).
Fontos korlátozás: Tegyük fel, hogy nem léteznek hosszú válasz- vagy továbbítási láncok. Azaz léteznek, de a hosszuk nem haladja meg a három üzenetet. Az üzenetlánc teljes egészét el kell juttatni a frontendhez.

Külső szolgáltatásokból származó adatok beszerzéséhez 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>
}

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

Egyszerű megvalósítások

Naiv megvalósítás

A REST vezérlőnk első naiv implementációja 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 rendkívül világos és érthető, ami egy hatalmas plusz.

Kötegelt feldolgozást használunk, és kötegekben fogadunk adatokat egy külső szolgáltatástól. De mi történik a teljesítménnyel?

Minden üzenethez egy UserRemoteApi hívás történik a szerző mező lekéréséhez, és egy FileRemoteApi hívás az összes csatolt fájl lekéréséhez. Úgy tűnik, hogy ennyi az egész. Tegyük fel, hogy a ChatMessage forwardFrom és replyTo mezői úgy vannak generálva, hogy ez nem igényel további hívásokat. Azonban a ChatMessageUI-vá konvertálásuk rekurzióhoz vezet, ami azt jelenti, hogy a hívásszámlálók jelentősen megnőhetnek. Ahogy korábban említettük, tegyük fel, hogy nincs sok beágyazásunk, és a szál három üzenetre korlátozódik.

Ennek eredményeként üzenetenként kettő-hat külső szolgáltatáshívást, és teljes üzenetkötegenként egy JPA-hívást fogunk kapni. A hívások teljes száma 2*N+1 és 6*N+1 között fog változni. Mennyi ez valós egységekben? Tegyük fel, hogy egy oldalnak 20 üzenetre van szüksége a megjelenítéshez. Ezek lekérése 4 és 10 másodperc között tart. Szörnyű! Szeretnénk 500 ms alatt tartani. És mivel a frontend csapat zökkenőmentes görgetést akart elérni, a végpont teljesítménykövetelményei megduplázódhatnak.

Előnyök:

  1. A kód rövid és öndokumentáló (egy támogató személy álma).
  2. A kód egyszerű, így szinte semmi esélyed sincs arra, hogy magadnak lődd a lábad.
  3. A kötegelt feldolgozás nem tűnik idegennek, és zökkenőmentesen illeszkedik a logikába.
  4. A logikai változtatások könnyen elvégezhetők lesznek, és lokálisak lesznek.

kevesebb:

Szörnyű teljesítmény a túl kicsi csomagok miatt.

Ez a megközelítés meglehetősen gyakori egyszerű szolgáltatások vagy prototípusok esetében. Ha a változás sebessége fontos, akkor aligha érdemes bonyolítani a rendszert. Azonban a mi nagyon egyszerű szolgáltatásunk esetében a teljesítmény szörnyű, így a megközelítés alkalmazhatósága nagyon korlátozott.

Naiv párhuzamos feldolgozás

Az összes üzenetfeldolgozást párhuzamosan futtathatja – ez kiküszöböli a feldolgozási idő lineáris növekedését az üzenetek számától függően. Ez nem egy különösebben jó megközelítés, mivel jelentős csúcsterhelést okoz 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ás használatával ideális esetben 300-700 ms-ot kapunk, ami sokkal jobb, mint a naiv implementáció, de még mindig nem elég gyors.

Ezzel a megközelítéssel a userRepository és a fileRepository felé irányuló kérések szinkronban kerülnek végrehajtásra, ami nem hatékony. Ennek javításához jelentősen módosítani kell a hívási logikát. 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. Ez azért van, mert el kellett választanunk a külső szolgáltatásokhoz irányuló hívásokat attól, ahol az eredményeket felhasználjuk. Ez önmagában nem rossz. De a hívások kombinálása nem tűnik elegánsnak, és tipikus reaktív "tésztákra" hasonlít.

Ha korutinokat használsz, minden tisztességesebbnek fog tűnni:

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ással 200–400 ms-ot kapunk, ami már közel van az elvárásainkhoz.

Sajnos ilyen jó párhuzamosítás nem létezik, és az ára is elég súlyos: mivel csak néhány felhasználó dolgozik egyszerre, a szolgáltatásokat elárasztják olyan kérések, amelyeket egyébként sem fognak párhuzamosan feldolgozni, így visszakerülünk a szomorú 4 másodpercünkhöz.

Az eredmény ezzel a szolgáltatással 1300–1700 ms 20 üzenet feldolgozása esetén. Ez gyorsabb, mint az első implementáció, de még mindig nem oldja meg a problémát.

A párhuzamos lekérdezések alternatív használataMi van, ha a harmadik féltől származó szolgáltatások nem támogatják a kötegelt feldolgozást? Például elrejtheti a kötegelt feldolgozás implementációjának hiányát az interfészmetódusokon belül:

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

Ez logikus, ha van remény arra, hogy a kötegelt feldolgozás megjelenjen a jövőbeli verziókban.
Előnyök:

  1. Az üzenetvezérelt párhuzamos feldolgozás egyszerű megvalósítása.
  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ása során.
  2. Megnövekedett terhelés a harmadik féltől származó szolgáltatásokon.

Nyilvánvaló, hogy az alkalmazhatósági kör nagyjából megegyezik a naiv megközelítésével. A párhuzamos lekérdezések használata akkor van értelme, ha a szolgáltatás teljesítményét többszörösére szeretné növelni mások teljesítményének könyörtelen kihasználásával. 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

Külső szolgáltatásokhoz JPA-stílusú gyorsítótárat is létrehozhatsz, amely a lekért objektumokat egy munkameneten belül tárolja, így elkerülhető az újbóli lekérésük (beleértve a kötegelt feldolgozás során is). Az ilyen gyorsítótárakat magad is megvalósíthatod, használhatod a Spring-et a @Cacheable-lel, vagy manuálisan is használhatsz egy kész gyorsítótárat, például az EhCache-t.

Az általános probléma az, hogy a gyorsítótárak csak akkor hasznosak, ha vannak találatok. Esetünkben a szerző mezőben a találatok nagy valószínűséggel jelennek meg (mondjuk 50%), de a fájlokban nem lesznek találatok. Ez a megközelítés némi javulást eredményez, de nem fogja radikálisan javítani a teljesítményt (és áttörésre van szükségünk).

A munkamenetek közötti (hosszú) gyorsítótárak összetett érvénytelenítési logikát igényelnek. Általánosságban elmondható, hogy minél tovább várunk a teljesítményproblémák megoldásával a munkamenetek közötti gyorsítótárak igénybevételével, annál jobb.

Előnyök:

  1. Gyorsítótárazás megvalósítása kód módosítása nélkül.
  2. A termelékenység többszörös növekedése (egyes esetekben).

Hátrányok:

  1. Teljesítményromlás lehetősége nem megfelelő használat esetén.
  2. Nagy memória-többlet, különösen hosszú gyorsítótárak esetén.
  3. Komplex érvénytelenítés, amelynek során elkövetett hibák futásidőben nehezen reprodukálható problémákhoz vezetnek.

A gyorsítótárakat gyakran egyszerűen a tervezési problémák gyors megoldásaként használják. Ez nem jelenti azt, hogy nem szabad használni őket. Azonban mindig érdemes óvatosan megközelíteni őket, és a döntés meghozatala előtt értékelni az ebből eredő teljesítménynövekedést.

A példánkban a gyorsítótárak körülbelül 25%-os teljesítménynövekedést biztosítanak. Azonban a gyorsítótáraknak számos hátrányuk van, ezért itt nem használnám őket.

Eredményei

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

Mindezen módszerek fő előnye az egyszerűségük, amely számos kellemes következménnyel jár.

Ezen módszerek gyakori problémája a gyenge teljesítmény, elsősorban a csomagméret miatt. Ezért, ha ezek a megoldások nem működnek, érdemes radikálisabb megközelítéseket fontolóra venni.

A megoldások keresésének két fő iránya van:

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

A kötegek durvább feldolgozása jelentősen csökkenti a külső hívások számát, miközben a kód szinkronban marad. Ezt a témát a cikk következő szakaszában tárgyaljuk.

Forrás: will.com

Vásároljon megbízható tárhelyet DDoS védelemmel, VPS VDS szerverekkel rendelkező webhelyekhez 🔥 Vásároljon megbízható weboldal tárhelyet DDoS védelemmel, VPS VDS szerverekkel | ProHoster