Problem med bearbetning av batchfrågor och deras lösningar (del 1)

Problem med bearbetning av batchfrågor och deras lösningar (del 1)Nästan alla moderna mjukvaruprodukter består av flera tjänster. Ofta blir långa svarstider för interservicekanaler en källa till prestandaproblem. Standardlösningen på denna typ av problem är att packa flera förfrågningar mellan olika tjänster i ett paket, vilket kallas batchning.

Om du använder batchbearbetning kanske du inte är nöjd med resultaten när det gäller prestanda eller kodtydlighet. Den här metoden är inte så lätt för den som ringer som du kanske tror. För olika ändamål och i olika situationer kan lösningarna variera mycket. Med hjälp av specifika exempel kommer jag att visa för- och nackdelar med flera tillvägagångssätt.

Demonstrationsprojekt

För tydlighetens skull, låt oss titta på ett exempel på en av tjänsterna i applikationen som jag för närvarande arbetar med.

Förklaring av plattformsval för exempelProblemet med dålig prestanda är ganska generellt och berör inte några specifika språk eller plattformar. Den här artikeln kommer att använda Spring + Kotlin-kodexempel för att visa problem och lösningar. Kotlin är lika förståeligt (eller obegripligt) för Java- och C#-utvecklare; dessutom är koden mer kompakt och begriplig än i Java. För att göra saker lättare att förstå för rena Java-utvecklare kommer jag undvika Kotlins svarta magi och enbart använda den vita magin (i Lomboks anda). Det kommer att finnas några förlängningsmetoder, men de är faktiskt bekanta för alla Java-programmerare som statiska metoder, så detta kommer att vara ett litet socker som inte kommer att förstöra smaken på rätten.
Det finns en tjänst för dokumentgodkännande. Någon skapar ett dokument och skickar in det för diskussion, under vilket redigeringar görs, och i slutändan kommer man överens om dokumentet. Godkännandetjänsten själv vet ingenting om dokument: det är bara en pratstund av godkännare med små tilläggsfunktioner som vi inte kommer att överväga här.

Så det finns chattrum (motsvarande dokument) med en fördefinierad uppsättning deltagare i var och en av dem. Som i vanliga chattar innehåller meddelanden text och filer och kan vara svar eller vidarebefordran:

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- och användarlänkar är länkar till andra domäner. Här bor vi så här:

typealias FileReference Long
typealias UserReference Long

Användardata lagras i Keycloak och tas emot via REST. Detsamma gäller filer: filer och metainformation om dem finns i en separat fillagringstjänst.

Alla samtal till dessa tjänster är tunga förfrågningar. Detta innebär att omkostnaderna för att transportera dessa förfrågningar är mycket större än den tid det tar för dem att behandlas av en tredjepartstjänst. På våra testbänkar är den typiska samtalstiden för sådana tjänster 100 ms, så vi kommer att använda dessa nummer i framtiden.

Vi behöver göra en enkel REST-kontroller för att ta emot de sista N meddelandena med all nödvändig information. Det vill säga vi tror att i frontend är meddelandemodellen nästan densamma och all data måste skickas. Skillnaden mellan front-end-modellen är att filen och användaren måste presenteras i en något dekrypterad form för att göra dem länkar:

/** В таком виде отдаются ссылки на сущности для фронта */
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 behöver implementera följande:

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

Postfix UI betyder DTO-modeller för frontend, det vill säga vad vi måste servera via REST.

Vad som kan vara förvånande här är att vi inte skickar någon chattidentifierare och även i ChatMessage/ChatMessageUI-modellen finns det ingen. Jag gjorde detta med avsikt för att inte röra koden för exemplen (chattarna är isolerade, så vi kan anta att vi bara har en).

Filosofisk utvikningBåde ChatMessageUI-klassen och ChatRestApi.getLast-metoden använder datatypen List, när det i själva verket är en beställd uppsättning. I JDK är allt dåligt, så att deklarera ordningen på element på gränssnittsnivå (bevara ordningen vid tillägg och hämtning) kommer inte att fungera. Så det har blivit vanligt att använda List i de fall där ett beställt Set behövs (det finns också LinkedHashSet, men detta är inte ett gränssnitt).
Viktig begränsning: Vi kommer att anta att det inte finns några långa kedjor av svar eller överföringar. Det vill säga de finns, men deras längd överstiger inte tre meddelanden. Hela kedjan av meddelanden måste överföras till frontend.

För att ta emot data från externa tjänster finns följande 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 att externa tjänster initialt tillhandahåller batchbehandling, och i båda varianterna: genom Set (utan att bevara ordningen på element, med unika nycklar) och genom List (det kan finnas dubbletter - ordningen bevaras).

Enkla implementeringar

Naiv implementering

Den första naiva implementeringen av vår REST-kontroller kommer att se ut ungefär så här i de flesta fall:

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

Allt är väldigt tydligt, och detta är ett stort plus.

Vi använder batchbearbetning och tar emot data från en extern tjänst i omgångar. Men vad händer med vår produktivitet?

För varje meddelande kommer ett anrop till UserRemoteApi att göras för att få data om författarens fält och ett anrop till FileRemoteApi för att få alla bifogade filer. Det verkar som att det är det. Låt oss säga att forwardFrom och replyTo-fälten för ChatMessage erhålls på ett sådant sätt att detta inte kräver onödiga samtal. Men att förvandla dem till ChatMessageUI kommer att leda till rekursion, det vill säga att samtalsräknarna kan öka avsevärt. Som vi noterade tidigare, låt oss anta att vi inte har mycket häckande och kedjan är begränsad till tre meddelanden.

Som ett resultat kommer vi att få från två till sex samtal till externa tjänster per meddelande och ett JPA-samtal för hela meddelandepaketet. Det totala antalet samtal kommer att variera från 2*N+1 till 6*N+1. Hur mycket är detta i riktiga enheter? Låt oss säga att det tar 20 meddelanden för att rendera en sida. För att få dem behöver du från 4 s till 10 s. Fruktansvärd! Jag skulle vilja hålla det inom 500ms. Och eftersom de drömde om att göra sömlös rullning på frontend, kan prestandakraven för denna slutpunkt fördubblas.

Fördelar:

  1. Koden är kortfattad och självdokumenterande (ett supportteams dröm).
  2. Koden är enkel, så det finns nästan inga möjligheter att skjuta sig själv i foten.
  3. Batchbearbetning ser inte ut som något främmande och är organiskt integrerat i logiken.
  4. Logiska förändringar kommer att vara enkla att göra och kommer att vara lokala.

Minus:

Hemsk prestanda på grund av mycket små paket.

Detta tillvägagångssätt kan ses ganska ofta i enkla tjänster eller i prototyper. Om snabbheten att göra ändringar är viktig är det knappast värt att komplicera systemet. Samtidigt är prestandan för vår mycket enkla tjänst fruktansvärd, så tillämpningsområdet för detta tillvägagångssätt är mycket snävt.

Naiv parallell bearbetning

Du kan börja behandla alla meddelanden parallellt - detta gör att du kan bli av med den linjära ökningen i tid beroende på antalet meddelanden. Detta är inte en särskilt bra väg eftersom det kommer att resultera i en stor toppbelastning på den externa tjänsten.

Att implementera parallell bearbetning är mycket enkelt:

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

Med parallell meddelandebehandling får vi 300–700 ms idealiskt, vilket är mycket bättre än med en naiv implementering, men ändå inte tillräckligt snabbt.

Med detta tillvägagångssätt kommer förfrågningar till userRepository och fileRepository att exekveras synkront, vilket inte är särskilt effektivt. För att fixa detta måste du ändra samtalslogiken ganska mycket. Till exempel, via CompletionStage (alias 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 att den initialt enkla mappningskoden har blivit mindre begriplig. Det beror på att vi var tvungna att separera samtal till externa tjänster där resultaten används. Detta i sig är inte dåligt. Men att kombinera samtal ser inte särskilt elegant ut och liknar en typisk reaktiv "nudel".

Om du använder koroutiner kommer allt att se mer anständigt 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()
    )
  }

Var:

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

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

Tyvärr sker inte så bra parallellisering, och priset att betala är ganska grymt: med bara ett fåtal användare som arbetar samtidigt kommer tjänsterna att drabbas av en uppsjö av förfrågningar som ändå inte kommer att behandlas parallellt, så vi kommer tillbaka till våra sorgliga 4:or.

Mitt resultat när jag använder en sådan tjänst är 1300–1700 ms för att behandla 20 meddelanden. Detta är snabbare än i den första implementeringen, men löser fortfarande inte problemet.

Alternativ användning av parallella frågorVad händer om tredjepartstjänster inte tillhandahåller batchbearbetning? Till exempel kan du dölja bristen på implementering av batchbearbetning i gränssnittsmetoder:

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

Detta är vettigt om du hoppas att se batchbearbetning i framtida versioner.
Fördelar:

  1. Implementera enkelt meddelandebaserad parallell bearbetning.
  2. Bra skalbarhet.

Nackdelar:

  1. Behovet av att separera datainsamling från dess behandling vid parallellbehandling av förfrågningar till olika tjänster.
  2. Ökad belastning på tredjepartstjänster.

Man kan se att tillämpningsområdet är ungefär detsamma som det naiva synsättet. Det är vettigt att använda metoden för parallell begäran om du vill öka prestandan för din tjänst flera gånger på grund av andras skoningslösa utnyttjande. I vårt exempel ökade produktiviteten med 2,5 gånger, men det räcker uppenbarligen inte.

cachelagring

Du kan göra cachning i JPA:s anda för externa tjänster, det vill säga lagra mottagna objekt inom en session för att inte ta emot dem igen (inklusive under batchbearbetning). Du kan göra sådana cacher själv, du kan använda Spring med dess @Cacheable, plus att du alltid kan använda en färdig cache som EhCache manuellt.

Ett vanligt problem skulle vara att cacher bara är användbara om de har träffar. I vårt fall är träffar på författarfältet mycket troliga (låt oss säga 50%), men det kommer inte att finnas några träffar på filer alls. Detta tillvägagångssätt kommer att ge vissa förbättringar, men det kommer inte att radikalt förändra produktiviteten (och vi behöver ett genombrott).

Intersession (långa) cacher kräver komplex ogiltigförklaringslogik. Generellt sett gäller att ju senare du kommer in på att lösa prestandaproblem med intersession-cachar, desto bättre.

Fördelar:

  1. Implementera cachning utan att ändra kod.
  2. Ökad produktivitet flera gånger (i vissa fall).

Nackdelar:

  1. Möjlighet till minskad prestanda vid felaktig användning.
  2. Stort minne överhead, speciellt med långa cacher.
  3. Komplex ogiltigförklaring, fel i vilka kommer att leda till svåra att återskapa problem under körning.

Mycket ofta används cacher bara för att snabbt korrigera designproblem. Det betyder inte att de inte ska användas. Du bör dock alltid behandla dem med försiktighet och först utvärdera den resulterande prestationsvinsten och först därefter fatta ett beslut.

I vårt exempel kommer cachar ge en prestandaökning på cirka 25 %. Samtidigt har cacher ganska många nackdelar, så jag skulle inte använda dem här.

Resultat av

Så vi tittade på en naiv implementering av en tjänst som använder batchbearbetning, och flera enkla sätt att påskynda den.

Den största fördelen med alla dessa metoder är enkelheten, från vilken det finns många trevliga konsekvenser.

Ett vanligt problem med dessa metoder är dålig prestanda, främst relaterade till storleken på paketen. Därför, om dessa lösningar inte passar dig, är det värt att överväga mer radikala metoder.

Det finns två huvudriktningar där du kan leta efter lösningar:

  • asynkront arbete med data (kräver ett paradigmskifte, så det diskuteras inte i den här artikeln);
  • förstoring av partier med bibehållen synkron bearbetning.

Förstoring av partier kommer att kraftigt minska antalet externa samtal och samtidigt hålla koden synkron. Nästa del av artikeln kommer att ägnas åt detta ämne.

Källa: will.com

Lägg en kommentar