Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Event-Driven Architecture auf AWS, Teil III: Hard Basics

By Vlad KhononovDec 16, 20248 min read

Diese Seite ist auch in English, Español, Français, Italiano, 日本語 und Português verfügbar.

Im vorherigen Beitrag dieser Serie ging es um Zuverlässigkeitsprobleme messaging-basierter Systeme und wie sich diese mit dem Outbox-Pattern in den Griff bekommen lassen. In diesem Beitrag verschiebt sich der Fokus vom Publizieren hin zum Verarbeiten der Nachrichten, die verschiedene Komponenten eines Systems veröffentlichen. Wie zuvor steige ich mit der Definition des Problems ein, das es zu lösen gilt.

Das Problem

Werfen wir noch einmal einen Blick auf das Outbox-Pattern. Es löst das verbreitete Problem, dass Nachrichten erst nach dem Commit der zugrunde liegenden Geschäftstransaktion veröffentlicht werden – und dabei womöglich gar nicht erst rausgehen. Mit diesem Pattern stellen Sie sicher, dass alle Events publiziert werden – mindestens einmal. Lassen Sie mich auf den letzten Teil näher eingehen.

Abbildung 1: Das Outbox-Pattern

Das Publishing-Relay holt die Events aus der operativen Datenbank, und sobald sie veröffentlicht sind, werden sie bestätigt – entweder durch Löschen der Datensätze oder durch Markieren als publiziert. Aber was passiert, wenn der Prozess genau zwischen Publizieren und Markieren abbricht? Ganz einfach: Das Event wird beim nächsten Durchlauf erneut veröffentlicht, und die Subscriber erhalten dasselbe Event mehrfach. Ein großes Problem? Keineswegs.

At-Least-Once Delivery

Wenn Sie AWS SQS einsetzen, um Events zu verarbeiten, die ein Service abonniert hat (und das sollten Sie), sollten Sie wissen, dass der Service ohnehin Garantien für "at-least-once delivery" bietet:

"Amazon SQS speichert Kopien Ihrer Nachrichten zur Redundanz und hohen Verfügbarkeit auf mehreren Servern. In seltenen Fällen ist einer der Server, auf dem eine Kopie liegt, beim Empfangen oder Löschen der Nachricht möglicherweise nicht verfügbar … Tritt das ein, kann es passieren, dass Sie diese Nachrichtenkopie beim nächsten Empfangsvorgang erneut erhalten."

Das gilt nicht nur für AWS SQS, sondern für jeden (ehrlichen) verteilten Message Bus. Jetzt fragen Sie sich vielleicht: Was ist mit SQS FIFO Queues? Laut Dokumentation sollen genau diese das Problem adressieren:

"Anders als Standard-Queues erzeugen FIFO-Queues keine doppelten Nachrichten. FIFO-Queues helfen Ihnen, Duplikate in einer Queue zu vermeiden. Wenn Sie die SendMessage-Aktion innerhalb des fünfminütigen Deduplizierungsintervalls erneut ausführen, fügt Amazon SQS keine Duplikate in die Queue ein."

Damit ist das Problem aber nur teilweise gelöst. Tatsächlich: Veröffentlicht die Outbox dieselbe Nachricht mehrfach (innerhalb von 5 Minuten), erkennt eine FIFO-Queue das Duplikat und ignoriert es. Schauen wir uns aber die Consumer-Seite an. Das Verarbeiten von Nachrichten aus einer verteilten Queue umfasst diese drei Schritte:

  1. Die nächste verfügbare Nachricht abholen.
  2. Die Nachricht verarbeiten.
  3. Die Nachricht bestätigen, indem sie als verarbeitet markiert oder – im Fall von SQS – aus der Queue gelöscht wird.

Setzen Sie nun bitte Ihre Pessimisten-Brille auf und stellen Sie sich die drei Fragen, die ich schon im vorherigen Beitrag gestellt habe:

  1. Was kann hier schiefgehen?
  2. Welche Folgen hätte das?
  3. Würden Sie diesen Ablauf der Nachrichtenverarbeitung als zuverlässig bezeichnen?

Halten Sie kurz inne und denken Sie darüber nach, bevor Sie weiterlesen.

Okay, vergleichen wir unsere Antworten.

Wenn zwischen dem Verarbeiten einer Nachricht und ihrer Bestätigung etwas schiefgeht, wird sie im nächsten Durchlauf erneut abgerufen – und damit mehrfach verarbeitet. Auch wenn FIFO-Queues "exactly-once processing" versprechen: Diese Garantie bezieht sich auf die Verarbeitung auf SQS-Seite, nicht in Ihrer Anwendung. Was ist Ihnen wichtiger? Natürlich Letzteres. Für ein zuverlässiges verteiltes System müssen Sie also davon ausgehen, dass jede Nachricht den Subscribern mehr als einmal zugestellt werden kann.

Die Lösung

Zurück zur SQS-Dokumentationsseite: Sie räumt nicht nur die Möglichkeit doppelter Zustellung ein, sondern skizziert auch die Lösung:

Gestalten Sie Ihre Anwendungen idempotent (sie sollen keinen Schaden nehmen, wenn dieselbe Nachricht mehrfach verarbeitet wird).

Ein guter Rat. Wenn das mehrfache Übergeben desselben Events oder Commands an Ihre Anwendungslogik dasselbe Ergebnis liefert, müssen Sie sich um Edge Cases mit doppelten Nachrichten keine Gedanken machen.

Lässt sich eine zuverlässige, wirklich wasserdichte idempotente Nachrichtenverarbeitung leicht umsetzen? Ganz und gar nicht! Bevor ich aber darauf eingehe, wie es geht, ein paar Worte dazu, wie man es nicht machen sollte.

Idempotente Event-Verarbeitung: Der falsche und einfache Weg

Viele versuchen, idempotente Verarbeitung über das Idempotent-Consumer-Pattern zu erreichen. An der Idee dahinter ist nichts auszusetzen, aber die meisten Implementierungen verfehlen genau den Punkt, weshalb das Pattern überhaupt existiert. Üblicherweise wird es so umgesetzt:

Erstens muss jedem Command/Event, das Ihre Anwendung verarbeitet, eine eindeutige Kennung zugewiesen werden. Zweitens benötigen Sie einen "Idempotency Store" – eine Key/Value-Datenbank, die Request-IDs auf ihre Ergebnisse abbildet (z. B. Redis, DynamoDB, In-Memory-Maps usw.).

Sind beide Voraussetzungen erfüllt, folgt die Verarbeitung eines eingehenden Requests dieser einfachen Logik:

Geht ein Request ein, wird zunächst geprüft, ob seine ID bereits im Idempotency Store vorhanden ist. Falls ja, wird das in der Tabelle gespeicherte Ergebnis zurückgegeben; eine Verarbeitung erübrigt sich. Falls die ID dort nicht existiert:

  1. Den Request ausführen.
  2. Die ID und das Ergebnis im Idempotency Store persistieren.

Setzen Sie bitte erneut die Pessimisten-Brille auf und sagen Sie mir, was hier schiefgehen kann.

Klar: Bricht der Prozess aus irgendeinem Grund genau nach Abschluss der Verarbeitung, aber vor dem Schreiben des Ergebnisses in den Idempotency Store ab, wird die Operation mehrfach ausgeführt.

Sie zweifeln, dass eine derart naive Lösung tatsächlich im Einsatz ist? Werfen Sie einen Blick auf diesen Blogbeitrag zu AWS Lambda Powertools.

Es gibt eine etwas fortgeschrittenere Variante dieses Algorithmus, die einen Request zunächst als "in Bearbeitung" markiert und nach einem bestimmten Intervall ein Timeout setzt – sie hat aber denselben Schwachpunkt: Bricht der Prozess nach Abschluss der Verarbeitung, aber vor der Aktualisierung des Idempotency Stores ab, wird der Request erneut verarbeitet.

Generell gilt: Solange die Operation eine Datenbank nutzt, die nicht an derselben Transaktion wie der Idempotency Store teilnimmt, kann ein Request mehr als einmal verarbeitet werden.

An dieser Stelle fragen Sie sich vielleicht: Der Edge Case klingt doch so selten – warum sollte ich mich überhaupt darum kümmern? Dasselbe ließe sich über das ursprüngliche Problem sagen, das wir adressieren wollen. Für Ihr konkretes System mag es akzeptabel sein. Wenn ja: Warum dann zusätzliche bewegliche Teile ins System einbauen? So oder so – ob doppelte Verarbeitung in Ordnung ist oder nicht, ist eine Designentscheidung, die bewusst getroffen werden muss.

Wie also implementiert man eine zuverlässige, wasserdichte idempotente Nachrichtenverarbeitung?

Idempotente Event-Verarbeitung: Der zuverlässige und schwierige Weg

Wie beim Outbox-Pattern erfordert auch eine zuverlässige Implementierung des Idempotent-Consumer-Patterns eine atomare Transaktion, die sowohl die Geschäftslogik als auch das Tracking der verarbeiteten Events umspannt. Und ebenfalls wie beim Outbox hängt die Umsetzung vom Technologie-Stack ab.

Idempotenz mit Multi-Record-Transaktionen

Wenn Ihre operative Datenbank Multi-Record-Transaktionen unterstützt, legen Sie eine Tabelle für Idempotency Keys an – die IDs eingehender Requests (ich nenne diese Tabelle gern "Inbox"). Sobald die Ausführung eines Requests abgeschlossen ist, aktualisieren Sie die operativen Daten und fügen den Idempotency Key in einer einzigen atomaren Transaktion ein. Wichtig: Das Einfügen des Keys muss an eine Bedingung geknüpft sein – es darf nur dann gelingen, wenn ein solcher Wert noch nicht in der Tabelle existiert. Andernfalls soll die Operation fehlschlagen; passiert das, wissen Sie, dass der Request bereits verarbeitet wurde.

...

response = dynamodb.transact_write_items(
    TransactItems=[\
        {\
            'Put': {\
                'TableName': 'operational_data',\
                'Item': {\
                    ...\
                }\
            }\
        },\
        {\
            'Put': {\
                'TableName': 'inbox',\
                'Item': {\
                    'request_id': {'S': event_id},\
                },\
                'ConditionExpression': 'attribute_not_exists(request_id)'\
            }\
        }\
    ]
)

...

Als zusätzliche Optimierung können Sie schon vor der Ausführung der Verarbeitungslogik prüfen, ob der Idempotency Key bereits existiert.

Idempotenz mit Optimistic Concurrency Control

Falls Ihre Datenbank keine Multi-Record-Transaktionen unterstützt, lässt sich zuverlässige idempotente Verarbeitung trotzdem umsetzen – über Optimistic Concurrency Control:

  1. Jede Operation modifiziert genau einen Datensatz in der Tabelle (z. B. ein JSON-Dokument).
  2. Jeder Datensatz besitzt ein Versionsfeld zur Behandlung von Concurrency-Exceptions.
  3. Jedes Update eines Datensatzes erhöht den Wert seiner Version.
  4. Beim Update muss die Datenbank prüfen, dass die Version des überschriebenen Datensatzes mit der ursprünglich gelesenen übereinstimmt.

Sobald Optimistic Concurrency Control eingerichtet ist, lässt sich der verwaltete Datensatz um ein zusätzliches Feld erweitern: processed_events (Array). Prüfen Sie vor der Ausführung eines Requests, ob die Request-ID im processed_events-Array enthalten ist. Falls nicht, verarbeiten Sie das Event und hängen die ID an das Array an.

Beiträge der Serie

  1. Event-Driven Architecture auf AWS, Teil I: The Basics
  2. Event-Driven Architecture auf AWS, Teil II: The Advanced Basics
  3. Event-Driven Architecture auf AWS, Teil III: The Hard Basics (aktueller Beitrag)

Ursprünglich veröffentlicht auf https://vladikk.com.

Mit diesem Beitrag schließe ich meine dreiteilige Serie zu den Grundlagen des Designs Event-Driven-Architecture-basierter Systeme auf AWS ab:

  1. Im ersten Beitrag haben Sie gelernt, wie Sie die Managed Services von AWS nutzen, um das Publizieren und das Abonnieren von Events bereits auf Architekturebene voneinander zu entkoppeln.
  2. Der zweite Beitrag hat häufige Probleme beim Veröffentlichen von Events untersucht und gezeigt, wie sie sich mit dem Outbox-Pattern lösen lassen.
  3. Dieser Beitrag schließlich hat beleuchtet, wie Sie Ihr System mit Idempotenz in der Event-Verarbeitungslogik zuverlässiger machen.

Zusammengefasst: Wenn Sie ein verteiltes System entwerfen, denken Sie nicht nur an die Fallacies of Distributed Computing, sondern auch an Murphys Gesetz. Gehen Sie immer davon aus: Was schiefgehen kann, geht auch schief. Machen Sie das zum festen Bestandteil Ihrer Systemarchitektur. Wer alle denkbaren Fehlerszenarien einplant, verbringt Wochenenden und Feiertage deutlich entspannter ;)