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 kadhaa. Mara nyingi, muda mrefu wa majibu ya njia za huduma huwa chanzo cha matatizo ya utendaji. Suluhisho la kawaida la aina hii ya shida ni kupakia maombi mengi ya huduma kwenye kifurushi kimoja, kinachoitwa batching.

Ikiwa unatumia usindikaji wa kundi, huenda usifurahie matokeo katika suala la utendakazi au uwazi wa msimbo. Njia hii sio rahisi kwa mpiga simu kama unavyoweza kufikiria. Kwa madhumuni tofauti na katika hali tofauti, ufumbuzi unaweza kutofautiana sana. Kwa kutumia mifano maalum, nitaonyesha faida na hasara za mbinu kadhaa.

Mradi wa maonyesho

Kwa uwazi, hebu tuangalie mfano wa moja ya huduma katika programu ninayofanyia kazi kwa sasa.

Ufafanuzi wa uteuzi wa jukwaa kwa mifanoShida ya utendaji duni ni ya jumla kabisa na haiathiri lugha au majukwaa yoyote maalum. Nakala hii itatumia mifano ya nambari ya Spring + Kotlin ili kuonyesha shida na suluhisho. Kotlin inaeleweka kwa usawa (au haielewiki) kwa watengenezaji wa Java na C #, kwa kuongeza, kanuni ni ngumu zaidi na inaeleweka kuliko Java. Ili iwe rahisi kuelewa kwa watengenezaji wa Java safi, nitaepuka uchawi mweusi wa Kotlin na kutumia tu uchawi nyeupe (katika roho ya Lombok). Kutakuwa na njia chache za upanuzi, lakini kwa kweli zinajulikana kwa watengeneza programu wote wa Java kama njia tuli, kwa hivyo hii itakuwa sukari ndogo ambayo haitaharibu ladha ya sahani.
Kuna huduma ya idhini ya hati. Mtu huunda hati na kuiwasilisha kwa majadiliano, wakati ambapo uhariri hufanywa, na hatimaye hati hiyo inakubaliwa. Huduma ya idhini yenyewe haijui chochote kuhusu hati: ni gumzo la waidhinishaji tu na vitendaji vidogo vya ziada ambavyo hatutazingatia hapa.

Kwa hivyo, kuna vyumba vya mazungumzo (sambamba na hati) na seti iliyoainishwa ya washiriki katika kila moja yao. 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 vikoa vingine. Hapa tunaishi kama hii:

typealias FileReference Long
typealias UserReference Long

Data ya mtumiaji huhifadhiwa katika Keycloak na kurejeshwa kupitia REST. Vile vile huenda kwa faili: faili na maelezo ya habari juu yao huishi katika huduma tofauti ya kuhifadhi faili.

Simu zote kwa huduma hizi ni maombi mazito. Hii ina maana kwamba gharama ya juu ya kusafirisha maombi haya ni kubwa zaidi kuliko muda inachukua ili kuchakatwa na huduma ya mtu mwingine. Kwenye benchi zetu za majaribio, muda wa kawaida wa kupiga simu kwa huduma kama hizo ni ms 100, kwa hivyo tutatumia nambari hizi katika siku zijazo.

Tunahitaji kutengeneza kidhibiti rahisi cha REST ili kupokea ujumbe wa N wa mwisho wenye taarifa zote muhimu. Hiyo ni, tunaamini kwamba muundo wa ujumbe katika sehemu ya mbele ni karibu sawa na data yote inahitaji kutumwa. Tofauti kati ya mfano wa mwisho ni kwamba faili na mtumiaji zinahitaji kuwasilishwa kwa fomu iliyosimbwa kidogo ili kuzifanya 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 inamaanisha miundo ya DTO ya sehemu ya mbele, yaani, tunachopaswa kutumikia kupitia REST.

Kinachoweza kushangaza hapa ni kwamba hatupitishi kitambulisho chochote cha gumzo na hata kielelezo cha ChatMessage/ChatMessageUI hakina. Nilifanya hivi kwa makusudi ili nisichanganye nambari za mifano (soga zimetengwa, kwa hivyo tunaweza kudhani kuwa tunayo moja tu).

Mchepuko wa kifalsafaDarasa la ChatMessageUI na mbinu ya ChatRestApi.getLast hutumia aina ya data ya Orodha wakati kwa kweli ni Seti iliyoagizwa. Hii ni mbaya katika JDK, kwa hivyo kutangaza mpangilio wa vitu kwenye kiwango cha kiolesura (kuhifadhi mpangilio wakati wa kuongeza na kuondoa) haitafanya kazi. Kwa hivyo imekuwa kawaida kutumia Orodha katika hali ambapo Seti iliyoagizwa inahitajika (pia kuna LinkedHashSet, lakini hii sio kiolesura).
Kizuizi muhimu: Tutachukulia kuwa hakuna misururu mirefu ya majibu au uhamishaji. Hiyo ni, zipo, lakini urefu wao hauzidi ujumbe tatu. Msururu mzima wa ujumbe lazima usambazwe kwenye sehemu ya mbele.

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

Inaweza kuonekana kuwa huduma za nje hutoa awali kwa usindikaji wa kundi, na katika matoleo yote mawili: kwa njia ya Kuweka (bila kuhifadhi utaratibu wa vipengele, na funguo za kipekee) na kupitia Orodha (kunaweza kuwa na nakala - utaratibu umehifadhiwa).

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 tija yetu?

Kwa kila ujumbe, simu moja kwa UserRemoteApi itapigwa ili kupata data kwenye uwanja wa mwandishi na simu moja kwa FileRemoteApi ili kupata faili zote zilizoambatishwa. Inaonekana ndivyo hivyo. Hebu tuseme kwamba sehemu za forwardFrom na replyTo za ChatMessage zinapatikana kwa njia ambayo hii haihitaji simu zisizo za lazima. Lakini kuzigeuza kuwa ChatMessageUI kutasababisha kujirudia, yaani, vihesabu simu vinaweza kuongezeka kwa kiasi kikubwa. Kama tulivyoona hapo awali, hebu tuchukulie kuwa hatuna viota vingi na mlolongo unazuiwa kwa jumbe tatu.

Kwa hivyo, tutapata kutoka simu mbili hadi sita hadi huduma za nje kwa kila ujumbe na simu moja ya JPA kwa kifurushi kizima cha ujumbe. Jumla ya nambari za simu zitatofautiana kutoka 2*N+1 hadi 6*N+1. Je, hii ni kiasi gani katika vitengo halisi? Hebu tuseme inachukua ujumbe 20 kutoa ukurasa. Ili kuwapokea, itachukua kutoka 4 hadi 10 s. Ya kutisha! Ningependa kuiweka ndani ya 500 ms. Na kwa kuwa walikuwa na ndoto ya kufanya usogezaji usio na mshono katika sehemu ya mbele, mahitaji ya utendaji ya sehemu hii ya mwisho yanaweza kuongezeka maradufu.

Faida:

  1. Kanuni ni fupi na inajiandikisha (ndoto ya timu ya usaidizi).
  2. Kanuni ni rahisi, kwa hiyo kuna karibu hakuna fursa za kujipiga kwenye mguu.
  3. Uchakataji wa bechi hauonekani kama kitu kigeni na umeunganishwa kikaboni kwenye mantiki.
  4. Mabadiliko ya mantiki yatafanywa kwa urahisi na yatakuwa ya ndani.

Ondoa:

Utendaji wa kutisha kutokana na pakiti ndogo sana.

Njia hii inaweza kuonekana mara nyingi katika huduma rahisi au katika prototypes. Ikiwa kasi ya kufanya mabadiliko ni muhimu, haifai kutatiza mfumo. Wakati huo huo, kwa huduma yetu rahisi sana utendaji ni wa kutisha, hivyo upeo wa matumizi ya njia hii ni nyembamba sana.

Usindikaji sambamba na ujinga

Unaweza kuanza kusindika ujumbe wote kwa sambamba - hii itawawezesha kuondokana na ongezeko la mstari kwa wakati kulingana na idadi ya ujumbe. Hii sio njia nzuri haswa kwa sababu 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 sawia, tunapata 300-700 ms kwa kufaa, ambayo ni bora zaidi kuliko kwa utekelezaji wa ujinga, lakini bado sio haraka vya kutosha.

Kwa mbinu hii, maombi kwa userRepository na fileRepository yatatekelezwa kwa usawa, ambayo sio ufanisi sana. Ili kurekebisha hii, itabidi ubadilishe mantiki ya simu sana. 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()!!

Inaweza kuonekana kuwa msimbo rahisi wa kuchora ramani umekuwa haueleweki. Hii ni kwa sababu tulilazimika kutenganisha simu kwa huduma za nje kutoka mahali ambapo matokeo hutumiwa. Hii yenyewe sio mbaya. Lakini kuchanganya simu haionekani kifahari sana na inafanana na "tambi" ya kawaida inayofanya kazi.

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, tutapata 200-400 ms, ambayo tayari iko karibu na matarajio yetu.

Kwa bahati mbaya, ulinganifu mzuri kama huo haupo, na bei ya kulipa ni ya kikatili kabisa: na watumiaji wachache tu wanaofanya kazi wakati huo huo, maombi mengi yataanguka kwenye huduma, ambazo hazitashughulikiwa kwa usawa, kwa hivyo sisi. itarudi kwa huzuni 4 s.

Matokeo yangu wakati wa kutumia huduma kama hiyo ni 1300-1700 ms kwa kuchakata ujumbe 20. Hii ni kasi zaidi kuliko katika utekelezaji wa kwanza, lakini bado haina kutatua tatizo.

Matumizi mbadala ya hoja sambambaJe, ikiwa huduma za wahusika wengine hazitoi usindikaji wa kundi? 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 inaeleweka ikiwa unatarajia kuona usindikaji wa bechi katika matoleo yajayo.
Faida:

  1. Tekeleza kwa urahisi uchakataji sawia unaotegemea ujumbe.
  2. Scalability nzuri.

Minus:

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

Inaweza kuonekana kuwa upeo wa utumiaji ni takriban sawa na ule wa mbinu ya kutojua. Inaleta akili kutumia mbinu ya ombi sambamba ikiwa unataka kuongeza utendaji wa huduma yako mara kadhaa kutokana na unyonyaji usio na huruma wa wengine. Katika mfano wetu, utendaji uliongezeka kwa mara 2,5, lakini hii haitoshi.

Kuhifadhi akiba

Unaweza kufanya caching katika roho ya JPA kwa huduma za nje, yaani, kuhifadhi vitu vilivyopokelewa ndani ya kikao ili usipokee tena (ikiwa ni pamoja na wakati wa usindikaji wa kundi). Unaweza kutengeneza akiba kama hizo mwenyewe, unaweza kutumia Spring na @Cacheable yake, na unaweza kutumia akiba iliyotengenezwa tayari kila wakati kama EhCache mwenyewe.

Shida ya kawaida itakuwa kwamba kache zinafaa tu ikiwa zina vibao. Kwa upande wetu, hits kwenye uwanja wa mwandishi ni uwezekano mkubwa (hebu tuseme, 50%), lakini hakutakuwa na hits kwenye faili kabisa. Mbinu hii itatoa maboresho kadhaa, lakini haitabadilisha utendakazi kwa kiasi kikubwa (na tunahitaji mafanikio).

Akiba (ndefu) zinahitaji mantiki changamano ya ubatilifu. Kwa ujumla, kadri unavyoshuka baadaye kutatua matatizo ya utendaji kwa kutumia kache za intersession, ni bora zaidi.

Faida:

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

Minus:

  1. Uwezekano wa kupunguzwa kwa utendaji ikiwa unatumiwa vibaya.
  2. Kumbukumbu kubwa ya juu, haswa na kache ndefu.
  3. Ubatilishaji mgumu, makosa ambayo yatasababisha shida ngumu kuzaliana wakati wa kukimbia.

Mara nyingi, cache hutumiwa tu kurekebisha haraka shida za muundo. Hii haimaanishi kuwa hazipaswi kutumiwa. Walakini, unapaswa kuwatendea kila wakati kwa uangalifu na kwanza utathmini faida ya utendaji unaopatikana, na kisha tu kufanya uamuzi.

Katika mfano wetu, akiba itatoa ongezeko la utendaji la karibu 25%. Wakati huo huo, kache zina shida nyingi, kwa hivyo singezitumia hapa.

Matokeo ya

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

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

Tatizo la kawaida kwa njia hizi ni utendaji mbaya, hasa kutokana na ukubwa wa pakiti. Kwa hivyo, ikiwa suluhisho hizi hazikufaa, basi inafaa kuzingatia njia kali zaidi.

Kuna njia mbili kuu ambazo unaweza kutafuta suluhisho:

  • kazi ya asynchronous na data (inahitaji mabadiliko ya dhana, kwa hivyo haijajadiliwa katika nakala hii);
  • upanuzi wa batches wakati wa kudumisha usindikaji synchronous.

Kuongezeka kwa batches kutapunguza sana idadi ya simu za nje na wakati huo huo kuweka msimbo sawa. Sehemu inayofuata ya kifungu hicho itajitolea kwa mada hii.

Chanzo: mapenzi.com

Kuongeza maoni