Vandamál við lotufyrirspurnavinnslu og lausnir þeirra (hluti 1)

Vandamál við lotufyrirspurnavinnslu og lausnir þeirra (hluti 1)Næstum allar nútíma hugbúnaðarvörur samanstanda af nokkrum þjónustum. Oft verður langur viðbragðstími milliþjónusturása uppspretta frammistöðuvandamála. Staðlaða lausnin á vandamálum af þessu tagi er að pakka mörgum beiðnum milli þjónustu í einn pakka, sem kallast hópur.

Ef þú notar lotuvinnslu gætirðu verið ekki ánægður með niðurstöðurnar hvað varðar frammistöðu eða skýrleika kóðans. Þessi aðferð er ekki eins auðveld fyrir þann sem hringir og þú gætir haldið. Í mismunandi tilgangi og við mismunandi aðstæður geta lausnir verið mjög mismunandi. Með sérstökum dæmum mun ég sýna kosti og galla nokkurra aðferða.

Sýningarverkefni

Til glöggvunar skulum við skoða dæmi um eina af þjónustunum í forritinu sem ég er að vinna að núna.

Útskýring á vali á palli fyrir dæmiVandamálið við lélega frammistöðu er frekar almennt og varðar ekki nein sérstök tungumál eða vettvang. Þessi grein mun nota Spring + Kotlin kóða dæmi til að sýna fram á vandamál og lausnir. Kotlin er jafn skiljanlegt (eða óskiljanlegt) fyrir Java og C# forritara; auk þess er kóðinn fyrirferðarmeiri og skiljanlegri en í Java. Til að gera hlutina auðveldari að skilja fyrir hreina Java forritara mun ég forðast svartagaldur Kotlin og aðeins nota hvíta galdra (í anda Lombok). Það verða nokkrar framlengingaraðferðir, en þær þekkja reyndar allir Java forritarar sem kyrrstöðuaðferðir, þannig að þetta verður lítill sykur sem eyðileggur ekki bragðið af réttinum.
Það er skjalasamþykktarþjónusta. Einhver býr til skjal og sendir það til umræðu, þar sem breytingar eru gerðar og að lokum er samið um skjalið. Samþykkisþjónustan sjálf veit ekkert um skjöl: þetta er bara spjall samþykkjenda með litlum viðbótaraðgerðum sem við munum ekki íhuga hér.

Svo, það eru spjallrásir (sem samsvarar skjölum) með fyrirfram skilgreindu hópi þátttakenda í hverju þeirra. Eins og í venjulegu spjalli innihalda skilaboð texta og skrár og geta verið svör eða send:

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
)

Skrá- og notendatenglar eru tenglar á önnur lén. Hér búum við svona:

typealias FileReference Long
typealias UserReference Long

Notendagögn eru geymd í Keycloak og móttekin með REST. Sama gildir um skrár: skrár og metaupplýsingar um þær eru í sérstakri skráageymsluþjónustu.

Öll símtöl í þessa þjónustu eru þungar beiðnir. Þetta þýðir að kostnaður við að flytja þessar beiðnir er mun meiri en tíminn sem það tekur að afgreiða þær af þriðja aðila þjónustu. Á prófunarbekkunum okkar er venjulegur símtalstími fyrir slíka þjónustu 100 ms, þannig að við munum nota þessi númer í framtíðinni.

Við þurfum að búa til einfaldan REST stjórnanda til að fá síðustu N skilaboðin með öllum nauðsynlegum upplýsingum. Það er, við teljum að í framendanum sé skilaboðalíkanið nánast það sama og öll gögn þurfi að senda. Munurinn á framenda líkaninu er að skráin og notandinn þurfa að vera kynnt á örlítið afkóðuðu formi til að gera þá tengla:

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

Við þurfum að innleiða eftirfarandi:

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

Postfix UI þýðir DTO módel fyrir framenda, það er það sem við ættum að þjóna í gegnum REST.

Það sem gæti komið á óvart hér er að við sendum ekki nein spjallauðkenni og jafnvel í ChatMessage/ChatMessageUI líkaninu er enginn. Ég gerði þetta viljandi til að rugla ekki kóða dæmanna (spjallin eru einangruð, svo við getum gert ráð fyrir að við höfum aðeins eitt).

Heimspekileg útrásBæði ChatMessageUI flokkurinn og ChatRestApi.getLast aðferðin nota gagnategundina List, þegar það er í raun skipað sett. Í JDK er þetta allt slæmt, svo að lýsa yfir röð þátta á viðmótsstigi (að varðveita röðina þegar bætt er við og sótt) mun ekki virka. Þannig að það hefur orðið algeng venja að nota List í þeim tilvikum þar sem pantað Set er þörf (það er líka LinkedHashSet, en þetta er ekki viðmót).
Mikilvæg takmörkun: Við munum gera ráð fyrir að það séu engar langar keðjur af svörum eða millifærslum. Það er að segja, þau eru til, en lengd þeirra fer ekki yfir þrjú skilaboð. Öll skilaboðakeðjan verður að senda til framenda.

Til að taka á móti gögnum frá ytri þjónustu eru eftirfarandi 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>
}

Það má sjá að ytri þjónusta veitir upphaflega lotuvinnslu, og í báðum afbrigðum: í gegnum Set (án þess að varðveita röð þátta, með einstökum lyklum) og í gegnum List (það getur verið afrit - röðin er varðveitt).

Einfaldar útfærslur

Barnlaus útfærsla

Fyrsta barnalega útfærslan á REST stjórnanda okkar mun líta eitthvað svona út í flestum tilfellum:

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

Allt er mjög skýrt og þetta er stór plús.

Við notum lotuvinnslu og tökum á móti gögnum frá utanaðkomandi þjónustu í lotum. En hvað er að gerast með framleiðni okkar?

Fyrir hvert skeyti verður hringt í UserRemoteApi til að fá gögn um höfundareitinn og eitt símtal í FileRemoteApi til að fá allar viðhengdar skrár. Það virðist vera það. Segjum að forwardFrom og replyTo reitirnir fyrir ChatMessage séu fengnir á þann hátt að það þurfi ekki óþarfa símtöl. En að breyta þeim í ChatMessageUI mun leiða til endurtekningar, það er að hringjateljararnir geta aukist verulega. Eins og við tókum fram áðan, gerum ráð fyrir að við höfum ekki mikið hreiður og keðjan er takmörkuð við þrjú skilaboð.

Fyrir vikið fáum við frá tveimur til sex símtölum í utanaðkomandi þjónustu í hvert skeyti og eitt JPA símtal fyrir allan skilaboðapakkann. Heildarfjöldi símtala er breytilegur frá 2*N+1 til 6*N+1. Hversu mikið er þetta í raunverulegum einingum? Segjum að það þurfi 20 skilaboð til að birta síðu. Til að fá þá þarftu frá 4 s til 10 s. Hræðilegt! Ég vil halda því innan 500ms. Og þar sem þeir dreymdi um að gera óaðfinnanlega flun á framendanum, er hægt að tvöfalda frammistöðukröfur fyrir þennan endapunkt.

Kostir:

  1. Kóðinn er hnitmiðaður og skjalfestir sjálfan sig (draumur stuðningsteymis).
  2. Kóðinn er einfaldur og því eru nánast engin tækifæri til að skjóta sig í fótinn.
  3. Lotuvinnsla lítur ekki út eins og eitthvað framandi og er lífrænt samþætt rökfræðinni.
  4. Rökfræðilegar breytingar verða auðvelt að gera og verða staðbundnar.

Mínus:

Hræðileg frammistaða vegna mjög lítilla pakka.

Þessa nálgun má sjá nokkuð oft í einföldum þjónustum eða í frumgerðum. Ef hraði breytinga er mikilvægur er varla þess virði að flækja kerfið. Á sama tíma, fyrir mjög einfalda þjónustu okkar, er árangurinn hræðilegur, þannig að gildissvið þessarar aðferðar er mjög þröngt.

Barnlaus samhliða vinnsla

Þú getur byrjað að vinna öll skilaboð samhliða - þetta gerir þér kleift að losna við línulega aukningu í tíma eftir fjölda skilaboða. Þetta er ekki sérlega góð leið því það mun hafa í för með sér mikið álag á ytri þjónustu.

Það er mjög einfalt að útfæra samhliða vinnslu:

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

Með því að nota samhliða skilaboðavinnslu fáum við 300–700 ms helst, sem er miklu betra en með barnalegri útfærslu, en samt ekki nógu hratt.

Með þessari nálgun verða beiðnir til userRepository og fileRepository keyrðar samstillt, sem er ekki mjög skilvirkt. Til að laga þetta þarftu að breyta símtalafræðinni töluvert. Til dæmis, í gegnum 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()!!

Það má sjá að upphaflega einfaldi kortlagningarkóðinn er orðinn óskiljanlegri. Þetta er vegna þess að við þurftum að aðgreina símtöl í utanaðkomandi þjónustu þar sem niðurstöðurnar eru notaðar. Þetta er í sjálfu sér ekki slæmt. En að sameina símtöl lítur ekki sérstaklega glæsileg út og líkist dæmigerðri hvarfgjarnri „núðlu“.

Ef þú notar coroutines mun allt líta almennilega út:

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

Hvar:

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

Fræðilega séð, með því að nota slíka samhliða vinnslu, munum við fá 200–400 ms, sem er nú þegar nálægt væntingum okkar.

Því miður, svo góð samhliða samsvörun gerist ekki, og verðið sem þarf að borga er frekar grimmt: þar sem aðeins fáir notendur vinna á sama tíma mun þjónustan verða fyrir barðinu á flæði beiðna sem verður ekki afgreitt samhliða hvort sem er, svo við mun snúa aftur til okkar sorglegu 4s.

Niðurstaðan mín við notkun slíkrar þjónustu er 1300–1700 ms fyrir vinnslu 20 skilaboða. Þetta er hraðari en í fyrstu útfærslu, en leysir samt ekki vandann.

Önnur notkun samhliða fyrirspurnaHvað ef þjónusta þriðja aðila veitir ekki lotuvinnslu? Til dæmis geturðu falið skort á útfærslu runuvinnslu innan viðmótsaðferða:

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

Þetta er skynsamlegt ef þú vonast til að sjá lotuvinnslu í framtíðarútgáfum.
Kostir:

  1. Innleiða á auðveldan hátt samhliða vinnslu sem byggir á skilaboðum.
  2. Góð sveigjanleiki.

Gallar:

  1. Þörfin á að aðgreina gagnaöflun frá vinnslu þeirra þegar unnið er samhliða beiðnum til mismunandi þjónustu.
  2. Aukið álag á þjónustu þriðja aðila.

Það má sjá að gildissviðið er um það bil það sama og barnalegra nálgunar. Það er skynsamlegt að nota samhliða beiðniaðferð ef þú vilt auka afköst þjónustunnar nokkrum sinnum vegna miskunnarlausrar misnotkunar annarra. Í okkar dæmi jókst framleiðni um 2,5 sinnum, en það er greinilega ekki nóg.

skyndiminni

Þú getur gert skyndiminni í anda JPA fyrir utanaðkomandi þjónustu, það er að geyma móttekna hluti innan lotu til að fá þá ekki aftur (þar á meðal við lotuvinnslu). Þú getur búið til slík skyndiminni sjálfur, þú getur notað Spring með @Cacheable þess, auk þess sem þú getur alltaf notað tilbúið skyndiminni eins og EhCache handvirkt.

Algengt vandamál væri að skyndiminni eru aðeins gagnleg ef þau hafa hits. Í okkar tilviki eru hitting á höfundareitnum mjög líkleg (við skulum segja, 50%), en það verður alls ekki hitt á skrár. Þessi nálgun mun veita nokkrar umbætur, en það mun ekki róttækan breyta framleiðni (og við þurfum bylting).

Intersession (löng) skyndiminni krefjast flókins ógildingarrökfræði. Almennt séð, því seinna sem þú kemst að því að leysa árangursvandamál með því að nota intersession skyndiminni, því betra.

Kostir:

  1. Innleiða skyndiminni án þess að breyta kóða.
  2. Aukin framleiðni nokkrum sinnum (í sumum tilfellum).

Gallar:

  1. Möguleiki á minni afköstum ef notað er rangt.
  2. Stórt minni yfir höfuð, sérstaklega með löngum skyndiminni.
  3. Flókin ógilding, villur sem leiða til erfiðra vandamála í keyrslu sem er erfitt að endurskapa.

Mjög oft eru skyndiminni aðeins notuð til að laga fljótt hönnunarvandamál. Þetta þýðir ekki að þeir eigi ekki að nota. Hins vegar ættir þú alltaf að meðhöndla þá með varúð og fyrst meta árangursávinninginn sem af því leiðir og aðeins þá taka ákvörðun.

Í dæminu okkar munu skyndiminni veita um 25% aukningu afkasta. Á sama tíma hafa skyndiminni ansi marga ókosti, svo ég myndi ekki nota þau hér.

Niðurstöður

Svo skoðuðum við barnalega útfærslu þjónustu sem notar lotuvinnslu og nokkrar einfaldar leiðir til að flýta henni.

Helsti kosturinn við allar þessar aðferðir er einfaldleiki, sem það hefur margar skemmtilegar afleiðingar af.

Algengt vandamál við þessar aðferðir er léleg frammistaða, fyrst og fremst tengd stærð pakkana. Þess vegna, ef þessar lausnir henta þér ekki, þá er það þess virði að íhuga róttækari aðferðir.

Það eru tvær meginstefnur þar sem þú getur leitað lausna:

  • ósamstillt vinna með gögn (krefst hugmyndabreytingar, svo það er ekki fjallað um það í þessari grein);
  • stækkun á lotum á sama tíma og samstilltur vinnsla er viðhaldið.

Stækkun á lotum mun fækka mjög ytri símtölum og halda kóðanum samstilltum á sama tíma. Næsti hluti greinarinnar verður helgaður þessu efni.

Heimild: www.habr.com

Bæta við athugasemd