Problemoj de bata prilaborado kaj iliaj solvoj (parto 1)

Problemoj de bata prilaborado kaj iliaj solvoj (parto 1)Preskaŭ ĉiuj modernaj softvaraĵoj konsistas el pluraj servoj. Ofte, longaj respondaj tempoj de interservokanaloj iĝas fonto de agadoproblemoj. La norma solvo al ĉi tiu speco de problemo estas paki plurajn inter-servajn petojn en unu pakaĵon, kiu nomiĝas batching.

Se vi uzas batan prilaboradon, vi eble ne ĝojos pri la rezultoj laŭ rendimento aŭ koda klareco. Ĉi tiu metodo ne estas tiel facila por la alvokanto kiel vi povus pensi. Por malsamaj celoj kaj en malsamaj situacioj, solvoj povas multe varii. Uzante specifajn ekzemplojn, mi montros la avantaĝojn kaj malavantaĝojn de pluraj aliroj.

Demonstra projekto

Por klareco, ni rigardu ekzemplon de unu el la servoj en la aplikaĵo, pri kiu mi nuntempe laboras.

Klarigo pri platformelekto por ekzemplojLa problemo de malbona agado estas sufiĉe ĝenerala kaj ne koncernas specifajn lingvojn aŭ platformojn. Ĉi tiu artikolo uzos Spring + Kotlin-kodekzemplojn por montri problemojn kaj solvojn. Kotlin estas same komprenebla (aŭ nekomprenebla) por programistoj de Java kaj C#; krome, la kodo estas pli kompakta kaj komprenebla ol en Java. Por plifaciligi aferojn por puraj Java-programistoj, mi evitos la nigran magion de Kotlin kaj nur uzos la blankan magion (en la spirito de Lombok). Estos kelkaj etendaj metodoj, sed ili estas fakte konataj al ĉiuj Java-programistoj kiel statikaj metodoj, do ĉi tio estos malgranda sukero, kiu ne ruinigos la guston de la plado.
Estas servo de aprobo de dokumentoj. Iu kreas dokumenton kaj sendas ĝin por diskuto, dum kiu redaktoj estas faritaj, kaj finfine la dokumento estas interkonsentita. La aprobservo mem scias nenion pri dokumentoj: ĝi estas nur babilejo de aprobantoj kun malgrandaj kromaj funkcioj, kiujn ni ne konsideros ĉi tie.

Do, ekzistas babilejoj (korespondantaj al dokumentoj) kun antaŭdifinita aro da partoprenantoj en ĉiu el ili. Kiel en regulaj babilejoj, mesaĝoj enhavas tekston kaj dosierojn kaj povas esti respondoj aŭ plusenditaj:

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
)

Dosieroj kaj uzantligoj estas ligiloj al aliaj domajnoj. Ĉi tie ni vivas tiel:

typealias FileReference Long
typealias UserReference Long

Uzantdatenoj estas konservitaj en Keycloak kaj ricevitaj per REST. Same pri dosieroj: dosieroj kaj metainformoj pri ili vivas en aparta dosiera stokado.

Ĉiuj vokoj al ĉi tiuj servoj estas pezaj petoj. Ĉi tio signifas, ke la ŝarĝo de transportado de ĉi tiuj petoj estas multe pli granda ol la tempo necesa por ili esti procesitaj de triaparta servo. Sur niaj testbenkoj, la tipa voka tempo por tiaj servoj estas 100 ms, do ni uzos ĉi tiujn numerojn estonte.

Ni devas fari simplan REST-regilon por ricevi la lastajn N mesaĝojn kun ĉiuj necesaj informoj. Tio estas, ni kredas, ke en la fasado la mesaĝmodelo estas preskaŭ la sama kaj ĉiuj datumoj devas esti senditaj. La diferenco inter la antaŭa modelo estas, ke la dosiero kaj la uzanto devas esti prezentitaj en iomete deĉifrita formo por fari ilin ligiloj:

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

Ni devas efektivigi la jenajn:

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

Postfix UI signifas DTO-modelojn por la fasado, tio estas, kion ni devas servi per REST.

Kio povas esti surpriza ĉi tie estas, ke ni ne pasas ajnan babilidentigilon kaj eĉ en la ChatMessage/ChatMessageUI-modelo ekzistas neniu. Mi faris tion intence por ne malordigi la kodon de la ekzemploj (la babilejoj estas izolitaj, do ni povas supozi, ke ni havas nur unu).

Filozofia digresoKaj la ChatMessageUI-klaso kaj la ChatRestApi.getLast-metodo uzas la List-datupon, kiam fakte ĝi estas ordigita Aro. En la JDK ĉi tio estas tute malbona, do deklari la ordon de elementoj ĉe la interfaca nivelo (konservado de la ordon dum aldonado kaj retrovado) ne funkcios. Do fariĝis ofta praktiko uzi Liston en kazoj kie ordigita Aro estas bezonata (ekzistas ankaŭ LinkedHashSet, sed ĉi tio ne estas interfaco).
Grava limigo: Ni supozos, ke ne ekzistas longaj ĉenoj de respondoj aŭ translokigoj. Tio estas, ili ekzistas, sed ilia longeco ne superas tri mesaĝojn. La tuta ĉeno de mesaĝoj devas esti transdonita al la fasado.

Por ricevi datumojn de eksteraj servoj ekzistas la jenaj API-oj:

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

Videblas, ke eksteraj servoj komence zorgas pri bata prilaborado, kaj en ambaŭ variantoj: per Aro (sen konservi la ordon de elementoj, kun unikaj klavoj) kaj per Listo (povas esti duplikatoj - la ordo estas konservita).

Simplaj efektivigoj

Naiva efektivigo

La unua naiva efektivigo de nia REST-regilo aspektos kiel ĉi tio plejofte:

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

Ĉio estas tre klara, kaj ĉi tio estas granda pluso.

Ni uzas grupan prilaboradon kaj ricevas datumojn de ekstera servo en aroj. Sed kio okazas al nia produktiveco?

Por ĉiu mesaĝo, unu alvoko al UserRemoteApi estos farita por ricevi datumojn pri la aŭtorkampo kaj unu alvoko al FileRemoteApi por ricevi ĉiujn kunigitajn dosierojn. Ŝajnas, ke tio estas. Ni diru, ke la kampoj forwardFrom kaj replyTo por ChatMessage estas akiritaj tiel, ke tio ne postulas nenecesajn vokojn. Sed igi ilin ChatMessageUI kondukos al rekursio, tio estas, vokaj nombriloj povas signife pliiĝi. Kiel ni rimarkis antaŭe, ni supozu, ke ni ne havas multe da nestado kaj la ĉeno estas limigita al tri mesaĝoj.

Kiel rezulto, ni ricevos de du ĝis ses vokojn al eksteraj servoj per mesaĝo kaj unu JPA-vokon por la tuta pako da mesaĝoj. La totala nombro de vokoj varias de 2*N+1 al 6*N+1. Kiom tio estas en realaj unuoj? Ni diru, ke necesas 20 mesaĝoj por bildigi paĝon. Por akiri ilin, vi bezonos de 4 s ĝis 10 s. Terure! Mi ŝatus konservi ĝin ene de 500 ms. Kaj ĉar ili sonĝis fari senjuntan movadon en la fasado, la rendimentpostuloj por ĉi tiu finpunkto povas esti duobligitaj.

Pros:

  1. La kodo estas konciza kaj memdokumenta (la revo de subtena teamo).
  2. La kodo estas simpla, do preskaŭ ne ekzistas ŝancoj pafi vin en la piedon.
  3. Bata prilaborado ne aspektas kiel io fremda kaj estas organike integrita en la logiko.
  4. Logikaj ŝanĝoj estos facile fareblaj kaj estos lokaj.

Minuso:

Terura agado pro tre malgrandaj pakoj.

Ĉi tiu aliro povas esti vidita sufiĉe ofte en simplaj servoj aŭ en prototipoj. Se la rapideco fari ŝanĝojn gravas, apenaŭ indas kompliki la sistemon. Samtempe, por nia tre simpla servo la agado estas terura, do la amplekso de aplikeco de ĉi tiu aliro estas tre malvasta.

Naiva paralela prilaborado

Vi povas komenci prilabori ĉiujn mesaĝojn paralele - ĉi tio permesos al vi forigi la linearan pliiĝon en tempo depende de la nombro da mesaĝoj. Ĉi tio ne estas precipe bona vojo ĉar ĝi rezultigos grandan pintan ŝarĝon sur la ekstera servo.

Efektivigi paralelan prilaboradon estas tre simpla:

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

Uzante paralelan mesaĝan prilaboradon, ni ricevas 300–700 ms ideale, kio estas multe pli bona ol kun naiva efektivigo, sed tamen ne sufiĉe rapida.

Kun ĉi tiu aliro, petoj al userRepository kaj fileRepository estos ekzekutitaj sinkrone, kio ne estas tre efika. Por ripari ĉi tion, vi devos multe ŝanĝi la vokan logikon. Ekzemple, per CompletionStage (alinome 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()!!

Oni povas vidi, ke la komence simpla mapa kodo fariĝis malpli komprenebla. Ĉi tio estas ĉar ni devis apartigi vokojn al eksteraj servoj de kie la rezultoj estas uzataj. Ĉi tio en si mem ne estas malbona. Sed kombini vokojn ne aspektas precipe eleganta kaj similas tipan reaktivan "nudelon".

Se vi uzas korutinojn, ĉio aspektos pli deca:

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

Kie:

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

Teorie, uzante tian paralelan prilaboradon, ni ricevos 200–400 ms, kio jam estas proksima al niaj atendoj.

Bedaŭrinde, tia bona paraleligo ne okazas, kaj la prezo por pagi estas sufiĉe kruela: kun nur kelkaj uzantoj samtempe laborantaj, la servoj estos trafitaj per aro da petoj, kiuj ĉiuokaze ne estos paralele procesitaj, do ni revenos al niaj malĝojaj 4-oj.

Mia rezulto kiam vi uzas tian servon estas 1300–1700 ms por prilaborado de 20 mesaĝoj. Ĉi tio estas pli rapida ol en la unua efektivigo, sed ankoraŭ ne solvas la problemon.

Alternativaj uzoj de paralelaj demandojKio se triaj servoj ne provizas grupan prilaboradon? Ekzemple, vi povas kaŝi la mankon de efektivigo de bata prilaborado en interfacaj metodoj:

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

Ĉi tio havas sencon se vi esperas vidi grupan prilaboradon en estontaj versioj.
Pros:

  1. Facile efektivigi mesaĝ-bazitan paralelan prilaboradon.
  2. Bona skaleblo.

Kons:

  1. La bezono apartigi datuman akiron de ĝia prilaborado dum prilaborado de petoj al malsamaj servoj paralele.
  2. Pliigita ŝarĝo sur triaj servoj.

Oni povas vidi, ke la amplekso de aplikebleco estas proksimume la sama kiel tiu de la naiva aliro. Estas senco uzi la paralelan petan metodon se vi volas plurfoje pliigi la rendimenton de via servo pro la senkompata ekspluatado de aliaj. En nia ekzemplo, produktiveco pliiĝis je 2,5 fojojn, sed ĉi tio klare ne sufiĉas.

kaŝmemoro

Vi povas fari kaŝmemoron laŭ la spirito de JPA por eksteraj servoj, tio estas, stoki ricevitajn objektojn ene de sesio por ne ricevi ilin denove (inkluzive dum bata prilaborado). Vi povas mem fari tiajn kaŝmemorojn, vi povas uzi Spring kun ĝia @Cacheable, krome vi ĉiam povas uzi pretan kaŝmemoron kiel EhCache permane.

Ofta problemo estus, ke kaŝmemoroj nur utilas se ili havas sukcesojn. En nia kazo, trafoj sur la aŭtoro-kampo estas tre verŝajnaj (ni diru, 50%), sed tute ne estos trafoj en dosieroj. Ĉi tiu aliro provizos kelkajn plibonigojn, sed ĝi ne radikale ŝanĝos produktivecon (kaj ni bezonas sukceson).

Intersesiaj (longaj) kaŝmemoroj postulas kompleksan malvalidiglogikon. Ĝenerale, ju pli poste vi solvis rendimentajn problemojn uzante intersedantajn kaŝmemorojn, des pli bone.

Pros:

  1. Efektivigu kaŝmemoron sen ŝanĝi kodon.
  2. Pliigita produktiveco plurfoje (en iuj kazoj).

Kons:

  1. Eblo de reduktita rendimento se uzata malĝuste.
  2. Granda memoro supre, precipe kun longaj kaŝmemoroj.
  3. Kompleksa malvalidigo, eraroj en kiuj kondukos al malfacile reprodukteblaj problemoj en rultempo.

Tre ofte, kaŝmemoroj estas uzataj nur por rapide fliki dezajnproblemojn. Ĉi tio ne signifas, ke ili ne devas esti uzataj. Tamen, vi ĉiam devas trakti ilin singarde kaj unue taksi la rezultan rendimentan gajnon, kaj nur tiam fari decidon.

En nia ekzemplo, kaŝmemoroj provizos rendimenton pliiĝon de ĉirkaŭ 25%. Samtempe, kaŝmemoroj havas sufiĉe multajn malavantaĝojn, do mi ne uzus ilin ĉi tie.

Rezultoj

Do, ni rigardis naivan efektivigon de servo, kiu uzas batan prilaboradon, kaj plurajn simplajn manierojn por akceli ĝin.

La ĉefa avantaĝo de ĉiuj ĉi tiuj metodoj estas simpleco, el kiu estas multaj agrablaj konsekvencoj.

Ofta problemo kun ĉi tiuj metodoj estas malbona efikeco, ĉefe rilata al la grandeco de la pakaĵetoj. Tial, se ĉi tiuj solvoj ne konvenas al vi, tiam indas konsideri pli radikalajn metodojn.

Estas du ĉefaj direktoj en kiuj vi povas serĉi solvojn:

  • nesinkrona laboro kun datumoj (postulas paradigmoŝanĝon, do ĝi ne estas diskutita en ĉi tiu artikolo);
  • pligrandigo de aroj konservante sinkronan prilaboradon.

Pligrandigo de aroj multe reduktos la nombron de eksteraj vokoj kaj samtempe konservos la kodon sinkrona. La sekva parto de la artikolo estos dediĉita al ĉi tiu temo.

fonto: www.habr.com

Aldoni komenton