Suche
Mit Polling schnell zum Ziel

Resilient Microservices – ein Architekturmuster für die Praxis

Oliver Wronka

@shutterstock/kirill_makarov

Es ist mittlerweile schon viel gesagt worden zu den Themen Microservices und Resilient Softwaredesign. Was liegt also näher, als beides einmal zu kombinieren und das Ganze mit einem wiederverwendbaren Architekturmuster in die Praxis umzusetzen.

Bevor wir starten, widmen wir uns noch schnell ein paar Begriffsbestimmungen. Unter Microservice verstehe ich ein selbständig laufendes Softwaremodul, das Teile oder die Gesamtheit einer fachlichen Anforderung umsetzt. Es ist also möglich, Microservices zu definieren, welche nur eine einzige Anforderung umsetzen, z. B. „Brief drucken“, oder aber komplexere Anforderung wie z. B. das gesamte Billing. Resilient hingegen ist ein aktuelles Designparadigma, das davon ausgeht, dass Kommunikationspartner nicht zur Verfügung stehen könnten und man sich bereits im Design Gedanken darüber machen muss, wie man mit derlei Eventualitäten umgehen möchte.

Basierend auf einem Multi-Tier-Ansatz je Microservice können diese in unterschiedlichen Ausprägungen vorkommen. Hier ein paar mögliche Flavours:

  • GUI – Logik – Persistenz (vollständige Anwendung)
  • Logik – Persistenz (Service)
  • Batch

Eine mögliche reale Ausprägung ist z. B. der Microservice zur Selbstregistrierung eines neuen Anwenders (Abb. 1).

Abb. 1: 3-Tier-Aufbau des Microservice „Registration“

Abb. 1: 3-Tier-Aufbau des Microservice „Registration“

Ein weiteres Beispiel ist der Microservice zum Login. Dieser nutzt in dieser Ausprägung den Microservice „Registration“, um die Accountinformationen des Nutzers während des Logins abzufragen (Abb. 2).

Abb. 2: Microservice „Login“ mit Onlineschnittstelle zum Microservice „Registration“

Abb. 2: Microservice „Login“ mit Onlineschnittstelle zum Microservice „Registration“

Dieses Beispiel zeigt nun, wie Microservices geschnitten werdenund ggf. miteinander kommunizieren können, doch genau dieses Beispiel zeigt auch, dass der Microservice „Login“ gegenüber dem eventuellen Ausfall des Microservice „Registration“nicht fehlertolerant ist.

Redundancy
An dieser Stelle kommt nun eine Designentscheidung zum Tragen. Ich entscheide mich nun bewusst für Datenredundanz, damit diese Abhängigkeit aufgelöst wird. Die grundlegende Idee besteht also darin, Daten nur an einer Stelle leben zu lassen (also lesend und schreibend zugänglich zu machen), aus Performance- und Stabilitätsgründen aber gezielt (unter Berücksichtigung des Need-to-know-Prinzips) an die Mircoservices zu verteilen. Wichtig ist hierbei, dass auf diese verteilten Daten je Microservice nur lesend zugegriffen werden kann. Entscheidend ist jedoch, dass der lesende Microservice auch bei Ausfall des Microservice, welcher der eigentliche Master der Daten ist, weiterarbeiten kann.

Bei der Verteilung der Daten kommt nun ein Architekturmuster zum Einsatz, das eigentlich ein wenig verstaubt erscheint, in Zeiten von Gigabit-LANs mit PING-Zeiten im kleinen, einstelligen Millisekundenbereich aber eigentlich seine Berechtigung hat: das Polling.

Informationaspects

Hierzu werden die bereitzustellenden Daten in Informationsaspekte unterteilt, was bedeutet, dass eine Person z. B. folgende Informationsaspekte haben könnte:

  • Person
  • Account
  • Address
  • Bankaccount

Eine entsprechende XML-Repräsentation sähe dann aus wie in Listing 1.

Listing 1:
1234owronka
2fc16e07… b295d117135…Oliver
Wronka

Rheinweg 86
53129
Bonn
Kurfürstenallee 5
53177
Bonn

Oliver Wronka
DE#######################
COLS#####XXX

Zum besseren Verständnis werden hier die Account- und Kontoinformationen im Klartext übertragen. Im realen Leben müssten diese jedoch contentverschlüsselt sein. Der Datenfluss zwischen dem eigentlichen Datenmaster (Registration) und den nutzenden Microservices sähe dann für einen einzelnen Datensatz wie in Abbildung 3 dargestellt aus.

Abb. 3: Verteilung der Informationsaspekte zwischen den Microservices

Abb. 3: Verteilung der Informationsaspekte zwischen den Microservices

Versioning

Nach diesem grundlegenden Designprinzip ist es nun an der Zeit, für ein sinnvolles Antwortverhalten des Datenmasters zu sorgen. Zu diesem Zweck wird jeder Datensatz eines Informationsaspekts mit einer Versionsnummer versehen. Konkret heißt dies, dass in einer relationalen Datenbank ein Informationsaspekt auf einer Tabelle abgebildet ist. Jede Tabelle erhält somit also eine zusätzliche Spalte mit einer Versionsinformation.

Für den Datenmaster gilt nun, dass er mit jedem Insert oder Update die Versionsnummer global immer um eins erhöht und in diese Spalte einträgt. Somit bin ich in der Lage, nur Datensätze an einen Microservice zu liefern, welche er noch nicht kennt. Der lesende Microservice liefert bei jedem Polling-Zyklus seine persönlich bekannte Versionsnummer mit und erhält somit nur die Daten, die sich seit dem letzten Zyklus geändert haben.

Chunking

Der nächste Schritt besteht darin, Datensätze nicht einzeln, sondern als Chunks zu übertragen. Nun wird es ein wenig tricky. Auch wenn ich die Chunkgröße großzügig wähle, so kann es sein, dass nicht alle Datensätze mit einem Polling-Aufruf übertragen werden können. Die Kommunikation zwischen Datenmaster und lesenden Microservices muss somit um einige Informationen ergänzt werden.

Als Erstes muss neben der Versionsnummer auch immer der größte Index des Masterdatensatzes für diesen Chunk mitgegeben werden. In diesem Beispiel gehe ich davon aus, dass die Person die Mastertabelle ist und alle anderen Informationsaspekte als Detailtabellen mit einem Fremdschlüssel auf die Mastertabelle abgebildet werden.

Die Chunkgröße ist hierbei fest vorgegeben, z. B. 100 Datensätze je Aufruf. Werden weniger als 100 Datensätze zurückgeliefert, so bedeutet dies, dass dieser Zyklus beendet ist und erst nach einer gewissen Zeitspanne wieder gepollt werden soll. Andernfalls erfolgt die erneute Abfrage nach weiteren Datensätzen unmittelbar. Die als REST-URL übermittelte Abfrage müsste also wie in Listing 2 in der Datenbank umgesetzt werden.

Listing 2
// REST-URL
http://axxessio.com/customers?aspects=~account&version=123
// SQL-Pseudo-Statement
select
A.*
from
ACCOUNT A, CUSTOMER C
where A.CID=C.ID and C.VERSION>{version}

Unschön ist nun, dass während des Polling-Zyklus auch schreibende Vorgänge passieren können. Somit muss man dafür sorgen, dass ein Polling-Zyklus immer nur einen Snapshot zum Zeitpunkt des ersten Aufrufs darstellt. Aus diesem Grund wird mit der Rückgabe des ersten Chunks die höchste Versionsnummer mit zurückgeliefert (Listing 3).

Listing 3
// Ergebnis der ersten Abfrage im Pollingzyklus<?xml version=“1.0″?><idx>100</idx>

<ver>246</ver>

<customers>

<customer>

</customer>

<customer>

</customer>

</customers>

Diese Rückgabe teilt mit, dass die aktuell höchste Versionsnummer 246 ist und der höchste Index dieses Chunks bei 100 liegt. Mit diesen Parametern muss die zweite Polling-Abfrage wie in Listing 4 gezeigt werden.

Listing 4
// REST-URL
http://axxessio.com/customers?aspects=~account&idx=100&version=246
// SQL-Pseudo-Statement
select
A.*
from
ACCOUNT A, CUSTOMER C
where
A.CID=C.ID and C.ID>{idx} and C.VERSION<={version}

Batching vs. Messaging

Nun haben wir alles zusammen, um unsere Daten mit einem einfachen Architekturmuster zwischen den Microservices zu verteilen. Kommen wir also zu weiterführenden Aspekten.

Dieses Muster ist nun universell einsetzbar, so auch für Batching. Häufig besteht die Anforderung, dass eine Bestellung über ein Billing abgerechnet werden muss. Eine mögliche Lösung im Sinne eines resilient Softwaredesigns könnte nun darin bestehen, den Betrag der Bestellung in eine Queue einzustellen. Der Billing-Microservice lauscht nun an dieser Queue und verbucht die Bestellung. Sollte der Billing-Microservice vorübergehend nicht verfügbar sein, so merkt der nutzende Microservice dies erst mal nicht, denn es liegt ja asynchrone Kommunikation vor.

Nachteilig ist hierbei, dass man für eine ähnliche Aufgabenstellung zwei unterschiedliche Implementierungen nutzt. Hinzu kommt, dass insbesondere bei Berücksichtigung von Informationsaspekten eine Abbildung dieser Kommunikation mittels Messaging aufwändiger werden kann. Die Anbindung neuer Microservices an einen Datenmaster hat dann in der Regel mindestens einen konfigurativen Eingriff zur Folge, schlimmstenfalls muss sogar kodiert werden.

Das lässt sich mit unserem hippen Architekturmuster anders umsetzen. Anstatt ein Messaging aufzusetzen, bietet der Microservice mit der Bestellung eine Polling-Schnittstelle an, die der Billing-Microservice zyklisch abruft. That’s it!

Setzen wir nun noch einen drauf: Wir können davon ausgehen, dass der bestellende Microservice dem Kunden signalisieren möchte, dass seine Bestellung erfolgreich verarbeitet wurde. Hierzu bietet dann der Billing-Microservice eine Schnittstelle an, über welche er den Status der Bestellung zurückliefert. Wenn wir diese Schnittstelle schon anbieten, dann ist es sinnvoll, noch zusätzliche Angaben zu einem Kunden bereitzustellen. In diesem Falle wäre es naheliegend, die Bonität zu übermitteln. Der Sinn liegt darin, eine Buchung zu akzeptieren, ohne dass der Billing-Microservice verfügbar ist. Übermitteln wir also z. B. das Guthaben des Kunden und prüfen dies gegen seine Bestellung, so können wir diese eine Bestellung akzeptieren. An dieser Stelle muss man dann aber auch vorsichtig sein. Es darf nicht passieren, dass der Betrag der Bestellung nun auch im bestellenden Microservice direkt verbucht wird. Dies obliegt der Hoheit des Billing-Microsservice. Wir sollten uns also lokal nur merken, ob wir diesen Joker schon einmal gezogen haben. Beim nächsten Mal müssen wir dann leider passen, sofern der Billing-Microservice nicht wieder verfügbar ist.

Scaling

Nachdem wir nun geklärt haben, wo und wie Bartel den Most holt, sollten wir noch klären, wo der Hammer hängt. Unser Muster bietet sich an, die Kommunikation teilweise reaktiv abzubilden (Abb. 4).

Abb. 4: Reactive Pattern zur Steigerung des Durchsatzes

Abb. 4: Reactive Pattern zur Steigerung des Durchsatzes

Hier die Beschreibung der einzelnen Schritte:
1. Der erste Aufruf im Polling-Zyklus erfolgt synchron
2. Mit dem ersten Ergebnisset wissen wir, ob noch weitere Daten vorliegen
3. Sofern weitere Daten vorliegen, wird der zweite Aufruf asynchron abgesetzt
4. Anschließend werden die lokal vorliegenden Daten im Storage des abrufenden Microservice synchron abgelegt
5. Anschließend wird auf das Ergebnis des asynchronen Aufrufs gewartet und bei dessen Eintreffen verarbeitet

Je nach Menge der zu übertragenden Daten kann man real bis zu 1 000 Datensätze pro Sekunde auf diese Art und Weise übertragen. Wem das noch nicht reicht, der kann die folgende Stufe zünden.

Der abrufende Microservice hat nun die Möglichkeit, selbst abzuschätzen, ob er den Durchsatz erhöhen möchte. Stellt er z. B. fest, dass er nacheinander zehn Chunks abgerufen hat, so kann er nun beginnen, die Abfragen selbst zu skalieren. Hierzu startet er mehrere Threads und gibt je Anfrage einen Modulo sowie den Rest mit. Listing 5 zeigt also vier parallele Abfragen und deren Abarbeitung im Pseudocode.

Listing 5
// REST-URLs für vier parallele Threads
http://axxessio.com/customers?aspects=~account&version=123&threads=4&this=0
http://axxessio.com/customers?aspects=~account&version=123&threads=4&this=1
http://axxessio.com/customers?aspects=~account&version=123&threads=4&this=2
http://axxessio.com/customers?aspects=~account&version=123&threads=4&this=3
// SQL-Pseudo-Statement
select
A.*
from
ACCOUNT A, CUSTOMER C
where A.CID=C.ID and C.VERSION>{version} and MOD(C.ID,{threads})={this}

Der erste Thread erhält also nur Datensätze, in denen die ID der Person geteilt durch die Anzahl der Threads einen Rest von 0 ergibt, der zweite Thread erhält nur Datensätze, in denen die ID der Person geteilt durch die Anzahl der Threads einen Rest von 1 ergibt usw.

Availability

Was aber nun, wenn die Polling-Schnittstelle zwischen zwei Microservices doch nicht ausfallen darf? Das Architekturmuster bietet auch hierfür ein paar schicke Lösungen an. Es müssen zwei mögliche Fehlerursachen betrachtet werden: Zum einen kann der datenliefernde Microservice ausfallen, zum andern der lesende. Für beide Szenarien gibt es eine Lösung.

Ausgehend davon, dass ein Microservice in mehreren Instanzen läuft, wird vor die datenliefernden Microservices ein HAProxy geschaltet. Dieser kann dann bei Ausfall eines Microservice auf einen anderen, noch laufenden Microservice schwenken. Darüber hinaus kann der HAProxy auch noch eine Lastverteilung umsetzen, indem er die Anfragen per Round Robin verteilt (Abb. 5).

Abb. 5: Verfügbarkeit der Services herstellen

Abb. 5: Verfügbarkeit der Services herstellen

Das Ausfallen eines lesenden Microservice kann man auffangen, indem man zwei Instanzen startet. Diese laufen jedoch in unterschiedlichen Rollen. Eine Rolle ist die des Primary, der aktiv gegen den datenliefernden Service pollt, während der Andere, der Secondary, passiv ist. Passiv ist dieser Service aber nur in dem Sinne, dass er nicht aktiv gegen den datenliefernden Microservice pollt. Allerdings pollt er permanent den Primary an (Heartbeat). Antwortet dieser für eine definierte Zeit nicht, so geht der Secondary davon aus, dass der primäre Microservice ausgefallen ist. Er übernimmt nun dessen Rolle und erklärt sich selbst zum primären Microservice. Fährt der ehemalige Primary wieder hoch, so pollt er erst gegen den ehemaligen Secondary. Er erkennt, dass dieser vorhanden und aktuell der Primary ist, also erklärt er sich selbst zum Secondary.

Weiterführende Themen

Abschließend möchte ich noch auf weiterführende Themen hinweisen, die in diesem Artikel nicht mehr berücksichtigt werden. Zum einen ist da die Versionierung von Schnittstellen. Wie geht ein Microservice damit um, wenn er eine neue Version seiner Schnittstelle zur Verfügung stellen möchte, die lesenden Microservices aber noch die alte Schnittstelle benötigen?

Wie sieht der Aufbau der eigentlichen Webpräsenz aus, die sich ja aus einer Vielzahl von GUI-Komponenten je Microservice zusammensetzt?

Hierzu möchte ich auf eine Präsentation verweisen, die ich auf Slideshare zur Verfügung gestellt habe. Auch zum Thema Security habe ich eine Präsentation veröffentlicht, die zeigt, wie man REST-Schnittstellen mittels OAuth 2.0 absichern kann.

Verwandte Themen:

Geschrieben von
Oliver Wronka
Oliver Wronka
Oliver Wronka befasst sich mit Java seit der Version 1.0. Schwerpunkt in seiner Funktion als Principal-Softwarearchitekt bei der axxessio GmbH mit Sitz in Bonn sind Java-basierte Backend-Systeme.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.