Shida za usindikaji wa hoja za kundi na suluhisho zao (sehemu ya 1)

Shida za usindikaji wa hoja za kundi na suluhisho zao (sehemu ya 1)Karibu bidhaa zote za kisasa za programu zinajumuisha huduma nyingi. Muda wa majibu polepole kati ya huduma mara nyingi huwa chanzo cha masuala ya utendaji. Suluhisho la kawaida la tatizo hili ni kufunga maombi mengi ya huduma katika pakiti moja, mchakato unaojulikana kama batching.

Ikiwa unatumia usindikaji wa bechi, unaweza kutoridhishwa na utendaji wake au uwazi wa msimbo. Njia hii sio moja kwa moja kwa mpiga simu kama unavyoweza kufikiria. Suluhisho kwa madhumuni na hali tofauti zinaweza kutofautiana sana. Kwa kutumia mifano maalum, nitaonyesha faida na hasara za mbinu kadhaa.

Mradi wa maonyesho

Ili kufafanua hili, hebu tuangalie mfano wa mojawapo ya huduma katika programu ninayofanyia kazi kwa sasa.

Ufafanuzi wa uteuzi wa jukwaa kwa mifanoTatizo la utendakazi duni ni la jumla kabisa na halihusu lugha au mifumo mahususi. Nakala hii itatumia mifano ya msimbo wa Spring na Kotlin ili kuonyesha changamoto na masuluhisho. Kotlin inaeleweka sawa (au haieleweki) kwa watengenezaji wa Java na C #, na nambari inayotokana ni ngumu zaidi na inaeleweka kuliko Java. Ili iwe rahisi kwa watengenezaji wa Java safi kuelewa, nitaepuka uchawi mweusi wa Kotlin na kutumia uchawi nyeupe tu (katika roho ya Lombok). Kutakuwa na njia chache za upanuzi, lakini hizi zinajulikana kwa watengeneza programu wote wa Java kama njia tuli, kwa hivyo hii itakuwa tamu ndogo ambayo haitaharibu sahani.
Kuna huduma ya idhini ya hati. Mtu huunda hati na kuiwasilisha kwa majadiliano, wakati ambapo uhariri hufanywa, na hatimaye hati hiyo inaidhinishwa. Huduma ya uidhinishaji yenyewe haijui lolote kuhusu hati: ni chumba cha gumzo kwa walioidhinisha chenye vipengele vichache vya ziada ambavyo hatutavijadili hapa.

Kwa hivyo, kuna vyumba vya mazungumzo (sambamba na hati) na seti iliyoainishwa ya washiriki katika kila moja. Kama ilivyo katika mazungumzo ya kawaida, ujumbe una maandishi na faili na unaweza kuwa majibu au kusambaza:

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
)

Viungo vya faili na mtumiaji ni viungo vya faili nyingine vikoaHivi ndivyo inavyofanya kazi kwetu:

typealias FileReference Long
typealias UserReference Long

Data ya mtumiaji huhifadhiwa katika Keycloak na kurejeshwa kupitia REST. Vile vile hutumika kwa faili: faili na metadata zao hukaa katika huduma tofauti ya kuhifadhi faili.

Simu zote kwa huduma hizi ni maombi magumuHii ina maana kwamba gharama ya juu ya kusafirisha maombi haya ni kubwa zaidi kuliko muda unaochukua ili kuyashughulikia na huduma ya mtu mwingine. Kwenye vitanda vyetu vya majaribio, muda wa kawaida wa kupiga simu kwa huduma kama hizo ni ms 100, kwa hivyo tutatumia takwimu hizi kuanzia sasa na kuendelea.

Tunahitaji kuunda kidhibiti rahisi cha REST ili kurejesha ujumbe wa N wa mwisho wenye taarifa zote muhimu. Kwa maneno mengine, tunadhani muundo wa ujumbe wa mbele unakaribia kufanana, na data yote inahitaji kutumwa. Tofauti katika muundo wa mbele ni kwamba faili na mtumiaji wanahitaji kuwakilishwa kwa njia iliyosimbwa kidogo ili kuwafanya kuwa viungo:

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

Tunahitaji kutekeleza yafuatayo:

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

UI postfix inasimamia mifano ya DTO ya sehemu ya mbele, yaani, tunachopaswa kurudisha kupitia REST.

Kinachoweza kushangaza hapa ni kwamba hatupitishi kitambulisho chochote cha gumzo, na hakuna hata kimoja katika muundo wa ChatMessage/ChatMessageUI. Nilifanya hivi kwa makusudi ili kuweka nambari ya mfano kuwa rahisi (soga zimetengwa, kwa hivyo tunaweza kujiona kuwa na moja tu).

Mchepuko wa kifalsafaDarasa la ChatMessageUI na mbinu ya ChatRestApi.getLast hutumia aina ya data ya Orodha, lakini kwa kweli ni Seti iliyoagizwa. JDK haiungi mkono hili, kwa hivyo kutangaza agizo la kipengee katika kiwango cha kiolesura (kuhifadhi mpangilio wakati wa kuingizwa na kurejesha) hakuwezekani. Kwa hivyo, ni kawaida kutumia Orodha wakati Seti iliyoagizwa inahitajika (LinkedHashSet inapatikana pia, lakini sio kiolesura).
Kizuizi muhimu: Hebu tuchukulie kwamba misururu mirefu ya majibu au ya mbele haipo. Hiyo ni, zipo, lakini urefu wao hauzidi jumbe tatu. Msururu mzima wa ujumbe lazima usambazwe kwenye sehemu ya mbele.

Ili kupata data kutoka kwa huduma za nje, kuna API zifuatazo:

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

Ni wazi kuwa huduma za nje hapo awali hutoa usindikaji wa kundi, na katika anuwai zote mbili: kupitia Kuweka (bila kuhifadhi mpangilio wa vipengee, na funguo za kipekee) na kupitia Orodha (kunaweza kuwa na nakala - agizo limehifadhiwa).

Utekelezaji rahisi

Utekelezaji wa ujinga

Utekelezaji wa kwanza wa ujinga wa kidhibiti chetu cha REST utaonekana kitu kama hiki katika hali nyingi:

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

Kila kitu ni wazi sana, na hii ni pamoja na kubwa.

Tunatumia usindikaji wa bechi na kupokea data kutoka kwa huduma ya nje katika vikundi. Lakini nini kinatokea kwa utendaji?

Kwa kila ujumbe, simu moja ya UserRemoteApi itapigwa ili kupata sehemu ya mwandishi na simu moja ya FileRemoteApi ili kupata faili zote zilizoambatishwa. Hiyo inaonekana kuwa yote. Hebu tuchukulie kuwa sehemu za forwardFrom na replyTo za ChatMessage zimetolewa kwa njia ambayo hii haihitaji simu zozote za ziada. Walakini, kuzibadilisha kuwa ChatMessageUI kutasababisha kujirudia, ikimaanisha kuwa vihesabu vya simu vinaweza kuongezeka sana. Kama tulivyoona hapo awali, wacha tuchukue kuwa hatuna viota vingi na uzi unazuiliwa kwa jumbe tatu.

Kwa hivyo, tutaishia na simu mbili hadi sita za huduma za nje kwa kila ujumbe na simu moja ya JPA kwa kila kundi zima la ujumbe. Jumla ya nambari za simu zitatofautiana kutoka 2*N+1 hadi 6*N+1. Je, ni kiasi gani hicho katika vitengo halisi? Wacha tuseme ukurasa unahitaji ujumbe 20 ili kutoa. Kuzirejesha itachukua kati ya sekunde 4 na sekunde 10. Ya kutisha! Tungependa kuiweka chini ya 500 ms. Na kwa kuwa timu ya eneo la mbele ilitaka kufikia usogezaji bila mshono, mahitaji ya utendaji ya sehemu hii ya mwisho yanaweza kuongezeka maradufu.

Faida:

  1. Nambari hiyo ni fupi na inajiandikisha (ndoto ya mtu anayeunga mkono).
  2. Kanuni ni rahisi, kwa hiyo kuna karibu hakuna fursa za kujipiga kwenye mguu.
  3. Uchakataji wa bechi hauonekani kuwa geni na unafaa kikamilifu kwenye mantiki.
  4. Mabadiliko ya mantiki yatakuwa rahisi kufanya na yatakuwa ya ndani.

Ondoa:

Utendaji mbaya kutokana na pakiti kuwa ndogo sana.

Njia hii ni ya kawaida katika huduma rahisi au prototypes. Ikiwa kasi ya mabadiliko ni muhimu, haifai kutatiza mfumo. Hata hivyo, kwa huduma yetu rahisi sana, utendaji ni wa kutisha, hivyo utumiaji wa mbinu hii ni mdogo sana.

Usindikaji sambamba na ujinga

Unaweza kuendesha uchakataji wa ujumbe wote sambamba—hii itaondoa ongezeko la mstari katika muda wa kuchakata kulingana na idadi ya ujumbe. Hii sio mbinu nzuri, kwani itasababisha mzigo mkubwa wa kilele kwenye huduma ya nje.

Utekelezaji wa usindikaji sambamba ni rahisi sana:

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

Kwa kutumia uchakataji wa ujumbe sambamba, tunapata 300-700 ms kwa njia bora, ambayo ni bora zaidi kuliko utekelezaji wa ujinga, lakini bado sio haraka vya kutosha.

Kwa mbinu hii, maombi kwa userRepository na fileRepository yatatekelezwa kwa usawa, ambayo haifai. Ili kurekebisha hili, mantiki ya simu itabidi ibadilishwe kwa kiasi kikubwa. Kwa mfano, kupitia 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()!!

Unaweza kuona kwamba msimbo rahisi wa kuchora ramani umekuwa wazi kidogo. Hii ni kwa sababu tulilazimika kutenganisha simu kwa huduma za nje kutoka mahali ambapo matokeo hutumiwa. Hii yenyewe sio mbaya. Lakini kuchanganya simu kunaonekana chini ya kifahari na inafanana na "noodles" tendaji za kawaida.

Ikiwa unatumia coroutines, kila kitu kitaonekana kuwa cha heshima zaidi:

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

Ambapo:

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

Kinadharia, kwa kutumia usindikaji huo sambamba, tunapata 200-400 ms, ambayo tayari iko karibu na matarajio yetu.

Kwa bahati mbaya, ulinganifu mzuri kama huu haupo, na bei ni kali sana: kwa watumiaji wachache tu wanaofanya kazi kwa wakati mmoja, huduma zitajazwa na maombi ambayo hata hivyo hayatachakatwa sambamba, kwa hivyo tutarejea kwa sekunde 4 zetu za huzuni.

Matokeo yangu kwa kutumia huduma hii ni 1300–1700 ms kwa kuchakata jumbe 20. Hii ni haraka kuliko utekelezaji wa kwanza, lakini bado haisuluhishi shida.

Matumizi mbadala ya maswali sambambaJe, ikiwa huduma za wahusika wengine haziauni uchakataji wa bechi? Kwa mfano, unaweza kuficha ukosefu wa utekelezaji wa usindikaji wa kundi ndani ya njia za kiolesura:

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

Hii inaleta maana ikiwa kuna matumaini ya usindikaji wa bechi kuonekana katika matoleo yajayo.
Faida:

  1. Utekelezaji rahisi wa usindikaji sambamba unaoendeshwa na ujumbe.
  2. Scalability nzuri.

Minus:

  1. Haja ya kutenganisha upataji wa data kutoka kwa usindikaji wake wakati wa usindikaji sambamba wa maombi kwa huduma tofauti.
  2. Kuongezeka kwa mzigo kwenye huduma za watu wengine.

Kwa wazi, upeo wa utumiaji ni takriban sawa na wa mbinu ya ujinga. Kutumia hoja sambamba kunaeleweka ikiwa unataka kuongeza utendaji wa huduma yako mara kadhaa kwa kuwanyonya wengine bila huruma. Katika mfano wetu, utendaji uliongezeka kwa mara 2,5, lakini hii haitoshi.

Kuhifadhi akiba

Unaweza kutekeleza uakibishaji wa mtindo wa JPA kwa huduma za nje, kuhifadhi vitu vilivyorejeshwa ndani ya kipindi ili kuzuia kuvipata tena (pamoja na wakati wa kuchakata bechi). Unaweza kutekeleza akiba kama hizo mwenyewe, tumia Spring na @Cacheable yake, au unaweza kutumia kache iliyotengenezwa tayari kama EhCache mwenyewe.

Shida ya jumla ni kwamba kache ni muhimu tu ikiwa kuna hits. Kwa upande wetu, hits kwenye uwanja wa mwandishi ni uwezekano mkubwa (wacha tuseme 50%), lakini hakutakuwa na hits yoyote kwenye faili. Mbinu hii itatoa maboresho fulani, lakini haitaboresha utendakazi kwa kiasi kikubwa (na tunahitaji mafanikio).

Akiba (ndefu) zinahitaji mantiki changamano ya ubatilifu. Kwa ujumla, kadri unavyosubiri kwa muda mrefu kugeukia kache za makutano ili kutatua matatizo ya utendakazi, ndivyo inavyokuwa bora zaidi.

Faida:

  1. Tekeleza akiba bila kubadilisha msimbo.
  2. Kuongezeka kwa tija mara kadhaa (katika baadhi ya matukio).

Minus:

  1. Uwezekano wa uharibifu wa utendaji ikiwa hautatumiwa kwa usahihi.
  2. Kumbukumbu kubwa ya juu, haswa na kache ndefu.
  3. Ubatilishaji mgumu, makosa ambayo yatasababisha matatizo magumu-kuzalisha tena wakati wa kukimbia.

Akiba mara nyingi hutumiwa kama suluhu ya haraka kwa masuala ya muundo. Hii haimaanishi kuwa hazipaswi kutumiwa. Hata hivyo, inafaa kuwafikia kwa tahadhari na kutathmini matokeo ya utendaji kazi kabla ya kufanya uamuzi.

Katika mfano wetu, kache zitaongeza utendaji wa karibu 25%. Walakini, kache zina mapungufu machache, kwa hivyo nisingetumia hapa.

Matokeo ya

Kwa hivyo, tumeangalia utekelezaji wa ujinga wa huduma ambayo hutumia usindikaji wa bechi, na njia zingine rahisi za kuharakisha.

Faida kuu ya njia hizi zote ni unyenyekevu wao, ambayo ina matokeo mengi ya kupendeza.

Shida ya kawaida na njia hizi ni utendaji duni, haswa kwa sababu ya saizi ya pakiti. Kwa hivyo, ikiwa suluhisho hizi hazifanyi kazi kwako, unapaswa kuzingatia mbinu kali zaidi.

Kuna njia mbili kuu ambazo suluhisho zinaweza kutafutwa:

  • kazi ya asynchronous na data (inahitaji mabadiliko ya dhana, kwa hiyo haijajadiliwa katika makala hii);
  • Upanuzi wa batches wakati wa kudumisha usindikaji wa usawazishaji.

Vikundi vya kubana vitapunguza kwa kiasi kikubwa idadi ya simu za nje huku msimbo ukiendelea kusawazishwa. Mada hii itajadiliwa katika sehemu inayofuata ya kifungu hicho.

Chanzo: mapenzi.com

Nunua upangishaji wa kuaminika wa tovuti zilizo na ulinzi wa DDoS, seva za VPS VDS 🔥 Nunua upangishaji wa tovuti unaoaminika kwa ulinzi wa DDoS, seva za VPS VDS | ProHoster