Fast alle modernen Softwareprodukte bestehen aus mehreren Diensten. Oft wird die Latenzzeit zwischen den Dienstkanälen zu einer Quelle von Leistungsproblemen. Die Standardlösung für solche Probleme ist das Verpacken mehrerer Dienstanfragen in ein Paket, das als Batch-Verarbeitung bezeichnet wird.
Wenn Sie Batch-Verarbeitung verwenden, sind Sie möglicherweise mit dem Ergebnis hinsichtlich der Leistung oder der Verständlichkeit des Codes unzufrieden. Diese Methode ist für die aufrufende Seite nicht so einfach, wie man denken könnte. Für unterschiedliche Zwecke und in verschiedenen Situationen können die Lösungen stark variieren. An konkreten Beispielen werde ich die Vor- und Nachteile mehrerer Ansätze zeigen.
Demo-Projekt
Zur Veranschaulichung betrachten wir ein Beispiel eines Dienstes in der Anwendung, an der ich gerade arbeite.
Erläuterung zur Auswahl der Plattform für die BeispieleDas Problem der schlechten Leistung ist recht allgemein und betrifft keine spezifischen Sprachen oder Plattformen. In diesem Artikel werden zur Veranschaulichung der Aufgaben und Lösungen Beispiele in Spring + Kotlin verwendet. Kotlin ist sowohl für Java- als auch für C#-Entwickler gleichermaßen verständlich (oder unverständlich). Darüber hinaus ist der Code kompakter und nachvollziehbarer als in Java. Um das Verständnis für reine Java-Entwickler zu erleichtern, werde ich auf die dunkle Magie von Kotlin verzichten und nur die weiße verwenden (im Geiste von Lombok). Es wird einige Extension-Methoden geben, die aber eigentlich allen Java-Programmierern als statische Methoden bekannt sind, sodass dies eine kleine Süßigkeit ist, die den Geschmack des Gerichts nicht verderben wird.
Es gibt einen Dienst zur Genehmigung von Dokumenten. Jemand erstellt ein Dokument und bringt es zur Diskussion, während derer Änderungen vorgenommen werden, und schließlich wird das Dokument genehmigt. Der Genehmigungsdienst weiß nichts über die Dokumente: Es ist lediglich ein Chat für Genehmiger mit einigen wenigen zusätzlichen Funktionen, die wir hier nicht betrachten werden.
Es gibt also Chat-Räume (entsprechend den Dokumenten) mit einer festgelegten Teilnehmeranzahl in jedem. Wie in normalen 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: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val dateien: Liste<DateiReferenz>? = null,
/** Если является ответом, то здесь будет оригинал */
val AntwortAuf: ChatNachricht? = null,
/** Если является пересылкой, то здесь будет оригинал */
val WeitergeleitetVon: ChatNachricht? = null
)
Links auf die Datei und den Benutzer – das sind Links zu anderen Domains. So funktioniert es bei uns:
Typalias DateiReferenz = Lang
Typalias BenutzerReferenz = Lang
Benutzerdaten werden in Keycloak gespeichert und über REST abgerufen. Gleiches gilt für Dateien: Dateien und Metainformationen darüber leben in einem separaten Dateispeicherdienst.
Alle Aufrufe dieser Dienste sind schwere Anfragen. Das bedeutet, dass die Transportkosten dieser Anfragen viel höher sind als die Bearbeitungszeit durch den externen Dienst. An unseren Testständen beträgt die typische Aufrufzeit solcher Dienste 100 ms, sodass wir diese Zahlen in Zukunft verwenden werden.
Wir müssen einen einfachen REST-Controller implementieren, um die letzten N Nachrichten mit allen erforderlichen Informationen zu erhalten. Das heißt, wir gehen davon aus, dass das Nachrichtenmodell im Frontend fast dasselbe ist und alle Daten weitergeleitet werden müssen. Der Unterschied im Modell für das Frontend besteht darin, dass Datei und Benutzer in etwas entschlüsselter Form dargestellt werden müssen, um sie als Links zu gestalten:
/** В таком виде отдаются ссылки на сущности для фронта */
Datenklasse Referenz-UI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val Name: String
)
Datenklasse ChatNachricht-UI(
val ID: Lang,
/** Ссылка на автора */
val Autor: Referenz-UI,
/** Сообщение */
val Nachricht: String,
/** Ссылки на аттачи */
val dateien: Liste<Referenz-UI>,
/** Если являтся ответом, то здесь будет оригинал */
val AntwortAuf: ChatNachricht-UI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val WeitergeleitetVon: ChatNachricht-UI? = null
)
Wir müssen Folgendes umsetzen:
interface ChatRestApi {
Spaß getLast(n: Int): Liste<ChatNachricht-UI>
}
Das Postfix-UI bedeutet DTO-Modelle für das Frontend, das heißt, was wir über REST zurückgeben müssen.
Es mag überraschend erscheinen, dass wir keine Chat-ID übergeben, und sogar im Modell ChatMessage/ChatMessageUI ist diese nicht enthalten. Ich habe dies absichtlich so gemacht, um den Beispielcode nicht zu überladen (Chats sind isoliert, sodass man annehmen kann, dass wir insgesamt nur einen haben).
Philosophische AbschweifungSowohl in der ChatMessageUI-Klasse als auch in der Methode ChatRestApi.getLast wird der Datentyp List verwendet, während es sich in Wirklichkeit um ein geordnetes Set handelt. Mit dem JDK ist das problematisch, deshalb kann die Reihenfolge der Elemente auf der Ebene des Interfaces (Reihenfolge beim Hinzufügen und Abrufen beibehalten) nicht deklariert werden. Daher hat sich als allgemeine Praxis die Verwendung von List in den Fällen etabliert, in denen ein geordnetes Set benötigt wird (es gibt auch LinkedHashSet, aber das ist kein Interface).
Wichtige Einschränkung: Wir nehmen an, dass es keine langen Antwort- oder Weiterleitungsketten gibt. Das bedeutet, sie existieren, aber ihre Länge überschreitet nicht drei Nachrichten. Im Frontend sollte die Nachrichtenkette vollständig übergeben werden.
Für den Datenabruf aus externen Diensten gibt es folgende APIs:
interface ChatNachrichtenRepository {
Spaß findeZuletzt(n: Int): Liste<ChatNachricht>
}
Datenklasse DateiKopfRemote(
val ID: DateiReferenz,
val Name: String
)
interface DateiRemoteApi {
Spaß holeKopfNachId(id: DateiReferenz): DateiKopfRemote
Spaß holeKöpfeNachIds(id: Set<DateiReferenz>): Set<DateiKopfRemote>
Spaß holeKöpfeNachIds(id: Liste<DateiReferenz>): Liste<DateiKopfRemote>
Spaß holeKöpfeNachChat(): Liste<DateiKopfRemote>
}
Datenklasse BenutzerRemote(
val ID: BenutzerReferenz,
val Name: String
)
interface BenutzerRemoteApi {
Spaß holeBenutzerNachId(id: BenutzerReferenz): BenutzerRemote
Spaß holeBenutzerNachIds(id: Set<BenutzerReferenz>): Set<BenutzerRemote>
Spaß holeBenutzerNachIds(id: Liste<BenutzerReferenz>): Liste<BenutzerRemote>
}
Offensichtlich ist in den externen Diensten ursprünglich eine Batchverarbeitung vorgesehen, und zwar in beiden Varianten: über Set (ohne Beibehalten der Reihenfolge der Elemente, mit einzigartigen Schlüsseln) und über List (Dublikate sind möglich – die Reihenfolge bleibt erhalten).
Einfache Implementierungen
Naive Implementierung
Die naive erste Implementierung unseres REST-Controllers wird in den meisten Fällen so aussehen:
class ChatRestController(
private val messageRepository: ChatNachrichtenRepository,
private val userRepository: BenutzerRemoteApi,
private val fileRepository: DateiRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun ChatNachricht.toFrontModel(): ChatNachricht-UI =
ChatMessageUI(
id = id ?: throw IllegalStateException("$this muss gespeichert werden"),
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 verständlich, was ein großer Vorteil ist.
Wir verwenden Batch-Verarbeitung und beziehen Daten aus einem externen Dienst in Chargen. Aber wie sieht es mit der Performance aus?
Für jede Nachricht wird ein Aufruf von UserRemoteApi gemacht, um die Daten zum Feld author zu erhalten, und ein Aufruf von FileRemoteApi, um alle angehängten Dateien abzurufen. Das ist schon alles. Angenommen, die Felder forwardFrom und replyTo für ChatMessage werden so erhoben, dass dies keine zusätzlichen Aufrufe erfordert. Aber die Umwandlung in ChatMessageUI könnte zu Rekursion führen, also könnten die Zählerwerte der Aufrufe erheblich steigen. Wie bereits erwähnt, gehen wir davon aus, dass wir keine große Verschachtelung haben und die Kette auf drei Nachrichten begrenzt ist.
Am Ende erhalten wir zwischen zwei und sechs Aufrufen externer Dienste für eine Nachricht und einen JPA-Aufruf für das gesamte Nachrichtenpaket. Die Gesamtzahl der Aufrufe variiert von 2*N+1 bis 6*N+1. Was bedeutet das in realen Einheiten? Angenommen, um die Seite darzustellen, benötigt man 20 Nachrichten. Um diese zu erhalten, benötigt man zwischen 4 s und 10 s. Schrecklich! Wir möchten in 500 ms bleiben. Und da im Frontend ein nahtloser Scroll geplant war, können die Anforderungen an die Performance dieses Endpunkts verdoppelt werden.
Vorteile:
- Der Code ist kurz und selbstdokumentierend (ein Traum für den Support).
- Der Code ist einfach, daher gibt es kaum Möglichkeiten, sich selbst ins Bein zu schießen.
- Batch-Verarbeitung scheint nichts Fremdes zu sein und fügt sich organisch in die Logik ein.
- Änderungen in der Logik werden einfach vorzunehmen sein und lokal bleiben.
Nachteil:
Furchtbare Performance, die durch sehr kleine Batches verursacht wird.
Dieser Ansatz lässt sich häufig in einfachen Diensten oder Prototypen beobachten. Wenn die Geschwindigkeit der Änderungen wichtig ist, sollte man das System nicht unnötig komplizieren. Gleichzeitig ist die Performance für unseren sehr einfachen Dienst katastrophal, sodass die Anwendbarkeit eines solchen Ansatzes sehr eng ist.
Naive parallele Verarbeitung
Die Verarbeitung aller Nachrichten kann parallel gestartet werden – das würde das lineare Wachstum der Zeit in Abhängigkeit von der Anzahl der Nachrichten beseitigen. Das ist jedoch nicht der besonders gute Weg, da es zu einer hohen Spitzenlast auf dem externen Dienst führen wird.
Die Implementierung der parallelen Verarbeitung ist ganz einfach:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Durch die Nutzung der parallelen Nachrichtenverarbeitung erhalten wir idealerweise 300–700 ms, was deutlich besser ist als bei der naiven Implementierung, aber immer noch nicht schnell genug.
Bei diesem Ansatz werden die Anfragen an userRepository und fileRepository synchron ausgeführt, was nicht sehr effizient ist. Um dies zu beheben, müssen wir die Aufruflogik erheblich ändern. Zum Beispiel durch CompletionStage (auch bekannt als CompletableFuture):
private fun ChatNachricht.toFrontModel(): ChatNachricht-UI =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).toFrontReference()
}.thenCombine(
files?.let {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).map { it.toFrontReference() }
}
} ?: CompletableFuture.completedFuture(listOf())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this muss gespeichert werden"),
author = author,
message = message,
files = files,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}.get()!!
Es ist zu erkennen, dass der ursprünglich einfache Mapping-Code weniger verständlich geworden ist. Das liegt daran, dass wir die Aufrufe externer Dienste von den Stellen, an denen die Ergebnisse verwendet werden, trennen mussten. An sich ist das nicht schlecht. Aber die Kombination von Aufrufen sieht nicht besonders elegant aus und erinnert an typische reaktive 'Nudeln'.
Wenn wir Koroutinen verwenden, wird alles deutlich übersichtlicher:
private fun ChatNachricht.toFrontModel(): ChatNachricht-UI =
join(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { dateiRepository.getHeadsByIds(dateien)
.map { it.toFrontReference() } } ?: listOf() }
).let { (autor, dateien) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$this muss gespeichert werden"),
autor = autor,
message = message,
dateien = dateien,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Wo:
Spaß <A, B> join(a: () -> A, b: () -> B) =
runBlocking(H, High) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A to it[1] as B
}
Theoretisch erhalten wir mit einer solchen parallelen Verarbeitung 200–400 ms, was bereits nah an unseren Erwartungen ist.
Leider gibt es ein so gutes Parallelschalten nicht, und die Kosten sind ziemlich hoch: Wenn nur wenige Benutzer gleichzeitig auf die Dienste zugreifen, müssen sie eine Flut von Anfragen verarbeiten, die dennoch nicht parallel bearbeitet werden, sodass wir zu unseren bedauerlichen 4 s zurückkehren.
Mein Ergebnis bei der Verwendung eines solchen Dienstes beträgt 1300–1700 ms zur Verarbeitung von 20 Nachrichten. Das ist schneller als bei der ersten Implementierung, löst aber das Problem immer noch nicht.
Alternative Anwendung von parallelen AnfragenWas passiert, wenn in externen Diensten keine Batch-Verarbeitung vorgesehen ist? Zum Beispiel kann das Fehlen einer Implementierung der Batch-Verarbeitung in den Methoden der Schnittstellen verborgen werden:
interface BenutzerRemoteApi {
Spaß holeBenutzerNachId(id: BenutzerReferenz): BenutzerRemote
Spaß holeBenutzerNachIds(id: Set<BenutzerReferenz>): Set<BenutzerRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
Spaß holeBenutzerNachIds(id: Liste<BenutzerReferenz>): Liste<BenutzerRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Das macht Sinn, wenn Hoffnung auf eine batchfähige Verarbeitung in zukünftigen Versionen besteht.
Vorteile:
- Einfache Implementierung der Nachrichtenverarbeitung.
- Gute Skalierbarkeit.
Nachteile:
- Notwendigkeit, die Datenbeschaffung von deren Verarbeitung bei paralleler Verarbeitung von Anfragen an verschiedene Dienste zu trennen.
- Erhöhte Belastung für externe Dienste.
Es ist offensichtlich, dass die Anwendungsrahmen etwa die gleichen sind wie bei der naiven Herangehensweise. Die Verwendung der Methode der parallelen Abfragen macht Sinn, wenn Sie die Leistung Ihres Dienstes durch die rigorose Nutzung von Fremdressourcen erheblich steigern möchten. In unserem Beispiel hat sich die Leistung um das 2,5-Fache erhöht, aber das ist eindeutig nicht genug.
Caching
Eine Caching-Implementierung im Stil von JPA für externe Dienste ist möglich, das heißt, innerhalb der Sitzung einmal erhaltene Objekte zu speichern, um sie nicht erneut abfragen zu müssen (auch nicht bei der Batch-Verarbeitung). Man kann solche Caches selbst erstellen oder Spring mit @Cacheable verwenden, außerdem kann man jederzeit fertige Caches wie EhCache manuell nutzen.
Ein allgemeines Problem besteht darin, dass Caches nur nützlich sind, wenn es Treffer gibt. In unserem Fall sind Treffer im Feld author ziemlich wahrscheinlich (nehmen wir an, 50 %), während es gar keine Treffer bei den Dateien geben wird. Diese Methode bringt einige Verbesserungen, wird die Leistung aber nicht radikal verändern (und wir benötigen einen Durchbruch).
Inter-Sitzungs (lange) Caches erfordern eine komplexe Invalidierungslogik. Je später Sie zu dem Punkt kommen, an dem Sie Leistungsprobleme mit inter-Sitzungs-Caches lösen, desto besser.
Vorteile:
- Einführung von Caching ohne Änderungen am Code.
- Leistungssteigerung um das Mehrfache (in einigen Fällen).
Nachteile:
- Möglichkeit der Leistungsreduzierung bei falscher Nutzung.
- Hohe Speicherbelegung, insbesondere bei langen Caches.
- Komplexe Invalidierung, bei der Fehler zu schwer reproduzierbaren Problemen zur Laufzeit führen.
Sehr oft werden Caches nur verwendet, um schnell Designprobleme zu beheben. Das bedeutet nicht, dass sie nicht nützlich sind. Dennoch sollte man ihnen mit Vorsicht begegnen und zuerst den daraus resultierenden Leistungszuwachs bewerten, bevor man eine Entscheidung trifft.
In unserem Beispiel wird der Leistungszuwachs durch Caches etwa 25 % betragen. Dabei gibt es viele Nachteile bei Caches, sodass ich sie hier nicht verwenden würde.
Ergebnisse
Wir haben also die naive Implementierung eines Dienstes betrachtet, der die Batch-Verarbeitung nutzt, und einige einfache Wege, um ihn zu beschleunigen.
Der Hauptvorteil all dieser Methoden ist die Einfachheit, was viele angenehme Folgen hat.
Ein allgemeines Problem dieser Methoden 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 Sie radikalere Ansätze in Betracht ziehen.
Es gibt zwei Hauptrichtungen, in denen man nach Lösungen suchen kann:
- asynchrone Datenverarbeitung (erfordert einen Paradigmenwechsel, daher wird dies in diesem Artikel nicht behandelt);
- Verdichtung der Pakete bei gleichzeitiger Beibehaltung der synchronen Verarbeitung.
Die Verdichtung der Pakete wird die Anzahl der externen Aufrufe erheblich reduzieren und gleichzeitig den synchronen Code beibehalten. Der nächsten Teil des Artikels wird sich mit diesem Thema befassen.
Quelle: habr.com
