Problemen met batchqueryverwerking en hun oplossingen (deel 1)

Problemen met batchqueryverwerking en hun oplossingen (deel 1)Vrijwel alle moderne softwareproducten bestaan ​​uit verschillende diensten. Vaak worden lange reactietijden van interservicekanalen een bron van prestatieproblemen. De standaardoplossing voor dit soort problemen is het verpakken van meerdere interserviceverzoeken in één pakket, wat batching wordt genoemd.

Als u batchverwerking gebruikt, bent u mogelijk niet tevreden met de resultaten in termen van prestaties of duidelijkheid van de code. Deze methode is niet zo gemakkelijk voor de beller als je zou denken. Voor verschillende doeleinden en in verschillende situaties kunnen oplossingen sterk variëren. Aan de hand van specifieke voorbeelden zal ik de voor- en nadelen van verschillende benaderingen laten zien.

Demonstratieproject

Laten we voor de duidelijkheid eens kijken naar een voorbeeld van een van de services in de applicatie waar ik momenteel aan werk.

Uitleg platformselectie voor voorbeeldenHet probleem van slechte prestaties is vrij algemeen en heeft geen betrekking op specifieke talen of platforms. In dit artikel worden Spring + Kotlin-codevoorbeelden gebruikt om problemen en oplossingen te demonstreren. Kotlin is even begrijpelijk (of onbegrijpelijk) voor Java- en C#-ontwikkelaars; bovendien is de code compacter en begrijpelijker dan in Java. Om de zaken begrijpelijker te maken voor pure Java-ontwikkelaars, zal ik de zwarte magie van Kotlin vermijden en alleen de witte magie gebruiken (in de geest van Lombok). Er zullen een paar uitbreidingsmethoden zijn, maar deze zijn bij alle Java-programmeurs bekend als statische methoden, dus dit zal een klein suikertje zijn dat de smaak van het gerecht niet zal verpesten.
Er is een documentgoedkeuringsservice. Iemand maakt een document en legt dit ter discussie voor, waarbij wijzigingen worden aangebracht en uiteindelijk overeenstemming wordt bereikt over het document. De goedkeuringsdienst zelf weet niets van documenten: het is slechts een praatje van goedkeurders met kleine extra functies die we hier niet zullen bespreken.

Er zijn dus chatrooms (die overeenkomen met documenten) met in elk ervan een vooraf gedefinieerde groep deelnemers. Net als bij gewone chats bevatten berichten tekst en bestanden en kunnen ze beantwoord of doorgestuurd worden:

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
)

Bestands- en gebruikerslinks zijn links naar andere domeinen. Hier leven we zo:

typealias FileReference Long
typealias UserReference Long

Gebruikersgegevens worden opgeslagen in Keycloak en ontvangen via REST. Hetzelfde geldt voor bestanden: bestanden en meta-informatie daarover bevinden zich in een aparte bestandsopslagdienst.

Alle oproepen naar deze diensten zijn zware verzoeken. Dit betekent dat de overhead van het transporteren van deze verzoeken veel groter is dan de tijd die nodig is om ze te verwerken door een service van derden. Op onze testbanken is de typische beltijd voor dergelijke diensten 100 ms, dus we zullen deze nummers in de toekomst gebruiken.

We moeten een eenvoudige REST-controller maken om de laatste N-berichten met alle benodigde informatie te ontvangen. Dat wil zeggen dat wij van mening zijn dat in de frontend het berichtenmodel vrijwel hetzelfde is en dat alle data verzonden moet worden. Het verschil tussen het front-endmodel is dat het bestand en de gebruiker in een enigszins gedecodeerde vorm moeten worden gepresenteerd om er links van te kunnen maken:

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

We moeten het volgende implementeren:

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

Postfix UI betekent DTO-modellen voor de frontend, dat wil zeggen, wat we via REST moeten bedienen.

Wat hier misschien verrassend is, is dat we geen chat-ID doorgeven en zelfs in het ChatMessage/ChatMessageUI-model is er geen. Ik heb dit met opzet gedaan om de code van de voorbeelden niet onoverzichtelijk te maken (de chats zijn geïsoleerd, dus we kunnen ervan uitgaan dat we er maar één hebben).

Filosofische uitweidingZowel de klasse ChatMessageUI als de methode ChatRestApi.getLast gebruiken het gegevenstype List, terwijl het in feite een geordende set is. In de JDK is dit allemaal slecht, dus het aangeven van de volgorde van elementen op interfaceniveau (de volgorde behouden bij het toevoegen en ophalen) zal niet werken. Het is dus gebruikelijk geworden om List te gebruiken in gevallen waarin een bestelde Set nodig is (er is ook LinkedHashSet, maar dit is geen interface).
Belangrijke beperking: We gaan ervan uit dat er geen lange reeksen van antwoorden of overdrachten zijn. Dat wil zeggen, ze bestaan, maar hun lengte is niet groter dan drie berichten. De hele keten van berichten moet naar de frontend worden verzonden.

Om gegevens van externe diensten te ontvangen zijn er de volgende API’s:

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

Het is duidelijk dat externe services in eerste instantie zorgen voor batchverwerking, en in beide varianten: via Set (zonder de volgorde van de elementen te behouden, met unieke sleutels) en via List (er kunnen duplicaten zijn - de volgorde blijft behouden).

Eenvoudige implementaties

Naïeve implementatie

De eerste naïeve implementatie van onze REST-controller zal er in de meeste gevallen ongeveer zo uitzien:

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

Alles is heel duidelijk, en dit is een groot pluspunt.

Wij maken gebruik van batchverwerking en ontvangen gegevens van een externe dienst in batches. Maar wat gebeurt er met onze productiviteit?

Voor elk bericht wordt één aanroep naar UserRemoteApi gedaan om gegevens over het auteurveld op te halen en één aanroep naar FileRemoteApi om alle bijgevoegde bestanden op te halen. Dat lijkt het te zijn. Laten we zeggen dat de velden forwardFrom en respondTo voor ChatMessage zo worden verkregen dat hiervoor geen onnodige oproepen nodig zijn. Maar het omzetten ervan in ChatMessageUI zal tot recursie leiden, dat wil zeggen dat de oproeptellers aanzienlijk kunnen toenemen. Laten we, zoals we eerder opmerkten, aannemen dat er niet veel nesting is en dat de keten beperkt is tot drie berichten.

Als gevolg hiervan zullen we per bericht twee tot zes oproepen naar externe diensten ontvangen en één JPA-oproep voor het hele berichtenpakket. Het totaal aantal oproepen zal variëren van 2*N+1 tot 6*N+1. Hoeveel is dit in echte eenheden? Laten we zeggen dat er 20 berichten nodig zijn om een ​​pagina weer te geven. Om ze te krijgen, heb je 4 tot 10 seconden nodig. Vreselijk! Ik wil het binnen 500 ms houden. En omdat ze ervan droomden om naadloos te scrollen op de frontend, kunnen de prestatie-eisen voor dit eindpunt worden verdubbeld.

Voors:

  1. De code is beknopt en zelfdocumenterend (de droom van elk ondersteuningsteam).
  2. De code is eenvoudig, waardoor er bijna geen mogelijkheden zijn om jezelf in de voet te schieten.
  3. Batchverwerking ziet er niet uit als iets vreemds en is organisch geïntegreerd in de logica.
  4. Logische veranderingen zullen gemakkelijk door te voeren zijn en zullen lokaal zijn.

minder:

Vreselijke prestaties vanwege zeer kleine pakketten.

Deze aanpak is vrij vaak te zien in eenvoudige services of in prototypes. Als de snelheid waarmee veranderingen worden doorgevoerd belangrijk is, is het nauwelijks de moeite waard om het systeem ingewikkeld te maken. Tegelijkertijd zijn de prestaties voor onze zeer eenvoudige service verschrikkelijk, dus de reikwijdte van de toepasbaarheid van deze aanpak is erg beperkt.

Naïeve parallelle verwerking

U kunt beginnen met het parallel verwerken van alle berichten - hierdoor kunt u de lineaire tijdstoename, afhankelijk van het aantal berichten, wegnemen. Dit is geen bijzonder goed traject omdat het een grote piekbelasting op de externe dienst tot gevolg zal hebben.

Het implementeren van parallelle verwerking is heel eenvoudig:

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

Als we parallelle berichtverwerking gebruiken, halen we idealiter 300-700 ms, wat veel beter is dan met een naïeve implementatie, maar nog steeds niet snel genoeg.

Met deze aanpak worden verzoeken aan userRepository en fileRepository synchroon uitgevoerd, wat niet erg efficiënt is. Om dit op te lossen, moet u de oproeplogica behoorlijk veranderen. Bijvoorbeeld via CompletionStage (ook bekend als 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()!!

Het is duidelijk dat de aanvankelijk eenvoudige mappingcode minder begrijpelijk is geworden. Dit komt omdat we oproepen naar externe diensten moesten scheiden van waar de resultaten worden gebruikt. Dit is op zichzelf niet slecht. Maar het combineren van oproepen ziet er niet bijzonder elegant uit en lijkt op een typische reactieve “noodle”.

Als je coroutines gebruikt, ziet alles er fatsoenlijker uit:

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

Waar:

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

Theoretisch zullen we met behulp van dergelijke parallelle verwerking 200-400 ms krijgen, wat al dicht bij onze verwachtingen ligt.

Helaas gebeurt zo’n goede parallellisatie niet, en de prijs die daarvoor moet worden betaald is nogal wreed: omdat er maar een paar gebruikers tegelijkertijd werken, zullen de diensten worden overspoeld met een stortvloed aan verzoeken die toch niet parallel zullen worden verwerkt, dus we zal terugkeren naar onze trieste 4s.

Mijn resultaat bij het gebruik van een dergelijke service is 1300–1700 ms voor het verwerken van 20 berichten. Dit is sneller dan bij de eerste implementatie, maar lost het probleem nog steeds niet op.

Alternatief gebruik van parallelle query'sWat moet ik doen als services van derden geen batchverwerking bieden? U kunt bijvoorbeeld het gebrek aan batchverwerkingsimplementatie binnen interfacemethoden verbergen:

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

Dit is logisch als u batchverwerking in toekomstige versies hoopt te zien.
Voors:

  1. Implementeer eenvoudig op berichten gebaseerde parallelle verwerking.
  2. Goede schaalbaarheid.

Tegens:

  1. De noodzaak om de gegevensverzameling te scheiden van de verwerking ervan bij het parallel verwerken van verzoeken aan verschillende diensten.
  2. Verhoogde belasting van services van derden.

Het is duidelijk dat de reikwijdte van de toepasbaarheid ongeveer hetzelfde is als die van de naïeve benadering. Het is zinvol om de parallelle aanvraagmethode te gebruiken als u de prestaties van uw dienst meerdere keren wilt verbeteren vanwege de genadeloze uitbuiting van anderen. In ons voorbeeld is de productiviteit 2,5 keer toegenomen, maar dit is duidelijk niet genoeg.

caching

U kunt caching uitvoeren in de geest van JPA voor externe services, dat wil zeggen ontvangen objecten binnen een sessie opslaan om ze niet opnieuw te ontvangen (ook niet tijdens batchverwerking). Je kunt zulke caches zelf maken, je kunt Spring gebruiken met zijn @Cacheable, en je kunt een kant-en-klare cache zoals EhCache altijd handmatig gebruiken.

Een veelvoorkomend probleem zou zijn dat caches alleen nuttig zijn als ze hits hebben. In ons geval zijn treffers in het auteursveld zeer waarschijnlijk (laten we zeggen 50%), maar er zullen helemaal geen treffers voor bestanden zijn. Deze aanpak zal enige verbeteringen opleveren, maar zal de productiviteit niet radicaal veranderen (en we hebben een doorbraak nodig).

Intersessie (lange) caches vereisen complexe invalidatielogica. Over het algemeen geldt dat hoe later u aan de slag gaat met het oplossen van prestatieproblemen met behulp van intersessiecaches, hoe beter.

Voors:

  1. Implementeer caching zonder de code te wijzigen.
  2. Meerdere keren verhoogde productiviteit (in sommige gevallen).

Tegens:

  1. Mogelijkheid tot verminderde prestaties bij verkeerd gebruik.
  2. Grote geheugenoverhead, vooral bij lange caches.
  3. Complexe invalidatie, waarbij fouten leiden tot moeilijk te reproduceren problemen tijdens runtime.

Heel vaak worden caches alleen gebruikt om ontwerpproblemen snel op te lossen. Dit betekent niet dat ze niet mogen worden gebruikt. U moet ze echter altijd met voorzichtigheid behandelen en eerst de resulterende prestatiewinst evalueren, en pas daarna een beslissing nemen.

In ons voorbeeld zorgen caches voor een prestatieverbetering van ongeveer 25%. Tegelijkertijd hebben caches nogal wat nadelen, dus ik zou ze hier niet gebruiken.

Resultaten van

We hebben dus gekeken naar een naïeve implementatie van een dienst die gebruikmaakt van batchverwerking, en naar verschillende eenvoudige manieren om dit te versnellen.

Het belangrijkste voordeel van al deze methoden is eenvoud, waarvan er veel aangename gevolgen zijn.

Een veelvoorkomend probleem bij deze methoden zijn slechte prestaties, voornamelijk gerelateerd aan de grootte van de pakketten. Daarom, als deze oplossingen niet bij u passen, is het de moeite waard om radicalere methoden te overwegen.

Er zijn twee hoofdrichtingen waarin u naar oplossingen kunt zoeken:

  • asynchroon werken met data (vereist een paradigmaverschuiving, dus deze wordt in dit artikel niet besproken);
  • uitbreiding van batches met behoud van synchrone verwerking.

Door de batches uit te breiden, wordt het aantal externe oproepen aanzienlijk verminderd en blijft de code tegelijkertijd synchroon. Het volgende deel van het artikel zal aan dit onderwerp worden gewijd.

Bron: www.habr.com

Voeg een reactie