Problemer med batchforespørgselsbehandling og deres løsninger (del 1)

Problemer med batchforespørgselsbehandling og deres løsninger (del 1)Næsten alle moderne softwareprodukter består af flere tjenester. Ofte bliver lange svartider for interservice-kanaler en kilde til ydeevneproblemer. Standardløsningen på denne type problemer er at pakke flere interservice-anmodninger i én pakke, som kaldes batching.

Hvis du bruger batchbehandling, er du muligvis ikke tilfreds med resultaterne med hensyn til ydeevne eller kodeklarhed. Denne metode er ikke så let for den, der ringer, som du måske tror. Til forskellige formål og i forskellige situationer kan løsninger variere meget. Ved hjælp af konkrete eksempler vil jeg vise fordele og ulemper ved flere tilgange.

Demonstrationsprojekt

For klarhedens skyld, lad os se på et eksempel på en af ​​tjenesterne i den applikation, jeg i øjeblikket arbejder på.

Forklaring af platformvalg for eksemplerProblemet med dårlig ydeevne er ret generelt og påvirker ikke nogen specifikke sprog eller platforme. Denne artikel vil bruge Spring + Kotlin kodeeksempler til at demonstrere problemer og løsninger. Kotlin er lige så forståeligt (eller uforståeligt) for Java- og C#-udviklere, derudover er koden mere kompakt og forståelig end i Java. For at gøre det lettere at forstå for rene Java-udviklere, vil jeg undgå den sorte magi fra Kotlin og kun bruge den hvide magi (i Lomboks ånd). Der vil være et par udvidelsesmetoder, men de er faktisk kendte for alle Java-programmører som statiske metoder, så dette vil være et lille sukker, der ikke vil ødelægge smagen af ​​retten.
Der er en dokumentgodkendelsesservice. Nogen opretter et dokument og sender det til diskussion, hvor der foretages redigeringer, og i sidste ende aftales dokumentet. Godkendelsestjenesten ved ikke selv noget om dokumenter: det er blot en snak af godkendere med små ekstra funktioner, som vi ikke vil overveje her.

Så der er chatrum (svarende til dokumenter) med et foruddefineret sæt deltagere i hver af dem. Som i almindelige chats indeholder beskeder tekst og filer og kan være svar eller videresendelser:

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 brugerlinks er links til andre domæner. Her bor vi sådan her:

typealias FileReference Long
typealias UserReference Long

Brugerdata gemmes i Keycloak og hentes via REST. Det samme gælder filer: filer og metainformation om dem lever i en separat fillagringstjeneste.

Alle opkald til disse tjenester er tunge anmodninger. Det betyder, at omkostningerne ved at transportere disse anmodninger er meget større end den tid, det tager for dem at blive behandlet af en tredjepartstjeneste. På vores testbænke er den typiske opkaldstid for sådanne tjenester 100 ms, så vi vil bruge disse numre i fremtiden.

Vi skal lave en simpel REST-controller for at modtage de sidste N beskeder med al den nødvendige information. Det vil sige, at vi mener, at beskedmodellen i frontend er næsten den samme, og at alle data skal sendes. Forskellen mellem front-end-modellen er, at filen og brugeren skal præsenteres i en let dekrypteret form for at gøre dem til links:

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

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

UI-postfixet betyder DTO-modeller til frontend, det vil sige, hvad vi skal servere via REST.

Hvad der kan være overraskende her, er, at vi ikke videregiver noget chat-id, og selv ChatMessage/ChatMessageUI-modellen har ikke et. Jeg gjorde dette med vilje for ikke at rode koden i eksemplerne (chattene er isolerede, så vi kan antage, at vi kun har én).

Filosofisk digressionBåde ChatMessageUI-klassen og ChatRestApi.getLast-metoden bruger datatypen List, når det faktisk er et ordnet sæt. Dette er dårligt i JDK, så at erklære rækkefølgen af ​​elementer på grænsefladeniveauet (bevare rækkefølgen ved tilføjelse og fjernelse) vil ikke fungere. Så det er blevet almindelig praksis at bruge en List i tilfælde, hvor der er behov for et bestilt sæt (der er også et LinkedHashSet, men dette er ikke en grænseflade).
Vigtig begrænsning: Vi vil antage, at der ikke er lange kæder af svar eller overførsler. Det vil sige, de findes, men deres længde overstiger ikke tre beskeder. Hele kæden af ​​beskeder skal transmitteres til frontend.

For at modtage data fra eksterne tjenester er der følgende API'er:

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 ses, at eksterne tjenester i starten sørger for batchbehandling, og i begge versioner: gennem Set (uden at bevare rækkefølgen af ​​elementer, med unikke nøgler) og gennem List (der kan være dubletter - rækkefølgen bevares).

Simple implementeringer

Naiv implementering

Den første naive implementering af vores REST-controller vil se nogenlunde sådan ud i de fleste tilfælde:

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 meget klart, og det er et stort plus.

Vi bruger batchbehandling og modtager data fra en ekstern tjeneste i batch. Men hvad sker der med vores produktivitet?

For hver besked vil der blive foretaget et opkald til UserRemoteApi for at få data på forfatterfeltet og et opkald til FileRemoteApi for at få alle vedhæftede filer. Det ser ud til at være det. Lad os sige, at forwardFrom og replyTo felterne for ChatMessage er hentet på en sådan måde, at dette ikke kræver unødvendige opkald. Men at omdanne dem til ChatMessageUI vil føre til rekursion, det vil sige, at opkaldstællere kan stige betydeligt. Som vi bemærkede tidligere, lad os antage, at vi ikke har en masse rede, og kæden er begrænset til tre beskeder.

Som et resultat vil vi få fra to til seks opkald til eksterne tjenester pr. besked og et JPA-opkald for hele pakken af ​​beskeder. Det samlede antal opkald vil variere fra 2*N+1 til 6*N+1. Hvor meget er det i rigtige enheder? Lad os sige, at det tager 20 beskeder at gengive en side. For at modtage dem vil det tage fra 4 s til 10 s. Forfærdeligt! Jeg vil gerne holde det inden for 500 ms. Og da de drømte om at lave sømløs scrolling i frontend, kan ydeevnekravene for dette endepunkt fordobles.

Teknikere:

  1. Koden er kortfattet og selvdokumenterende (et supportteams drøm).
  2. Koden er enkel, så der er næsten ingen muligheder for at skyde sig selv i foden.
  3. Batchbehandling ligner ikke noget fremmed og er organisk integreret i logikken.
  4. Logiske ændringer vil blive foretaget let og vil være lokale.

Minus:

Frygtelig ydeevne på grund af meget små pakker.

Denne tilgang kan ses ret ofte i simple tjenester eller i prototyper. Hvis hastigheden på at lave ændringer er vigtig, er det næppe værd at komplicere systemet. På samme tid er ydeevnen for vores meget enkle service forfærdelig, så anvendelsesområdet for denne tilgang er meget snævert.

Naiv parallel bearbejdning

Du kan begynde at behandle alle beskeder parallelt - dette vil give dig mulighed for at slippe af med den lineære stigning i tid afhængigt af antallet af beskeder. Det er ikke en særlig god vej, fordi det vil resultere i en stor spidsbelastning på den eksterne service.

Implementering af parallel behandling er meget enkel:

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

Ved at bruge parallel meddelelsesbehandling får vi ideelt set 300–700 ms, hvilket er meget bedre end med en naiv implementering, men stadig ikke hurtig nok.

Med denne tilgang vil anmodninger til userRepository og fileRepository blive eksekveret synkront, hvilket ikke er særlig effektivt. For at rette op på dette bliver du nødt til at ændre opkaldslogikken ret meget. 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 ses, at den oprindeligt simple kortlægningskode er blevet mindre forståelig. Det skyldes, at vi var nødt til at adskille opkaldene til eksterne tjenester fra, hvor resultaterne bruges. Dette er i sig selv ikke dårligt. Men at kombinere opkald ser ikke særlig elegant ud og ligner en typisk reaktiv "nuddel".

Hvis du bruger coroutiner, vil alt se mere anstændigt ud:

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 set vil vi ved hjælp af en sådan parallel behandling få 200–400 ms, hvilket allerede er tæt på vores forventninger.

Desværre findes så god parallelisering ikke, og prisen at betale er ret grusom: med kun få brugere, der arbejder på samme tid, vil der falde en byge af forespørgsler på tjenesterne, som alligevel ikke vil blive behandlet parallelt, så vi vender tilbage til vores triste 4 s.

Mit resultat ved brug af en sådan tjeneste er 1300–1700 ms for behandling af 20 beskeder. Dette er hurtigere end i den første implementering, men løser stadig ikke problemet.

Alternativ anvendelse af parallelle forespørgslerHvad hvis tredjepartstjenester ikke leverer batchbehandling? For eksempel kan du skjule manglen på batchbehandlingsimplementering i grænseflademetoder:

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 giver mening, hvis du håber at se batchbehandling i fremtidige versioner.
Teknikere:

  1. Implementer nemt meddelelsesbaseret parallel behandling.
  2. God skalerbarhed.

Ulemper:

  1. Behovet for at adskille dataerhvervelse fra dens behandling, når der behandles anmodninger til forskellige tjenester parallelt.
  2. Øget belastning på tredjepartstjenester.

Det kan ses, at anvendelsesområdet er omtrent det samme som for den naive tilgang. Det giver mening at bruge den parallelle anmodningsmetode, hvis du vil øge ydeevnen af ​​din tjeneste flere gange på grund af andres nådesløse udnyttelse. I vores eksempel steg ydeevnen med 2,5 gange, men det er tydeligvis ikke nok.

caching

Du kan lave caching i JPA's ånd for eksterne tjenester, det vil sige gemme modtagne objekter i en session for ikke at modtage dem igen (inklusive under batchbehandling). Du kan selv lave sådanne caches, du kan bruge Spring med dens @Cacheable, plus du kan altid bruge en færdiglavet cache som EhCache manuelt.

Et almindeligt problem ville være, at caches kun er nyttige, hvis de har hits. I vores tilfælde er hits på forfatterfeltet meget sandsynlige (lad os sige 50%), men der vil slet ikke være hits på filer. Denne tilgang vil give nogle forbedringer, men den vil ikke ændre ydeevnen radikalt (og vi har brug for et gennembrud).

Intersession (lange) caches kræver kompleks invalideringslogik. Generelt, jo senere du kommer i gang med at løse præstationsproblemer ved hjælp af intersession-caches, jo bedre.

Teknikere:

  1. Implementer caching uden at ændre kode.
  2. Øget produktivitet flere gange (i nogle tilfælde).

Ulemper:

  1. Mulighed for nedsat ydeevne ved forkert brug.
  2. Stor hukommelse overhead, især med lange caches.
  3. Kompleks invalidering, fejl, som vil føre til problemer, der er svære at genskabe under kørsel.

Meget ofte bruges caches kun til hurtigt at rette op på designproblemer. Det betyder ikke, at de ikke skal bruges. Du bør dog altid behandle dem med forsigtighed og først evaluere den resulterende præstationsgevinst, og først derefter træffe en beslutning.

I vores eksempel vil caches give en ydelsesforøgelse på omkring 25 %. Samtidig har cacher ret mange ulemper, så dem ville jeg ikke bruge her.

Resultaterne af

Så vi så på en naiv implementering af en tjeneste, der bruger batchbehandling, og nogle enkle måder at fremskynde den på.

Den største fordel ved alle disse metoder er enkelhed, hvorfra der er mange behagelige konsekvenser.

Et almindeligt problem med disse metoder er dårlig ydeevne, primært på grund af pakkernes størrelse. Derfor, hvis disse løsninger ikke passer dig, så er det værd at overveje mere radikale metoder.

Der er to hovedretninger, hvor du kan lede efter løsninger:

  • asynkront arbejde med data (kræver et paradigmeskifte, så diskuteres ikke i denne artikel);
  • forstørrelse af batches og samtidig opretholde synkron behandling.

Forstørrelse af batches vil reducere antallet af eksterne opkald kraftigt og samtidig holde koden synkron. Den næste del af artiklen vil blive afsat til dette emne.

Kilde: www.habr.com

Tilføj en kommentar