Kapitel 4. Microservice Kommunikationsstile
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Die richtige Kommunikation zwischen Microservices ist für viele problematisch. Das liegt meiner Meinung nach zum großen Teil daran, dass die Menschen sich für einen bestimmten technologischen Ansatz entscheiden, ohne sich vorher Gedanken über die verschiedenen Arten der Kommunikation zu machen, die sie benötigen. In diesem Kapitel werde ich versuchen, die verschiedenen Kommunikationsstile auseinanderzuhalten, um dir zu helfen, die Vor- und Nachteile jedes Stils zu verstehen und herauszufinden, welcher Ansatz am besten zu deinem Problemfeld passt.
Wir werden uns synchrone blockierende und asynchrone nicht blockierende Kommunikationsmechanismen ansehen und die Anfrage-Antwort-Zusammenarbeit mit der ereignisgesteuerten Zusammenarbeit vergleichen.
Am Ende dieses Kapitels solltest du viel besser vorbereitet sein, um die verschiedenen Optionen zu verstehen, die dir zur Verfügung stehen, und du wirst über ein Grundwissen verfügen, das dir helfen wird, wenn wir uns in den folgenden Kapiteln mit detaillierteren Implementierungsfragen beschäftigen.
Von In-Process zu Inter-Process
OK, lasst uns zuerst die einfachen Dinge aus dem Weg räumen - oder zumindest das, was ich hoffe, dass es die einfachen Dinge sind. Aufrufe zwischen verschiedenen Prozessen über ein Netzwerk (inter-process) unterscheiden sich nämlich stark von Aufrufen innerhalb eines einzelnen Prozesses (in-process). Auf einer Ebene können wir diesen Unterschied ignorieren. Es ist zum Beispiel einfach, sich vorzustellen, dass ein Objekt einen Methodenaufruf an ein anderes Objekt richtet, und diese Interaktion dann einfach auf zwei Microservices abzubilden, die über ein Netzwerk kommunizieren. Abgesehen von der Tatsache, dass Microservices nicht einfach nur Objekte sind, kann uns diese Denkweise eine Menge Ärger einbringen.
Sehen wir uns einige dieser Unterschiede an und überlegen wir, wie sie die Interaktionen zwischen deinen Microservices verändern können.
Leistung
Die Leistung eines prozessinternen Aufrufs unterscheidet sich grundlegend von der eines prozessübergreifenden Aufrufs. Wenn ich einen prozessinternen Aufruf tätige, können der zugrunde liegende Compiler und die Laufzeitumgebung eine ganze Reihe von Optimierungen vornehmen, um die Auswirkungen des Aufrufs zu verringern, einschließlich des Inlinings des Aufrufs, so dass es so aussieht, als hätte es nie einen Aufruf gegeben. Solche Optimierungen sind bei prozessübergreifenden Aufrufen nicht möglich. Es müssen Pakete verschickt werden. Der Overhead eines prozessübergreifenden Aufrufs ist im Vergleich zum Overhead eines prozessinternen Aufrufs erheblich. Ersterer ist sehr messbar - allein das Roundtripping eines einzelnen Pakets in einem Rechenzentrum wird in Millisekunden gemessen -, während der Overhead eines Methodenaufrufs etwas ist, worüber du dir keine Gedanken machen musst.
Das führt oft dazu, dass du APIs überdenken musst. Eine API, die prozessintern sinnvoll ist, ist in prozessübergreifenden Situationen möglicherweise nicht sinnvoll. Ich kann ohne Bedenken tausend Aufrufe über eine API-Grenze im Prozess machen. Möchte ich tausend Netzwerkaufrufe zwischen zwei Microservices tätigen? Vielleicht nicht.
Wenn ich einen Parameter an eine Methode übergebe, bewegt sich die Datenstruktur, die ich übergebe, normalerweise nicht - wahrscheinlicher ist, dass ich einen Zeiger auf einen Speicherplatz übergebe. Wenn du ein Objekt oder eine Datenstruktur an eine andere Methode übergibst, muss kein weiterer Speicherplatz zugewiesen werden, um die Daten zu kopieren.
Bei Aufrufen zwischen Microservices über ein Netzwerk müssen die Daten hingegen in eine Form serialisiert werden, die über ein Netzwerk übertragen werden kann. Die Daten müssen dann am anderen Ende gesendet und deserialisiert werden. Deshalb müssen wir vielleicht mehr auf die Größe der Nutzdaten achten, die zwischen Prozessen übertragen werden. Wann hast du das letzte Mal auf die Größe einer Datenstruktur geachtet, die du innerhalb eines Prozesses weitergibst? Die Realität ist, dass du es wahrscheinlich nicht wissen musstest; jetzt schon. Das kann dazu führen, dass du die Menge der gesendeten oder empfangenen Daten reduzierst (was vielleicht gar nicht so schlecht ist, wenn wir an das Verstecken von Informationen denken), effizientere Serialisierungsmechanismen wählst oder sogar Daten in ein Dateisystem auslagerst und stattdessen einen Verweis auf den Dateispeicherort weitergibst.
Diese Unterschiede werden dir vielleicht nicht sofort Probleme bereiten, aber du solltest sie auf jeden Fall kennen. Ich habe schon viele Versuche gesehen, die Tatsache, dass ein Netzwerkaufruf stattfindet, vor dem Entwickler zu verbergen. Unser Wunsch, Abstraktionen zu schaffen, um Details zu verbergen, trägt wesentlich dazu bei, dass wir mehr Dinge effizienter tun können, aber manchmal schaffen wir Abstraktionen, die zu viel verbergen. Ein Entwickler muss sich darüber im Klaren sein, wenn er etwas tut, das einen Netzwerkaufruf zur Folge hat. Andernfalls darf man sich nicht wundern, wenn man im weiteren Verlauf mit unangenehmen Leistungsengpässen konfrontiert wird, die durch seltsame Interaktionen zwischen den Diensten verursacht werden, die dem Entwickler, der den Code schreibt, nicht bewusst waren.
Ändern von Schnittstellen
Wenn wir Änderungen an einer Schnittstelle innerhalb eines Prozesses betrachten, ist das Ausrollen der Änderung ganz einfach. Der Code, der die Schnittstelle implementiert, und der Code, der die Schnittstelle aufruft, sind alle in demselben Prozess verpackt. Wenn ich eine Methodensignatur in einer IDE mit Refactoring-Funktionen ändere, wird die IDE die Aufrufe der geänderten Methode oft automatisch refaktorisieren. Das Ausrollen einer solchen Änderung kann auf atomare Weise erfolgen - beide Seiten der Schnittstelle werden in einem einzigen Prozess zusammengeführt.
Bei der Kommunikation zwischen Microservices sind jedoch der Microservice, der eine Schnittstelle zur Verfügung stellt, und die konsumierenden Microservices, die diese Schnittstelle nutzen, separat deploybare Microservices. Wenn wir eine rückwärtskompatible Änderung an einer Microservice-Schnittstelle vornehmen, müssen wir entweder ein Lockstep-Deployment mit den Consumern durchführen und sicherstellen, dass sie die neue Schnittstelle verwenden, oder wir müssen einen Weg finden, den neuen Microservice-Vertrag schrittweise zu implementieren. Wir werden dieses Konzept später in diesem Kapitel genauer untersuchen.
Fehlerbehandlung
Wenn ich innerhalb von eine Methode aufrufe, ist die Art der Fehler in der Regel ziemlich einfach. Entweder sind die Fehler zu erwarten und einfach zu behandeln, oder sie sind so katastrophal, dass wir den Fehler einfach auf dem Aufrufstapel weitergeben. Im Großen und Ganzen sind Fehler deterministisch.
Bei einem verteilten System kann die Art der Fehler eine andere sein. Du bist anfällig für eine Vielzahl von Fehlern, die außerhalb deiner Kontrolle liegen. Netzwerke fallen aus. Nachgelagerte Microservices können vorübergehend nicht verfügbar sein. Netzwerke werden unterbrochen, Container werden wegen zu hohem Speicherverbrauch abgeschaltet und in extremen Situationen können Teile deines Rechenzentrums Feuer fangen.1
In ihrem Buch Distributed Systems,2 Andrew Tanenbaum und Maarten Steen beschreiben in ihrem Buch "Verteilte Systeme" die fünf Arten von Fehlern, die bei der Kommunikation zwischen Prozessen auftreten können. Hier ist eine vereinfachte Version:
- Crash-Ausfall
-
Alles war gut, bis der Server abstürzte. Neustart!
- Unterlassungserklärung
-
Du hast etwas gesendet, aber keine Antwort erhalten. Dazu gehören auch Situationen, in denen du erwartest, dass ein nachgelagerter Microservice Nachrichten sendet (vielleicht auch Ereignisse), aber er bleibt einfach stehen.
- Zeitversagen
-
Etwas ist zu spät passiert (du hast es nicht rechtzeitig bekommen), oder etwas ist zu früh passiert!
- Antwortausfall
-
Du hast eine Antwort erhalten, aber sie scheint einfach falsch zu sein. Du hast zum Beispiel um eine Zusammenfassung deiner Bestellung gebeten, aber in der Antwort fehlen wichtige Informationen.
- Willkürliches Versagen
-
Das ist der Fall, wenn etwas schief gelaufen ist, die Beteiligten sich aber nicht einigen können, ob der Fehler aufgetreten ist (oder warum). Wie es sich anhört, ist das eine schlechte Zeit für alle Beteiligten.
Viele dieser Fehler sind oft vorübergehender Natur - es sind kurzlebige Probleme, die wieder verschwinden können. Stell dir vor, du sendest eine Anfrage an einen Microservice, bekommst aber keine Antwort (eine Art Unterlassungsfehler). Das könnte bedeuten, dass der nachgelagerte Microservice die Anfrage gar nicht erst erhalten hat und wir sie erneut senden müssen. Andere Probleme lassen sich nicht so einfach lösen und erfordern möglicherweise das Eingreifen eines menschlichen Operators. Daher kann es wichtig sein, eine umfassendere Semantik für die Rückgabe von Fehlern zu haben, damit die Kunden entsprechende Maßnahmen ergreifen können.
HTTP ist ein Beispiel für ein Protokoll, das die Bedeutung dieses Punktes versteht. Jede HTTP-Antwort hat einen Code, wobei die Codes der Serien 400 und 500 für Fehler reserviert sind. Fehlercodes der Serie 400 sind Anforderungsfehler - im Wesentlichen teilt ein nachgeschalteter Dienst dem Kunden mit, dass mit der ursprünglichen Anfrage etwas nicht stimmt. Deshalb solltest du es wahrscheinlich aufgeben - hat es zum Beispiel Sinn, eine 404 Not Found
erneut zu versuchen? Die Antwortcodes der 500er-Reihe beziehen sich auf nachgelagerte Probleme, von denen eine Teilmenge dem Kunden anzeigt, dass das Problem möglicherweise nur vorübergehend ist. Ein 503 Service Unavailable
zeigt zum Beispiel an, dass der nachgelagerte Server die Anfrage nicht bearbeiten kann, aber es kann sich um einen vorübergehenden Zustand handeln. Erhält ein Kunde hingegen eine Antwort von 501 Not Implemented
, ist es unwahrscheinlich, dass ein erneuter Versuch viel bringt.
Unabhängig davon, ob du ein HTTP-basiertes Protokoll für die Kommunikation zwischen Microservices wählst oder nicht: Wenn du über eine umfangreiche Semantik für die Art des Fehlers verfügst, kannst du es den Clients leichter machen, kompensierende Maßnahmen durchzuführen, was dir wiederum helfen sollte, robustere Systeme zu bauen.
Technologie für die Kommunikation zwischen Prozessen: So viele Auswahlmöglichkeiten
Und in einer Welt, in der wir zu viele Möglichkeiten und zu wenig Zeit haben, ist es naheliegend, Dinge einfach zu ignorieren.
Seth Godin
Die Bandbreite an Technologien, die uns für die prozessübergreifende Kommunikation zur Verfügung stehen, ist riesig. Das kann dazu führen, dass wir mit der Auswahl oft überfordert sind. Ich stelle oft fest, dass die Leute sich zu Technologien hingezogen fühlen, die ihnen vertraut sind, oder zu der neuesten Technologie, von der sie auf einer Konferenz erfahren haben. Das Problem dabei ist, dass man mit der Entscheidung für eine bestimmte Technologie oft auch eine Reihe von Ideen und Einschränkungen in Kauf nimmt, die mit der Technologie einhergehen. Diese Zwänge sind vielleicht nicht die richtigen für dich - und die Denkweise, die hinter der Technologie steht, passt vielleicht nicht zu dem Problem, das du lösen willst.
Wenn du versuchst, eine Website zu erstellen, sind Single-Page-App-Technologien wie Angular oder React schlecht geeignet. Ebenso ist es keine gute Idee, Kafka für Request-Response zu verwenden, da es für ereignisbasierte Interaktionen entwickelt wurde (zu diesem Thema kommen wir gleich noch). Und doch sehe ich immer wieder, wie Technologien an der falschen Stelle eingesetzt werden. Die Leute entscheiden sich für die neueste Technologie (wie Microservices!), ohne zu überlegen, ob sie wirklich zu ihrem Problem passt.
Wenn es um die verwirrende Vielfalt an Technologien geht, die uns für die Kommunikation zwischen Microservices zur Verfügung stehen, ist es meiner Meinung nach wichtig, zuerst über den gewünschten Kommunikationsstil zu sprechen und erst dann nach der richtigen Technologie zur Umsetzung dieses Stils zu suchen. Werfen wir deshalb einen Blick auf ein Modell, das ich seit mehreren Jahren verwende, um die verschiedenen Ansätze für die Kommunikation zwischen Microservices zu unterscheiden.
Stile der Microservice-Kommunikation
In Abbildung 4-1 sehen wir einen Überblick über das Modell, das ich verwende, um über verschiedene Kommunikationsstile nachzudenken. Dieses Modell erhebt keinen Anspruch auf Vollständigkeit (ich versuche hier nicht, eine große einheitliche Theorie der Kommunikation zwischen Prozessen zu präsentieren), aber es bietet einen guten Überblick über die verschiedenen Kommunikationsstile, die in Microservice-Architekturen am häufigsten verwendet werden.
Wir werden uns die verschiedenen Elemente dieses Modells in Kürze genauer ansehen, aber zunächst möchte ich sie kurz umreißen:
- Synchrone Blockierung
-
Ein Microservice ruft einen anderen Microservice auf und blockiert den Betrieb, während er auf die Antwort wartet.
- Asynchrones Nicht-Blockieren
-
Der Microservice, der einen Anruf sendet, kann die Verarbeitung fortsetzen, egal ob der Anruf eingeht oder nicht.
- Anfrage/Antwort
-
Ein Microservice sendet eine Anfrage an einen anderen Microservice mit der Bitte, etwas zu erledigen. Er erwartet eine Antwort, die ihn über das Ergebnis informiert.
- Ereignisgesteuert
-
Microservices senden Ereignisse aus, die andere Microservices konsumieren und entsprechend darauf reagieren. Der Microservice, der das Ereignis sendet, weiß nicht, welche Microservices die von ihm gesendeten Ereignisse konsumieren, wenn überhaupt.
- Gemeinsame Daten
-
Microservices werden oft nicht als Kommunikationsstil angesehen, sondern arbeiten über eine gemeinsame Datenquelle zusammen.
Wenn ich dieses Modell verwende, um Teams bei der Entscheidung für den richtigen Ansatz zu helfen, verbringe ich viel Zeit damit, den Kontext zu verstehen, in dem sie arbeiten. Ihre Bedürfnisse in Bezug auf zuverlässige Kommunikation, akzeptable Latenzzeiten und das Kommunikationsvolumen spielen bei der Wahl der Technologie eine Rolle. Aber im Allgemeinen beginne ich mit der Entscheidung, ob eine Anfrage-Antwort- oder eine ereignisgesteuerte Art der Zusammenarbeit für die jeweilige Situation besser geeignet ist. Wenn ich mich für eine Anfrage-Antwort-Lösung entscheide, stehen mir sowohl synchrone als auch asynchrone Implementierungen zur Verfügung, so dass ich eine zweite Entscheidung treffen muss. Wenn ich mich jedoch für eine ereignisgesteuerte Zusammenarbeit entscheide, kann ich nur eine asynchrone, nicht blockierende Implementierung wählen.
Bei der Auswahl der richtigen Technologie gibt es noch eine ganze Reihe anderer Überlegungen, die über die Art der Kommunikation hinausgehen - zum Beispiel die Notwendigkeit einer Kommunikation mit geringerer Latenz, sicherheitsrelevante Aspekte oder die Fähigkeit zur Skalierung. Es ist unwahrscheinlich, dass du eine vernünftige Technologieauswahl treffen kannst, ohne die Anforderungen (und Einschränkungen) deines speziellen Problembereichs zu berücksichtigen. Wenn wir uns in Kapitel 5 mit den Technologieoptionen befassen, werden wir einige dieser Fragen diskutieren.
Mischen und anpassen
Es ist wichtig zu beachten, dass eine Microservice-Architektur insgesamt eine Mischung aus verschiedenen Arten der Zusammenarbeit aufweisen kann, und das ist in der Regel die Norm. Einige Interaktionen machen nur als Anfrage-Antwort Sinn, während andere ereignisgesteuert sind. Es ist sogar üblich, dass ein einzelner Microservice mehr als eine Form der Zusammenarbeit implementiert. Nehmen wir einen Microservice Order
, der eine Anfrage-Antwort-API bereitstellt, über die Bestellungen aufgegeben oder geändert werden können, und dann Ereignisse auslöst, wenn diese Änderungen vorgenommen werden.
Schauen wir uns diese verschiedenen Kommunikationsstile also genauer an.
Muster: Synchrones Blockieren
Bei, einem synchronen blockierenden Aufruf, sendet ein Microservice eine Art Aufruf an einen nachgelagerten Prozess (wahrscheinlich einen anderen Microservice) und blockiert, bis der Aufruf abgeschlossen ist und möglicherweise eine Antwort empfangen wurde. In Abbildung 4-2 sendet Order Processor
einen Aufruf an den Microservice Loyalty
, um ihm mitzuteilen, dass dem Konto eines Kunden einige Punkte hinzugefügt werden sollen.
Ein synchroner blockierender Aufruf ist in der Regel ein Aufruf, der auf eine Antwort von einem nachgelagerten Prozess wartet. Das kann daran liegen, dass das Ergebnis des Aufrufs für eine weitere Operation benötigt wird, oder einfach daran, dass sichergestellt werden soll, dass der Aufruf funktioniert hat, und andernfalls eine Art Wiederholungsversuch durchgeführt werden soll. Daher ist praktisch jeder synchrone blockierende Aufruf, den ich sehe, auch ein Request-Response-Aufruf, den wir uns gleich ansehen werden.
Vorteile
Ein blockierender, synchroner Aufruf hat etwas Einfaches und Vertrautes an sich. Viele von uns haben gelernt, grundsätzlich synchron zu programmieren - einen Code wie ein Skript zu lesen, bei dem jede Codezeile der Reihe nach ausgeführt wird und die nächste Codezeile wartet, bis sie an der Reihe ist, etwas zu tun. Die meisten Situationen, in denen du prozessübergreifende Aufrufe verwendet hast, wurden wahrscheinlich in einem synchronen, blockierenden Stil ausgeführt - zum Beispiel eine SQL-Abfrage an eine Datenbank oder eine HTTP-Anfrage an eine nachgeschaltete API.
Wenn man von einer weniger verteilten Architektur, wie der eines Monolithen mit nur einem Prozess, umsteigt, kann es sinnvoll sein, an den vertrauten Ideen festzuhalten, wenn so viel anderes brandneu ist.
Benachteiligungen
Die größte Herausforderung bei synchronen Aufrufen ist die inhärente zeitliche Kopplung, ein Thema, das wir in Kapitel 2 kurz erörtert haben. Wenn Order Processor
im vorangegangenen Beispiel einen Aufruf an Loyalty
tätigt, muss der Microservice Loyalty
erreichbar sein, damit der Aufruf funktioniert. Wenn der Microservice Loyalty
nicht erreichbar ist, schlägt der Aufruf fehl, und Order Processor
muss sich überlegen, wie er den Ausfall kompensieren kann - entweder durch einen sofortigen Wiederholungsversuch, durch Zwischenspeichern des Aufrufs für einen späteren Versuch oder durch völliges Aufgeben.
Diese Kopplung erfolgt in beide Richtungen. Bei dieser Art der Integration wird die Antwort normalerweise über dieselbe eingehende Netzwerkverbindung an den vorgelagerten Microservice gesendet. Wenn also der Microservice Loyalty
eine Antwort an Order Processor
zurückschicken möchte, die vorgelagerte Instanz aber nicht mehr erreichbar ist, geht die Antwort verloren. Die zeitliche Kopplung besteht hier nicht nur zwischen zwei Microservices, sondern auch zwischen zwei bestimmten Instanzen dieser Microservices.
Da der Absender des Aufrufs blockiert und darauf wartet, dass der nachgelagerte Microservice antwortet, folgt daraus auch, dass der Absender des Aufrufs für einen längeren Zeitraum blockiert wird, wenn der nachgelagerte Microservice langsam antwortet oder wenn es ein Problem mit der Latenz des Netzwerks gibt. Wenn der Microservice Loyalty
stark ausgelastet ist und nur langsam auf Anfragen reagiert, führt dies wiederum dazu, dass auch Order Processor
nur langsam reagiert.
Daher kann die Verwendung von synchronen Aufrufen ein System anfälliger für kaskadierende Probleme machen, die durch nachgelagerte Ausfälle verursacht werden, als die Verwendung von asynchronen Aufrufen.
Wo wird es eingesetzt?
Bei einfachen Microservice-Architekturen habe ich kein großes Problem mit der Verwendung von synchronen, blockierenden Aufrufen. Ihre Vertrautheit ist für viele Menschen ein Vorteil, wenn sie sich mit verteilten Systemen auseinandersetzen.
Für mich werden diese Arten von Aufrufen problematisch, wenn es mehr Ketten von Aufrufen gibt - in Abbildung 4-3 haben wir zum Beispiel einen Beispielfluss von MusicCorp, bei dem wir eine Zahlung auf potenziell betrügerische Aktivitäten überprüfen. Der Dienst Order Processor
ruft den Dienst Payment
auf, um die Zahlung entgegenzunehmen. Der Dienst Payment
wiederum möchte mit dem Microservice Fraud Detection
abklären, ob dies erlaubt ist oder nicht. Der Microservice Fraud Detection
wiederum muss Informationen vom Microservice Customer
erhalten.
Wenn alle diese Aufrufe synchron und blockierend sind, gibt es eine Reihe von Problemen, mit denen wir konfrontiert werden könnten. Ein Problem in einem der vier beteiligten Microservices oder in den Netzwerkaufrufen zwischen ihnen könnte dazu führen, dass der gesamte Vorgang fehlschlägt. Ganz abgesehen davon, dass diese Art von langen Ketten zu erheblichen Ressourcenkonflikten führen kann. Hinter den Kulissen hat Order Processor
wahrscheinlich eine Netzwerkverbindung geöffnet und wartet auf eine Rückmeldung von Payment
. Payment
wiederum hat eine Netzwerkverbindung geöffnet und wartet auf eine Antwort von Fraud Detection
und so weiter. Viele Verbindungen, die offen gehalten werden müssen, können sich auf das laufende System auswirken - es ist viel wahrscheinlicher, dass du Probleme bekommst, wenn dir die verfügbaren Verbindungen ausgehen oder das Netzwerk dadurch überlastet wird.
Um diese Situation zu verbessern, könnten wir die Interaktionen zwischen den Microservices zunächst überdenken. Wir könnten zum Beispiel Fraud Detection
aus dem Hauptkaufprozess herausnehmen, wie in Abbildung 4-4 dargestellt, und stattdessen im Hintergrund laufen lassen. Wenn es ein Problem mit einem bestimmten Kunden findet, werden dessen Datensätze entsprechend aktualisiert, und das könnte schon früher im Zahlungsprozess überprüft werden. Das bedeutet, dass wir einen Teil dieser Arbeit parallel erledigen. Durch die Verringerung der Länge der Aufrufkette verbessert sich die Gesamtlatenz des Vorgangs, und wir nehmen einen unserer Microservices (Fraud Detection
) aus dem kritischen Pfad für den Kaufvorgang heraus, sodass wir uns um eine Abhängigkeit weniger kümmern müssen.
Natürlich könnten wir die blockierenden Aufrufe auch durch eine Art von nicht blockierender Interaktion ersetzen, ohne den Arbeitsablauf zu verändern.
Muster: Asynchrones Nicht-Blockieren
Bei der asynchronen Kommunikation von wird der aufrufende Microservice durch das Senden eines Aufrufs über das Netzwerk nicht blockiert. Er kann alle anderen Prozesse fortsetzen, ohne auf eine Antwort warten zu müssen. Nicht blockierende asynchrone Kommunikation gibt es in vielen Formen, aber wir werden uns die drei häufigsten Formen, die ich in der Microservice-Architektur sehe, genauer ansehen. Sie sind:
- Kommunikation durch gemeinsame Daten
-
Der vorgeschaltete Microservice ändert einige gemeinsame Daten, die später von einem oder mehreren Microservices genutzt werden.
- Anfrage/Antwort
-
Ein Microservice sendet eine Anfrage an einen anderen Microservice und fordert ihn auf, etwas zu tun. Wenn der angeforderte Vorgang abgeschlossen ist, ob erfolgreich oder nicht, erhält der vorgelagerte Microservice die Antwort. Jede Instanz des vorgelagerten Microservices sollte in der Lage sein, die Antwort zu verarbeiten.
- Ereignisgesteuerte Interaktion
-
Ein Microservice sendet ein Ereignis, das man sich als eine faktische Aussage über etwas vorstellen kann, das passiert ist. Andere Microservices können auf die Ereignisse, die sie interessieren, hören und entsprechend reagieren.
Vorteile
Bei der nicht blockierenden asynchronen Kommunikation sind der Microservice, der den ersten Aufruf tätigt, und der Microservice (oder die Microservices), die den Aufruf empfangen, zeitlich entkoppelt. Die Microservices, die den Aufruf erhalten, müssen nicht zum gleichen Zeitpunkt erreichbar sein, zu dem der Aufruf erfolgt. So vermeiden wir die Probleme der zeitlichen Entkopplung, die wir in Kapitel 2 besprochen haben (siehe "Eine kurze Anmerkung zur zeitlichen Kopplung").
Diese Art der Kommunikation ist auch dann von Vorteil, wenn die Funktion, die durch einen Anruf ausgelöst wird, eine lange Zeit in Anspruch nimmt. Kommen wir noch einmal auf unser Beispiel von MusicCorp zurück, und zwar auf den Prozess des Paketversands. In Abbildung 4-5 hat Order Processor
die Zahlung entgegengenommen und entschieden, dass es an der Zeit ist, das Paket zu versenden, also sendet es einen Aufruf an den Microservice Warehouse
. Der Prozess, die CDs zu finden, sie aus dem Regal zu nehmen, sie zu verpacken und sie abholen zu lassen, kann viele Stunden und möglicherweise sogar Tage dauern, je nachdem, wie der eigentliche Versandprozess funktioniert. Deshalb ist es sinnvoll, dass Order Processor
einen nicht blockierenden asynchronen Aufruf an Warehouse
sendet und Warehouse
später zurückruft, um Order Processor
über seinen Fortschritt zu informieren. Dies ist eine Form der asynchronen Anfrage-Antwort-Kommunikation.
Wenn wir versuchen würden, etwas Ähnliches mit synchronen blockierenden Aufrufen zu machen, müssten wir die Interaktionen zwischen Order Processor
und Warehouse
umstrukturieren - es wäre nicht machbar, dass Order Processor
eine Verbindung öffnet, eine Anfrage sendet, alle weiteren Operationen beim Aufruf des Threads blockiert und stunden- oder tagelang auf eine Antwort wartet.
Benachteiligungen
Die größten Nachteile der nicht blockierenden asynchronen Kommunikation im Vergleich zur blockierenden synchronen Kommunikation sind der Grad der Komplexität und die Auswahlmöglichkeiten. Wie wir bereits beschrieben haben, gibt es verschiedene Arten der asynchronen Kommunikation - welche ist die richtige für dich? Wenn wir uns damit beschäftigen, wie diese verschiedenen Kommunikationsstile umgesetzt werden, gibt es eine potenziell verwirrende Liste von Technologien, die wir uns ansehen könnten.
Wenn die asynchrone Kommunikation nicht in dein mentales Modell der Datenverarbeitung passt, wird die Einführung eines asynchronen Kommunikationsstils anfangs eine Herausforderung sein. Und wie wir bei der detaillierten Betrachtung der verschiedenen Arten der asynchronen Kommunikation noch sehen werden, gibt es viele verschiedene, interessante Möglichkeiten, wie du dich in Schwierigkeiten bringen kannst.
Wo wird es eingesetzt?
Wenn du überlegst, ob asynchrone Kommunikation das Richtige für dich ist, musst du auch abwägen, welche Art der asynchronen Kommunikation du wählen willst, denn jede Art hat ihre eigenen Kompromisse. Im Allgemeinen gibt es jedoch einige spezielle Anwendungsfälle, bei denen ich zu einer Form der asynchronen Kommunikation greifen würde. Lang laufende Prozesse sind ein offensichtlicher Kandidat, wie wir in Abbildung 4-5 gezeigt haben. Auch Situationen, in denen du lange Aufrufketten hast, die du nicht einfach umstrukturieren kannst, könnten ein guter Kandidat sein. Wir werden uns mit den drei häufigsten Formen der asynchronen Kommunikation befassen: Anfrage-Antwort-Aufrufe, ereignisgesteuerte Kommunikation und Kommunikation über gemeinsame Daten.
Muster: Kommunikation durch gemeinsame Daten
Eine Art der Kommunikation, die sich über eine Vielzahl von Implementierungen erstreckt, ist die Kommunikation über gemeinsame Daten. Dieses Muster wird verwendet, wenn ein Microservice Daten an einem bestimmten Ort ablegt und ein anderer Microservice (oder möglicherweise mehrere Microservices) diese Daten dann nutzt. Das kann so einfach sein, dass ein Microservice eine Datei an einem bestimmten Ort ablegt und ein anderer Microservice diese Datei zu einem späteren Zeitpunkt abholt und etwas damit macht. Diese Art der Integration ist im Grunde genommen asynchroner Natur.
Ein Beispiel für diesen Stil ist in Abbildung 4-6 zu sehen, wo New Product Importer
eine Datei erstellt, die dann von den nachgeschaltetenMicroservices Inventory
und Catalog
gelesen wird.
Dieses Muster ist in gewisser Weise das häufigste allgemeine Kommunikationsmuster zwischen Prozessen, das du sehen wirst, und doch fehlt es uns manchmal, es überhaupt als Kommunikationsmuster zu erkennen - ich denke, das liegt vor allem daran, dass die Kommunikation zwischen den Prozessen oft so indirekt ist, dass sie schwer zu erkennen ist.
Umsetzung
Um dieses Muster umzusetzen, brauchst du eine Art dauerhaften Speicher für die Daten. Ein Dateisystem kann in vielen Fällen ausreichen. Ich habe schon viele Systeme gebaut, die regelmäßig ein Dateisystem durchsuchen, das Vorhandensein einer neuen Datei feststellen und entsprechend darauf reagieren. Du könntest natürlich auch eine Art robusten verteilten Speicher verwenden. Zu beachten ist, dass jeder nachgelagerte Microservice, der auf diese Daten reagieren soll, einen eigenen Mechanismus benötigt, um zu erkennen, dass neue Daten verfügbar sind - Polling ist eine häufige Lösung für dieses Problem.
Zwei gängige Beispiele für dieses Muster sind der Data Lake und das Data Warehouse. In beiden Fällen sind diese Lösungen in der Regel darauf ausgelegt, große Datenmengen zu verarbeiten, aber in Bezug auf die Kopplung liegen sie wohl an entgegengesetzten Enden des Spektrums. Bei einem Data Lake laden die Quellen die Rohdaten in einem beliebigen Format hoch, und von den nachgelagerten Nutzern dieser Rohdaten wird erwartet, dass sie wissen, wie sie die Informationen verarbeiten können. Bei einem Data Warehouse ist das Warehouse selbst ein strukturierter Datenspeicher. Microservices, die Daten in das Data Warehouse hochladen, müssen die Struktur des Data Warehouse kennen - wenn sich die Struktur rückwärtskompatibel ändert, müssen diese Produzenten aktualisiert werden.
Sowohl beim Data Warehouse als auch beim Data Lake wird davon ausgegangen, dass der Informationsfluss in eine einzige Richtung geht. Ein Microservice veröffentlicht Daten im gemeinsamen Datenspeicher, und nachgelagerte Verbraucher lesen diese Daten und führen die entsprechenden Aktionen aus. Dieser unidirektionale Fluss kann es einfacher machen, über den Informationsfluss nachzudenken. Eine problematischere Implementierung wäre die Verwendung einer gemeinsamen Datenbank, in der mehrere Microservices sowohl lesen als auch in denselben Datenspeicher schreiben. Ein Beispiel dafür haben wir in Kapitel 2 besprochen, als wir die gemeinsame Kopplung untersucht haben - Abbildung4-7 zeigt, wie sowohl Order Processor
als auch Warehouse
denselben Datensatz aktualisieren.
Vorteile
Dieses Muster kann sehr einfach mit allgemein verständlicher Technologie umgesetzt werden. Wenn du eine Datei lesen oder schreiben oder eine Datenbank lesen und beschreiben kannst, kannst du dieses Muster verwenden. Die Verwendung gängiger und gut verstandener Technologien ermöglicht auch die Interoperabilität zwischen verschiedenen Systemtypen, einschließlich älterer Mainframe-Anwendungen oder anpassbarer Standard-Softwareprodukte (COTS). Auch das Datenvolumen ist hier weniger problematisch - wenn du viele Daten auf einmal übermitteln willst, kann dieses Muster gut funktionieren.
Benachteiligungen
Nachgelagerte Microservices erfahren in der Regel über einen Polling-Mechanismus oder einen periodisch ausgelösten Zeitauftrag, dass es neue Daten zu verarbeiten gibt. Das bedeutet, dass dieser Mechanismus in Situationen mit geringer Latenz wahrscheinlich nicht sinnvoll ist. Du kannst dieses Muster natürlich mit einer anderen Art von Aufruf kombinieren, der einen nachgelagerten Microservice darüber informiert, dass neue Daten verfügbar sind. Ich könnte z. B. eine Datei in ein gemeinsames Dateisystem schreiben und dann einen Aufruf an den interessierten Microservice senden, um ihn darüber zu informieren, dass neue Daten vorhanden sind, die er vielleicht haben möchte. So kann die Lücke zwischen der Veröffentlichung und der Verarbeitung von Daten geschlossen werden. Wenn du dieses Muster für sehr große Datenmengen verwendest, ist es allerdings unwahrscheinlich, dass eine niedrige Latenzzeit auf deiner Anforderungsliste ganz oben steht. Wenn du daran interessiert bist, größere Datenmengen zu übermitteln und sie in "Echtzeit" zu verarbeiten, ist eine Streaming-Technologie wie Kafka besser geeignet.
Ein weiterer großer Nachteil, der ziemlich offensichtlich sein sollte, wenn du dich an unsere Untersuchung der gemeinsamen Kopplung in Abbildung 4-7 erinnerst, ist, dass der gemeinsame Datenspeicher zu einer potenziellen Quelle der Kopplung wird. Wenn sich die Struktur dieses Datenspeichers in irgendeiner Weise ändert, kann dies die Kommunikation zwischen den Microservices unterbrechen.
Wie robust die Kommunikation ist, hängt auch von der Robustheit des zugrunde liegenden Datenspeichers ab. Das ist streng genommen kein Nachteil, aber du solltest dir dessen bewusst sein. Wenn du eine Datei in einem Dateisystem ablegen willst, solltest du sicherstellen, dass das Dateisystem selbst nicht auf interessante Weise fehlschlägt.
Wo wird es eingesetzt?
Die wahre Stärke dieses Musters liegt in der Interoperabilität zwischen Prozessen, die möglicherweise Einschränkungen hinsichtlich der verwendeten Technologien haben. Wenn ein bestehendes System mit der GRPC-Schnittstelle deines Microservices kommuniziert oder sein Kafka-Topic abonniert, mag das aus Sicht des Microservices bequemer sein, aber nicht aus Sicht des Kunden. Ältere Systeme können Einschränkungen in Bezug auf die unterstützten Technologien haben und hohe Kosten für Änderungen mit sich bringen. Andererseits sollten auch alte Mainframe-Systeme in der Lage sein, Daten aus einer Datei zu lesen. Das alles hängt natürlich davon ab, ob du eine Datenspeichertechnologie verwendest, die weithin unterstützt wird - du könntest dieses Muster auch mit einem Cache wie Redis umsetzen. Aber kann dein altes Großrechnersystem mit Redis kommunizieren?
Ein weiterer großer Vorteil dieses Musters ist die gemeinsame Nutzung großer Datenmengen. Wenn du eine mehrere Gigabyte große Datei an ein Dateisystem senden oder ein paar Millionen Zeilen in eine Datenbank laden musst, ist dieses Muster genau das Richtige für dich.
Muster: Anfrage-Antwort-Kommunikation
Bei request-response sendet ein Microservice eine Anfrage an einen nachgelagerten Dienst und erwartet eine Antwort mit dem Ergebnis der Anfrage. Diese Interaktion kann über einen synchronen blockierenden Aufruf erfolgen oder asynchron und nicht blockierend implementiert werden. Ein einfaches Beispiel für diese Interaktion zeigt Abbildung 4-8: Der Microservice Chart
, der die meistverkauften CDs für verschiedene Genres zusammenstellt, sendet eine Anfrage an den Dienst Inventory
und fragt nach dem aktuellen Lagerbestand einiger CDs.
Das Abrufen von Daten von anderen Microservices ist ein häufiger Anwendungsfall für einen Request-Response-Aufruf. Manchmal muss man aber auch einfach nur sicherstellen, dass etwas erledigt wird. In Abbildung 4-9 erhält der Microservice Warehouse
eine Anfrage von Order Processor
mit der Bitte, Lagerbestände zu reservieren. Order Processor
muss nur wissen, dass der Bestand erfolgreich reserviert wurde, bevor er mit der Bezahlung fortfahren kann. Wenn der Bestand nicht reserviert werden kann, z. B. weil ein Artikel nicht mehr verfügbar ist, kann die Zahlung storniert werden. Die Verwendung von Request-Response-Aufrufen in Situationen wie dieser, in denen die Aufrufe in einer bestimmten Reihenfolge abgearbeitet werden müssen, ist gang und gäbe.
Implementierung: Synchron versus asynchron
Solche Request-Response-Aufrufe können entweder blockierend synchron oder nicht blockierend asynchron implementiert werden. Bei einem synchronen Aufruf wird normalerweise eine Netzwerkverbindung mit dem nachgelagerten Microservice geöffnet und die Anfrage über diese Verbindung gesendet. Die Verbindung wird offen gehalten, während der vorgelagerte Microservice auf die Antwort des nachgelagerten Microservices wartet. In diesem Fall muss der Microservice, der die Antwort sendet, nicht wirklich etwas über den Microservice wissen, der die Anfrage gesendet hat - er sendet nur etwas über eine eingehende Verbindung zurück. Wenn diese Verbindung unterbrochen wird, z. B. weil entweder der vor- oder der nachgelagerte Microservice ausfällt, könnte es ein Problem geben.
Bei einer asynchronen Anfrage/Antwort sind die Dinge weniger einfach. Schauen wir uns noch einmal den Prozess an, der mit der Reservierung von Lagerbeständen verbunden ist. In Abbildung 4-10 wird die Anfrage zur Reservierung von Lagerbeständen als Nachricht über eine Art Message Broker gesendet (wir werden uns später in diesem Kapitel mit Message Brokern beschäftigen). Die Nachricht wird nicht direkt von Order Processor
an den Microservice Inventory
gesendet, sondern befindet sich in einer Warteschlange. Die Inventory
konsumiert Nachrichten aus dieser Warteschlange, wenn sie dazu in der Lage ist. Er liest die Anfrage, reserviert den Bestand und sendet die Antwort zurück an die Warteschlange, aus der Order Processor
gerade liest. Der Microservice Inventory
muss wissen, wohin er die Antwort weiterleiten soll. In unserem Beispiel sendet er diese Antwort über eine andere Warteschlange zurück, die wiederum von Order Processor
genutzt wird.
Bei einer nicht blockierenden asynchronen Interaktion muss der Microservice, der die Anfrage erhält, entweder implizit wissen, wohin er die Antwort weiterleiten soll, oder er muss wissen, wohin die Antwort gehen soll. Wenn wir eine Warteschlange verwenden, haben wir den zusätzlichen Vorteil, dass mehrere Anfragen in der Warteschlange gepuffert werden können, bis sie bearbeitet werden. Das kann in Situationen helfen, in denen die Anfragen nicht schnell genug bearbeitet werden können. Der Microservice kann die nächste Anfrage bearbeiten, wenn er bereit ist, und wird nicht von zu vielen Aufrufen überwältigt. Natürlich hängt dann viel von der Warteschlange ab, die diese Anfragen aufnimmt.
Wenn ein Microservice auf diese Weise eine Antwort erhält, muss er die Antwort möglicherweise mit der ursprünglichen Anfrage in Verbindung bringen. Das kann eine Herausforderung sein, denn es kann viel Zeit vergangen sein, und je nach Art des verwendeten Protokolls kommt die Antwort möglicherweise nicht an dieselbe Instanz des Microservices zurück, die die Anfrage gesendet hat. In unserem Beispiel der Reservierung von Lagerbeständen im Rahmen einer Bestellung müssen wir wissen, wie wir die Antwort "Lagerbestand reserviert" mit einer bestimmten Bestellung verknüpfen können, damit wir diese Bestellung weiter bearbeiten können. Eine einfache Möglichkeit wäre, den Status der ursprünglichen Anfrage in einer Datenbank zu speichern, so dass die empfangende Instanz beim Eintreffen der Antwort den Status neu laden und entsprechend handeln kann.
Ein letzter Hinweis: Alle Formen der Anfrage-Antwort-Interaktion erfordern wahrscheinlich eine Form der Timeout-Behandlung, um Probleme zu vermeiden, bei denen das System blockiert wird, weil es auf etwas wartet, das vielleicht nie passiert. Wie diese Timeout-Funktionalität implementiert wird, kann je nach Implementierungstechnologie variieren, aber sie wird benötigt. In Kapitel 12 werden wir uns ausführlicher mit Time-outs beschäftigen.
Wo wird es eingesetzt?
Request-Response-Aufrufe sind überall dort sinnvoll, wo das Ergebnis einer Anfrage benötigt wird, bevor eine weitere Verarbeitung stattfinden kann. Sie eignen sich auch sehr gut für Situationen, in denen ein Microservice wissen will, ob ein Aufruf nicht funktioniert hat, damit er eine Art Ausgleichsmaßnahme durchführen kann, z. B. einen erneuten Versuch. Wenn beides auf deine Situation zutrifft, ist Request-Response ein sinnvoller Ansatz. Es bleibt nur noch die Frage, ob du dich für eine synchrone oder asynchrone Implementierung entscheidest, mit den gleichen Kompromissen, die wir bereits besprochen haben.
Muster: Ereignisgesteuerte Kommunikation
Die ereignisgesteuerte Kommunikation sieht im Vergleich zu Request-Response-Aufrufen ziemlich seltsam aus. Anstatt dass ein Microservice einen anderen Microservice auffordert, etwas zu tun, sendet ein Microservice Ereignisse aus, die von anderen Microservices empfangen werden können oder auch nicht. Es handelt sich dabei um eine asynchrone Interaktion, da die Ereignis-Listener in ihrem eigenen Ausführungsstrang laufen.
Ein Ereignis ist eine Aussage über ein Ereignis, das fast immer innerhalb der Welt des Microservices, der das Ereignis sendet, stattgefunden hat. Der Microservice, der das Ereignis sendet, weiß nicht, ob andere Microservices das Ereignis nutzen wollen, und vielleicht weiß er nicht einmal, dass andere Microservices existieren. Er sendet das Ereignis, wenn es erforderlich ist, und das ist das Ende seiner Verantwortung.
In Abbildung 4-11 sehen wir, wie Warehouse
Ereignisse im Zusammenhang mit der Verpackung einer Bestellung aussendet. Diese Ereignisse werden von zwei Microservices, Notifications
und Inventory
, empfangen, die entsprechend reagieren. Der Microservice Notifications
sendet eine E-Mail, um den Kunden über Änderungen im Bestellstatus zu informieren, während der Microservice Inventory
die Lagerbestände aktualisieren kann, wenn die Artikel für die Bestellung des Kunden verpackt werden.
Die Warehouse
sendet lediglich Ereignisse und geht davon aus, dass interessierte Parteien entsprechend reagieren werden. Er weiß nicht, wer die Empfänger der Ereignisse sind, wodurch ereignisgesteuerte Interaktionen im Allgemeinen viel lockerer gekoppelt sind. Wenn du das mit einem Request-Response-Aufruf vergleichst, brauchst du vielleicht eine Weile, um die Umkehrung der Verantwortung zu begreifen. Bei einem Request-Response-Aufruf könnten wir stattdessen erwarten, dass Warehouse
dem Microservice Notifications
mitteilt, dass er bei Bedarf E-Mails versenden soll. In einem solchen Modell müsste Warehouse
wissen, welche Ereignisse eine Benachrichtigung des Kunden erfordern. Bei einer ereignisgesteuerten Interaktion verlagern wir diese Verantwortung stattdessen auf den Microservice Notifications
.
Die Absicht hinter einem Ereignis könnte man als das Gegenteil einer Aufforderung betrachten. Der Absender des Ereignisses überlässt es den Empfängern, zu entscheiden, was zu tun ist. Bei Request-Response weiß der Microservice, der die Anfrage sendet, was zu tun ist, und teilt dem anderen Microservice mit, was seiner Meinung nach als Nächstes geschehen muss. Das bedeutet natürlich, dass der Anfragende bei Request-Response wissen muss, was der nachgelagerte Empfänger tun kann, was ein höheres Maß an Domänenkopplung voraussetzt. Bei der ereignisgesteuerten Zusammenarbeit muss der Ereignissender nicht wissen, was die nachgelagerten Microservices können, und weiß vielleicht nicht einmal, dass es sie gibt - dadurch wird die Kopplung stark reduziert.
Die Verteilung der Verantwortung, die wir bei unseren ereignisgesteuerten Interaktionen sehen, kann die Verteilung der Verantwortung widerspiegeln, die wir bei Organisationen sehen, die versuchen, autonomere Teams zu schaffen. Anstatt die gesamte Verantwortung zentral zu halten, wollen wir sie in die Teams selbst verlagern, damit sie autonomer arbeiten können - ein Konzept, auf das wir in Kapitel 15 zurückkommen werden. In diesem Fall verlagern wir die Verantwortung von Warehouse
auf Notifications
und Inventory
. Das kann uns helfen, die Komplexität von Microservices wie Warehouse
zu reduzieren und zu einer gleichmäßigeren Verteilung von "Smarts" in unserem System führen. Wir werden diese Idee genauer untersuchen, wenn wir in Kapitel 6 Choreografie und Orchestrierung vergleichen.
Umsetzung
Es gibt zwei Hauptaspekte, die wir hier berücksichtigen müssen: eine Möglichkeit für unsere Microservices, Ereignisse auszusenden, und eine Möglichkeit für unsere Verbraucher, herauszufinden, ob diese Ereignisse eingetreten sind.
Traditionell versuchen Nachrichtenmakler wie RabbitMQ, beide Probleme zu lösen. Produzenten verwenden eine API, um ein Ereignis beim Broker zu veröffentlichen. Der Broker verwaltet die Abonnements, so dass die Verbraucher informiert werden, wenn ein Ereignis eintrifft. Diese Broker können sogar den Status der Verbraucher verwalten, indem sie z. B. den Überblick darüber behalten, welche Nachrichten sie bereits gesehen haben. Diese Systeme sind in der Regel so konzipiert, dass sie skalierbar und widerstandsfähig sind, aber das gibt es nicht umsonst. Sie können den Entwicklungsprozess komplizierter machen, denn es handelt sich um ein weiteres System, das du zum Entwickeln und Testen deiner Dienste einsetzen musst. Außerdem können zusätzliche Maschinen und Fachkenntnisse erforderlich sein, um diese Infrastruktur am Laufen zu halten. Aber wenn sie einmal läuft, kann sie eine unglaublich effektive Methode sein, um lose gekoppelte, ereignisgesteuerte Architekturen zu implementieren. Im Allgemeinen bin ich ein Fan.
Sei jedoch vorsichtig, wenn es um die Welt der Middleware geht, von der der Message Broker nur ein kleiner Teil ist. Warteschlangen sind an und für sich eine sinnvolle und nützliche Sache. Allerdings neigen die Anbieter dazu, viel Software mit ihnen zu verpacken, was dazu führen kann, dass immer mehr Intelligenz in die Middleware gepresst wird, wie z. B. der Enterprise Service Bus zeigt. Achte darauf, dass du weißt, was du bekommst: Halte deine Middleware stumm und behalte die Intelligenz in den Endpunkten.
Ein anderer Ansatz besteht darin, HTTP zur Weitergabe von Ereignissen zu nutzen. Atom ist eine REST-konforme Spezifikation, die (unter anderem) die Semantik für die Veröffentlichung von Ressourcen-Feeds definiert. Es gibt viele Client-Bibliotheken, mit denen wir diese Feeds erstellen und konsumieren können. Unser Kundenservice könnte also einfach ein Ereignis in einem solchen Feed veröffentlichen, wenn sich unser Kundenservice ändert. Unsere Kunden fragen den Feed einfach ab und suchen nach Änderungen. Einerseits ist die Tatsache, dass wir die bestehende Atom-Spezifikation und die damit verbundenen Bibliotheken wiederverwenden können, nützlich, und wir wissen, dass HTTP sehr gut skalieren kann. Diese Nutzung von HTTP ist jedoch nicht gut für niedrige Latenzzeiten (bei denen sich einige Message Broker hervortun), und wir müssen uns immer noch mit der Tatsache auseinandersetzen, dass die Verbraucher verfolgen müssen, welche Nachrichten sie gesehen haben, und ihren eigenen Abfrageplan verwalten.
Ich habe gesehen, wie Leute Ewigkeiten damit verbracht haben, mehr und mehr der Verhaltensweisen zu implementieren, die man mit einem geeigneten Message Broker erhält, damit Atom für bestimmte Anwendungsfälle funktioniert. Das Muster des konkurrierenden Verbrauchers beschreibt zum Beispiel eine Methode, bei der mehrere Worker-Instanzen um Nachrichten konkurrieren, was gut funktioniert, wenn man die Anzahl der Worker erhöht, um eine Liste unabhängiger Aufträge zu bearbeiten (wir werden im nächsten Kapitel darauf zurückkommen). Wir wollen jedoch vermeiden, dass zwei oder mehr Worker dieselbe Nachricht erhalten, da wir dann dieselbe Aufgabe öfter als nötig erledigen müssen. Bei einem Message Broker wird dies über eine Standard-Warteschlange abgewickelt. Mit Atom müssen wir nun unseren eigenen gemeinsamen Status für alle Worker verwalten, um die Wahrscheinlichkeit zu verringern, dass sich die Arbeit reproduziert.
Wenn du bereits über einen guten, stabilen Message Broker verfügst, solltest du ihn für die Veröffentlichung und das Abonnieren von Ereignissen einsetzen. Wenn du noch keinen hast, solltest du dir Atom ansehen, aber sei dir des Fehlschlusses der versunkenen Kosten bewusst. Wenn du dich dabei ertappst, dass du immer mehr von der Unterstützung eines Message Brokers brauchst, solltest du irgendwann deinen Ansatz ändern.
Für das, was wir tatsächlich über diese asynchronen Protokolle senden, gelten die gleichen Überlegungen wie für die synchrone Kommunikation. Wenn du mit der Kodierung von Anfragen und Antworten in JSON zufrieden bist, solltest du dabei bleiben.
Was ist ein Event?
In Abbildung 4-12 sehen wir ein Ereignis, das vom Microservice Customer
gesendet wird und interessierte Parteien darüber informiert, dass sich ein neuer Kunde im System registriert hat. Zwei der nachgelagerten Microservices, Loyalty
und Notifications
, interessieren sich für dieses Ereignis. Der Microservice Loyalty
reagiert auf das Ereignis, indem er ein Konto für den neuen Kunden einrichtet, damit er Punkte sammeln kann, während der Microservice Notifications
eine E-Mail an den neu registrierten Kunden sendet, um ihn in den wundersamen Freuden von MusicCorp willkommen zu heißen.
Mit einer Anfrage fordern wir einen Microservice auf, etwas zu tun, und stellen die erforderlichen Informationen bereit, damit der angeforderte Vorgang ausgeführt werden kann. Aber da der Microservice, der ein Ereignis sendet, nicht wissen kann und sollte, wer das Ereignis empfängt, wie können wir wissen, welche Informationen andere Parteien aus dem Ereignis benötigen? Was genau sollte in dem Ereignis stehen?
Nur eine ID
Eine Möglichkeit ist, dass das Ereignis nur einen Bezeichner für den neu registrierten Kunden enthält, wie in Abbildung 4-13 dargestellt. Der Microservice Loyalty
braucht nur diesen Identifikator, um das passende Treuekonto zu erstellen, und hat damit alle Informationen, die er braucht. Der Microservice Notifications
weiß zwar, dass er eine Willkommens-E-Mail senden muss, wenn ein solches Ereignis eintrifft, aber er braucht noch weitere Informationen, um seine Aufgabe zu erfüllen - zumindest eine E-Mail-Adresse und wahrscheinlich auch den Namen des Kunden, um der E-Mail eine persönliche Note zu geben. Da diese Informationen nicht in dem Ereignis enthalten sind, das der Microservice Notifications
empfängt, hat er keine andere Wahl, als diese Informationen vom Microservice Customer
zu holen, wie in Abbildung 4-13 zu sehen ist.
Dieser Ansatz hat auch einige Nachteile. Erstens muss der Microservice Notifications
jetzt über den Microservice Customer
Bescheid wissen, was eine zusätzliche Domänenkopplung bedeutet. Obwohl die Domänenkopplung, wie wir in Kapitel 2 besprochen haben, eher am unteren Ende des Kopplungsspektrums angesiedelt ist, möchten wir sie dennoch so weit wie möglich vermeiden. Wenn das Ereignis, das der Microservice von Notification
erhalten hat, alle benötigten Informationen enthielte, wäre dieser Callback nicht erforderlich. Der Callback vom empfangenden Microservice kann auch zu einem anderen großen Nachteil führen: In einer Situation mit vielen empfangenden Microservices könnte der Microservice, der das Ereignis sendet, eine Flut von Anfragen erhalten. Stell dir vor, fünf verschiedene Microservices würden alle dasselbe Kundenerstellungsereignis erhalten und müssten zusätzliche Informationen anfordern - sie müssten alle sofort eine Anfrage an den Microservice Customer
senden, um die benötigten Informationen zu erhalten. Je mehr Microservices sich für ein bestimmtes Ereignis interessieren, desto größer können die Auswirkungen dieser Aufrufe werden.
Vollständig detaillierte Ereignisse
Die Alternative, die ich bevorzuge, ist, alles in ein Ereignis zu packen, was du sonst gerne über eine API teilen würdest. Wenn du den Microservice Notifications
nach der E-Mail-Adresse und dem Namen eines bestimmten Kunden fragen lässt, warum legst du diese Informationen nicht gleich in das Ereignis? In Abbildung 4-14 sehen wir diesen Ansatz -Notification
ist jetzt autarker und kann seine Arbeit erledigen, ohne mit dem Microservice Customer
kommunizieren zu müssen. Es kann sogar sein, dass er gar nicht wissen muss, dass der Microservice Customer
existiert.
Abgesehen von der Tatsache, dass Ereignisse mit mehr Informationen eine lockerere Kopplung ermöglichen, können Ereignisse mit mehr Informationen auch als historische Aufzeichnung dessen dienen, was mit einer bestimmten Entität passiert ist. Dies könnte dir bei der Implementierung eines Auditsystems helfen oder vielleicht sogar die Möglichkeit bieten, eine Entität zu bestimmten Zeitpunkten wiederherzustellen - was bedeutet, dass diese Ereignisse als Teil eines Event Sourcing verwendet werden könnten, ein Konzept, das wir gleich noch näher erläutern werden.
Obwohl ich diesen Ansatz definitiv bevorzuge, hat er auch seine Nachteile. Wenn die Daten, die mit einem Ereignis verbunden sind, sehr groß sind, können wir uns Sorgen über die Größe des Ereignisses machen. Moderne Message Broker (vorausgesetzt, du verwendest einen, um deinen Event-Broadcast-Mechanismus zu implementieren) haben ziemlich großzügige Grenzen für die Nachrichtengröße; die Standard-Höchstgröße für eine Nachricht in Kafka ist 1 MB, und die neueste Version von RabbitMQ hat eine theoretische Obergrenze von 512 MB für eine einzelne Nachricht (vorher waren es 2 GB!), auch wenn man bei so großen Nachrichten interessante Leistungsprobleme erwarten kann. Aber selbst die 1 MB, die uns als maximale Größe einer Nachricht in Kafka zugestanden werden, geben uns eine Menge Spielraum, um eine Menge Daten zu versenden. Wenn du dich in einen Bereich wagst, in dem du dir über die Größe deiner Ereignisse Gedanken machst, würde ich einen hybriden Ansatz empfehlen, bei dem einige Informationen im Ereignis enthalten sind, aber andere (größere) Daten bei Bedarf nachgeschlagen werden können.
In Abbildung 4-14 braucht Loyalty
die E-Mail-Adresse oder den Namen des Kunden nicht zu kennen, erhält sie aber dennoch über das Ereignis. Dies könnte zu Problemen führen, wenn wir versuchen, den Umfang der Microservices einzuschränken, die welche Art von Daten sehen können - zum Beispiel könnte ich einschränken wollen, welche Microservices persönlich identifizierbare Informationen (oder PII), Zahlungskartendetails oder ähnliche sensible Daten sehen können. Eine Möglichkeit, dieses Problem zu lösen, könnte darin bestehen, zwei verschiedene Arten von Ereignissen zu senden - eines, das personenbezogene Daten enthält und von einigen Microservices gesehen werden kann, und ein anderes, das keine personenbezogenen Daten enthält und weiter verbreitet werden kann. Das macht die Verwaltung der Sichtbarkeit der verschiedenen Ereignisse und die Sicherstellung, dass beide Ereignisse tatsächlich ausgelöst werden, noch komplexer. Was passiert, wenn ein Microservice die erste Art von Ereignis sendet, aber stirbt, bevor das zweite Ereignis gesendet werden kann?
Eine weitere Überlegung ist, dass die Daten, die wir in ein Ereignis eingeben, Teil unseres Vertrags mit der Außenwelt werden. Wir müssen uns darüber im Klaren sein, dass wir externe Parteien verletzen können, wenn wir ein Feld aus einem Ereignis entfernen. Das Verstecken von Informationen ist nach wie vor ein wichtiges Konzept in der ereignisgesteuerten Zusammenarbeit - je mehr Daten wir in ein Ereignis einfügen, desto mehr Annahmen werden externe Parteien über das Ereignis haben. Meine allgemeine Regel lautet, dass es für mich in Ordnung ist, Informationen in ein Ereignis aufzunehmen, wenn ich die gleichen Daten auch über eine Anfrage-Antwort-API teilen kann.
Wo wird es eingesetzt?
Die ereignisgesteuerte Zusammenarbeit gedeiht in Situationen, in denen Informationen weitergegeben werden sollen und in denen du die Absicht gerne umkehrst. Die Abkehr von dem Modell, anderen zu sagen, was sie zu tun haben, und stattdessen die nachgelagerten Microservices dies selbst erledigen zu lassen, hat eine große Anziehungskraft.
In einer Situation, in der du dich mehr auf lose Kopplung als auf andere Faktoren konzentrierst, ist die ereignisgesteuerte Zusammenarbeit besonders attraktiv.
Die Warnung ist, dass diese Art der Zusammenarbeit oft neue Probleme mit sich bringt, vor allem, wenn du bisher wenig Erfahrung damit hattest. Wenn du dir bei dieser Form der Kommunikation unsicher bist, erinnere dich daran, dass unsere Microservice-Architektur eine Mischung aus verschiedenen Interaktionsformen enthalten kann (und wahrscheinlich auch wird). Du musst nicht gleich mit der ereignisgesteuerten Zusammenarbeit beginnen; vielleicht kannst du mit einem einzigen Ereignis anfangen und von dort aus weitermachen.
Ich persönlich tendiere fast schon standardmäßig zu ereignisgesteuerter Zusammenarbeit. Mein Gehirn scheint sich so umgestellt zu haben, dass diese Art der Kommunikation für mich ganz selbstverständlich ist. Das ist nicht unbedingt hilfreich, denn es ist schwierig zu erklären , warum das so ist, außer dass es sich richtig anfühlt. Aber das ist nur meine eigene, eingebaute Voreingenommenheit - ich fühle mich natürlich zu dem hingezogen, was ich aufgrund meiner eigenen Erfahrungen kenne. Es ist gut möglich, dass meine Vorliebe für diese Form der Interaktion fast ausschließlich auf meine früheren schlechten Erfahrungen mit zu stark gekoppelten Systemen zurückzuführen ist. Vielleicht bin ich nur der General, der immer wieder die letzte Schlacht schlägt, ohne zu bedenken, dass es dieses Mal vielleicht wirklich anders ist.
Abgesehen von meiner eigenen Voreingenommenheit möchte ich sagen, dass ich viel mehr Teams sehe, die Anfrage-Antwort-Interaktionen durch ereignisgesteuerte Interaktionen ersetzen als umgekehrt.
Mit Vorsicht vorgehen
Einige dieser asynchronen Dinge scheinen Spaß zu machen, oder? Ereignisgesteuerte Architekturen scheinen zu deutlich mehr entkoppelten, skalierbaren Systemen zu führen. Und das können sie auch. Aber diese Art der Kommunikation führt auch zu einer höheren Komplexität. Dabei geht es nicht nur um die Komplexität, die erforderlich ist, um das Veröffentlichen und Abonnieren von Nachrichten zu verwalten, wie wir gerade besprochen haben, sondern auch um die Komplexität anderer Probleme, mit denen wir konfrontiert werden könnten. Wenn wir zum Beispiel eine lang laufende asynchrone Anfrage/Antwort in Betracht ziehen, müssen wir uns überlegen, was wir tun, wenn die Antwort zurückkommt. Kommt sie an denselben Knoten zurück, der die Anfrage gestellt hat? Wenn ja, was passiert, wenn dieser Knoten nicht erreichbar ist? Wenn nicht, muss ich irgendwo Informationen speichern, damit ich entsprechend reagieren kann? Kurzlebige Asynchronität kann einfacher zu handhaben sein, wenn du die richtigen APIs hast, aber selbst dann ist es eine andere Denkweise für Programmierer, die an prozessinterne synchrone Nachrichtenaufrufe gewöhnt sind.
Es ist Zeit für eine abschreckende Geschichte. Im Jahr 2006 arbeitete ich für eine Bank an der Entwicklung eines Preissystems. Wir betrachteten die Marktereignisse und ermittelten, welche Positionen in einem Portfolio neu bewertet werden mussten. Sobald wir die Liste der zu bearbeitenden Posten festgelegt hatten, stellten wir sie alle in eine Nachrichtenwarteschlange. Wir nutzten ein Grid, um einen Pool von Pricing Workern zu erstellen, mit denen wir die Pricing-Farm bei Bedarf vergrößern oder verkleinern konnten. Diese Worker arbeiteten nach dem Muster der konkurrierenden Konsumenten und verschlangen die Nachrichten so schnell wie möglich, bis nichts mehr zu verarbeiten war.
Das System lief und wir waren ziemlich selbstzufrieden. Doch eines Tages, kurz nachdem wir eine neue Version veröffentlicht hatten, stießen wir auf ein unangenehmes Problem: Unsere Arbeiter starben immer wieder. Und starben. Und starben.
Schließlich kamen wir dem Problem auf die Spur. Es hatte sich ein Fehler eingeschlichen, der dazu führte, dass eine bestimmte Art von Preisanfrage einen Worker zum Absturz brachte. Wir benutzten eine transaktionale Warteschlange: Als der Worker starb, wurde die Sperre für die Anfrage aufgehoben und die Preisanfrage wieder in die Warteschlange gestellt - nur damit ein anderer Worker sie aufnimmt und stirbt. Dies war ein klassisches Beispiel für das, was Martin Fowler einen katastrophalen Failover nennt.
Abgesehen von dem Fehler selbst, hatten wir es versäumt, eine maximale Wiederholungszahl für den Auftrag in der Warteschlange anzugeben. Also haben wir den Fehler behoben und eine maximale Wiederholungsrate festgelegt. Uns wurde aber auch klar, dass wir eine Möglichkeit brauchten, um die fehlerhaften Nachrichten zu sehen und eventuell wiederzugeben. Schließlich mussten wir ein Nachrichtenkrankenhaus (oder eine Warteschlange für tote Briefe) einrichten, in dem Nachrichten gesendet wurden, wenn sie fehlschlugen. Außerdem haben wir eine Benutzeroberfläche entwickelt, mit der wir diese Nachrichten anzeigen und bei Bedarf wiederholen können. Diese Art von Problemen ist nicht sofort ersichtlich, wenn du nur mit synchroner Punkt-zu-Punkt-Kommunikation vertraut bist.
Die Komplexität, die mit ereignisgesteuerten Architekturen und asynchroner Programmierung im Allgemeinen einhergeht, lässt mich zu der Überzeugung gelangen, dass du vorsichtig sein solltest, wenn es darum geht, diese Ideen zu übernehmen. Stelle sicher, dass du über ein gutes Monitoring verfügst, und ziehe die Verwendung von Korrelations-IDs in Betracht, mit denen du Anfragen über Prozessgrenzen hinweg verfolgen kannst (siehe Kapitel 10).
Ich empfehle außerdem das Buch Enterprise Integration Patterns von Gregor Hohpe und Bobby Woolf,4 das viele weitere Details zu den verschiedenen Nachrichtenmustern enthält, die du in diesem Bereich in Betracht ziehen solltest.
Wir müssen aber auch ehrlich sein, wenn es um die Integrationsarten geht, die wir als "einfacher" ansehen - die Probleme, die damit verbunden sind, herauszufinden, ob etwas funktioniert hat oder nicht, sind nicht auf asynchrone Formen der Integration beschränkt. Wenn bei einem synchronen, blockierenden Aufruf eine Zeitüberschreitung auftritt, liegt das daran, dass die Anfrage verloren gegangen ist und die nachgeschaltete Partei sie nicht erhalten hat? Oder ist die Anfrage durchgekommen, aber die Antwort ist verloren gegangen? Was tust du in diesem Fall? Wenn du es erneut versuchst, die ursprüngliche Anfrage aber durchgekommen ist, was dann? (Das ist der Punkt, an dem Idempotenz ins Spiel kommt, ein Thema, das wir in Kapitel 12 behandeln).
Was die Fehlerbehandlung angeht, können uns synchrone blockierende Aufrufe genauso viel Kopfzerbrechen bereiten, wenn es darum geht, herauszufinden, ob etwas passiert ist (oder nicht). Nur sind uns diese Kopfschmerzen vielleicht vertrauter!
Zusammenfassung
In diesem Kapitel habe ich einige der wichtigsten Arten der Microservice-Kommunikation beschrieben und die verschiedenen Kompromisse erörtert. Es gibt nicht immer nur eine richtige Option, aber ich hoffe, dass ich genug Informationen über synchrone und asynchrone Aufrufe sowie ereignisgesteuerte und Request-Response-Kommunikationsstile gegeben habe, um dir zu helfen, die richtige Entscheidung für deinen jeweiligen Kontext zu treffen. Meine Vorliebe für die asynchrone, ereignisgesteuerte Zusammenarbeit hängt nicht nur mit meinen Erfahrungen zusammen, sondern auch mit meiner Abneigung gegen die Kopplung im Allgemeinen. Aber diese Art der Kommunikation ist mit einer erheblichen Komplexität verbunden, die man nicht ignorieren kann, und jede Situation ist einzigartig.
In diesem Kapitel habe ich kurz ein paar spezifische Technologien erwähnt, die zur Umsetzung dieser Interaktionsstile verwendet werden können. Jetzt können wir mit dem zweiten Teil dieses Buches beginnen - der Implementierung. Im nächsten Kapitel werden wir uns eingehender mit der Implementierung der Microservice-Kommunikation beschäftigen.
1 Wahre Geschichte.
2 Maarten van Steen und Andrew S. Tanenbaum, Distributed Systems, 3rd ed. (Scotts Valley, CA: CreateSpace Independent Publishing Platform, 2017).
3 Bitte beachte, dass dies sehr vereinfacht ist - ich habe zum Beispiel den Code zur Fehlerbehandlung komplett weggelassen. Wenn du mehr über async/await wissen willst, besonders in JavaScript, ist das Modern JavaScript Tutorial ein guter Ausgangspunkt.
4 Gregor Hohpe und Bobby Woolf, Enterprise Integration Patterns (Boston: Addison-Wesley, 2003).
Get Aufbau von Microservices, 2. Auflage now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.