Eräkyselyn käsittelyn ongelmat ja niiden ratkaisut (osa 1)

Eräkyselyn käsittelyn ongelmat ja niiden ratkaisut (osa 1)Lähes kaikki nykyaikaiset ohjelmistotuotteet koostuvat useista palveluista. Usein yksiköiden välisten kanavien pitkät vasteajat aiheuttavat suorituskykyongelmia. Vakioratkaisu tällaiseen ongelmaan on pakata useita palvelujen välisiä pyyntöjä yhteen pakettiin, jota kutsutaan eräksi.

Jos käytät eräkäsittelyä, et ehkä ole tyytyväinen tuloksiin suorituskyvyn tai koodin selkeyden suhteen. Tämä menetelmä ei ole soittajalle niin helppo kuin luulisi. Eri tarkoituksiin ja eri tilanteissa ratkaisut voivat vaihdella suuresti. Käytän erityisiä esimerkkejä, ja näytän useiden lähestymistapojen edut ja haitat.

Esittelyprojekti

Selvyyden vuoksi tarkastellaan esimerkkiä yhdestä palvelusta sovelluksessa, jonka parissa työskentelen parhaillaan.

Selitys alustan valinnasta esimerkeilleHuonon suorituskyvyn ongelma on melko yleinen, eikä se koske tiettyjä kieliä tai alustoja. Tässä artikkelissa käytetään Spring + Kotlin -koodiesimerkkejä ongelmien ja ratkaisujen havainnollistamiseen. Kotlin on yhtä ymmärrettävää (tai käsittämätöntä) Java- ja C#-kehittäjille, lisäksi koodi on kompaktimpi ja ymmärrettävämpi kuin Javassa. Jotta Java-kehittäjien olisi helpompi ymmärtää asioita, vältän Kotlinin mustaa magiaa ja käytän vain valkoista magiaa (Lombokin hengessä). Muutamia laajennusmenetelmiä tulee olemaan, mutta ne ovat itse asiassa tuttuja kaikille Java-ohjelmoijille staattisina menetelminä, joten tämä on pieni sokeri, joka ei pilaa ruuan makua.
Siellä on asiakirjojen hyväksymispalvelu. Joku luo dokumentin ja lähettää sen keskusteluun, jonka aikana tehdään muokkauksia ja lopulta asiakirjasta sovitaan. Hyväksyntäpalvelu ei itse tiedä asiakirjoista mitään: se on vain hyväksyjien keskustelu, jossa on pieniä lisätoimintoja, joita emme tässä käsittele.

Joten on olemassa chat-huoneita (jotka vastaavat asiakirjoja), joissa jokaisessa on ennalta määritetty osallistujajoukko. Kuten tavallisissa chateissa, viestit sisältävät tekstiä ja tiedostoja, ja ne voivat olla vastauksia tai edelleenlähetyksiä:

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
)

Tiedosto- ja käyttäjälinkit ovat linkkejä muihin verkkotunnuksiin. Täällä eletään näin:

typealias FileReference Long
typealias UserReference Long

Käyttäjätiedot tallennetaan Keycloakiin ja vastaanotetaan RESTin kautta. Sama koskee tiedostoja: tiedostot ja metatiedot niistä elävät erillisessä tiedostojen tallennuspalvelussa.

Kaikki puhelut näihin palveluihin ovat raskaita pyyntöjä. Tämä tarkoittaa, että näiden pyyntöjen kuljetuskustannukset ovat paljon suuremmat kuin aika, joka kuluu niiden käsittelyyn kolmannen osapuolen palvelussa. Testipenkeillämme tällaisten palveluiden tyypillinen soittoaika on 100 ms, joten käytämme näitä numeroita jatkossa.

Meidän on tehtävä yksinkertainen REST-ohjain vastaanottaaksemme viimeiset N viestiä, joissa on kaikki tarvittavat tiedot. Eli uskomme, että käyttöliittymässä viestimalli on lähes sama ja kaikki tiedot on lähetettävä. Erona käyttöliittymämallin välillä on, että tiedosto ja käyttäjä on esitettävä hieman salatussa muodossa, jotta ne linkittävät:

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

Meidän on toteutettava seuraavat:

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

Postfix UI tarkoittaa DTO-malleja käyttöliittymälle, eli mitä meidän on palveltava RESTin kautta.

Tässä voi olla yllättävää, että emme välitä mitään chat-tunnistetta, eikä edes ChatMessage/ChatMessageUI-mallissa ole sellaista. Tein tämän tarkoituksella, jotta en sotkenut esimerkkien koodia (chatit ovat eristettyjä, joten voimme olettaa, että meillä on vain yksi).

Filosofinen poikkeamaSekä ChatMessageUI-luokka että ChatRestApi.getLast-menetelmä käyttävät List-tietotyyppiä, vaikka itse asiassa se on järjestetty joukko. JDK:ssa tämä kaikki on huonoa, joten elementtien järjestyksen ilmoittaminen käyttöliittymätasolla (järjestyksen säilyttäminen lisättäessä ja haettaessa) ei toimi. Joten yleiseksi käytännöksi on tullut Listin käyttäminen tapauksissa, joissa tarvitaan tilattua Settiä (on myös LinkedHashSet, mutta tämä ei ole käyttöliittymä).
Tärkeä rajoitus: Oletamme, ettei vastausten tai siirtojen pitkiä ketjuja ole. Eli ne ovat olemassa, mutta niiden pituus ei ylitä kolmea viestiä. Koko viestiketju on välitettävä käyttöliittymään.

Tietojen vastaanottamiseen ulkoisista palveluista on olemassa seuraavat API:t:

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

Voidaan nähdä, että ulkoiset palvelut tarjoavat aluksi eräkäsittelyä ja molemmissa versioissa: Setin kautta (säilyttämättä elementtien järjestystä, ainutlaatuisilla avaimilla) ja Listan kautta (saattaa olla kaksoiskappaleita - järjestys säilyy).

Yksinkertaiset toteutukset

Naiivi toteutus

REST-ohjaimemme ensimmäinen naiivi toteutus näyttää useimmissa tapauksissa tältä:

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

Kaikki on hyvin selvää, ja tämä on iso plussa.

Käytämme eräkäsittelyä ja vastaanotamme tietoja ulkopuolisesta palvelusta erissä. Mutta mitä tuottavuudellemme tapahtuu?

Jokaista viestiä kohden soitetaan yksi puhelu UserRemoteApille, jotta saadaan tietoja tekijäkentästä, ja yksi puhelu FileRemoteApille kaikkien liitetiedostojen saamiseksi. Näyttää siltä, ​​että se on siinä. Oletetaan, että ChatMessagen forwardFrom- ja replyTo-kentät saadaan siten, että tämä ei vaadi tarpeettomia puheluita. Mutta niiden muuttaminen ChatMessageUI:ksi johtaa rekursioon, eli puhelulaskurit voivat kasvaa merkittävästi. Kuten aiemmin totesimme, oletetaan, että meillä ei ole paljon sisäkkäisyyttä ja ketju on rajoitettu kolmeen viestiin.

Tämän seurauksena saamme kahdesta kuuteen puhelua ulkoisiin palveluihin viestiä kohden ja yhden edustajakokouksen kutsun koko viestipaketille. Puheluiden kokonaismäärä vaihtelee välillä 2*N+1 - 6*N+1. Kuinka paljon tämä on todellisissa yksiköissä? Oletetaan, että sivun hahmontamiseen kuluu 20 viestiä. Niiden saamiseksi tarvitset 4 s - 10 s. Kauhea! Haluaisin pitää sen 500 ms sisällä. Ja koska he haaveilivat saumattoman vierityksen tekemisestä käyttöliittymässä, tämän päätepisteen suorituskykyvaatimukset voidaan kaksinkertaistaa.

Plussat:

  1. Koodi on ytimekäs ja itsedokumentoiva (tukitiimin unelma).
  2. Koodi on yksinkertainen, joten mahdollisuuksia ampua itseäsi jalkaan ei ole juuri lainkaan.
  3. Eräkäsittely ei näytä joltain vieraalta ja on orgaanisesti integroitu logiikkaan.
  4. Logiikkamuutokset ovat helppoja tehdä ja ne ovat paikallisia.

Miinus:

Kamala suorituskyky erittäin pienistä paketeista johtuen.

Tämä lähestymistapa näkyy melko usein yksinkertaisissa palveluissa tai prototyypeissä. Jos muutosten tekemisen nopeus on tärkeä, järjestelmää tuskin kannattaa monimutkaista. Samalla erittäin yksinkertaiselle palvelullemme suorituskyky on kauhea, joten tämän lähestymistavan soveltuvuus on hyvin kapea.

Naiivi rinnakkaiskäsittely

Voit aloittaa kaikkien viestien käsittelyn rinnakkain - näin voit päästä eroon lineaarisesta ajan pidentymisestä viestien lukumäärästä riippuen. Tämä ei ole erityisen hyvä tie, koska se johtaa suureen huippukuormitukseen ulkoiselle palvelulle.

Rinnakkaiskäsittelyn toteuttaminen on hyvin yksinkertaista:

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

Rinnakkaisviestienkäsittelyllä saamme ihanteellisesti 300–700 ms, mikä on paljon parempi kuin naiivi toteutuksessa, mutta ei silti tarpeeksi nopea.

Tällä lähestymistavalla userRepository- ja fileRepository-pyynnöt suoritetaan synkronisesti, mikä ei ole kovin tehokasta. Tämän korjaamiseksi sinun on muutettava puhelulogiikkaa melko paljon. Esimerkiksi CompletionStagen (alias CompletableFuture) kautta:

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

Voidaan nähdä, että alun perin yksinkertainen kuvauskoodi on muuttunut vähemmän ymmärrettäväksi. Tämä johtuu siitä, että meidän piti erottaa puhelut ulkoisiin palveluihin siitä, missä tuloksia käytetään. Tämä ei sinänsä ole huono. Mutta puheluiden yhdistäminen ei näytä erityisen tyylikkäältä ja muistuttaa tyypillistä reaktiivista "nuudelia".

Jos käytät korutiineja, kaikki näyttää kunnollisemmalta:

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

Missä:

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

Teoriassa tällaisella rinnakkaiskäsittelyllä saamme 200–400 ms, mikä on jo lähellä odotuksiamme.

Valitettavasti tällaista hyvää rinnakkaisua ei tapahdu, ja maksettava hinta on melko julma: kun vain muutama käyttäjä työskentelee samanaikaisesti, palveluihin kohdistuu valtava määrä pyyntöjä, joita ei muutenkaan käsitellä rinnakkain, joten me palaa meidän surullisiin 4s.

Tulokseni tällaista palvelua käytettäessä on 1300–1700 ms 20 viestin käsittelyssä. Tämä on nopeampi kuin ensimmäisessä toteutuksessa, mutta ei silti ratkaise ongelmaa.

Rinnakkaisten kyselyjen vaihtoehtoiset käyttötavatEntä jos kolmannen osapuolen palvelut eivät tarjoa eräkäsittelyä? Voit esimerkiksi piilottaa eräkäsittelyn puutteen käyttöliittymämenetelmien sisällä:

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

Tämä on järkevää, jos haluat nähdä eräkäsittelyn tulevissa versioissa.
Plussat:

  1. Toteuta helposti viestipohjainen rinnakkaiskäsittely.
  2. Hyvä skaalautuvuus.

Miinukset:

  1. Tarve erottaa tiedonhankinta sen käsittelystä käsiteltäessä pyyntöjä eri palveluihin rinnakkain.
  2. Lisääntynyt kolmannen osapuolen palveluiden kuormitus.

Voidaan nähdä, että sovellettavuus on suunnilleen sama kuin naiivilla lähestymistavalla. Rinnakkaispyyntömenetelmää on järkevää käyttää, jos haluat lisätä palvelusi suorituskykyä useaan kertaan muiden armottoman hyväksikäytön vuoksi. Esimerkissämme tuottavuus kasvoi 2,5-kertaiseksi, mutta tämä ei selvästikään riitä.

välimuisti

Voit tehdä välimuistin JPA:n hengessä ulkoisille palveluille, eli tallentaa vastaanotetut objektit istunnon sisällä, jotta niitä ei enää vastaanoteta (mukaan lukien eräkäsittelyn aikana). Voit tehdä tällaisia ​​​​kätköjä itse, voit käyttää Springiä sen @Cacheable-sovelluksella ja voit aina käyttää valmiita välimuistia, kuten EhCachea, manuaalisesti.

Yleinen ongelma on, että välimuistit ovat hyödyllisiä vain, jos niissä on osumia. Meidän tapauksessamme osumat kirjoittajakentässä ovat erittäin todennäköisiä (oletetaan 50%), mutta tiedostoihin ei tule lainkaan osumia. Tämä lähestymistapa tarjoaa joitain parannuksia, mutta se ei muuta tuottavuutta radikaalisti (ja tarvitsemme läpimurtoa).

Intersession (pitkät) välimuistit vaativat monimutkaista mitätöintilogiikkaa. Yleensä mitä myöhemmin ryhdyt ratkaisemaan suorituskykyongelmia istuntojen välimuistien avulla, sitä parempi.

Plussat:

  1. Toteuta välimuisti muuttamatta koodia.
  2. Lisääntynyt tuottavuus useita kertoja (joissakin tapauksissa).

Miinukset:

  1. Mahdollisuus heikentää suorituskykyä, jos sitä käytetään väärin.
  2. Suuri muisti, etenkin pitkien välimuistien kanssa.
  3. Monimutkainen mitätöinti, jonka virheet johtavat vaikeasti toistettavissa oleviin ongelmiin ajon aikana.

Hyvin usein välimuistia käytetään vain suunnitteluongelmien nopeaan korjaamiseen. Tämä ei tarkoita, etteikö niitä saisi käyttää. Sinun tulee kuitenkin aina suhtautua niihin varoen ja ensin arvioida tuloksena oleva suorituskyvyn lisäys ja tehdä vasta sitten päätös.

Esimerkissämme välimuistit lisäävät suorituskykyä noin 25 %. Samaan aikaan välimuistissa on melko paljon haittoja, joten en käyttäisi niitä tässä.

Tulokset

Joten tarkastelimme eräkäsittelyä käyttävän palvelun naiivia toteutusta ja useita yksinkertaisia ​​tapoja nopeuttaa sitä.

Kaikkien näiden menetelmien tärkein etu on yksinkertaisuus, josta on monia miellyttäviä seurauksia.

Yleinen ongelma näissä menetelmissä on heikko suorituskyky, joka liittyy ensisijaisesti pakettien kokoon. Siksi, jos nämä ratkaisut eivät sovi sinulle, kannattaa harkita radikaalimpia menetelmiä.

On kaksi pääsuuntaa, joihin voit etsiä ratkaisuja:

  • asynkroninen työskentely tietojen kanssa (vaatii paradigman muutoksen, joten sitä ei käsitellä tässä artikkelissa);
  • erien suurentaminen säilyttäen samalla synkronisen käsittelyn.

Erien suurentaminen vähentää huomattavasti ulkopuhelujen määrää ja samalla pitää koodin synkronisena. Artikkelin seuraava osa on omistettu tälle aiheelle.

Lähde: will.com

Lisää kommentti