Problem med batchbearbetning av förfrågningar och deras lösningar (del 1)

Problem med batchbearbetning av förfrågningar och deras lösningar (del 1)Nästan alla moderna programvaror består av flera tjänster. Ofta blir den långa svarstiden mellan tjänstekanaler en källa till prestandaproblem. En standardlösning för sådana problem är att paketera flera mellanservicemandat i ett enda paket, vilket kallas batchbearbetning.

Om du använder batchbearbetning kanske du inte är nöjd med resultatet när det gäller prestanda eller kodens tydlighet. Denna metod är inte så enkel för anropande part som man skulle kunna tro. För olika syften och i olika situationer kan lösningarna variera kraftigt. Med konkreta exempel kommer jag att visa för- och nackdelarna med flera tillvägagångssätt.

Demonstrationsprojekt

För att illustrera det, låt oss ta ett exempel på en av tjänsterna i applikationen som jag arbetar med just nu.

Förklaring av plattformsvalet för exemplenProblemet med dålig prestanda är ganska allmänt och berör inte specifika språk och plattformar. I denna artikel kommer exempel på kod som demonstrerar problem och lösningar att använda Spring + Kotlin. Kotlin är lika förståeligt (eller otydligt) för Java- och C#-utvecklare, och dessutom blir koden mer kompakt och förståelig än med Java. För att underlätta förståelsen för rena Java-utvecklare kommer jag att undvika Kotlin svart magi och använda endast vit (i Lombok-anda). Det kommer att bli några extension-metoder, men de är faktiskt bekanta för alla Java-programmerare som static-metoder, så detta kommer att vara ett litet socker som inte fördärvar smakupplevelsen.
Det finns en tjänst för dokumentgranskning. Någon skapar ett dokument och presenterar det för diskussion, där ändringar görs, och slutligen godkänns dokumentet. Själva dokumentgranskningstjänsten vet ingenting om dokumenten: det är bara en chattform för de som godkänner med några små ytterligare funktioner som vi inte kommer att diskutera här.

Så, det finns chattrum (som motsvarar dokumenten) med en förutbestämd uppsättning deltagare i varje. Som i vanliga chattar innehåller meddelanden text och filer och kan vara svar (reply) och vidarebefordringar (forward):

data klass ChatMeddelande(
  // nullable так как появляется только после persist
  val id: Lång? = null,
  /** Ссылка на автора */
  val författare: AnvändarReferens,
  /** Сообщение */
  val meddelande: Sträng,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val files: Lista<FilReferens?> = null,
  /** Если является ответом, то здесь будет оригинал */
  val svaraPå: ChatMeddelande? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val vidareFrån: ChatMeddelande? = null
)

Länkar till filer och användare är länkar till andra. domänerSå här lever det hos oss:

typalias FilReferens = Lång
typalias AnvändarReferens = Lång

Användardata lagras i Keycloak och hämtas via REST. Detsamma gäller filer: filer och metadata om dem finns i en separat filhanteringstjänst.

Alla anrop till dessa tjänster är tunga förfrågningar. Detta innebär att transportkostnaderna för dessa förfrågningar är mycket högre än den tid som behövs för att behandla dem av en extern tjänst. På våra testmiljöer är den typiska tiden för anrop till sådana tjänster 100 ms, så vi kommer framöver att använda dessa siffror.

Vi behöver skapa en enkel REST-kontroller för att hämta de senaste N meddelandena med all nödvändig information. Det vill säga, vi antar att frontend-modellen för meddelanden är nästan densamma och att vi behöver skicka vidare all data. Skillnaden i modellen för frontend är att filen och användaren behöver presenteras i en något avkodad form för att göra dem till länkar:

/** В таком виде отдаются ссылки на сущности для фронта */
data klass ReferensUI(
  /** Идентификатор для url */
  val ref: Sträng,
  /** Видимое пользователю название ссылки */
  val namn: Sträng
)
data klass ChatMeddelandeUI(
  val id: Lång,
  /** Ссылка на автора */
  val författare: ReferensUI,
  /** Сообщение */
  val meddelande: Sträng,
  /** Ссылки на аттачи */
  val files: Lista<ReferensUI> ,
  /** Если являтся ответом, то здесь будет оригинал */
  val svaraPå: ChatMeddelandeUI? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val vidareFrån: ChatMeddelandeUI? = null
)

Vi behöver implementera följande:

interface ChatRestApi {
  kul getLast(n: Int): Lista<ChatMeddelandeUI>
}

Postfix UI betyder DTO-modeller för frontend, det vill säga det som vi ska leverera via REST.

Det kan tyckas märkligt att vi inte överför något chat-ID och att det inte ens finns i modellen ChatMessage/ChatMessageUI. Jag gjorde detta avsiktligt för att inte överbelasta kodexemplen (chatter är isolerade, så vi kan anta att vi har dem en och en).

Filosofisk avstickareBåde i klassen ChatMessageUI och i metoden ChatRestApi.getLast används datatypen List, medan det i själva verket är en ordnad Set. JDK hanterar detta dåligt, så vi kan inte deklarera ordningen på elementen på gränssnittsnivå (bevara ordningen vid tillägg och hämtning). Så det har blivit en allmän praxis att använda List i de fall där en ordnad Set behövs (det finns också LinkedHashSet, men det är inte ett gränssnitt).
Viktig begränsning: vi förutsätter att långa kedjor av svar eller vidarebefordringar inte existerar. Det finns sådana kedjor, men deras längd överstiger inte tre meddelanden. I frontend ska kedjan av meddelanden överföras i sin helhet.

För att hämta data från externa tjänster finns följande API:

interface ChatMessageRepository {
  kul findLast(n: Int): Lista<ChatMeddelande>
}
data klass FileHeadRemote(
  val id: FilReferens,
  val namn: Sträng
)
interface FileRemoteApi {
  kul getHeadById(id: FilReferens): FileHeadRemote
  kul getHeadsByIds(id: Ställ in<FilReferens>): Ställ in<FileHeadRemote>
  kul getHeadsByIds(id: Lista<FilReferens>): Lista<FileHeadRemote>
  kul getHeadsByChat(): Lista<FileHeadRemote>
}
data klass UserRemote(
  val id: AnvändarReferens,
  val namn: Sträng
)
interface UserRemoteApi {
  kul getUserById(id: AnvändarReferens): UserRemote
  kul getUsersByIds(id: Ställ in<AnvändarReferens>): Ställ in<UserRemote>
  kul getUsersByIds(id: Lista<AnvändarReferens>): Lista<UserRemote>
}

Det är tydligt att externa tjänster ursprungligen är designade för batchbearbetning, både genom Set (utan att bevara ordningen på elementen, med unika nycklar) och genom List (kan ha dublikat – ordningen bevaras).

Enkla implementationer

Naiv implementering

Den första naiva implementeringen av vår REST-controller kommer i de flesta fall att se ut något i stil med detta:

klass ChatRestController(
  privat val meddelandeRepository: ChatMessageRepository,
  privat val användareRepository: UserRemoteApi,
  privat val filRepository: FileRemoteApi
) : ChatRestApi {
  överskridande fun getLast(n: Int) =
    meddelandeRepository.findLast(n)
      .map { det.tillFrontModel() }
  
  privat fun ChatMeddelande.tillFrontModel(): ChatMeddelandeUI =
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this måste sparas"),
      författare = användareRepository.getUserById(författare).tillFrontReference(),
      meddelande = meddelande,
      filer = filer?.låt { filer ->
        filRepository.getHeadsByIds(filer)
          .map { det.tillFrontReference() }
      } ?: listaÖver(),
      vidareFrån = vidareFrån?.tillFrontModel(),
      svaraTill = svaraTill?.tillFrontModel()
    )
}

Allt är tydligt och det är en stor fördel.

Vi använder batchbehandling och hämtar data från en extern tjänst i paket. Men hur står det till med prestandan?

För varje meddelande kommer ett anrop till UserRemoteApi att göras för att hämta data för fältet author och ett anrop till FileRemoteApi för att hämta alla bifogade filer. Det verkar vara allt. Låt oss anta att fälten forwardFrom och replyTo för ChatMessage fås på ett sätt som inte kräver några extra anrop. Men att omvandla dem till ChatMessageUI kommer att leda till rekursion, vilket innebär att antalet anrop kan öka kraftigt. Som vi nämnde tidigare, låt oss anta att vi inte har mycket djup och kedjan är begränsad till tre meddelanden.

I slutändan kommer vi att få mellan två och sex anrop till externa tjänster per meddelande och ett JPA-anrop för hela meddelandepaketet. Det totala antalet anrop kommer att variera från 2*N+1 till 6*N+1. Hur mycket är detta i verkliga enheter? Anta att 20 meddelanden behövs för att rendera sidan. För att få dem kommer det att ta mellan 4 och 10 sekunder. Förfärligt! Vi skulle vilja hålla oss under 500 ms. Eftersom front-end hade en vision om att skapa en sömlös scrollning kan kraven på prestanda för denna endpoint dubblas.

Fördelar:

  1. Koden är kort och självförklarande (en dröm för supporten).
  2. Koden är enkel, så möjligheterna till självskada är nästan obefintliga.
  3. Batchbehandling verkar inte vara något främmande och smälter organiskt in i logiken.
  4. Ändringar i logiken kommer att införas enkelt och vara lokala.

Nackdel:

Fruktansvärd prestanda kopplad till att paketen är mycket små.

Denna metod kan ofta ses i enklare tjänster eller prototyper. Om hastigheten för ändringar är viktig, är det troligtvis inte värt att komplicera systemet. Samtidigt får vår mycket enkla tjänst en fruktansvärd prestanda, så tillämpningsområdet för en sådan metod är mycket begränsat.

Naiv parallellbehandling

Behandling av alla meddelanden kan köras parallellt - detta kommer att eliminera den linjära tillväxten av tid beroende på antalet meddelanden. Detta är inte särskilt bra, eftersom det kommer att leda till en stor toppbelastning på den externa tjänsten.

Det är mycket enkelt att införa parallell bearbetning:

överskridande fun getLast(n: Int) =
  meddelandeRepository.findLast(n).parallelStream()
    .map { det.tillFrontModel() }
    .collect(toList())

Genom att använda parallell bearbetning av meddelanden kan vi uppnå 300–700 ms i idealfallet, vilket är mycket bättre än med en naiv implementering, men fortfarande inte tillräckligt snabbt.

Med denna metod kommer anropen till userRepository och fileRepository att utföras synkront, vilket inte är särskilt effektivt. För att lösa detta måste vi förändra logiken för anropen ganska mycket. Till exempel med CompletionStage (även kallad CompletableFuture):

privat fun ChatMeddelande.tillFrontModel(): ChatMeddelandeUI =
  CompletableFuture.supplyAsync {
    userRepository.getUserById(author).tillFrontReference()
  }.thenCombine(
    files?.låt {
      CompletableFuture.supplyAsync {
        fileRepository.getHeadsByIds(files).map { det.tillFrontReference() }
      }
    } ?: CompletableFuture.completedFuture(listaÖver())
  ) { author, filer ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this måste sparas"),
      author = author,
      meddelande = meddelande,
      files = filer,
      vidareFrån = vidareFrån?.tillFrontModel(),
      svaraTill = svaraTill?.tillFrontModel()
    )
  }.get()!!

Det är tydligt att den ursprungligen enkla mappningskoden har blivit mindre begriplig. Detta beror på att vi var tvungna att separera anropen till externa tjänster från platsen där resultaten används. I sig själv är det inte dåligt. Men kombinationen av anropen ser inte särskilt elegant ut och påminner om typisk reaktiv 'pasta'.

Om vi använder korutiner kommer allt att se mycket bättre ut:

privat fun ChatMeddelande.tillFrontModel(): ChatMeddelandeUI =
  join(
    { userRepository.getUserById(author).tillFrontReference() },
    { files?.låt { fileRepository.getHeadsByIds(filer)
      .map { det.tillFrontReference() } } ?: listaÖver() }
  ).låt { (författare, filer) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this måste sparas"),
      författare = författare,
      meddelande = meddelande,
      filer = filer,
      vidareFrån = vidareFrån?.tillFrontModel(),
      svaraTill = svaraTill?.tillFrontModel()
    )
  }

Hvor:

kul <A, B> join(a: () -> A, b: () -> B) =
  runBlocking(IO) {
    awaitAll(async { a() }, async { b() })
  }.låt {
    det[0] som A till det[1] som B
  }

Teoretiskt sett, med sådan parallell bearbetning, kan vi uppnå 200–400 ms, vilket redan ligger nära våra förväntningar.

Tyvärr finns det sällan så bra parallellisering, och priset är ganska högt: vid samtidig användning av bara några få användare kommer tjänsterna att drabbas av en överväldigande mängd förfrågningar som ändå inte kommer att behandlas parallellt, så vi kommer att återgå till våra sorgliga 4 s.

Mitt resultat med en sådan tjänst är 1300–1700 ms för att bearbeta 20 meddelanden. Det är snabbare än i den första implementeringen, men löser ändå inte problemet.

Alternativ tillämpning av parallella förfrågningarVad händer om externa tjänster inte har stöd för batchbearbetning? Till exempel kan vi dölja avsaknaden av batchbearbetning inom metodernas gränssnitt:

interface UserRemoteApi {
  kul getUserById(id: AnvändarReferens): UserRemote
  kul getUsersByIds(id: Ställ in<AnvändarReferens>): Ställ in<UserRemote> =
    id.parallelStream()
      .map { getUserById(det) }.collect(toSet())
  kul getUsersByIds(id: Lista<AnvändarReferens>): Lista<UserRemote> =
    id.parallelStream()
      .map { getUserById(det) }.collect(toList())
}

Detta är meningsfullt om vi har hopp om att batchbearbetning kommer att dyka upp i senare versioner.
Fördelar:

  1. Lätt implementering av parallell bearbetning av meddelanden.
  2. Bra skalbarhet.

Nackdelar:

  1. Behovet av att separera datainsamling från bearbetning vid parallell bearbetning av förfrågningar till olika tjänster.
  2. Ökad belastning på externa tjänster.

Det är tydligt att tillämpningsområdena är ungefär desamma som i den naiva metoden. Att använda parallella förfrågningar är meningsfullt om du vill öka prestandan för din tjänst flera gånger genom att utnyttja externa resurser. I vårt exempel ökade prestandan med 2,5 gånger, men detta är uppenbarligen otillräckligt.

Caching

Caching kan göras i stil med JPA för externa tjänster, det vill säga att lagra de mottagna objekten inom sessionen för att undvika att hämta dem igen (även vid batchbehandling). Du kan skapa sådana cacher själv, använda Spring med dess @Cacheable, eller alltid använda en färdig cache som EhCache manuellt.

Det övergripande problemet kommer att vara att cacher är bara användbara om det finns träffar. I vårt fall är träffar på fältet författare (låt oss säga 50 %) sannolika, medan det inte kommer att finnas några träffar på filerna överhuvudtaget. Vissa förbättringar kommer denna metod att ge, men den kommer inte att förändra prestandan radikalt (och vi behöver ett genombrott).

Inter-session (långa) cacher kräver komplex invalidationslogik. Generellt sett, ju senare du hamnar i att lösa prestandaproblem med inter-session cacher, desto bättre.

Fördelar:

  1. Implementering av caching utan att ändra koden.
  2. Prestandaförbättring flera gånger (i vissa fall).

Nackdelar:

  1. Risk för prestandaförsämring vid felaktig användning.
  2. Stora minnesöverhead, särskilt med långa cacher.
  3. Komplex invalidation, där fel kan leda till svårt reproducerbara problem i runtime.

Ofta används cacher endast för att snabbt laga designproblem. Det betyder inte att de inte ska användas. Men det är alltid bäst att vara försiktig med dem och först utvärdera den prestandaförbättring som uppnåtts, innan ett beslut fattas.

I vårt exempel kommer det att bli en prestandaförbättring på cirka 25 % från cacherna. Men det finns ganska många nackdelar med cacher, så jag skulle inte rekommendera att använda dem här.

Sammanfattning

Så, vi har tittat på en naiv implementation av tjänsten som använder batchbehandling och några enkla sätt att snabba upp den.

Den största fördelen med alla dessa metoder är enkelheten, som har många trevliga konsekvenser.

Ett gemensamt problem med dessa metoder är dålig prestanda, som främst beror på paketens storlek. Om dessa lösningar inte passar, bör du överväga mer radikala metoder.

Det finns två huvudsakliga inriktningar där man kan söka efter lösningar:

  • asynkron hantering av data (kräver ett skifte i paradigm, varför detta inte diskuteras i denna artikel);
  • öka paketens storlek samtidigt som synkron bearbetning bibehålls.

Att öka paketens storlek kommer att kraftigt minska antalet externa anrop och samtidigt behålla koden synkron. Detta ämne kommer att behandlas i nästa del av artikeln.

Källa: habr.com

Köp pålitlig webbhotell med DDoS-skydd, VPS VDS-servrar 🔥 Köp pålitlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster