Sie haben vielleicht schon gehört, dass wir viel mit der Google App Engine Plattform gearbeitet haben. Als sie zum ersten Mal auf den Markt kam, war sie eine Art revolutionäre Lösung. Sie ermöglicht Ihnen die Anwendungsentwicklung auf einer Cloud-nativen Plattform, ähnlich der, die Google intern für seine öffentlichen Dienste verwendet. Der Hauptvorteil ist die Skalierbarkeit, aber das hat seinen Preis; man muss sich der zugrunde liegenden Paradigmen sehr bewusst sein.
Für den Fall, dass Sie mit App Engine nicht vertraut sind, sind dies die Grundlagen:
Ein triviales Beispiel ist die NoSQL-Datenbank. In Cloud Datastore hat die Menge der gespeicherten Daten keinen Einfluss auf die Leistung Ihrer CRUD-Operationen und Abfragen. Der Vorteil ist, dass Sie sich nicht um die Kapazitätsplanung kümmern müssen. Alles wird automatisch skaliert, und Sie zahlen nur für den Verbrauch. Wenn sich Ihre Anwendung verbreitet und um das Zehnfache skaliert werden muss, brauchen Sie nichts zu unternehmen. Andererseits gibt es (unter anderem) sehr strenge Beschränkungen für Abfragen und Konsistenz. Sie können mit dieser Datenbank zwar Transaktionen verwenden, aber Sie können nur 25 Entitäten oder so genannte Entitätsgruppen innerhalb einer einzigen Transaktion ändern oder darauf zugreifen.
Eine sehr wichtige Aufgabe bei der Anwendungsentwicklung ist die Stabilität, insbesondere in einer Cloud-nativen Umgebung. Eine herkömmliche SQL-Datenbank lässt sich nicht wesentlich besser skalieren, selbst wenn Sie sie nur für einfache Anwendungsfälle verwenden. Cloud Datastore lässt sich gut skalieren, aber man kann damit keine komplexen Dinge tun. (Hinweis: Wenn Sie Cloud Spanner verpasst haben, sollten Sie es sich unbedingt ansehen).
Sie können die Skalierbarkeit Ihrer Anwendung dennoch beeinträchtigen, indem Sie Engpässe hinzufügen, z. B. einen global eindeutigen Sequenzzähler für bestimmte von Ihnen erstellte Geschäftseinheiten. Skalierbarkeitsprobleme können auch durch Funktionen verursacht werden, die sich auf Warteschlangen mit geringem Durchsatz oder auf Batch-Verarbeitungsaufträge im Hintergrund stützen, die in einer bestimmten Zeit abgeschlossen sein müssen.
Wenn Sie mit der Erstellung einer Anwendung beginnen oder eine neue Funktion hinzufügen, werden Sie bestimmte Annahmen über die Kardinalität von Entitäten und deren Beziehungen treffen. Es ist vernünftig, nicht zu früh zu optimieren und kein vollständig skalierbares System zu erstellen, wenn Sie nicht wissen, ob Ihre Anwendung jemals die Zahl von tausend Benutzern erreichen wird. Wenn die Anwendung jedoch weiter wächst, stoßen Sie möglicherweise an Grenzen, z. B. die maximale Größe einer Datenspeicherentität, die maximale Größe einer Warteschlangenaufgabe oder die Anzahl der Entitäten, die Sie in einer einzigen Anfrage verarbeiten können oder die sicher in den Speicher passen.
Wie auch Google in seinen SRE-Richtlinien betont, gibt es keine 100%ige Betriebszeit. In einem Cloud-nativen, verteilten, skalierbaren System hat dies auch Auswirkungen auf die Anwendungsentwicklung, nicht nur auf den Betrieb.
Es ist ziemlich schwer, sich daran zu gewöhnen, wenn man von einer Java EE-Mentalität kommt.
Jede Anfrage kann jederzeit fehlschlagen, auch Ihre. Ein Fehler bei der Netzwerkverbindung zu Ihrer Datenbank sollte nichts Außergewöhnliches sein, sondern zum normalen Geschäft gehören. In einem ausreichend großen System wird dies einfach zu oft vorkommen, als dass es Sie stören könnte.
Eine wichtige Konsequenz daraus ist, dass Sie alle Ihre externen Aufrufe (einschließlich Datenspeicher, Cache und Suche) mit ausreichenden Wiederholungsversuchen und Fehlerbehandlungen durchführen müssen. Im Falle des Cache beispielsweise sollte Ihre Anwendung auch dann ordnungsgemäß funktionieren, wenn auf den Cache nicht zugegriffen werden kann; dies ist ohnehin eine bewährte Praxis.
Ein kniffliger Fall eines RPC-Fehlers liegt vor, wenn Ihre Anfrage auf der Seite des Dienstes erfüllt wird, Sie aber die Antwort aufgrund eines Netzwerkfehlers nicht erhalten. Wenn Sie es erneut versuchen und der Vorgang nicht idempotent ist und fehlschlägt, kann es sein, dass Sie Ihre Anfrage abbrechen, obwohl der Vorgang eigentlich erfolgreich war. In anderen exotischen Fällen geben einige externe Systeme möglicherweise HTTP 200 zurück und führen den angeforderten Vorgang trotzdem nicht aus. Ihre Anfrage kann aber auch jederzeit unerwartet fehlschlagen. Es könnte Probleme in Ihrer Anwendung geben, die eine OutOfMemoryException verursachen, die letztendlich andere gleichzeitige Anfragen unterbricht. In solchen Fällen kann App Engine die Instanz zwangsweise beenden. Grundsätzlich kann Ihr Java-Code an jeder beliebigen Stelle beendet werden, ohne die finally-Blöcke auszuführen, so als ob die VM oder der gesamte Server abstürzen würde. Das passiert natürlich nicht sehr oft, aber es kommt vor.
Mit der Einführung von Cloud Firestore, der neuen Generation von Cloud Datastore (nicht zu verwechseln mit Firebase), erhalten neue Datenbankinstanzen nun auch höhere Konsistenzgarantien als vorherige Datenbankinstanzen. Zuvor waren Abfragen nur bedingt konsistent, d. h. es dauerte eine nicht definierte Zeit, bis Aktualisierungen von Entitäten in den Abfrageergebnissen reflektiert wurden. Abfragen sind nun in diesem Sinne konsistent, aber es kann immer noch andere überraschende Szenarien geben. Wenn Sie z. B. eine Reihe von Entitäten verarbeiten, sie in absteigender Reihenfolge der letzten Änderungszeit abfragen und die Ergebnisse mit einem Cursor iterieren, kann Ihre Abfrage bestimmte Entitäten übersehen, wenn sie in der Zwischenzeit geändert wurden.
Konsistenzprobleme können auch durch die fehlende referentielle Integrität entstehen. Kurz gesagt, Sie können zwar jede Art von Verweis zwischen einer PostalAddress- und einer User-Entität herstellen, indem Sie ihnen "id"- oder "key"-Felder hinzufügen, aber die Datenbank stellt nicht sicher, dass die referenzierten Entitäten tatsächlich vorhanden sind oder dass die Verweise aktuell sind.
Mit Cloud Datastore können Sie zum Beispiel mit Entitätsgruppen arbeiten, um die Konsistenz besser zu gewährleisten. In der Dokumentation gibt es hervorragende Artikel zu diesem Thema. Andere NoSQL-Datenbanken bieten ähnliche Lösungen, aber der Punkt ist, dass Sie immer noch nicht die vollständige Lösung erhalten, die Sie von traditionellen Datenbanken gewohnt sind.
Dies bedeutet, dass Sie beim Lesen von Daten aus dem Datenspeicher ausreichend auf fehlende Entitäten und veraltete Referenzen vorbereitet sein müssen.
Zum Beispiel könnte die Anfrage zur Benutzerregistrierung teilweise fehlgeschlagen sein und nicht die Standard-Postanschrift erstellt haben, die normalerweise erstellt wird. Es ist natürlich, dass ein Postdienst fehlschlägt, wenn es keine Postadresse gibt, aber Sie wollen wahrscheinlich, dass der Benutzer seine Profilinformationen auch dann einsehen kann, wenn die Standardpostadresse fehlt, also sollten Sie keine NullPointerException auslösen.
Je nach Anwendungsfall können Sie natürlich höhere Konsistenzgarantien erreichen, aber das hat einen gewissen Preis in Bezug auf Skalierbarkeit und Robustheit. Die SRE-Mentalität ermutigt Sie, nicht um jeden Preis die volle Konsistenz anzustreben, sondern das für den jeweiligen Anwendungsfall erforderliche, angemessene Maß an Konsistenz zu berücksichtigen.
In einer Cloud-nativen Umgebung müssen Sie, wenn Sie Anwendungen entwickeln wollen, auf ein Szenario vorbereitet sein, in dem viele Ihrer Anfragen fehlschlagen und die erfolgreichen ungültige Ergebnisse zurückgeben 🙂 Okay, die Realität ist nicht so hart, aber in Systemen, die groß genug sind, neigen unerwartete Szenarien dazu, regelmäßig aufzutreten.
Sehen wir uns nun einige wichtige Aufgaben an, die Sie während der Anwendungsentwicklung in einer Cloud-nativen Umgebung im Auge behalten sollten. Ein einfacher Aspekt davon ist, dass Sie bei der Implementierung einer API zur Rückgabe einer bestimmten Ressource sorgfältig entscheiden müssen, welche zugehörigen Entitäten einen Fehler verursachen sollen, wenn sie fehlen, und welche einfach nur "nice to have" sind, im besten Fall. Sie müssen auch überlegen, wie Sie dem Endbenutzer die Möglichkeit geben können, eine bestimmte aufgetretene Inkonsistenz manuell zu beheben. Bei anderen Inkonsistenzen kann es sinnvoll sein, den Kunden-Workflow zu blockieren, um weitere Datenbeschädigungen zu vermeiden.
Die Dinge werden komplizierter, wenn Sie Aktualisierungsvorgänge entwerfen, insbesondere wenn externe Systeme beteiligt sind. In App Engine können Sie eine einzige Transaktion für Datenspeicher- und Aufgabenwarteschlangenoperationen verwenden, aber diese Transaktion erstreckt sich nicht auf andere Dienste oder externe Aufrufe, so dass Sie die Art von verteilten Transaktionen, die Sie von traditionellen Unternehmenssystemen erhalten, vergessen müssen.
Bei einer Geschäftsaktualisierungsoperation müssen Sie jede Anfrage prüfen, um zu sehen, was passiert, wenn sie fehlschlägt oder was passiert, wenn die Ausführung an diesem Punkt abgebrochen wird. Sie müssen nicht unbedingt alle Fehler auf elegante Weise behandeln. Es ist in Ordnung, wenn manchmal HTTP 500 zurückgegeben wird, auch an Endbenutzer, aber Sie sollten versuchen, das System nicht in einem inkonsistenten Zustand zu lassen. Die schlimmste Art von Fehler ist, wenn Geschäftsdaten für den Endbenutzer so unzugänglich werden, dass er sie nicht manuell reparieren kann.
Wenn Sie Sperren verwenden, stellen Sie sicher, dass alle Ihre Sperren mit einem bestimmten Ablaufdatum versehen sind und dass die Ablauffristen relativ kurz sind. Da die Anfrage jederzeit abgebrochen werden oder mit einem unerwarteten Fehler fehlschlagen kann, wird die Sperre möglicherweise nicht ordnungsgemäß freigegeben. Eine festsitzende Sperre mit unnötig langem Ablaufdatum kann die Arbeit des Benutzers für eine unangenehm lange Zeit blockieren.
Wenn Sie die Datenbank ändern, versuchen Sie, eine Reihenfolge der Operationen zu finden, bei der es unwahrscheinlich ist, dass die Daten inkonsistent bleiben. Sie können versuchen, die Grundsätze eines traditionellen Systems durch die Verwendung von Transaktionen und Entitätsgruppen zu imitieren, aber sie können leicht unbeabsichtigt zu Engpässen führen, also verwenden Sie diese Funktionen mit Bedacht.
Genauso wie Sie externe Aufrufe wiederholen, wenn sie mit bestimmten Antwortcodes fehlschlagen, sollten Sie damit rechnen, dass auch Ihre Anfragen erneut versucht werden, wenn sie fehlschlagen.
Dies ist z. B. der Standardfall bei Cloud-Push-Tasks. Bei Cloud Tasks ist es außerdem wichtig, dass der Dienst, obwohl es Garantien für die Dauerhaftigkeit und die Zustellung gibt, garantiert, dass eine Aufgabe mindestens einmal ausgeführt wird. In der Praxis bedeutet dies, dass Sie damit rechnen müssen, dass jeder Hintergrundvorgang mehrmals aufgerufen wird. Ich habe das selten erlebt, aber das kann nicht nur durch das Warteschlangensystem verursacht werden. Der von Ihnen implementierte Vorgang kann auch kurz vor dem Ende der Anforderung fehlschlagen, wenn alle Geschäftsvorgänge bereits ausgeführt wurden und die Anforderung lediglich einige Diagnoseprotokolle zu bestimmten Warteschlangen hinzufügt; aufgrund eines kleinen Programmierfehlers und einer seltenen Bedingung kommt es zu einer Ausnahme. Dann wird die Aufgabe von der Warteschlange gemäß der konfigurierten Wiederholungsrichtlinie erneut versucht.
Das bedeutet, dass jeder Lock- oder Mutex-Mechanismus, den Sie verwenden, ausreichend re-entrant sein muss. Mit ausreichend meine ich, dass, selbst wenn eine Warteschlangenaufgabe nicht sofort wieder in den Mutex eintreten kann, die Zeitüberschreitung der Sperre und die Wiederholungen der Warteschlangenaufgabe so konfiguriert sind, dass der Mutex in einer angemessenen Zeit wieder betreten werden kann.
Idempotenz kann schwieriger sein. Sie müssen die Optionen je nach Geschäftsfall sorgfältig abwägen. Es ist kein Problem, wenn ein Forumskommentar zweimal gepostet wird, aber bei Geldtransfers ist mehr Vorsicht geboten. Die Neubewertung aller Ausgangsbedingungen könnte das Haupterfolgsszenario kostspieliger machen, nur um die sehr seltenen Fehlschläge zu bewältigen. Andere Operationen können so implementiert werden, dass sie von vornherein idempotent sind.
Die Dinge werden interessanter, wenn man mit externen Systemen arbeitet. Externe Systeme neigen dazu, langsamer zu reagieren. Die Wahrscheinlichkeit von Netzwerkfehlern ist größer, und sie bieten in der Regel auch eine geringere Verfügbarkeit als die Dienste der GCP-Plattform. Langsamere Antwortzeiten können die Anzahl der Operationen, die Sie mit einer einzigen Serveranfrage durchführen können, erheblich einschränken, so dass weniger Zeit für Wiederholungsversuche bleibt. Die Sicherstellung der Idempotenz kann auch mit einem höheren Preis verbunden sein. Während ein zusätzlicher Datenspeicherlesevorgang, der 10 ms dauert, ein vernünftiger Kompromiss sein kann, um eine kritische Operation idempotent zu machen, kann ein Vorgang, der mehrere hundert ms dauert, die Benutzererfahrung stark beeinträchtigen.
Für mich, der ich aus einer Java EE-Umgebung komme, war es schwierig, mich an die Anwendungsentwicklung in einer Cloud-nativen Umgebung auf diese Weise zu gewöhnen. Und natürlich geht es hier nicht wirklich um Java EE. Man kann skalierbare und robuste Anwendungen auf der Grundlage von Java EE entwickeln, aber die Branchenpraktiken und die Art und Weise, wie große Anwendungsserver aufgebaut sind, führen dazu, dass man bestimmte Dinge als selbstverständlich voraussetzt. Wenn diese Dinge kaputt gehen, kommen die Systembetreiber und reparieren sie.
Nachdem ich mich an den Cloud-Ansatz gewöhnt habe, ist es sehr befriedigend zu sehen, wie mühelos eine Anwendung skaliert und wie robust sie mit bestimmten Vorfällen umgeht.