Kapitel 1. Verteilte Systeme
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Wir beginnen unsere Reise durch Serverless, indem wir über verteilte Systeme sprechen. Bevor wir uns mit Definitionen und Beispielen befassen, möchte ich dich fragen, was du über verteilte Systeme wissen musst, um mit Serverless erfolgreich zu sein? Wenn du eine Anwendung entwickelst, musst du eine Vielzahl von Annahmen treffen. Einige davon sind so einfach wie das Wissen, dass ein Schritt nach dem anderen erfolgen wird. Andere können weitaus komplexer sein. Verteilte Systeme machen alle deine Annahmen über die Umgebung, in der dein Code ausgeführt wird, und darüber, wie er funktioniert, zunichte. Wenn du für einen einzelnen Computer entwickelst, werden viele der rauen Realitäten der physischen Welt wegabstrahiert. Sobald du anfängst, ein System zu entwickeln, das auf mehreren Computern läuft, tauchen plötzlich alle diese Realitäten auf - auch wenn sie vielleicht nicht offensichtlich sind.
In diesem Kapitel erhältst du zunächst einen allgemeinen Überblick, damit du besser verstehst, worauf du dich eingelassen hast.
Wenn du keine Erfahrung in der Entwicklung von Backend-Systemen hast, ist es mein Ziel, dir zu erklären, was sich in deiner Welt verändert hat. Aber auch wenn du Erfahrung hast, wirst du hier fündig: Verteilte Systeme können selbst bei erfahrenen Softwareentwicklern und Systemadministratoren Pessimismus und Zynismus hervorrufen. Wir werden darüber sprechen, was schiefgehen kann und was du dagegen tun kannst.
Was ist ein verteiltes System?
Ein verteiltes System ist jedes System, bei dem die einzelnen Komponenten getrennt sind und über ein Netzwerk kommunizieren. Ein verteiltes System kann Teil eines größeren oder kleineren verteilten Systems sein. Das Internet ist ein riesiges verteiltes System. Dein Mobilfunkanbieter betreibt ein riesiges verteiltes System, um dich mit einem noch größeren System zu verbinden. Ihr System umfasst drahtlose Geräte, Netzwerkgeräte und Anwendungen wie Rechnungen und Kundeninformationen.
Wenn wir mit Anwendungen arbeiten, erwarten wir normalerweise Determinismus: Bei einer bestimmten Eingabe sind die Ausgabe sowie die Zustände und Sequenzen zum Erreichen dieser Ausgabe immer gleich. Die Realität verteilter Systeme ist jedoch der Nondeterminismus. Je komplexer deine Anwendung wird, desto schwieriger wird es, den Zustand zu einem bestimmten Zeitpunkt vorherzusagen. Es wird davon ausgegangen, dass alle Teile des Systems entweder auf offensichtliche oder nicht offensichtliche Weise unzuverlässig sind, aber es ist deine Aufgabe, aus diesen unzuverlässigen Komponenten ein zuverlässiges System zu bauen.
Deine Anwendung lebt nicht an einem einzigen Ort und verarbeitet keine Logik. Wenn du eine browserbasierte Anwendung oder eine mobile Anwendung hast, ist dein System verteilt, sobald du eine Codezeile an einem anderen Ort einfügst, z. B. in einer Funktion in der Cloud. Im Allgemeinen sind die Komponenten eines verteilten Systems asynchron, das heißt, sie geben eine Aufgabe weiter und warten nicht direkt auf das Ergebnis. Auf viele wichtige Vorgänge, wie die Verarbeitung von Kreditkartentransaktionen, wird jedoch synchron zugegriffen, da sie den Fortschritt der aufrufenden Aufgabe blockieren sollten, bis die wichtige Transaktion abgeschlossen ist.
Ein serverloses System ist von Natur aus verteilt. Da eine serverlose Funktion von Natur aus zustandslos ist, muss deine Anwendung, wenn sie irgendeine Form von Zustand enthält, verteilt sein. Aber sind nicht alle modernen Anwendungen verteilt? Die Antwort lautet wahrscheinlich ja, aber auf jeden Fall für Anwendungen, die wachsen und komplexer werden sollen.
Wenn du eine einfache Webanwendung mit einem Client-Frontend, einem monolithischen Backend und einer Datenbank baust (auch bekannt als dreistufige Webanwendung), dann baust du ein verteiltes System. Viele Entwickler/innen vernachlässigen diese Tatsache jedoch, wenn sie darüber nachdenken, wie die Anwendung ihren Zustand in einer Datenbank speichert. Und das führt zu Problemen. Irgendwann bei der Skalierung ihres Systems werden sie wahrscheinlich auf ein Problem stoßen, das dadurch verursacht wird, dass ein Anwendungsserver, der als leicht und horizontal skalierbar gilt, sich mit ihrer Datenbank (vertikal skalierbar) verbindet. Dieses Problem kann so weit gehen, dass mehr Ressourcen für die Datenbank benötigt werden oder einfach die Datenbankkonfiguration aktualisiert werden muss, um zusätzliche Verbindungen zu ermöglichen. Diejenigen Entwickler/innen, die vergessen, dass sie an einem verteilten System arbeiten, werden alle Probleme eines verteilten Systems haben, ohne die üblichen Muster, um Probleme zu minimieren.
Serverless verlagert viele Verantwortlichkeiten auf deinen Cloud-Provider. Als Softwareentwickler, der die Geschäftslogik schreibt, musst du jedoch immer noch einige Dinge wissen und verstehen, um deinen Stakeholdern Versprechungen in Bezug auf die Zuverlässigkeit, Verfügbarkeit und Skalierbarkeit deiner Software machen zu können.
Warum wollen wir ein verteiltes System?
du eine Lösung brauchst, die sich darum kümmert, was passiert, wenn jemand einen Fehler einführt, der dazu führt, dass deine Datenbank gesperrt wird und dein Hauptsystem nicht mehr funktioniert? Das ist eigentlich eine Stärke verteilter Systeme, denn du kannst den fehlschlagenden Dienst abschalten und die gesamte Arbeit, die von ihm erwartet wird, wird verzögert, geht aber nicht verloren, da sie in der Warteschlange steht. Wenn dein Code für die Benutzerregistrierung den Code für den E-Mail-Versand direkt ausführt, würdest du komplett ausfallen. Wie du sicherstellen kannst, dass ein Fehler nicht zu einem anderen führt, ist Thema der Kapitel 4 und 11.
Jede Anwendung, die skaliert werden soll, muss ein verteiltes System sein. Andernfalls bist du auf die Rechenleistung und die Speicherung auf einem einzigen Computer beschränkt, und deine Nutzer/innen müssen diesen Computer persönlich aufsuchen und nutzen, immer nur einer nach dem anderen. Es gibt viele Vorteile verteilter Systeme, aber es gibt keine Wahlmöglichkeit. Du baust ein verteiltes System auf. Du musst dich mit den Nachteilen vertraut machen, um die Auswirkungen auf deinen Betrieb bestmöglich zu begrenzen.
Die harten Realitäten verteilter Systeme
Nichts über das Netzwerk kann man trauen. Und in einem verteilten System müssen die Nachrichten über das Netzwerk weitergeleitet werden. In Designing Data-Intensive Applications (O'Reilly) erläutert Martin Kleppmann diese Verbindung zwischen deinem Code und der Quelle so vieler Probleme, dem Netzwerk:
Ein Knoten im Netzwerk kann nichts mit Sicherheit wissen - er kann nur Vermutungen anstellen, die auf den Nachrichten basieren, die er über das Netzwerk erhält (oder nicht erhält). Ein Knoten kann nur herausfinden, in welchem Zustand sich ein anderer Knoten befindet (welche Daten er gespeichert hat, ob er richtig funktioniert usw.), indem er Nachrichten mit ihm austauscht. Wenn ein entfernter Knoten nicht antwortet, gibt es keine Möglichkeit herauszufinden, in welchem Zustand er sich befindet, da Probleme im Netzwerk nicht zuverlässig von Problemen an einem Knoten unterschieden werden können.
Netzwerke scheinen ziemlich zuverlässig zu sein, aber hin und wieder musst du auf den Aktualisierungsknopf drücken oder warten. In einem verteilten System muss sich dein System um die Automatisierung dieser Aktualisierung kümmern. Was passiert, wenn ein System ausfällt und alle anderen Systeme anfangen, es mit Anfragen zu überschwemmen, wenn es schon nicht mehr mithalten kann?
Es gibt viel weniger Dinge zu beachten, wenn zwei Teile des Codes im selben Stapel laufen. Asynchronität kann viele unbeabsichtigte Auswirkungen haben, vor allem, wenn sie vom Programmierer unerwartet kommt. Dazu kommt noch die Zuverlässigkeit eines Netzwerks.
Um diese Probleme zu veranschaulichen, schauen wir uns eine gewöhnliche Anwendung an. Diese Anwendung hat einen Prozess der Benutzerregistrierung. Neue Registrierungen werden in eine Aufgaben-Warteschlange gestellt, um bestimmte Vorgänge auszuführen, wie z. B. das Versenden einer Willkommens-E-Mail. Der Entwickler hat eine kluge Entscheidung getroffen: Er hat die Registrierung des Nutzers von der Logik im Hintergrund, z. B. dem Versand einer E-Mail, entkoppelt. Wenn die Anwendung Probleme mit dem E-Mail-Versand hatte, sollte sie den Nutzer nicht daran hindern, sich erfolgreich zu registrieren. Auch andere Aktionen im System können dazu führen, dass eine Aufgabe in die Warteschlange gestellt wird, die eine Art von Benachrichtigung sendet. Klingt einfach, oder? Dann lass uns loslegen.
Die physische Welt
In den Nachwirkungen des Hurrikans Sandy im Jahr 2012 befand sich eine Gruppe von Betriebsingenieuren in einer prekären Situation. In Lower Manhattan war der Strom ausgefallen. Das Rechenzentrum verfügte über Generatoren und Dieseltreibstoff, aber die Dieselpumpe war aufgrund von Überschwemmungen fehlgeschlagen; die Pumpe befand sich im Keller und die Generatoren auf dem Dach. Heldenhaft setzten die Techniker eine Eimerkette ein, um den Dieselkraftstoff in 5-Gallonen-Eimern im Dunkeln 17 Stockwerke hinaufzutragen.
Die physische Welt selbst ist nicht annähernd perfekt. Nur weil dein Unternehmen die Server nicht besitzt oder sie nicht einmal sehen oder anfassen kann, heißt das nicht, dass du nicht von einem Feuer, einer Stromunterbrechung oder einer anderen Katastrophe betroffen bist, egal ob es sich um eine Naturkatastrophe handelt oder nicht. Die Unternehmen, die sich auf dieses Rechenzentrum verlassen, wurden durch den Heldenmut der Eimerbrigade verschont, ohne zu wissen, dass ihre Server jeden Moment abgeschaltet werden könnten. Wenn du in der Cloud hostest, bist du zwar nicht für das Tragen eines Eimers verantwortlich, aber du musst deine Anwendung trotz der Umstände an die Endnutzer/innen liefern. Die Cloud löst dieses Problem, soweit es die aktuelle Technologie zulässt, mit mehreren Verfügbarkeitszonen, die bei Serverless Compute in der Regel kostenlos sind, aber bei der Persistenz von Daten und anderen Diensten berücksichtigt werden müssen.
Die physische Welt kann die Ursache für viele andere Fehler sein, denen wir bei der Arbeit in der Cloud begegnen werden:
- Netzwerkprobleme
-
Jemand ist vielleicht über ein Kabel gestolpert und hat es aus der Steckdose gezogen.
- Probleme mit der Uhr
-
Die physische Hardware des Servers, die für die Zeitmessung verantwortlich ist, ein Quarz, könnte defekt sein.
- Unempfindlicher Knoten
-
Es könnte ein Feuer geben.
Wenn wir uns das vor Augen führen, können wir den Rest der Probleme, mit denen wir konfrontiert werden, drastisch vereinfachen und uns bei der Planung deiner Systeme stärker auf die Auswirkungen dieser Probleme konzentrieren.
Fehlende Nachrichten
Hast du schon mal eine E-Mail verschickt, um sie später in den Entwürfen oder im Postausgang zu finden?
gibt es keine Garantie dafür, dass eine Anfrage, die du über ein Netzwerk stellst, auch wirklich zugestellt oder bearbeitet wird. Das ist eines der vielen Dinge, die wir für selbstverständlich halten, wenn wir an Software arbeiten, die lokal läuft. Diese Probleme sind die einfache Realität der Datenverarbeitung, die von den Ingenieuren so weit abstrahiert wurde, dass die Menschen ihre Existenz vergessen haben. Netzwerke können genauso überlastet sein wie die örtliche Autobahn während der Rushhour. Das moderne Netzwerk in einer Cloud-Computing-Umgebung ist selbst ein verteiltes System. Am einfachsten lässt sich das bei der Nutzung eines mobilen Netzwerks beobachten. Wir alle haben schon Erfahrungen mit Apps gemacht, die sich aufhängen, weil sie eine sofortige Antwort von einem entfernten Rechnersystem erwarten. Wie würde sich das auf deinen Code auswirken, wenn es sich um eine Art Live- oder Echtzeitspiel handelt? Wenn sich deine Antwort zu sehr verzögert, könnte sie sogar von dem entfernten System als Anti-Zeichen-Logik zurückgewiesen werden. Nachrichten gehen verloren, kommen zu spät oder sogar am falschen Ort an. Die Leitungen sind nicht vertrauenswürdig.
Unzuverlässige Uhren
Wie wichtig ist es für dein System, zu wissen, wie spät es ist? Was passiert, wenn du dein iPhone in die Zeit zurückstellst, bevor es iPhones gab? Oder was ist, wenn du es auf eine Zeit einstellst, die 30 Jahre in der Zukunft liegt? In jedem Fall besteht eine gute Chance, dass es nicht mehr startet. Apple hat das Problem nie bestätigt, aber es wird darauf zurückgeführt, dass die Zeitstempel auf Unix-Systemen am 1. Januar 1970 gestartet wurden, was zu einem Datum von 0 führte. Die Ingenieure, die am iPhone gearbeitet haben, haben wahrscheinlich nicht damit gerechnet, dass die Benutzer/innen ihr Datum so weit in die Vergangenheit zurückstellen würden, aber sie haben es ihnen erlaubt. Das hat zu unerwarteten Fehlern geführt, sogar bei Apple.
Bei Servern wird die Systemuhr automatisch über das Network Time Protocol eingestellt. Sich auf die Systemuhr zu verlassen, scheint zwar eine sichere Sache zu sein, aber es gibt potenzielle Probleme. Google hat einen Bericht über seine interne Spanner-Datenbank veröffentlicht, in dem beschrieben wird, wie das Unternehmen mit der Zeit für dieses kritische System umgeht. Wenn die Knotenpunkte so eingestellt waren, dass sie alle 30 Sekunden abgefragt wurden, wich die Systemuhr um bis zu 7 ms ab. Für dich ist das vielleicht kein Problem, denn sowohl Google als auch Amazon bieten eine verbesserte Synchronisierung auf Basis von GPS und Atomuhren für hochsensible Systeme wie z.B. den Aktienhandel an, obwohl die gewöhnliche Systemuhr einige andere Macken hat. Wenn deine Uhr abweicht, wird sie irgendwann so korrigiert, dass sie die Wirkung deines zeitkritischen Codes verändert. Mehrere CPU-Kerne haben unterschiedliche Referenzen für die aktuelle Zeit, und die Logik in einem virtualisierten System in der Cloud ist zusätzlich von der Realität des Zeitablaufs in der Außenwelt getrennt. Dein Code kann jederzeit Zeitsprünge erleben, vorwärts oder rückwärts. Es ist wichtig, eine monotone Uhr zu verwenden, um den Zeitablauf zu messen. Eine monotone Uhr ist eine Uhr, die garantiert ansteigt.
Abgesehen davon, dass sich die Uhr in jeder Sekunde um mehr als eine Sekunde ändern kann, gibt es keine Möglichkeit zu garantieren, dass die Uhren aller Knotenpunkte auf dieselbe Zeit eingestellt sind. Sie unterliegen denselben Problemen der Netzwerkzuverlässigkeit, die wir bereits besprochen haben. Wie bei allen Problemen, mit denen du konfrontiert wirst, gibt es auch hier einen Kompromiss zwischen der Bedeutung eines Aspekts deines Systems für den Anwendungsfall und der Menge der verfügbaren technischen Ressourcen. Du baust ein soziales Netzwerk für Haustiere? Diese Sekunden sind deine Mühe vielleicht nicht wert. Du baust ein Hochfrequenzhandelssystem? Dann musst du vielleicht Hardware-Atomuhren verwenden, die per GPS gestellt werden, denn diese Mikrosekunden können viel Geld kosten.
Die aktuelle Zeit, wie sie deiner Geschäftslogik erscheint, kann unerwartet nach vorne springen. Serverlose Funktionen, wie auch andere Formen von Cloud Computing, führen deinen Code in einer virtualisierten oder isolierten Weise aus. Die Software, die diese Virtualisierung oder Isolierung ermöglicht, kann die Zeit auf verschiedene Weise verzerren. Wenn dein Code um gemeinsam genutzte Ressourcen konkurriert, kann es sein, dass er aufgrund von Multithreading eine Pause einlegt. Er wird in den Schlaf versetzt und dann plötzlich wieder aktiviert, ohne dass er weiß, wie viel Zeit in der Außenwelt verstrichen ist. Ähnlich verhält es sich mit Prozessen wie Speicherswaps, Speicherbereinigung oder sogar dem synchronen Warten auf eine Ressource, auf die über das Netzwerk zugegriffen wird. Behalte dies im Hinterkopf, wenn du versuchst, mehr Leistung zu erzielen, indem du Threads oder Unterprozesse einsetzt, um zusätzliche Arbeit in deinem System zu erledigen.
Diese Tatsachen können sich beispielsweise darin äußern, dass du nicht zuverlässig wissen kannst, welche Änderung in einer Reihe von Ereignissen in einer Warteschlange zuerst stattgefunden hat. Wenn du mit modernen verteilten Systemen arbeitest, musst du damit rechnen, dass dein System an verschiedenen Orten läuft. In diesem Fall haben wir bereits gelernt, dass Ereignisse nicht in der richtigen Reihenfolge eintreten können und werden, und es gibt keine wirkliche Möglichkeit, die Reihenfolge zu bestimmen, ohne eine Art von Locking, das teuer sein kann und seine eigenen Probleme mit sich bringt. Aber wenn du diese Art von Wissen in deinem System brauchst, wirst du keine andere Wahl haben. Selbst dann kann und wird man sich irren, wenn es darum geht, welche Aufgabe zuerst gesperrt werden soll. Das musst du in der Software regeln. Kurzfristig wird es keinen Dienst geben, der dieses Problem für dich lösen kann. Selbst wenn sie anfangen, "Consensus as a Service" oder etwas Ähnliches anzubieten, musst du immer noch die Kompromisse und Probleme rund um ihre Nutzung verstehen, wenn du deine Geschäftslogik implementierst.
Kaskadierende Ausfälle
Nehmen wir an, dass du, der Entwickler der Anwendung in diesem Beispiel, gute Arbeit geleistet hast, indem du die beiden bereitgestellten Komponenten lose gekoppelt hast. Wenn das System zur Benutzerregistrierung ausfällt, macht das dem E-Mail-System überhaupt nichts aus. Wenn das E-Mail-System serverlos ist, wird es nicht einmal ausgeführt (wie effizient!). Wenn das E-Mail-System ausfällt, bleibt das System zur Benutzerregistrierung in Betrieb. Das denkst du vielleicht. Was passiert, wenn dein Aufgabensystem voll ist und keine neuen Aufgaben mehr annimmt und deine Nutzer/innen sich nicht mehr anmelden können? So können sich Probleme gegenseitig verstärken und andere Probleme verursachen.
Als in diesem Beispiel ein System (das Versenden von E-Mails) lange und stark genug ausfiel, führte der Ausfall dazu, dass ein anderes System fehlschlug. Wenn dein System aus Dominosteinen besteht, solltest du sie so anordnen, dass es nicht zu einer Kettenreaktion kommt, wenn einer ausfällt. Egal wie langsam es ist (es hätte ein ganzes Wochenende dauern können, bis sich die Warteschlange füllte), ein robustes System ist so konzipiert, dass dieses Problem vermieden wird. Vielleicht kannst du dir eine solche Ausfallsicherheit in deinem aktuellen Projekt nicht leisten, aber du musst sie im Hinterkopf behalten.
Unerwartete Bestellung
Hast du schon einmal eine neue Version deines Codes ausgeliefert, die ein zusätzliches Zeitstempelfeld enthält, und dann festgestellt, dass Einfügevorgänge immer noch ohne dieses Feld übertragen werden? In einem verteilten System gibt es keine Garantie für die Reihenfolge der Ausführung von Logik, die auf mehrere Knoten verteilt ist. Aber wie kann es sein, dass die von dir vorgenommenen Änderungen nicht wirksam werden? Ganz einfach: Die alte Version des Codes läuft noch irgendwo. Dabei kann es sich um einen Taskserver handeln, der treu vor sich hin tuckert und sich weigert, auf die Aufforderung zu reagieren, ihn abzuschalten, damit er durch die neue Version des Codes ersetzt werden kann.
In der Zwischenzeit gibt es eine weitere Änderung, die darauf wartet, in die Produktion überführt zu werden. Dabei geht es um eine Art Pflichtfeld bei der Registrierung, z. B. einen Vornamen, und um die Aufnahme dieses Namens in die Willkommens-E-Mail. Du hast einen großen Tag mit vielen Neuanmeldungen, und dieser Code wird in die Produktion übernommen. Plötzlich bekommen die Leute keine Willkommens-E-Mails mehr und du hast jetzt große Kopfschmerzen - was ist schief gelaufen? Die Synchronizität wurde angenommen.
Es gab eine Reihe von Willkommens-E-Mails, die darauf warteten, verschickt zu werden. Als der neue Code in die Produktion ging, bestand die Aufgabe darin, Willkommens-E-Mails an die Nutzer zu senden, die ihren Namen enthielten, was in diesen Datensätzen nicht der Fall ist. Dieses Problem kann auch aufgrund von Netzwerklatenz auftreten.
Entpotenzierung
Idempotenz ist die Vorstellung, dass eine bestimmte Aufgabe, die mehr als einmal wiederholt wird, das gleiche Ergebnis hat. Es ist relativ einfach, ein System zu bauen, das eine bestimmte Aufgabe mindestens einmal ausführt. Viel schwieriger, wenn nicht sogar unmöglich, ist es, ein System zu bauen, das eine bestimmte Aufgabe nur einmal und nur garantiert ausführt.
Wie auch immer dein System E-Mails versendet, ob über SMTP direkt an den Mail-Exchanger deiner Nutzer/innen oder über eine API eines Drittanbieters, es ist nicht schwer, sich eine Situation vorzustellen, in der eine E-Mail erfolgreich versendet wurde, aber ein Fehler gemeldet wird. Das passiert öfter, als du dir vorstellen kannst, wenn du anfängst zu skalieren, und das Chaos nimmt seinen Lauf. Du versuchst, die E-Mail zu verschicken, und sie wird auch verschickt, aber kurz bevor die andere Seite erfolgreich antwortet, wird die Netzwerkverbindung getrennt, und du bekommst die erfolgreiche Antwort nicht. Als verantwortungsbewusster Entwickler hast du das System so konzipiert, dass es noch einmal versucht wird. Dieses Mal klappt es. Aber deine Aufgabe, die zweimal versucht wurde, wurde auch zweimal abgeschlossen, so dass der Benutzer zwei Willkommens-E-Mails erhalten hat.
Das ist ein so schwerwiegender Fall, dass du nicht versuchen solltest, dein System so zu optimieren, dass du immer genau eine Willkommensnachricht verschickst, und das kannst du auch nicht, wenn du keinen Zugriff auf das Postfach deines Nutzers hast. Aber selbst dann, was ist, wenn sie die Nachricht löschen, bevor du sie überprüfen kannst? Du wirst sie immer und immer wieder verschicken. Ein einzelner Knotenpunkt kann nie wirklich die Wahrheit über die Außenwelt erfahren, weil er sich darauf verlässt, dass das Netzwerk die Wahrheit erfährt, und bis er eine Antwort erhält, kann diese Wahrheit schon veraltet sein.
Wenn du das einmal akzeptiert hast, kannst du es gestalten.
Es ist wichtig, sich mit dem Design zu befassen, um zu sehen, wie diese Dinge fehlschlagen werden, aber genauso wichtig ist es, den seltenen Fall, dass ein Nutzer zwei Willkommens-E-Mails erhält, zu depriorisieren. Selbst wenn es alle Nutzer an einem bestimmten Tag betrifft, ist das kein Problem. Aber was ist, wenn die Aufgabe darin besteht, 20 Dollar von Benutzer A an Benutzer B zu schicken? Oder, da wir uns auf die Registrierung konzentrieren, Benutzer A eine Gutschrift für die Empfehlung von Benutzer B zu geben? Wenn dieser Auftrag in einer Warteschlange landet und immer wieder fehlschlägt, hast du möglicherweise ein echtes Problem. Es ist am besten, wenn du deine Aufgaben so gestaltest, dass sie idempotent sind - das Ergebnis ist immer dasselbe, egal wie oft die Aktion wiederholt wird.
Wofür bin ich verantwortlich?
Wenn du ein Angebot wie Amazons Simple Queue Service (SQS) oder Googles Pub/Sub nutzt, musst du dich nicht darum kümmern, es am Laufen zu halten. Du musst wissen, wo die Grenzen dieser Angebote liegen (wie lange eine Nachricht warten kann, ohne gelesen zu werden, bevor sie gelöscht wird), und du musst deine Systeme so gestalten, dass sie auch bei einem Ausfall oder einer Störung dieser Systeme funktionieren. Es ist am besten, wenn du so viel wie möglich über die Funktionsweise dieser Systeme weißt, um zu verstehen, wie, wann und warum sie fehlschlagen werden und welche Auswirkungen alles hat, was du baust und was sich auf diese Angebote stützt. Außerdem ist es gut zu sehen, wie zuverlässige und robuste Systeme implementiert und konzipiert wurden. Bevor du ein neues System einsetzt, solltest du die vorgesehenen Anwendungsfälle und Einschränkungen lesen und dir ein Video des Cloud-Providers über die Implementierung des Systems ansehen.1
Wenn du dich mit Serverless Compute beschäftigst, musst du dich nicht direkt um die Uhren und Netzwerke kümmern, aber du musst sie möglicherweise konfigurieren (Netzwerk) und lernen, bewährte Methoden für andere (Uhren) einzubauen.
Was musst du beachten, wenn du ein verteiltes System entwickelst?
Stell dir vor, ein Schüler, der eine Matheaufgabe lösen soll. Das scheint ganz einfach zu sein. Selbst wenn sie den Deckel eines Grafikrechners abnehmen müssen, um die Aufgabe zu lösen, werden sie es synchron tun, einen Schritt nach dem anderen. Stell dir einen Raum voller Schüler vor. Wie würde es funktionieren, wenn die Schüler/innen in Zweiergruppen arbeiten und die Aufgaben zu zweit lösen müssten? Was wäre, wenn nur einer der Schüler vom Aufgabenblatt lesen und schreiben könnte und der andere nur den Taschenrechner benutzen dürfte und keiner von beiden vom Antwortblatt oder einem anderen Blatt Papier lesen oder schreiben dürfte? Wie würde das die Sache verkomplizieren? Das ist eine Möglichkeit, sich vorzustellen, wie die Komponenten deines verteilten Systems in einem größeren, zusammenhängenden System zusammenarbeiten müssen.
Es ist fast eine Garantie, dass jede Komponente deines Systems fehlschlägt. Teilausfälle sind besonders schwer zu bewältigen, weil sie den Determinismus desSystems unterbrechen. Totalausfälle sind in der Regel leichter zu erkennen, und es ist wichtig, die Komponenten voneinander zu isolieren, damit sich die Ausfälle nicht wie ein Lauffeuer in deinem System ausbreiten können.
Lose Kopplung (oder Entkopplung)
Einer der wichtigsten Faktoren eines gut konzipierten verteilten Systems ist, dass seine Komponenten lose gekoppelt sind. Das bedeutet, dass die einzelnen Komponenten unabhängig voneinander geändert werden können, ohne dass dies negative Auswirkungen hat. Dies wird durch die Definition von APIs erreicht, an die sich jeder Dienst binden kann, während die Implementierungsdetails abstrahiert und vor dem konsumierenden Dienst verborgen werden. So können die Teams unabhängig voneinander arbeiten und sich auf die Details konzentrieren, die für ihren Bereich wichtig sind. Dieses Konzept wird auch als "vollständig entkoppelt" bezeichnet. Load Balancer tun dies, indem sie deine Logik von den unvorhersehbaren Anfragen der Nutzer/innen isolieren.
Entwirf dein System so, dass es lose gekoppelt ist. Finde deine Schwachstellen und finde heraus, wie du kaskadierende Ausfälle vermeiden kannst. Lasse nicht zu, dass verschiedene Komponenten deines Systems den Betrieb eines anderen Systems beeinträchtigen oder sich an private Integrationspunkte wie die gemeinsame Nutzung einer Datenbank anschließen.2 Die Teams können den Code der anderen einsehen, lernen und Änderungen einreichen, aber erlaube keine Umgehung der oben genannten APIs. Wenn sich zwei Dienste eine Datenbank teilen, sind sie eigentlich keine getrennten Dienste. Selbst wenn sie mit unterschiedlichen Tabellen arbeiten, kann eine Komponente die andere leicht zum Absturz bringen, da sie eng miteinander gekoppelt und auf ähnliche Komponenten angewiesen sind. Wir werden dieses Konzept in Kapitel 4 näher erläutern.
Fehlertoleranz
Du musst Spielraum in dein System einbauen, um mit Fehlern umgehen zu können. Je mehr Fehler dein System verkraften kann, desto weniger müssen deine Benutzer und deine Entwicklungsabteilung tolerieren. Hast du schon einmal in einem Team gearbeitet, das ständig mit Bränden in der Produktion zu kämpfen hat? Je nach Größe des Systems ist es eine bewusste Entscheidung, die dein Team jeden Tag trifft, indem es nicht kommuniziert, wie wichtig es ist, deine Systeme für den Produktionsverkehr abzusichern und die Prioritäten für die Verbesserung von auf technische Schulden zu setzen.
Ein wichtiger Bestandteil der Fehlertoleranz ist die Gewissheit, dass ein Knotenpunkt betriebsbereit ist. Hier kommt der Gesundheitscheck ins Spiel. Eine Gesundheitsprüfung ist eine einfache API für einen Teil des Systems, die einfach auf eine Anfrage antwortet, um einem anderen System mitzuteilen, dass es tatsächlich funktioniert. Manche implementieren dies als einfache statische Antwort, aber wenn die Komponente Zugriff auf andere Systeme, wie z. B. eine Datenbank, benötigt, möchtest du vielleicht überprüfen, ob sich die Komponente mit der Datenbank verbinden und eine einfache Abfrage erfolgreich ausführen kann, bevor du antwortest, dass die Komponente selbst in Betrieb ist.
Du musst Überwachungs- und Warnsysteme einbauen, um jederzeit über den Betrieb deines Systems informiert zu sein (siehe Kapitel 7), und du musst Pläne für den Umgang mit Störungen haben (siehe Kapitel 11).
Eindeutige (primäre) Schlüssel erzeugen
Lose Kopplung sollte die Regel für jeden Integrationspunkt sein. Wenn du planst, wie deine Daten gespeichert werden sollen, kannst du dich darauf verlassen, dass deine Datenbank eindeutige Bezeichner für die gespeicherten Daten erstellt. Wenn du aber etwas in einem Bucket-System wie S3 speicherst, bist du gezwungen, deine eigenen Bezeichner zu erstellen. Warum ist das so?
Es gilt als bewährte Methode, eindeutige Identifikatoren, sogenannte Distributed ID, mit einer bestehenden Implementierung zu erzeugen, wie z.B. Twitters Snowflake. So können Probleme mit der Kopplung an eine bestimmte Datenbank vermieden werden, wie es bei Twitter der Fall war, als Snowflake 2010 eingeführt wurde. Die Verwendung von verteilten IDs bietet auch einen Vorteil für relationale Datenbanken, da Operationen nicht auf eine Einfügung warten müssen, um einen Primärschlüssel zu erzeugen. Wenn du eine Einfügung durchführst, musst du warten, bis der Primärschlüssel zurückgegeben wird, um andere verknüpfte Objekte zu erstellen oder zu aktualisieren. Das kann bei einer komplizierten Transaktion ohne verteilte IDs zu einer Kaskade führen. Der Vorgang ist viel einfacher, wenn du ihn in einer einzigen Transaktion durchführst, indem du deine eigenen IDs erzeugst. Das Gleiche gilt auch für komplexe Microservices.
Eine verteilte ID besteht zum Beispiel aus einer Kombination aus der Zeit (in der Regel, damit die Objekte sortiert werden können) und einer Form von Entropie, um die Wahrscheinlichkeit eines Zusammenstoßes von doppelten IDs auf eine verschwindend geringe Chance zu reduzieren. Diese IDs ermöglichen es dir, nach der Reihenfolge der Erstellung zu sortieren, obwohl es innerhalb eines bestimmten Zeitstempels unmöglich ist, die Reihenfolge zu kennen, in der die Objekte erstellt wurden. Da wir bereits über die Ungenauigkeiten der Systemuhren gesprochen haben, solltest du dich nicht zu sehr auf die Genauigkeit eines Zeitstempels verlassen, vor allem nicht bei der Fehlersuche.
Planung für Idempotenz
Eine Möglichkeit, gegen die Idempotenz vorzugehen, besteht darin, bestimmte Aktionen so zu gestalten, dass sie so oft wie nötig wiederholt werden, um erfolgreich zu sein. Ich habe zum Beispiel ein System entwickelt, und die Organisation, für die ich arbeite, hat beschlossen, dass es für alle unsere Systeme wichtig ist, multiregional zu sein. Der nachgelagerte Effekt war, dass mein System ein anderes System benachrichtigte, dass etwas passiert war. Dieses System war so konzipiert, dass es diese Benachrichtigungen dedupliziert. Mein erster Gedanke war, das System einfach in mehreren Regionen zu betreiben. Es würde zwar doppelt so viel kosten und doppelt so viel Arbeit machen, aber die Anforderung würde mit minimalem Aufwand erfüllt werden. Als es dann tatsächlich an der Zeit war, die Unterstützung mehrerer Regionen zu implementieren, haben wir natürlich eine optimierte Version entwickelt und eingesetzt. Wir waren sogar in der Lage, die Nachrichten selbst zu deduplizieren, mussten uns aber nicht um die Garantie der Deduplizierung kümmern.
Zweiphasige Veränderungen
Eine zweiphasige Änderung liegt vor, wenn eine Änderung in zwei getrennte Teile (Phasen) aufgeteilt wird, um sicher in die Produktion überführt werden zu können. In einem verteilten System müssen bestimmte Änderungen (z. B. Datenmigrationen) in zwei Teilen durchgeführt werden. Bei der ersten Änderung aktualisierst du den Code, um den Code sowohl vor als auch nach der Änderung zu behandeln. Sobald der Code so aktualisiert wurde, dass er mit beiden Situationen umgehen kann, kannst du die neue Situation sicher in Kraft setzen. In dem früheren Beispiel, in dem ein neues Feld eingeführt wurde und die Logik für den E-Mail-Code von diesem neuen Feld abhing, wurde davon ausgegangen, dass keine neuen Benutzer ohne dieses Feld registriert werden können, so dass dies kein Problem darstellen würde. Diese Änderung berücksichtigte jedoch nicht Aufgaben, die sich im Transit oder in Warteschlangen befanden, oder sogar Live-Anfragen, die während des Einsatzes erfolgten. Es gibt eine Reihe von Möglichkeiten, solche Probleme zu lösen, aber es ist eine gute Ausrede, um dir das Konzept der zweiphasigen Änderungen oder Migrationen vorzustellen. Wenn du die neue Funktion in zwei verschiedene Änderungen aufteilst, kannst du sie nacheinander freigeben, um dieses Problem zu vermeiden. Du könntest das neue Feld bereitstellen und nach einer angemessenen Zeitspanne, in der sich diese Änderung gesetzt hat, die zweite freigeben. In diesem Fall solltest du jedoch sicherstellen, dass der E-Mail-Prozess nicht fehlschlägt, weil er sich auf ein Feld verlässt, das vorher nicht existierte. In diesem Fall könntest du die Änderung in einem Arbeitsgang veröffentlichen, aber behalte dieses Muster für andere Anwendungsfälle im Hinterkopf, wenn du die Struktur deiner Datenbank änderst.
Weitere Lektüre
Unter findest du weitere Informationen zu den in diesem Kapitel behandelten Themen:
-
Release It!, 2. Auflage von Michael T. Nygard (Pragmatic Bookshelf)
-
Designing Data-Intensive Applications von Martin Kleppmann (O'Reilly). Ich empfehle dir unbedingt Kapitel 8, "The Trouble with Distributed Systems". Die Abschnitte über den Entwurf von Konsensprotokollen kannst du jedoch überspringen, da sie an diesem Punkt deiner Reise zu weit fortgeschritten sein könnten.
-
Site Reliability Engineering von Betsy Beyer et al. und The Site Reliability Workbook von Betsy Beyer et al. (beide O'Reilly)
-
Refactoring Databases: Evolutionary Database Design von Scott Ambler und Pramod Sadalage (Addison-Wesley)
Fazit
Im nächsten Kapitel werden wir uns mit einer speziellen Art und Weise beschäftigen, ein verteiltes System aufzubauen: Microservices.
1 Du kannst dir zum Beispiel dieses Video zu DynamoDB ansehen.
2 Oder im Fall von NoSQL, eine Tabelle.
Get Serverless lernen 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.