Pakešu vaicājumu apstrādes problēmas un to risinājumi (1. daļa)

Pakešu vaicājumu apstrādes problēmas un to risinājumi (1. daļa)Gandrīz visi mūsdienu programmatūras produkti sastāv no vairākiem pakalpojumiem. Bieži vien starpdienestu kanālu ilgs reakcijas laiks kļūst par veiktspējas problēmu avotu. Standarta risinājums šāda veida problēmai ir vairāku starppakalpojumu pieprasījumu iesaiņošana vienā pakotnē, ko sauc par pakešu komplektēšanu.

Ja izmantojat pakešu apstrādi, iespējams, neesat apmierināts ar rezultātiem veiktspējas vai koda skaidrības ziņā. Šī metode zvanītājam nav tik vienkārša, kā jūs varētu domāt. Dažādiem mērķiem un dažādās situācijās risinājumi var ievērojami atšķirties. Izmantojot konkrētus piemērus, es parādīšu vairāku pieeju plusus un mīnusus.

Demonstrācijas projekts

Skaidrības labad apskatīsim piemēru vienam no pakalpojumiem lietojumprogrammā, pie kuras es pašlaik strādāju.

Platformas izvēles skaidrojums piemēriemSliktas veiktspējas problēma ir diezgan vispārīga un neietekmē konkrētas valodas vai platformas. Šajā rakstā tiks izmantoti Spring + Kotlin koda piemēri, lai parādītu problēmas un risinājumus. Kotlins ir vienlīdz saprotams (vai nesaprotams) Java un C# izstrādātājiem, turklāt kods ir kompaktāks un saprotamāks nekā Java. Lai būtu vieglāk saprotams tīriem Java izstrādātājiem, es izvairīšos no Kotlinas melnās maģijas un izmantošu tikai balto maģiju (Lombok garā). Būs dažas pagarināšanas metodes, taču tās faktiski ir pazīstamas visiem Java programmētājiem kā statiskas metodes, tāpēc šis būs mazs cukurs, kas nesabojās ēdiena garšu.
Ir dokumentu apstiprināšanas dienests. Kāds izveido dokumentu un nodod to apspriešanai, kuras laikā tiek veikti labojumi un galu galā dokuments tiek saskaņots. Pats apstiprināšanas dienests neko nezina par dokumentiem: tā ir tikai apstiprinātāju tērzēšana ar nelielām papildu funkcijām, kuras mēs šeit neapskatīsim.

Tātad ir tērzēšanas istabas (kas atbilst dokumentiem) ar iepriekš noteiktu dalībnieku kopu katrā no tām. Tāpat kā parastajās tērzēšanas sarunās, ziņojumos ir teksts un faili, un tie var būt atbildes vai pārsūtīšanas:

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
)

Failu un lietotāju saites ir saites uz citiem domēniem. Šeit mēs dzīvojam šādi:

typealias FileReference Long
typealias UserReference Long

Lietotāja dati tiek glabāti Keycloak un izgūti, izmantojot REST. Tas pats attiecas uz failiem: faili un metainformācija par tiem atrodas atsevišķā failu krātuves pakalpojumā.

Visi zvani uz šiem pakalpojumiem ir smagi lūgumi. Tas nozīmē, ka šo pieprasījumu pārsūtīšanas izmaksas ir daudz lielākas nekā laiks, kas nepieciešams, lai tos apstrādātu trešās puses pakalpojums. Mūsu izmēģinājumu stendos parastais zvanu laiks šādiem pakalpojumiem ir 100 ms, tāpēc turpmāk izmantosim šos numurus.

Mums ir jāizveido vienkāršs REST kontrolieris, lai saņemtu pēdējos N ziņojumus ar visu nepieciešamo informāciju. Tas ir, mēs uzskatām, ka ziņojuma modelis priekšgalā ir gandrīz vienāds un ir jānosūta visi dati. Atšķirība starp priekšgala modeli ir tāda, ka fails un lietotājs ir jāuzrāda nedaudz atšifrētā veidā, lai tie būtu saistīti:

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

Mums ir jāīsteno šādas darbības:

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

UI postfix nozīmē DTO modeļus priekšgalam, tas ir, to, ko mums vajadzētu apkalpot, izmantojot REST.

Šeit var būt pārsteidzoši tas, ka mēs nenododam nevienu tērzēšanas ID, un pat ChatMessage/ChatMessageUI modelim tāda nav. Es to darīju ar nolūku, lai nepārblīvētu piemēru kodu (tērzēšana ir izolēta, tāpēc varam pieņemt, ka mums ir tikai viens).

Filozofiska atkāpeGan ChatMessageUI klasei, gan ChatRestApi.getLast metodei tiek izmantots datu tips List, lai gan patiesībā tā ir sakārtota kopa. Tas ir slikti JDK, tāpēc elementu secības deklarēšana saskarnes līmenī (saglabājot secību pievienojot un noņemot) nedarbosies. Tāpēc par ierastu praksi ir kļuvusi List izmantošana gadījumos, kad nepieciešama pasūtīta Set (ir arī LinkedHashSet, bet tas nav interfeiss).
Svarīgs ierobežojums: Mēs pieņemsim, ka nav garu atbilžu vai pārsūtīšanas ķēžu. Tas ir, tie pastāv, bet to garums nepārsniedz trīs ziņojumus. Visa ziņojumu ķēde ir jāpārsūta uz priekšgalu.

Lai saņemtu datus no ārējiem pakalpojumiem, ir šādas API:

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

Redzams, ka ārējie pakalpojumi sākotnēji nodrošina pakešu apstrādi, turklāt abās versijās: caur Set (nesaglabājot elementu secību, ar unikālām atslēgām) un caur List (var būt dublikāti - secība tiek saglabāta).

Vienkāršas ieviešanas

Naiva īstenošana

Pirmā mūsu REST kontrollera naivā ieviešana vairumā gadījumu izskatīsies šādi:

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

Viss ir ļoti skaidrs, un tas ir liels pluss.

Mēs izmantojam pakešu apstrādi un saņemam datus no ārējā pakalpojuma pakešu veidā. Bet kas notiek ar mūsu produktivitāti?

Katram ziņojumam tiks veikts viens zvans uz UserRemoteApi, lai iegūtu datus par autora lauku, un viens zvans uz FileRemoteApi, lai iegūtu visus pievienotos failus. Šķiet, ka tas tā ir. Pieņemsim, ka ChatMessage lauki forwardFrom un replyTo ir iegūti tā, lai tas neprasa liekus zvanus. Bet to pārvēršana par ChatMessageUI izraisīs rekursiju, tas ir, zvanu skaitītāji var ievērojami palielināties. Kā jau minēts iepriekš, pieņemsim, ka mums nav daudz ligzdošanas un ķēde ir ierobežota līdz trim ziņojumiem.

Rezultātā mēs saņemsim no diviem līdz sešiem zvaniem ārējiem dienestiem uz vienu ziņojumu un vienu JPA zvanu visai ziņojumu paketei. Kopējais zvanu skaits mainīsies no 2*N+1 līdz 6*N+1. Cik tas ir reālajās vienībās? Pieņemsim, ka lapas renderēšanai ir nepieciešami 20 ziņojumi. Lai tos saņemtu, būs nepieciešams no 4 s līdz 10 s. Briesmīgi! Es vēlētos to saglabāt 500 ms robežās. Un, tā kā viņi sapņoja par netraucētu ritināšanu priekšgalā, veiktspējas prasības šim galapunktam var dubultot.

Plusi:

  1. Kods ir kodolīgs un pašdokumentējošs (atbalsta komandas sapnis).
  2. Kods ir vienkāršs, tāpēc iespējas iešaut sev kājā tikpat kā nav.
  3. Pakešu apstrāde neizskatās pēc kaut kā sveša un ir organiski integrēta loģikā.
  4. Loģiskas izmaiņas tiks veiktas viegli un būs lokālas.

Mīnus:

Šausmīgs sniegums ļoti mazo paciņu dēļ.

Šo pieeju diezgan bieži var redzēt vienkāršos pakalpojumos vai prototipos. Ja svarīgs ir izmaiņu veikšanas ātrums, diez vai ir vērts sistēmu sarežģīt. Tajā pašā laikā mūsu ļoti vienkāršajam pakalpojumam veiktspēja ir šausmīga, tāpēc šīs pieejas piemērošanas joma ir ļoti šaura.

Naiva paralēlā apstrāde

Visu ziņojumu apstrādi var sākt paralēli – tas ļaus atbrīvoties no lineārā laika pieauguma atkarībā no ziņojumu skaita. Tas nav īpaši labs ceļš, jo tas radīs lielu maksimālo slodzi ārējam pakalpojumam.

Paralēlās apstrādes ieviešana ir ļoti vienkārša:

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

Izmantojot paralēlo ziņojumu apstrādi, ideālā gadījumā mēs iegūstam 300–700 ms, kas ir daudz labāk nekā ar naivu ieviešanu, taču joprojām nav pietiekami ātri.

Izmantojot šo pieeju, pieprasījumi userRepository un fileRepository tiks izpildīti sinhroni, kas nav īpaši efektīvi. Lai to labotu, jums būs diezgan daudz jāmaina zvanu loģika. Piemēram, izmantojot CompletionStage (pazīstams arī kā 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()!!

Var redzēt, ka sākotnēji vienkāršais kartēšanas kods ir kļuvis mazāk saprotams. Tas ir tāpēc, ka mums bija jānodala zvani uz ārējiem pakalpojumiem no tiem, kur tiek izmantoti rezultāti. Tas pats par sevi nav slikti. Taču zvanu apvienošana neizskatās īpaši eleganti un atgādina tipisku reaktīvo “nūdeli”.

Ja izmantojat korutīnas, viss izskatīsies pieklājīgāk:

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

Kur:

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

Teorētiski, izmantojot šādu paralēlo apstrādi, mēs iegūsim 200–400 ms, kas jau ir tuvu mūsu cerībām.

Diemžēl tik labas paralēles neeksistē, un cena, kas jāmaksā, ir diezgan nežēlīga: vienlaikus strādājot tikai dažiem lietotājiem, uz pakalpojumiem kritīs pieprasījumu bars, kas tik un tā netiks apstrādāti paralēli, tāpēc mēs atgriezīsies mūsu skumjās 4 s.

Mans rezultāts, izmantojot šādu pakalpojumu, ir 1300–1700 ms 20 ziņojumu apstrādei. Tas ir ātrāk nekā pirmajā ieviešanā, taču joprojām neatrisina problēmu.

Paralēlo vaicājumu alternatīvas izmantošanas iespējasKo darīt, ja trešās puses pakalpojumi nenodrošina pakešu apstrādi? Piemēram, interfeisa metožu ietvaros varat paslēpt pakešu apstrādes ieviešanas trūkumu:

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

Tam ir jēga, ja cerat, ka nākamajās versijās tiks veikta pakešu apstrāde.
Plusi:

  1. Ērti ieviesiet uz ziņojumiem balstītu paralēlo apstrādi.
  2. Laba mērogojamība.

Mīnusi:

  1. Nepieciešamība nodalīt datu iegūšanu no to apstrādes, paralēli apstrādājot pieprasījumus dažādiem pakalpojumiem.
  2. Palielināta slodze uz trešo pušu pakalpojumiem.

Redzams, ka pielietojamības joma ir aptuveni tāda pati kā naivai pieejai. Ir lietderīgi izmantot paralēlo pieprasījumu metodi, ja vēlaties vairākas reizes palielināt sava pakalpojuma veiktspēju citu nežēlīgas ekspluatācijas dēļ. Mūsu piemērā veiktspēja palielinājās 2,5 reizes, taču ar to acīmredzami nepietiek.

kešatmiņa

Varat veikt kešatmiņu JPA garā ārējiem pakalpojumiem, tas ir, saglabāt saņemtos objektus sesijas laikā, lai tos vairs nesaņemtu (tostarp pakešu apstrādes laikā). Jūs varat izveidot šādas kešatmiņas pats, varat izmantot Spring ar tā @Cacheable, kā arī vienmēr varat manuāli izmantot gatavu kešatmiņu, piemēram, EhCache.

Izplatīta problēma ir tāda, ka kešatmiņas ir noderīgas tikai tad, ja tajās ir trāpījumi. Mūsu gadījumā trāpījumi autora laukā ir ļoti iespējami (teiksim, 50%), taču failos trāpījumu nebūs vispār. Šī pieeja nodrošinās dažus uzlabojumus, taču tā radikāli nemainīs veiktspēju (un mums ir nepieciešams izrāviens).

Starpsesiju (garajām) kešatmiņām ir nepieciešama sarežģīta nederīguma loģika. Kopumā, jo vēlāk jūs risināsiet veiktspējas problēmas, izmantojot starpsesiju kešatmiņas, jo labāk.

Plusi:

  1. Ieviesiet kešatmiņu, nemainot kodu.
  2. Vairākas reizes palielināta produktivitāte (dažos gadījumos).

Mīnusi:

  1. Iespēja samazināt veiktspēju, ja to lieto nepareizi.
  2. Liela atmiņa, īpaši ar lielu kešatmiņu.
  3. Sarežģīta nederība, kuras kļūdas radīs grūti reproducējamas problēmas izpildlaikā.

Ļoti bieži kešatmiņas tiek izmantotas tikai, lai ātri novērstu dizaina problēmas. Tas nenozīmē, ka tos nevajadzētu lietot. Tomēr vienmēr pret tiem jāizturas piesardzīgi un vispirms jānovērtē veiktspējas pieaugums un tikai tad jāpieņem lēmums.

Mūsu piemērā kešatmiņas nodrošinās veiktspējas pieaugumu par aptuveni 25%. Tajā pašā laikā kešatmiņām ir diezgan daudz trūkumu, tāpēc es tos šeit neizmantotu.

Rezultāti

Tātad, mēs apskatījām naivu pakalpojuma ieviešanu, kas izmanto pakešu apstrādi, un dažus vienkāršus veidus, kā to paātrināt.

Visu šo metožu galvenā priekšrocība ir vienkāršība, no kuras ir daudz patīkamu seku.

Šo metožu izplatīta problēma ir slikta veiktspēja, galvenokārt pakešu lieluma dēļ. Tāpēc, ja šie risinājumi jums nav piemēroti, ir vērts apsvērt radikālākas metodes.

Ir divi galvenie virzieni, kuros varat meklēt risinājumus:

  • asinhrons darbs ar datiem (nepieciešama paradigmas maiņa, tāpēc šajā rakstā tas nav apskatīts);
  • partiju palielināšana, saglabājot sinhrono apstrādi.

Pakešu palielināšana ievērojami samazinās ārējo zvanu skaitu un tajā pašā laikā saglabās kodu sinhronu. Nākamā raksta daļa būs veltīta šai tēmai.

Avots: www.habr.com

Pievieno komentāru