Probleme van bondelnavraagverwerking en hul oplossings (deel 1)

Probleme van bondelnavraagverwerking en hul oplossings (deel 1)Byna alle moderne sagteware produkte bestaan ​​uit verskeie dienste. Dikwels word lang reaksietye van interdienskanale 'n bron van prestasieprobleme. Die standaardoplossing vir hierdie soort probleem is om veelvuldige interdiensversoeke in een pakket te pak, wat groepering genoem word.

As jy bondelverwerking gebruik, is jy dalk nie tevrede met die resultate in terme van prestasie of kode duidelikheid nie. Hierdie metode is nie so maklik vir die beller as wat jy dalk dink nie. Vir verskillende doeleindes en in verskillende situasies kan oplossings baie verskil. Deur spesifieke voorbeelde te gebruik, sal ek die voor- en nadele van verskeie benaderings wys.

Demonstrasie projek

Vir duidelikheid, kom ons kyk na 'n voorbeeld van een van die dienste in die toepassing waaraan ek tans werk.

Verduideliking van platformkeuse vir voorbeeldeDie probleem van swak prestasie is redelik algemeen en gaan nie oor enige spesifieke tale of platforms nie. Hierdie artikel sal Spring + Kotlin-kodevoorbeelde gebruik om probleme en oplossings te demonstreer. Kotlin is ewe verstaanbaar (of onverstaanbaar) vir Java- en C#-ontwikkelaars; Boonop is die kode meer kompak en verstaanbaar as in Java. Om dinge makliker te maak om te verstaan ​​vir suiwer Java-ontwikkelaars, sal ek die swart magie van Kotlin vermy en net die wit magie gebruik (in die gees van Lombok). Daar sal 'n paar uitbreidingsmetodes wees, maar dit is eintlik aan alle Java-programmeerders bekend as statiese metodes, so dit sal 'n klein suiker wees wat nie die smaak van die gereg sal verwoes nie.
Daar is 'n dokumentgoedkeuringsdiens. Iemand skep 'n dokument en dien dit in vir bespreking, waartydens wysigings gemaak word, en uiteindelik word die dokument ooreengekom. Die goedkeuringsdiens self weet niks van dokumente nie: dit is net 'n geselsie van goedkeurders met klein bykomende funksies wat ons nie hier sal oorweeg nie.

Dus, daar is kletskamers (wat ooreenstem met dokumente) met 'n voorafbepaalde stel deelnemers in elk van hulle. Soos in gewone kletse, bevat boodskappe teks en lêers en kan antwoorde of aanstuur wees:

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
)

Lêer- en gebruikerskakels is skakels na ander domeine. Hier leef ons so:

typealias FileReference Long
typealias UserReference Long

Gebruikersdata word in Keycloak gestoor en via REST ontvang. Dieselfde geld vir lêers: lêers en meta-inligting daaroor leef in 'n aparte lêerbergingsdiens.

Alle oproepe na hierdie dienste is swaar versoeke. Dit beteken dat die bokoste van die vervoer van hierdie versoeke baie groter is as die tyd wat dit neem om dit deur 'n derdeparty-diens te verwerk. Op ons toetsbanke is die tipiese oproeptyd vir sulke dienste 100 ms, so ons sal hierdie nommers in die toekoms gebruik.

Ons moet 'n eenvoudige REST kontroleerder maak om die laaste N boodskappe met al die nodige inligting te ontvang. Dit wil sê, ons glo dat in die frontend die boodskapmodel amper dieselfde is en alle data moet gestuur word. Die verskil tussen die front-end model is dat die lêer en die gebruiker in 'n effens gedekripteer vorm aangebied moet word om hulle skakels te maak:

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

Ons moet die volgende implementeer:

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

Postfix UI beteken DTO-modelle vir die frontend, dit wil sê wat ons via REST moet bedien.

Wat hier dalk verbasend is, is dat ons geen kletsidentifiseerder deurgee nie en selfs in die ChatMessage/ChatMessageUI-model is daar geen. Ek het dit doelbewus gedoen om nie die kode van die voorbeelde deurmekaar te maak nie (die geselsies is geïsoleer, so ons kan aanvaar dat ons net een het).

Filosofiese afwykingBeide die ChatMessageUI-klas en die ChatRestApi.getLast-metode gebruik die Lys-datatipe, terwyl dit in werklikheid 'n geordende Stel is. In die JDK is dit alles sleg, so om die volgorde van elemente op die koppelvlakvlak te verklaar (om die volgorde te behou wanneer dit bygevoeg en herwin word) sal nie werk nie. Dit het dus algemene praktyk geword om List te gebruik in gevalle waar 'n geordende Stel nodig is (daar is ook LinkedHashSet, maar dit is nie 'n koppelvlak nie).
Belangrike beperking: Ons sal aanvaar dat daar geen lang kettings van antwoorde of oorplasings is nie. Dit wil sê, hulle bestaan, maar hul lengte oorskry nie drie boodskappe nie. Die hele ketting boodskappe moet na die frontend oorgedra word.

Om data van eksterne dienste te ontvang is daar die volgende API's:

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

Dit kan gesien word dat eksterne dienste aanvanklik voorsiening maak vir bondelverwerking, en in beide variante: deur Stel (sonder om die volgorde van elemente te behou, met unieke sleutels) en deur List (daar kan duplikate wees - die volgorde word bewaar).

Eenvoudige implementerings

Naïewe implementering

Die eerste naïewe implementering van ons REST-beheerder sal in die meeste gevalle so lyk:

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

Alles is baie duidelik, en dit is 'n groot pluspunt.

Ons gebruik bondelverwerking en ontvang data van 'n eksterne diens in bondels. Maar wat gebeur met ons produktiwiteit?

Vir elke boodskap sal een oproep na UserRemoteApi gemaak word om data op die outeurveld te kry en een oproep na FileRemoteApi om alle aangehegte lêers te kry. Dit blyk dit te wees. Kom ons sê dat die forwardFrom- en replyTo-velde vir ChatMessage op so 'n manier verkry word dat dit nie onnodige oproepe vereis nie. Maar om hulle in ChatMessageUI te verander, sal tot rekursie lei, dit wil sê, die oproeptellers kan aansienlik toeneem. Soos ons vroeër opgemerk het, kom ons neem aan dat ons nie veel nes het nie en die ketting is beperk tot drie boodskappe.

Gevolglik sal ons van twee tot ses oproepe na eksterne dienste per boodskap kry en een JPA-oproep vir die hele pakket boodskappe. Die totale aantal oproepe sal wissel van 2*N+1 tot 6*N+1. Hoeveel is dit in regte eenhede? Kom ons sê dit neem 20 boodskappe om 'n bladsy weer te gee. Om hulle te kry, sal jy van 4 s tot 10 s nodig hê. Verskriklik! Ek wil dit graag binne 500ms hou. En aangesien hulle daarvan gedroom het om naatlose blaai op die voorkant te maak, kan die prestasievereistes vir hierdie eindpunt verdubbel word.

Pros:

  1. Die kode is bondig en selfdokumenterend ('n ondersteuningspan se droom).
  2. Die kode is eenvoudig, so daar is amper geen geleenthede om jouself in die voet te skiet nie.
  3. Batch-verwerking lyk nie soos iets uitheems nie en is organies geïntegreer in die logika.
  4. Logika veranderinge sal maklik wees om te maak en sal plaaslik wees.

minus:

Aaklige prestasie as gevolg van baie klein pakkies.

Hierdie benadering kan dikwels in eenvoudige dienste of in prototipes gesien word. As die spoed om veranderinge aan te bring belangrik is, is dit skaars die moeite werd om die stelsel te kompliseer. Terselfdertyd, vir ons baie eenvoudige diens is die prestasie verskriklik, so die omvang van die toepaslikheid van hierdie benadering is baie smal.

Naïewe parallelle verwerking

Jy kan begin om alle boodskappe parallel te verwerk - dit sal jou toelaat om ontslae te raak van die lineêre toename in tyd, afhangende van die aantal boodskappe. Dit is nie 'n besonder goeie pad nie, want dit sal 'n groot pieklas op die eksterne diens tot gevolg hê.

Die implementering van parallelle verwerking is baie eenvoudig:

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

Deur parallelle boodskapverwerking te gebruik, kry ons ideaal 300–700 ms, wat baie beter is as met 'n naïewe implementering, maar steeds nie vinnig genoeg nie.

Met hierdie benadering sal versoeke aan userRepository en fileRepository sinchronies uitgevoer word, wat nie baie doeltreffend is nie. Om dit reg te stel, sal jy die oproeplogika baie moet verander. Byvoorbeeld, via CompletionStage (ook bekend as 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()!!

Dit kan gesien word dat die aanvanklik eenvoudige karteringkode minder verstaanbaar geword het. Dit is omdat ons oproepe na eksterne dienste moes skei van waar die resultate gebruik word. Dit op sigself is nie sleg nie. Maar die kombinasie van oproepe lyk nie besonder elegant nie en lyk soos 'n tipiese reaktiewe "noedel".

As jy coroutines gebruik, sal alles ordentliker lyk:

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

Waar:

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

Teoreties, deur sulke parallelle verwerking te gebruik, sal ons 200–400 ms kry, wat reeds naby aan ons verwagtinge is.

Ongelukkig gebeur sulke goeie parallellisering nie, en die prys om te betaal is nogal wreed: met net 'n paar gebruikers wat gelyktydig werk, sal die dienste getref word met 'n vlaag versoeke wat in elk geval nie parallel verwerk sal word nie, so ons sal terugkeer na ons hartseer 4s.

My resultaat by die gebruik van so 'n diens is 1300–1700 ms vir die verwerking van 20 boodskappe. Dit is vinniger as in die eerste implementering, maar los steeds nie die probleem op nie.

Alternatiewe gebruike van parallelle navraeWat as derdepartydienste nie bondelverwerking verskaf nie? U kan byvoorbeeld die gebrek aan bondelverwerkingsimplementering binne koppelvlakmetodes versteek:

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

Dit maak sin as jy hoop om bondelverwerking in toekomstige weergawes te sien.
Pros:

  1. Implementeer maklik boodskapgebaseerde parallelle verwerking.
  2. Goeie skaalbaarheid.

Nadele:

  1. Die behoefte om data-verkryging te skei van die verwerking daarvan wanneer versoeke na verskillende dienste in parallel verwerk word.
  2. Verhoogde las op derdeparty-dienste.

Daar kan gesien word dat die omvang van toepaslikheid ongeveer dieselfde is as dié van die naïewe benadering. Dit maak sin om die parallelle versoekmetode te gebruik as jy die prestasie van jou diens verskeie kere wil verhoog as gevolg van die genadelose uitbuiting van ander. In ons voorbeeld het produktiwiteit met 2,5 keer toegeneem, maar dit is duidelik nie genoeg nie.

kas

Jy kan caching in die gees van JPA vir eksterne dienste doen, dit wil sê, ontvangde voorwerpe binne 'n sessie stoor om dit nie weer te ontvang nie (insluitend tydens bondelverwerking). Jy kan self sulke kas maak, jy kan Spring met sy @Cacheable gebruik, plus jy kan altyd 'n klaargemaakte kas soos EhCache met die hand gebruik.

'n Algemene probleem sou wees dat kas slegs nuttig is as hulle trefslae het. In ons geval is treffers op die skrywersveld baie waarskynlik (kom ons sê, 50%), maar daar sal glad nie treffers op lêers wees nie. Hierdie benadering sal 'n paar verbeterings verskaf, maar dit sal nie produktiwiteit radikaal verander nie (en ons het 'n deurbraak nodig).

Intersessie (lang) kas vereis komplekse ongeldigmakingslogika. Oor die algemeen, hoe later jy daaraan begin om prestasieprobleme op te los deur intersessie-caches te gebruik, hoe beter.

Pros:

  1. Implementeer caching sonder om kode te verander.
  2. Verhoogde produktiwiteit verskeie kere (in sommige gevalle).

Nadele:

  1. Moontlikheid van verminderde werkverrigting as dit verkeerd gebruik word.
  2. Groot geheue bokoste, veral met lang kas.
  3. Komplekse ongeldigmaking, foute wat sal lei tot moeilik om te reproduseer probleme in looptyd.

Dikwels word kas net gebruik om ontwerpprobleme vinnig op te los. Dit beteken nie dat hulle nie gebruik moet word nie. U moet hulle egter altyd met omsigtigheid behandel en eers die gevolglike prestasiewins evalueer, en eers dan 'n besluit neem.

In ons voorbeeld sal kas 'n prestasieverhoging van ongeveer 25% bied. Terselfdertyd het caches nogal baie nadele, so ek sal dit nie hier gebruik nie.

Resultate van

Dus, ons het gekyk na 'n naïewe implementering van 'n diens wat bondelverwerking gebruik, en verskeie eenvoudige maniere om dit te bespoedig.

Die grootste voordeel van al hierdie metodes is eenvoud, waaruit daar baie aangename gevolge is.

'n Algemene probleem met hierdie metodes is swak werkverrigting, wat hoofsaaklik verband hou met die grootte van die pakkies. Daarom, as hierdie oplossings jou nie pas nie, is dit die moeite werd om meer radikale metodes te oorweeg.

Daar is twee hoofrigtings waarin jy na oplossings kan soek:

  • asynchrone werk met data (vereis 'n paradigmaskuif, dus word dit nie in hierdie artikel bespreek nie);
  • vergroting van bondels terwyl sinchrone verwerking gehandhaaf word.

Vergroting van bondels sal die aantal eksterne oproepe aansienlik verminder en terselfdertyd die kode sinchroon hou. Die volgende deel van die artikel sal aan hierdie onderwerp gewy word.

Bron: will.com

Voeg 'n opmerking