Problemet e përpunimit të pyetjeve në grup dhe zgjidhjet e tyre (pjesa 1)

Problemet e përpunimit të pyetjeve në grup dhe zgjidhjet e tyre (pjesa 1)Pothuajse të gjitha produktet moderne softuerike përbëhen nga disa shërbime. Shpesh, kohët e gjata të përgjigjes së kanaleve ndërservice bëhen burim i problemeve të performancës. Zgjidhja standarde për këtë lloj problemi është paketimi i kërkesave të shumta ndër-shërbimesh në një paketë, e cila quhet batching.

Nëse përdorni përpunimin në grup, mund të mos jeni të kënaqur me rezultatet për sa i përket performancës ose qartësisë së kodit. Kjo metodë nuk është aq e lehtë për telefonuesin sa mund të mendoni. Për qëllime të ndryshme dhe në situata të ndryshme, zgjidhjet mund të ndryshojnë shumë. Duke përdorur shembuj specifikë, unë do të tregoj të mirat dhe të këqijat e disa qasjeve.

Projekti demonstrues

Për qartësi, le të shohim një shembull të një prej shërbimeve në aplikacionin me të cilin jam duke punuar aktualisht.

Shpjegimi i zgjedhjes së platformës për shembujProblemi i performancës së dobët është mjaft i përgjithshëm dhe nuk prek ndonjë gjuhë apo platformë specifike. Ky artikull do të përdorë shembuj kodesh Spring + Kotlin për të demonstruar problemet dhe zgjidhjet. Kotlin është po aq i kuptueshëm (ose i pakuptueshëm) për zhvilluesit Java dhe C#, përveç kësaj, kodi është më kompakt dhe më i kuptueshëm sesa në Java. Për ta bërë më të lehtë për t'u kuptuar për zhvilluesit e pastër Java, unë do të shmang magjinë e zezë të Kotlin dhe do të përdor vetëm magjinë e bardhë (në frymën e Lombok). Do të ketë disa metoda shtesë, por ato janë në të vërtetë të njohura për të gjithë programuesit Java si metoda statike, kështu që ky do të jetë një sheqer i vogël që nuk do të prishë shijen e pjatës.
Ekziston një shërbim për miratimin e dokumenteve. Dikush krijon një dokument dhe e paraqet për diskutim, gjatë të cilit bëhen redaktime dhe në fund bihet dakord për dokumentin. Vetë shërbimi i miratimit nuk di asgjë për dokumentet: është thjesht një bisedë miratuesish me funksione të vogla shtesë që nuk do t'i shqyrtojmë këtu.

Pra, ka dhoma bisede (që korrespondojnë me dokumentet) me një grup të paracaktuar pjesëmarrësish në secilën prej tyre. Ashtu si në bisedat e rregullta, mesazhet përmbajnë tekst dhe skedarë dhe mund të jenë përgjigje ose përcjellje:

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
)

Lidhjet e skedarëve dhe përdoruesve janë lidhje me domene të tjera. Këtu jetojmë kështu:

typealias FileReference Long
typealias UserReference Long

Të dhënat e përdoruesit ruhen në Keycloak dhe merren nëpërmjet REST. E njëjta gjë vlen edhe për skedarët: skedarët dhe metainformacionet rreth tyre jetojnë në një shërbim të veçantë të ruajtjes së skedarëve.

Të gjitha thirrjet në këto shërbime janë kërkesa të rënda. Kjo do të thotë se shpenzimet e transportit të këtyre kërkesave janë shumë më të mëdha se koha që i duhet për t'u përpunuar nga një shërbim i palës së tretë. Në bankat tona të provës, koha tipike e thirrjeve për shërbime të tilla është 100 ms, kështu që ne do t'i përdorim këta numra në të ardhmen.

Duhet të bëjmë një kontrollues të thjeshtë REST për të marrë N mesazhet e fundit me të gjithë informacionin e nevojshëm. Kjo do të thotë, ne besojmë se modeli i mesazhit në frontend është pothuajse i njëjtë dhe të gjitha të dhënat duhet të dërgohen. Dallimi midis modelit të përparmë është se skedari dhe përdoruesi duhet të paraqiten në një formë pak të deshifruar për t'i bërë ato lidhje:

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

Ne duhet të zbatojmë sa vijon:

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

Postfix UI do të thotë modele DTO për frontend, domethënë atë që duhet të shërbejmë nëpërmjet REST.

Ajo që mund të jetë befasuese këtu është se ne nuk po kalojmë asnjë ID të bisedës dhe madje edhe modeli ChatMessage/ChatMessageUI nuk e ka një të tillë. E bëra këtë qëllimisht që të mos rrëmbej kodin e shembujve (chatat janë të izoluara, kështu që mund të supozojmë se kemi vetëm një).

Digresioni filozofikSi klasa ChatMessageUI ashtu edhe metoda ChatRestApi.getLast përdorin llojin e të dhënave List kur në fakt është një grup i renditur. Kjo është e keqe në JDK, kështu që deklarimi i renditjes së elementeve në nivelin e ndërfaqes (ruajtja e rendit kur shtohet dhe hiqet) nuk do të funksionojë. Pra, është bërë praktikë e zakonshme përdorimi i Listës në rastet kur nevojitet një Set i porositur (ekziston edhe një LinkedHashSet, por kjo nuk është një ndërfaqe).
Kufizimi i rëndësishëm: Ne do të supozojmë se nuk ka zinxhirë të gjatë përgjigjesh ose transferimesh. Domethënë ato ekzistojnë, por gjatësia e tyre nuk i kalon tre mesazhe. I gjithë zinxhiri i mesazheve duhet të transmetohet në front.

Për të marrë të dhëna nga shërbimet e jashtme ekzistojnë API-të e mëposhtme:

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

Mund të shihet se shërbimet e jashtme fillimisht parashikojnë përpunim grupor, dhe në të dy versionet: përmes Set (pa ruajtur renditjen e elementeve, me çelësa unikë) dhe përmes Listës (mund të ketë dublikatë - rendi ruhet).

Implementime të thjeshta

Zbatim naiv

Zbatimi i parë naiv i kontrolluesit tonë REST do të duket diçka e tillë në shumicën e rasteve:

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

Gjithçka është shumë e qartë, dhe ky është një plus i madh.

Ne përdorim përpunimin e grupeve dhe marrim të dhëna nga një shërbim i jashtëm në grupe. Por çfarë ndodh me produktivitetin tonë?

Për çdo mesazh, do të bëhet një telefonatë në UserRemoteApi për të marrë të dhëna në fushën e autorit dhe një telefonatë në FileRemoteApi për të marrë të gjithë skedarët e bashkangjitur. Duket se kaq është. Le të themi se fushat ForwardFrom dhe replyTo për ChatMessage janë marrë në atë mënyrë që kjo të mos kërkojë thirrje të panevojshme. Por shndërrimi i tyre në ChatMessageUI do të çojë në rekursion, domethënë numëruesit e thirrjeve mund të rriten ndjeshëm. Siç kemi theksuar më herët, le të supozojmë se nuk kemi shumë fole dhe zinxhiri është i kufizuar në tre mesazhe.

Si rezultat, ne do të marrim nga dy deri në gjashtë thirrje drejt shërbimeve të jashtme për mesazh dhe një thirrje JPA për të gjithë paketën e mesazheve. Numri i përgjithshëm i thirrjeve do të variojë nga 2*N+1 deri në 6*N+1. Sa është kjo në njësi reale? Le të themi se duhen 20 mesazhe për të dhënë një faqe. Për t'i marrë ato, do të duhen nga 4 deri në 10 sekonda. E tmerrshme! Do të doja ta mbaja brenda 500 ms. Dhe meqenëse ata ëndërronin të bënin lëvizje pa probleme në pjesën e përparme, kërkesat e performancës për këtë pikë përfundimtare mund të dyfishohen.

Pro:

  1. Kodi është konciz dhe vetë-dokumentues (ëndrra e një ekipi mbështetës).
  2. Kodi është i thjeshtë, kështu që nuk ka pothuajse asnjë mundësi për të qëlluar veten në këmbë.
  3. Përpunimi i grupit nuk duket si diçka e huaj dhe është i integruar organikisht në logjikë.
  4. Ndryshimet logjike do të bëhen lehtësisht dhe do të jenë lokale.

Minus:

Performancë e tmerrshme për shkak të paketave shumë të vogla.

Kjo qasje mund të shihet mjaft shpesh në shërbime të thjeshta ose në prototipe. Nëse shpejtësia e bërjes së ndryshimeve është e rëndësishme, vështirë se ia vlen të komplikoni sistemin. Në të njëjtën kohë, për shërbimin tonë shumë të thjeshtë performanca është e tmerrshme, kështu që shtrirja e zbatueshmërisë së kësaj qasjeje është shumë e ngushtë.

Përpunim paralel naiv

Mund të filloni të përpunoni të gjitha mesazhet paralelisht - kjo do t'ju lejojë të heqni qafe rritjen lineare të kohës në varësi të numrit të mesazheve. Kjo nuk është një rrugë veçanërisht e mirë sepse do të rezultojë në një ngarkesë të madhe maksimale në shërbimin e jashtëm.

Zbatimi i përpunimit paralel është shumë i thjeshtë:

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

Duke përdorur përpunimin paralel të mesazheve, ne marrim në mënyrë ideale 300-700 ms, që është shumë më mirë sesa me një zbatim naiv, por ende jo mjaftueshëm i shpejtë.

Me këtë qasje, kërkesat për userRepository dhe fileRepository do të ekzekutohen në mënyrë sinkrone, gjë që nuk është shumë efikase. Për ta rregulluar këtë, do t'ju duhet të ndryshoni shumë logjikën e thirrjes. Për shembull, nëpërmjet 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()!!

Mund të shihet se kodi fillestar i thjeshtë i hartës është bërë më pak i kuptueshëm. Kjo për shkak se na është dashur të veçojmë thirrjet drejt shërbimeve të jashtme nga ku përdoren rezultatet. Kjo në vetvete nuk është e keqe. Por kombinimi i thirrjeve nuk duket shumë elegant dhe i ngjan një "petë" tipike reaktive.

Nëse përdorni korutina, gjithçka do të duket më e mirë:

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

Ku:

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

Teorikisht, duke përdorur një përpunim të tillë paralel, do të marrim 200-400 ms, që tashmë është afër pritjeve tona.

Fatkeqësisht, një paralelizim i tillë i mirë nuk ekziston dhe çmimi që duhet paguar është mjaft mizor: me vetëm disa përdorues që punojnë në të njëjtën kohë, një breshëri kërkesash do të bjerë mbi shërbimet, të cilat gjithsesi nuk do të përpunohen paralelisht, kështu që ne do të kthehet në 4 s tona të trishtuara.

Rezultati im kur përdor një shërbim të tillë është 1300–1700 ms për përpunimin e 20 mesazheve. Kjo është më e shpejtë se në zbatimin e parë, por ende nuk e zgjidh problemin.

Përdorime alternative të pyetjeve paralelePo nëse shërbimet e palëve të treta nuk ofrojnë përpunim grupor? Për shembull, ju mund të fshehni mungesën e zbatimit të përpunimit të grupit brenda metodave të ndërfaqes:

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

Kjo ka kuptim nëse shpresoni të shihni përpunimin e grupeve në versionet e ardhshme.
Pro:

  1. Zbatoni me lehtësi përpunimin paralel të bazuar në mesazhe.
  2. Shkallueshmëri e mirë.

Cons:

  1. Nevoja për të ndarë marrjen e të dhënave nga përpunimi i tyre kur përpunohen paralelisht kërkesat për shërbime të ndryshme.
  2. Rritja e ngarkesës në shërbimet e palëve të treta.

Mund të shihet se shtrirja e zbatueshmërisë është afërsisht e njëjtë me atë të qasjes naive. Ka kuptim të përdorni metodën e kërkesës paralele nëse dëshironi të rrisni performancën e shërbimit tuaj disa herë për shkak të shfrytëzimit të pamëshirshëm të të tjerëve. Në shembullin tonë, performanca u rrit me 2,5 herë, por kjo nuk mjafton qartë.

caching

Ju mund të bëni caching në frymën e JPA për shërbimet e jashtme, domethënë të ruani objektet e marra brenda një sesioni në mënyrë që të mos i merrni përsëri (përfshirë gjatë përpunimit të grupit). Ju mund të bëni vetë cache të tilla, mund të përdorni Spring me @Cacheable të saj, plus që gjithmonë mund të përdorni një memorie të gatshme si EhCache manualisht.

Një problem i zakonshëm do të ishte se cache-të janë të dobishme vetëm nëse kanë hite. Në rastin tonë, goditjet në fushën e autorit janë shumë të mundshme (le të themi, 50%), por nuk do të ketë fare goditje në skedarë. Kjo qasje do të sigurojë disa përmirësime, por nuk do të ndryshojë rrënjësisht performancën (dhe ne kemi nevojë për një përparim).

Memoriet e memories intersesionale (të gjata) kërkojnë logjikë komplekse të zhvlerësimit. Në përgjithësi, sa më vonë të filloni të zgjidhni problemet e performancës duke përdorur cache intersession, aq më mirë.

Pro:

  1. Zbatoni caching pa ndryshuar kodin.
  2. Rritja e produktivitetit disa herë (në disa raste).

Cons:

  1. Mundësia e reduktimit të performancës nëse përdoret gabim.
  2. Mbështetje e madhe memorie, veçanërisht me cache të gjata.
  3. Pavlefshmëri komplekse, gabime në të cilat do të çojnë në probleme të vështira për t'u riprodhuar në kohën e ekzekutimit.

Shumë shpesh, cache përdoren vetëm për të rregulluar shpejt problemet e projektimit. Kjo nuk do të thotë se ato nuk duhet të përdoren. Sidoqoftë, duhet t'i trajtoni gjithmonë me kujdes dhe së pari të vlerësoni përfitimin e performancës që rezulton dhe vetëm atëherë të merrni një vendim.

Në shembullin tonë, cache do të ofrojnë një rritje të performancës prej rreth 25%. Në të njëjtën kohë, cache kanë mjaft disavantazhe, kështu që unë nuk do t'i përdorja ato këtu.

Rezultatet e

Pra, ne shikuam një zbatim naiv të një shërbimi që përdor përpunimin në grup, dhe disa mënyra të thjeshta për ta përshpejtuar atë.

Avantazhi kryesor i të gjitha këtyre metodave është thjeshtësia, nga e cila ka shumë pasoja të këndshme.

Një problem i zakonshëm me këto metoda është performanca e dobët, kryesisht për shkak të madhësisë së paketave. Prandaj, nëse këto zgjidhje nuk ju përshtaten, atëherë ia vlen të merren parasysh metoda më radikale.

Ekzistojnë dy drejtime kryesore në të cilat mund të kërkoni zgjidhje:

  • puna asinkrone me të dhënat (kërkon një ndryshim paradigme, kështu që nuk diskutohet në këtë artikull);
  • zmadhimi i grupeve duke ruajtur përpunimin sinkron.

Zgjerimi i grupeve do të zvogëlojë shumë numrin e thirrjeve të jashtme dhe në të njëjtën kohë do ta mbajë kodin sinkron. Pjesa tjetër e artikullit do t'i kushtohet kësaj teme.

Burimi: www.habr.com

Shto një koment