Problemer med batch-spørringsbehandling og deres løsninger (del 1)

Problemer med batch-spørringsbehandling og deres løsninger (del 1)Nesten alle moderne programvareprodukter består av flere tjenester. Ofte blir lange responstider for kanaler mellom tjenestene en kilde til ytelsesproblemer. Standardløsningen på denne typen problemer er å pakke flere forespørsler mellom tjenestene i én pakke, som kalles batching.

Hvis du bruker batchbehandling, er du kanskje ikke fornøyd med resultatene når det gjelder ytelse eller kodeklarhet. Denne metoden er ikke så lett for den som ringer som du kanskje tror. For ulike formål og i ulike situasjoner kan løsninger variere sterkt. Ved å bruke konkrete eksempler vil jeg vise fordeler og ulemper ved flere tilnærminger.

Demonstrasjonsprosjekt

For klarhets skyld, la oss se på et eksempel på en av tjenestene i applikasjonen som jeg jobber med.

Forklaring av plattformvalg for eksemplerProblemet med dårlig ytelse er ganske generelt og gjelder ikke noen spesifikke språk eller plattformer. Denne artikkelen vil bruke Spring + Kotlin-kodeeksempler for å demonstrere problemer og løsninger. Kotlin er like forståelig (eller uforståelig) for Java- og C#-utviklere; i tillegg er koden mer kompakt og forståelig enn i Java. For å gjøre ting enklere å forstå for rene Java-utviklere, vil jeg unngå den svarte magien til Kotlin og bare bruke den hvite magien (i Lomboks ånd). Det vil være noen få utvidelsesmetoder, men de er faktisk kjent for alle Java-programmerere som statiske metoder, så dette vil være et lite sukker som ikke vil ødelegge smaken på retten.
Det er en dokumentgodkjenningstjeneste. Noen oppretter et dokument og sender det til diskusjon, hvor det gjøres redigeringer, og til slutt blir dokumentet avtalt. Godkjenningstjenesten selv vet ikke noe om dokumenter: det er bare en prat med godkjennere med små tilleggsfunksjoner som vi ikke skal vurdere her.

Så det er chatterom (tilsvarende dokumenter) med et forhåndsdefinert sett med deltakere i hver av dem. Som i vanlige chatter inneholder meldinger tekst og filer og kan være svar eller videresendinger:

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
)

Fil- og brukerlenker er lenker til andre domener. Her bor vi slik:

typealias FileReference Long
typealias UserReference Long

Brukerdata lagres i Keycloak og mottas via REST. Det samme gjelder filer: filer og metainformasjon om dem lever i en egen fillagringstjeneste.

Alle anrop til disse tjenestene er tunge forespørsler. Dette betyr at kostnadene ved å transportere disse forespørslene er mye større enn tiden det tar før de blir behandlet av en tredjepartstjeneste. På våre testbenker er typisk ringetid for slike tjenester 100 ms, så vi vil bruke disse tallene i fremtiden.

Vi må lage en enkel REST-kontroller for å motta de siste N meldingene med all nødvendig informasjon. Det vil si at vi tror at i frontend er meldingsmodellen nesten den samme og all data må sendes. Forskjellen mellom front-end-modellen er at filen og brukeren må presenteres i en litt dekryptert form for å gjøre dem til lenker:

/** В таком виде отдаются ссылки на сущности для фронта */
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 må implementere følgende:

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

Postfix UI betyr DTO-modeller for frontend, det vil si det vi skal betjene via REST.

Det som kan være overraskende her er at vi ikke sender noen chat-identifikator, og selv i ChatMessage/ChatMessageUI-modellen er det ingen. Jeg gjorde dette med vilje for ikke å rote koden til eksemplene (chattene er isolert, så vi kan anta at vi bare har én).

Filosofisk digresjonBåde ChatMessageUI-klassen og ChatRestApi.getLast-metoden bruker datatypen List, når det faktisk er et bestilt sett. I JDK er alt dette dårlig, så å deklarere rekkefølgen av elementer på grensesnittnivå (bevare rekkefølgen når du legger til og henter) vil ikke fungere. Så det har blitt vanlig praksis å bruke List i tilfeller der et bestilt sett er nødvendig (det finnes også LinkedHashSet, men dette er ikke et grensesnitt).
Viktig begrensning: Vi vil anta at det ikke er lange kjeder av svar eller overføringer. Det vil si at de eksisterer, men lengden deres overstiger ikke tre meldinger. Hele meldingskjeden må overføres til frontend.

For å motta data fra eksterne tjenester er det følgende APIer:

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

Det kan sees at eksterne tjenester i utgangspunktet sørger for batchbehandling, og i begge varianter: gjennom Set (uten å bevare rekkefølgen på elementer, med unike nøkler) og gjennom List (det kan være duplikater - rekkefølgen er bevart).

Enkle implementeringer

Naiv implementering

Den første naive implementeringen av REST-kontrolleren vår vil se omtrent slik ut i de fleste tilfeller:

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

Alt er veldig tydelig, og dette er et stort pluss.

Vi bruker batchbehandling og mottar data fra en ekstern tjeneste i batch. Men hva skjer med produktiviteten vår?

For hver melding vil det bli gjort ett anrop til UserRemoteApi for å få data på forfatterfeltet og ett anrop til FileRemoteApi for å få alle vedlagte filer. Det virker som det er det. La oss si at forwardFrom- og replyTo-feltene for ChatMessage er hentet på en slik måte at dette ikke krever unødvendige anrop. Men å gjøre dem om til ChatMessageUI vil føre til rekursjon, det vil si at samtaletellerne kan øke betydelig. Som vi bemerket tidligere, la oss anta at vi ikke har mye hekking og at kjeden er begrenset til tre meldinger.

Som et resultat vil vi få fra to til seks anrop til eksterne tjenester per melding og ett JPA-anrop for hele meldingspakken. Det totale antallet anrop vil variere fra 2*N+1 til 6*N+1. Hvor mye er dette i virkelige enheter? La oss si at det tar 20 meldinger for å gjengi en side. For å få dem trenger du fra 4 s til 10 s. Fryktelig! Jeg vil gjerne holde den innenfor 500 ms. Og siden de drømte om å lage sømløs rulling på frontend, kan ytelseskravene for dette endepunktet dobles.

Pros:

  1. Koden er kortfattet og selvdokumenterende (en supportteams drøm).
  2. Koden er enkel, så det er nesten ingen muligheter til å skyte seg selv i foten.
  3. Batchbehandling ser ikke ut som noe fremmed og er organisk integrert i logikken.
  4. Logiske endringer vil være enkle å gjøre og vil være lokale.

mindre:

Fryktelig ytelse på grunn av svært små pakker.

Denne tilnærmingen kan sees ganske ofte i enkle tjenester eller i prototyper. Hvis hastigheten på å gjøre endringer er viktig, er det neppe verdt å komplisere systemet. Samtidig, for vår veldig enkle tjeneste, er ytelsen forferdelig, så anvendelsesområdet for denne tilnærmingen er veldig smalt.

Naiv parallell prosessering

Du kan begynne å behandle alle meldinger parallelt - dette vil tillate deg å bli kvitt den lineære økningen i tid avhengig av antall meldinger. Dette er ikke en spesielt god vei fordi det vil gi stor toppbelastning på den eksterne tjenesten.

Implementering av parallell behandling er veldig enkelt:

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

Ved å bruke parallell meldingsbehandling får vi 300–700 ms ideelt sett, noe som er mye bedre enn med en naiv implementering, men likevel ikke rask nok.

Med denne tilnærmingen vil forespørsler til userRepository og fileRepository bli utført synkront, noe som ikke er veldig effektivt. For å fikse dette, må du endre anropslogikken ganske mye. For eksempel via 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()!!

Det kan sees at den i utgangspunktet enkle kartleggingskoden har blitt mindre forståelig. Dette er fordi vi måtte skille anrop til eksterne tjenester fra der resultatene brukes. Dette i seg selv er ikke dårlig. Men å kombinere samtaler ser ikke spesielt elegant ut og ligner en typisk reaktiv "nuddel".

Hvis du bruker koroutiner, vil alt se mer anstendig ut:

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

Hvor:

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

Teoretisk sett vil vi, ved bruk av slik parallell prosessering, få 200–400 ms, som allerede er nær forventningene våre.

Dessverre skjer ikke så god parallellisering, og prisen å betale er ganske grusom: med bare noen få brukere som jobber samtidig, vil tjenestene bli rammet av en mengde forespørsler som uansett ikke vil bli behandlet parallelt, så vi vil gå tilbake til vår triste 4-er.

Mitt resultat ved bruk av en slik tjeneste er 1300–1700 ms for behandling av 20 meldinger. Dette er raskere enn i den første implementeringen, men løser likevel ikke problemet.

Alternativ bruk av parallelle spørringerHva om tredjepartstjenester ikke tilbyr batchbehandling? For eksempel kan du skjule mangelen på batchbehandlingsimplementering i grensesnittmetoder:

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

Dette er fornuftig hvis du håper å se batchbehandling i fremtidige versjoner.
Pros:

  1. Implementer enkelt meldingsbasert parallellbehandling.
  2. God skalerbarhet.

Cons:

  1. Behovet for å skille datainnhenting fra behandlingen ved behandling av forespørsler til ulike tjenester parallelt.
  2. Økt belastning på tredjepartstjenester.

Man kan se at anvendelsesområdet er omtrent det samme som for den naive tilnærmingen. Det er fornuftig å bruke den parallelle forespørselsmetoden hvis du ønsker å øke ytelsen til tjenesten flere ganger på grunn av nådeløs utnyttelse av andre. I vårt eksempel økte produktiviteten med 2,5 ganger, men dette er tydeligvis ikke nok.

caching

Du kan gjøre caching i JPAs ånd for eksterne tjenester, det vil si lagre mottatte objekter i en økt for ikke å motta dem igjen (inkludert under batchbehandling). Du kan lage slike cacher selv, du kan bruke Spring med @Cacheable, pluss at du alltid kan bruke en ferdig cache som EhCache manuelt.

Et vanlig problem vil være at cacher bare er nyttige hvis de har treff. I vårt tilfelle er treff på forfatterfeltet svært sannsynlig (la oss si 50%), men det vil ikke være noen treff på filer i det hele tatt. Denne tilnærmingen vil gi noen forbedringer, men den vil ikke radikalt endre produktiviteten (og vi trenger et gjennombrudd).

Intersession (lange) cacher krever kompleks invalideringslogikk. Generelt, jo senere du kommer til å løse ytelsesproblemer ved å bruke intersession-cacher, jo bedre.

Pros:

  1. Implementer caching uten å endre kode.
  2. Økt produktivitet flere ganger (i noen tilfeller).

Cons:

  1. Mulighet for redusert ytelse ved feil bruk.
  2. Stort minne overhead, spesielt med lange cacher.
  3. Kompleks ugyldiggjøring, feil som vil føre til problemer som er vanskelige å reprodusere under kjøretid.

Svært ofte brukes cacher bare for å raskt lappe opp designproblemer. Dette betyr ikke at de ikke skal brukes. Du bør imidlertid alltid behandle dem med forsiktighet og først evaluere den resulterende ytelsesgevinsten, og først deretter ta en avgjørelse.

I vårt eksempel vil cacher gi en ytelsesøkning på rundt 25 %. Samtidig har cacher ganske mange ulemper, så jeg ville ikke brukt dem her.

Resultater av

Så vi så på en naiv implementering av en tjeneste som bruker batchbehandling, og flere enkle måter å øke hastigheten på.

Den største fordelen med alle disse metodene er enkelhet, hvorfra det er mange hyggelige konsekvenser.

Et vanlig problem med disse metodene er dårlig ytelse, først og fremst knyttet til størrelsen på pakkene. Derfor, hvis disse løsningene ikke passer deg, er det verdt å vurdere mer radikale metoder.

Det er to hovedretninger du kan se etter løsninger i:

  • asynkront arbeid med data (krever et paradigmeskifte, så det er ikke diskutert i denne artikkelen);
  • forstørrelse av batcher samtidig som synkron behandling opprettholdes.

Forstørrelse av batcher vil redusere antall eksterne samtaler kraftig og samtidig holde koden synkron. Den neste delen av artikkelen vil bli viet til dette emnet.

Kilde: www.habr.com

Legg til en kommentar