Probleme der Batchverarbeitung von Anfragen und deren Lösungen (Teil 1)

Probleme der Batchverarbeitung von Anfragen und deren Lösungen (Teil 1)Fast alle modernen Softwareprodukte bestehen aus mehreren Services. Oft wird die lange Reaktionszeit zwischen den Servicekanälen zur Quelle von Leistungsproblemen. Eine gängige Lösung für solche Probleme ist das Bündeln mehrerer Inter-Service-Anfragen zu einem Paket, das als Batchverarbeitung bezeichnet wird.

Wenn Sie Batchverarbeitung verwenden, sind Sie möglicherweise mit den Ergebnissen hinsichtlich Leistung oder Verständlichkeit des Codes unzufrieden. Diese Methode ist nicht so einfach für den Aufrufer, wie man denken könnte. Für verschiedene Zwecke und in unterschiedlichen Situationen können die Lösungen stark variieren. An konkreten Beispielen werde ich die Vor- und Nachteile mehrerer Ansätze aufzeigen.

Demonstrationsprojekt

Zur Veranschaulichung betrachten wir ein Beispiel eines der Services in der Anwendung, an der ich gerade arbeite.

Erläuterung zur Auswahl der Plattform für die BeispieleLeistungsprobleme sind ein häufiges Anliegen und betreffen keine spezifischen Programmiersprachen oder Plattformen. In diesem Artikel werden Beispiele in Spring + Kotlin verwendet, um Herausforderungen und Lösungen zu demonstrieren. Kotlin ist sowohl für Java- als auch für C#-Entwickler nachvollziehbar und der Code ist kompakter und verständlicher als in Java. Um es Java-Entwicklern leichter zu machen, werde ich auf komplexe Kotlin-Features verzichten und nur klare Ansätze verwenden (in Anlehnung an Lombok). Es wird einige Erweiterungsmethoden geben, die jedoch in Wirklichkeit den meisten Java-Programmierern als statische Methoden bekannt sind, sodass dies ein kleiner Zusatz ist, der den Gesamteindruck nicht beeinträchtigt.
Es gibt einen Dokumentenfreigabedienst. Jemand erstellt ein Dokument und bringt es zur Diskussion, wobei Änderungen vorgenommen werden, bis das Dokument letztendlich genehmigt wird. Der Freigabedienst selbst weiß nichts über die Dokumente: es handelt sich lediglich um einen Genehmigungschat mit einigen zusätzlichen Funktionen, die wir hier nicht weiter betrachten werden.

Es gibt also Chaträume (die den Dokumenten entsprechen) mit einer festgelegten Teilnehmeranzahl. Wie in herkömmlichen Chats enthalten die Nachrichten Text und Dateien und können Antworten (Reply) und Weiterleitungen (Forward) sein:

Datenklasse ChatNachricht(
  // nullable так как появляется только после persist
  val ID: Lang? = null,
  /** Ссылка на автора */
  val Autor: BenutzerReferenz,
  /** Сообщение */
  val Nachricht: für einfache Datentypen und Debugging.,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val Dateien: Liste<DateiReferenz>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val antwortenAuf: ChatNachricht? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val weiterleitenVon: ChatNachricht? = null
)

Links zu Dateien und Benutzern sind Verweise auf andere домены. So funktioniert es bei uns:

Typalias DateiReferenz Lang
Typalias BenutzerReferenz Lang

Benutzerdaten werden in Keycloak gespeichert und über REST abgerufen. Dasselbe gilt für Dateien: Dateien und ihre Metainformationen befinden sich in einem separaten Dateispeicherdienst.

Alle Aufrufe dieser Dienste sind schwere Anfragen. Das bedeutet, dass die Transportkosten dieser Anfragen viel höher sind als die Verarbeitungszeit durch den externen Dienst. In unseren Testumgebungen beträgt die typische Bearbeitungszeit solcher Dienste 100 ms, sodass wir diese Zahlen in der Folge verwenden werden.

Wir müssen einen einfachen REST-Controller erstellen, um die letzten N Nachrichten mit allen notwendigen Informationen abzurufen. Das heißt, wir gehen davon aus, dass das Nachrichtenmodell im Frontend nahezu identisch ist und alle Daten übertragen werden müssen. Der Unterschied zum Frontend-Modell besteht darin, dass die Datei und der Benutzer in einer etwas entschlüsselten Form dargestellt werden müssen, um sie als Links zu gestalten:

/** В таком виде отдаются ссылки на сущности для фронта */
Datenklasse Referenz-Benutzeroberfläche(
  /** Идентификатор для url */
  val Referenz: für einfache Datentypen und Debugging.,
  /** Видимое пользователю название ссылки */
  val Name: für einfache Datentypen und Debugging.
)
Datenklasse Chatnachrichten-Benutzeroberfläche(
  val ID: Lang,
  /** Ссылка на автора */
  val Autor: Referenz-Benutzeroberfläche,
  /** Сообщение */
  val Nachricht: für einfache Datentypen und Debugging.,
  /** Ссылки на аттачи */
  val Dateien: Liste<Referenz-Benutzeroberfläche>
  /** Если являтся ответом, то здесь будет оригинал */
  val antwortenAuf: Chatnachrichten-Benutzeroberfläche? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val weiterleitenVon: Chatnachrichten-Benutzeroberfläche? = null
)

Wir müssen Folgendes implementieren:

Schnittstelle ChatRestApi {
  . Bei holeLetzte(nInt): Liste<Chatnachrichten-Benutzeroberfläche>
}

Postfix UI bezieht sich auf die DTO-Modelle für das Frontend, also das, was wir über REST zurückgeben sollten.

Es mag überraschend erscheinen, dass wir keine Chat-ID übergeben und sogar im Modell ChatMessage/ChatMessageUI keine vorhanden ist. Ich habe dies absichtlich gemacht, um den Beispielcode nicht zu überladen (Chats sind isoliert, sodass wir annehmen können, dass wir insgesamt nur einen haben).

Philosophische AbschweifungSowohl in der Klasse ChatMessageUI als auch in der Methode ChatRestApi.getLast wird der Datentyp List verwendet, obwohl es sich tatsächlich um ein geordnetes Set handelt. Im JDK gibt es diesbezüglich Schwierigkeiten, daher kann die Reihenfolge der Elemente auf der Ebene des Interfaces (Reihenfolge beim Hinzufügen und Abrufen beibehalten) nicht sichergestellt werden. Daher ist es gängige Praxis, List zu verwenden, wenn ein geordnetes Set erforderlich ist (es gibt auch LinkedHashSet, aber das ist kein Interface).
Wichtige Einschränkung: Wir nehmen an, dass lange Antwort- oder Weiterleitungsketten nicht vorkommen. Das heißt, sie existieren, aber ihre Länge überschreitet nicht drei Nachrichten. Im Frontend sollte die gesamte Nachrichtenkette übermittelt werden.

Für den Datenabruf aus externen Diensten stehen folgende APIs zur Verfügung:

Schnittstelle ChatNachrichtenRepository {
  . Bei letzteFinden(nInt): Liste<ChatNachricht>
}
Datenklasse DateiKopfRemote(
  val ID: DateiReferenz,
  val Name: für einfache Datentypen und Debugging.
)
Schnittstelle DateiRemoteApi {
  . Bei kopfNachIdHolen(idDateiReferenz): DateiKopfRemote
  . Bei köpfeNachIdsHolen(idSet<DateiReferenz>): Set<DateiKopfRemote>
  . Bei köpfeNachIdsHolen(idListe<DateiReferenz>): Liste<DateiKopfRemote>
  . Bei köpfeNachChatHolen(): Liste<DateiKopfRemote>
}
Datenklasse BenutzerRemote(
  val ID: BenutzerReferenz,
  val Name: für einfache Datentypen und Debugging.
)
Schnittstelle BenutzerRemoteApi {
  . Bei benutzerNachIdHolen(idBenutzerReferenz): BenutzerRemote
  . Bei benutzerNachIdsHolen(idSet<BenutzerReferenz>): Set<BenutzerRemote>
  . Bei benutzerNachIdsHolen(idListe<BenutzerReferenz>): Liste<BenutzerRemote>
}

Es ist ersichtlich, dass in den externen Diensten ursprünglich eine batchweise Verarbeitung vorgesehen ist, und zwar in beiden Varianten: über Set (ohne Beibehaltung der Reihenfolge der Elemente, mit einzigartigen Schlüsseln) und über List (doppelte Werte sind möglich – die Reihenfolge wird beibehalten).

Einfache Implementierungen

Naive Implementierung

Die erste naive Implementierung unseres REST-Controllers wird in den meisten Fällen etwa so aussehen:

class ChatRestController(
  private val messageRepository: ChatNachrichtenRepository,
  private val userRepository: BenutzerRemoteApi,
  private val fileRepository: DateiRemoteApi
) : ChatRestApi {
  override fun holeLetzte(nInt) =
    messageRepository.findLast(n)
      .map it.toFrontModel() }
  
  private fun ChatNachricht.toFrontModel(): Chatnachrichten-Benutzeroberfläche =
    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 ist klar, und das ist ein großer Vorteil.

Wir verwenden Batch-Verarbeitung und erhalten Daten aus einem externen Dienst in Paketen. Aber wie sieht es mit unserer Leistung aus?

Für jede Nachricht wird ein Aufruf von UserRemoteApi zur Abfrage der Daten im Feld author und ein Aufruf von FileRemoteApi zur Beschaffung aller angehängten Dateien gemacht. Soweit, so gut. Nehmen wir an, dass die Felder forwardFrom und replyTo für ChatMessage so bezogen werden, dass sie keine zusätzlichen Aufrufe erfordern. Doch die Umwandlung in ChatMessageUI führt zu Rekursion, was bedeutet, dass die Zähler für die Aufrufe erheblich steigen können. Wie bereits erwähnt, nehmen wir an, dass wir keine große Verschachtelung haben und die Kette auf drei Nachrichten begrenzt ist.

Letztendlich erhalten wir zwei bis sechs Aufrufe externer Dienste für eine Nachricht und einen JPA-Aufruf für das gesamte Nachrichtenpaket. Die Gesamtzahl der Aufrufe variiert zwischen 2*N+1 und 6*N+1. Wie sieht das in realen Einheiten aus? Angenommen, um eine Seite zu rendern, benötigt man 20 Nachrichten. Um diese zu erhalten, sind zwischen 4 und 10 Sekunden erforderlich. Grauenhaft! Wir würden gerne unter 500 ms bleiben. Da im Frontend ein nahtloses Scrollen geplant war, könnten die Anforderungen an die Leistung dieses Endpunkts verdoppelt werden.

Vorteile:

  1. Der Code ist kurz und selbstdokumentierend (ein Traum für den Support).
  2. Der Code ist einfach, wodurch es kaum Möglichkeiten gibt, sich selbst ins Bein zu schießen.
  3. Batch-Verarbeitung wirkt nicht fremd und fügt sich organisch in die Logik ein.
  4. Änderungen an der Logik lassen sich leicht und lokal vornehmen.

Nachteil:

Schreckliche Leistung, die mit sehr kleinen Paketen verbunden ist.

Dieser Ansatz sieht man häufig in einfachen Diensten oder Prototypen. Wenn Geschwindigkeit bei Änderungen wichtig ist, sollte man es vermeiden, das System zu verkomplizieren. Gleichzeitig ist die Leistung in unserem sehr einfachen Dienst miserabel, sodass die Anwendbarkeit dieses Ansatzes sehr begrenzt ist.

Naive nebenläufige Verarbeitung

Man kann die Verarbeitung aller Nachrichten parallel durchführen – das ermöglicht, ein lineares Wachstum der Zeit im Verhältnis zur Anzahl der Nachrichten zu vermeiden. Das ist jedoch nicht besonders gut, da es zu einer hohen Spitzenlast auf den externen Dienst führen kann.

Die Implementierung der parallelen Verarbeitung ist sehr einfach:

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

Durch parallele Nachrichtenverarbeitung erreichen wir idealerweise 300–700 µs, was deutlich besser ist als bei einer naiven Implementierung, aber immer noch nicht schnell genug.

Bei diesem Ansatz werden die Anfragen an userRepository und fileRepository synchron ausgeführt, was nicht besonders effizient ist. Um das zu beheben, müssen wir die Logik der Aufrufe erheblich verändern. Beispielsweise durch CompletionStage (auch bekannt als CompletableFuture):

private fun ChatNachricht.toFrontModel(): Chatnachrichten-Benutzeroberfläche =
  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()!!

Es ist offensichtlich, dass der anfangs einfache Mapping-Code weniger verständlich geworden ist. Das liegt daran, dass wir die Aufrufe externer Dienste vom Einsatz der Ergebnisse getrennt haben. An sich ist das nicht schlecht. Aber die Kombination der Aufrufe wirkt nicht besonders elegant und erinnert an typische reaktive 'Nudeln'.

Wenn wir Koroutinen verwenden, wird alles ansprechender aussehen:

private fun ChatNachricht.toFrontModel(): Chatnachrichten-Benutzeroberfläche =
  beitreten(
    userRepository.getUserById(author).toFrontReference() },
    files?.let fileRepository.getHeadsByIds(dateien)
      .map it.toFrontReference() } } ?: listOf() }
  ).let (autor, dateien) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      autor = autor,
      message = message,
      dateien = dateien,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

Wobei:

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

Theoretisch könnten wir mit einer solchen parallelen Verarbeitung 200–400 µs erreichen, was bereits nah an unseren Erwartungen liegt.

Leider gibt es kein so effektives Parallelisieren, und die Kosten dafür sind ziemlich hoch: Bei gleichzeitigem Betrieb von nur wenigen Benutzern wird eine Flut von Anfragen auf die Services einprasseln, die trotzdem nicht parallel bearbeitet werden können, sodass wir zu unseren traurigen 4 s zurückkehren werden.

Mein Ergebnis bei der Nutzung eines solchen Services liegt bei 1300–1700 ms zur Verarbeitung von 20 Nachrichten. Das ist schneller als in der ersten Umsetzung, löst aber das Problem dennoch nicht.

Alternative Nutzung von parallelen AnfragenWas passiert, wenn in externen Services keine Batchverarbeitung vorgesehen ist? Zum Beispiel könnte man das Fehlen der Implementierung der Batchverarbeitung in den Methoden der Schnittstellen verstecken:

Schnittstelle BenutzerRemoteApi {
  . Bei benutzerNachIdHolen(idBenutzerReferenz): BenutzerRemote
  . Bei benutzerNachIdsHolen(idSet<BenutzerReferenz>): Set<BenutzerRemote>=
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  . Bei benutzerNachIdsHolen(idListe<BenutzerReferenz>): Liste<BenutzerRemote>=
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

Das macht Sinn, wenn Hoffnung besteht, dass in zukünftigen Versionen eine Batchverarbeitung verfügbar sein wird.
Vorteile:

  1. Einfache Implementierung der parallelen Verarbeitung nach Nachrichten.
  2. Gute Skalierbarkeit.

Nachteile:

  1. Notwendigkeit, das Abrufen von Daten von deren Verarbeitung bei parallelen Anfragen an verschiedene Services zu trennen.
  2. Erhöhte Last auf externe Services.

Die Anwendungsgrenzen sind offensichtlich ähnlich wie bei einem naiven Ansatz. Die Nutzung von parallelen Anfragen macht Sinn, wenn Sie die Leistung Ihres Dienstes durch unermüdliche Ausnutzung externer Ressourcen erheblich steigern möchten. In unserem Beispiel wurde die Leistung um das 2,5-fache gesteigert, was jedoch eindeutig nicht ausreicht.

, das durch Pufferpools bereitgestellt wird.

Es ist möglich, einen Cache im Sinne von JPA für externe Dienste zu implementieren, das heißt, innerhalb der Sitzung erhaltene Objekte zu speichern, um sie nicht erneut abzurufen (auch nicht bei der Batch-Verarbeitung). Solche Caches können selbst erstellt werden, oder man kann Spring mit seiner @Cacheable verwenden; außerdem kann immer ein fertiger Cache wie EhCache manuell eingesetzt werden.

Ein allgemeines Problem wird damit zusammenhängen, dass Caches nur dann nützlich sind, wenn es Treffer gibt. In unserem Fall sind Treffer im Feld author sehr wahrscheinlich (sagen wir 50 %), während es bei den Dateien keine Treffer geben wird. Dieser Ansatz wird einige Verbesserungen bringen, die Leistung jedoch nicht grundlegend verändern (und wir benötigen einen Durchbruch).

Zwischensitzungscaches (lange Caches) erfordern komplexe Invalidierungslogik. Allgemein gilt: Je später Sie sich mit Performanceproblemen durch Zwischensitzungscaches befassen, desto besser.

Vorteile:

  1. Implementierung von Caching ohne Codeänderungen.
  2. Performancezuwächse um ein Vielfaches (in einigen Fällen).

Nachteile:

  1. Möglichkeit der Leistungseinbußen bei unsachgemäßer Verwendung.
  2. Hohe Speicherüberhead insbesondere bei langen Caches.
  3. Komplexe Invalidierung, Fehler darin führen zu schwer reproduzierbaren Problemen zur Laufzeit.

Häufig werden Caches nur genutzt, um Designprobleme schnell zu beheben. Das bedeutet jedoch nicht, dass sie nicht verwendet werden sollten. Es ist jedoch immer ratsam, vorsichtig damit umzugehen und zuerst den Leistungsvorteil zu bewerten, bevor eine Entscheidung getroffen wird.

In unserem Beispiel wird ein Leistungsvorteil von etwa 25 % durch Caching erzielt. Dennoch gibt es zahlreiche Nachteile, weshalb ich sie hier nicht einsetzen würde.

Ergebnisse

Wir haben also die naive Implementierung eines Dienstes betrachtet, der mit Batchverarbeitung arbeitet, und einige einfache Möglichkeiten, ihn zu beschleunigen.

Der Hauptvorteil all dieser Methoden ist die Einfachheit, aus der viele positive Konsequenzen resultieren.

Ein allgemeines Problem dieser Ansätze ist die schlechte Leistung, die in erster Linie mit der Paketgröße zusammenhängt. Wenn diese Lösungen also nicht für Sie geeignet sind, sollten radikalere Methoden in Betracht gezogen werden.

Es gibt zwei Hauptrichtungen, in denen man nach Lösungen suchen kann:

  • asynchrone Datenverarbeitung (erfordert einen Paradigmenwechsel, daher wird dieser Aspekt in diesem Artikel nicht behandelt);
  • Aufstockung der Pakete bei gleichzeitiger Beibehaltung der synchronen Verarbeitung.

Die Aufstockung der Pakete wird die Anzahl der externen Aufrufe erheblich reduzieren und gleichzeitig den synchronen Code beibehalten. Dieses Thema wird der nächste Teil des Artikels behandeln.

Quelle: habr.com

Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen 🔥 Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen | ProHoster