Startseite / Artikel / Ausgangs- und Eingangslisten: Entwurf von Webhooks, die Ausfälle überstehen

Ausgangs- und Eingangslisten: Entwurf von Webhooks, die Ausfälle überstehen

Erfahren Sie, wie die transaktionale Ausgangswarteschlange, eine idempotente Eingangswarteschlange, state-aware Deferral sowie Dead-Letter-Warteschlangen eine zuverlässige Übermittlung von Webhooks auf AWS, Azure und GCP ermöglichen.

2740 Wörter

Webhooks scheinen die einfachste Form der Integration zu sein: Eine Seite sendet einen HTTP POST-Anfragen, die andere Seite kümmert sich darum. In der Praxis bergen sie alle Gefahren eines verteilten Systems, denn das Netzwerk zwischen zwei Diensten kann Anfragen verlieren, zur Hälfte abbrechen, denselben Inhalt zweimal übermitteln oder Ereignisse in der falschen Reihenfolge liefern. Behandelt man einen webhook wie einen gewöhnlichen CRUD-Aufruf, verliert man letztendlich Benachrichtigungen, auslöst Nebeneffekte zweimal und landet mit zwei Systemen, die sich über das Geschehene uneinig sind.

Dieser Leitfaden zeigt Ihnen ein Design, das unter diesen Bedingungen funktioniert. Sie erfahren, warum der naive Ansatz versagt, wie eine transaktionale Ausgabenschublade ausgehende Webhooks zuverlässig macht, wie eine idempotente Eingangsschublade eingehende Webhooks sicher zum Wiederholen macht, wie man mit Ereignissen umgeht, die in der falschen Reihenfolge ankommen, wie man Payloads in Quarantäne bringt, die niemals erfolgreich sein können, sowie welche verwalteten Dienste bei AWS, Azure und Google Cloud zu jedem Bestandteil passen.

Warum die offensichtliche Implementierung Daten verliert

Betrachten Sie einen SaaS-Backend, der eine bedeutende Zustandsänderung verarbeitet, wie zum Beispiel die Erfüllung einer Bestellung oder die Aktivierung eines Abonnements. Ein Partner ruft Ihre API auf, um die Aktion zu bestätigen, und Ihr Service hat nun zwei Aufgaben:

  1. Den neuen Zustand speichern, beispielsweise den Status der Entität auf Active setzen.
  2. Einem nachgelagerten Service mitteilen, dass die Entität bereit ist, indem ein Webhook gesendet wird.

Der intuitive Code schreibt in die Datenbank und sendet anschließend in der nächsten Zeile den HTTP-Anfrage. Das ist eine doppelte Schreibvorgang: Zwei unabhängige Systeme werden nacheinander aktualisiert, wobei nichts sie miteinander verbindet.

Daraus ergeben sich direkt zwei Fehlermöglichkeiten:

  • Der Prozess stirbt zwischen den beiden Schritten ab. Die Datenbank gibt an, dass die Entität aktiv ist, doch die Anfrage wurde nie gesendet. Ihre Datensätze sind korrekt, der nachgelagerte Dienst weiß nichts davon, und niemand bemerkt es, bis ein Kunde sich beschwert.
  • Die Anfrage wird gesendet, anschließend fehlschlägt die Transaktion. Dem nachgelagerten Dienst wurde mitgeteilt, dass die Entität aktiv ist, doch Ihre Datenbank hat den Vorgang rückgängig gemacht und betrachtet ihn weiterhin als fehlgeschlagen.

Auch keine der Reihenfolgen löst das Problem. Wenn man den HTTP-Aufruf zuerst ausführt, tritt der zweite Fehler auf; wenn man ihn zuletzt ausführt, tritt der erste Fehler auf. Die Ursache liegt darin, dass ein Datenbankkommit und ein Netzwerkaufruf nicht gleichzeitig atomar durchgeführt werden können, wodurch jeder Zwischenfehler oder Absturz dazu führt, dass die beiden Seiten aus dem Gleichklang geraten.

Toxische Webhooks zuverlässig mit einem transaktionalen Outbox-Mechanismus senden

Mit dem Outbox-Modell wird die doppelte Schreibvorgang vermieden, da der HTTP-Aufruf überhaupt nicht vom Anfragepfad aus ausgeführt wird. Stattdessen wird die Absicht, einen Webhook zu senden, in Daten umgewandelt, und diese Daten werden in derselben Datenbanktransaktion wie die Geschäftsänderung geschrieben. Entweder werden beide kommittiert oder keines von beidem.

Die vier Schritte des Outbox-Workflows

  1. Eine Transaktion öffnen. Die Geschäftsoperation startet eine normale Datenbanktransaktion.
  • Schreiben Sie den Zustand und das Ereignis zusammen auf. Innerhalb dieser Transaktion aktualisiert der Service die Tabelle entities (zum Beispiel indem er den Status auf Active setzt) und fügt eine Zeile in outbox_events ein, die den genauen Payload enthält, den der nachgelagerte Service erhalten soll.
  • Bestätigen. Sobald die Transaktion bestätigt wurde, speichert die Datenbank dauerhaft sowohl den neuen Zustand als auch die Zusage, ihn bekannt zu geben.
  • Das Ereignis weiterleiten. Ein separater Hintergrundprozess, meist als Relay oder Publisher bezeichnet, sucht wiederholt nach Zeilen in der Ausgangstabelle, die noch nicht gesendet wurden. Für jede dieser Zeilen führt er einen HTTP POST-Aufruf durch und markiert die Zeile anschließend als verarbeitet.
  • Die Ausgangstabelle

    Die untenstehende Tabelle enthält eine Zeile pro ausstehender Benachrichtigung. aggregate_type und aggregate_id geben an, um welches Geschäftsobjekt es sich bei dem Ereignis handelt, event_type beschreibt, was geschehen ist, payload enthält den zu übermittelnden Inhalt und processed_at bleibt leer, bis der Relay die Zustellung bestätigt hat. Das Abfragen von Zeilen, bei denen processed_at null ist, liefert dem Relay seine Arbeitsliste. Beachten Sie, dass die eingebetteten Kommentare einen einzelnen Bindestrich verwenden; in PostgreSQL benötigt ein Kommentar zwei (--), daher muss das vor dem Ausführen der Anweisung korrigiert werden.

    CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(50), - e.g., 'Order' or 'User'
    aggregate_id UUID, - e.g., Entity ID
    event_type VARCHAR(100), - e.g., 'order.activated'
    payload JSONB NOT NULL, - The exact webhook payload
    created_at TIMESTAMP DEFAULT NOW(),
    processed_at TIMESTAMP - Null until successfully sent
    );
    

    Was die Ausgangswarteschlange garantiert und was nicht

    Falls der Server nach dem Commit abstürzt, geht nichts verloren: Die Zeile befindet sich weiterhin in der Tabelle und wird vom Relay bei seinem nächsten Durchlauf gefunden. Wenn das downstream-Endepunkt nicht verfügbar ist, versucht das Relay einfach erneut – idealerweise mit exponentiellem Backoff – damit ein leistungsschwacher Empfänger nicht überlastet wird. Der Zustandswechsel und die Absicht, Benachrichtigungen zu senden, können sich nicht länger voneinander unterscheiden.

    Der Kompromiss besteht darin, dass die Lieferung mindestens einmal stattfindet. Ein Relay kann die Anfrage erfolgreich senden und anschließend abstürzen, bevor es processed_at aktualisiert; in diesem Fall wird dasselbe Ereignis beim nächsten Ausführungsvorgang erneut gesendet. Das ist nur dann akzeptabel, wenn die Empfänger Duplikate entfernen, was genau das Inbox-Muster auf der anderen Seite bietet. Durch Einbeziehung der id der Outbox-Zeile in den Payload oder einen Header erhalten die Empfänger eine stabile Schlüsselwerte, anhand derer sie Duplikate entfernen können. Wenn Sie mehrere Relay-Instanzen betreiben, stellen Sie sicher, dass zwei Worker nicht gleichzeitig dieselbe Zeile beanspruchen können; in PostgreSQL ist das Auswahl von Zeilen mit FOR UPDATE SKIP LOCKED eine gängige Methode dafür.

    Sicheres Empfangen von Webhooks mit einem idempotenten Inbox

    Wechseln wir nun die Perspektive zu den Webhooks, die Ihr Service von Partnern oder upstream-Systemen erhält.

    Nehmen wir an, Ihr Handler benötigt fünf Sekunden, weil er aufwändige Berechnungen durchführt oder auf einen von einem anderen Service gehaltenen Schutzmechanismus wartet. Der HTTP-Client des Senders könnte bereits vor Ihrer Antwort aufgeben, zu dem Schluss kommen, dass Sie das Ereignis nie erhalten haben, und es erneut senden. Nun kommt dasselbe Ereignis zweimal an. Wenn Ihr Handler jedes Mal, wenn er ausgeführt wird, eine E-Mail sendet oder einen Eintrag erstellt, erhält der Kunde zwei E-Mails und Sie erhalten eine doppelte Zeile.

    Mit dem Inbox-Modell wird die Annahme eines Webhooks von der Verarbeitung desselben getrennt.

    Die vier Schritte des Inbox-Flusses

    1. Empfangen und Überprüfen. Sobald die Anfrage eintrifft, prüfen Sie ihre HMAC-Signatur, um sicherzustellen, dass sie tatsächlich vom Partner stammt und nicht gefälscht oder verändert wurde.
  • Speichern Sie die Rohdaten des Payloads. Fügen Sie den unveränderten JSON-Body in eine webhook_inbox-Tabelle ein, wobei die Schlüsselung durch den einzigartigen Ereignisidentifikator des Partners erfolgt und die Tabelle durch eine Eindeutigkeitsbeschränkung der Datenbank geschützt wird.
  • Bestätigen Sie umgehend. Geben Sie sofort 200 OK zurück, noch bevor irgendeine Geschäftslogik ausgeführt wird.
  • Verarbeiten Sie im Hintergrund. Ein Worker übernimmt die ausstehenden Zeilen der Inbox, prüft, ob jedes Ereignis bereits bearbeitet wurde, überspringt es bei Ja und führt bei Nein die Geschäftslogik aus sowie markiert die Zeile als verarbeitet.
  • Die Inbox-Tabelle

    Hier gibt jede Zeile an, wer die Veranstaltung gesendet hat (partner_name), den Identifikator des Senders dafür (partner_event_id), die Nutzlast, ob die Signatur überprüft wurde, sowie einen status, der durch die Werte PENDING, PROCESSED oder QUARANTINED wechselt. Die wichtige Bedingung ist die zusammengesetzte Einschränkung UNIQUE(partner_name, partner_event_id): Sie sorgt dafür, dass Duplikate zu harmlosen Aktionen ohne Auswirkungen werden. Genau wie bei der Outbox-Tabelle müssen die Kommentare mit einem Bindestrich in -- umgewandelt werden, damit PostgreSQL den Satz akzeptiert.

    CREATE TABLE webhook_inbox (
      id UUID PRIMARY KEY,
      partner_name VARCHAR(50), - e.g., 'Stripe' or 'GitHub'
      partner_event_id VARCHAR(100), - The unique ID from the sender
      payload JSONB NOT NULL,
      signature_verified BOOLEAN,
      status VARCHAR(20), - 'PENDING', 'PROCESSED', 'QUARANTINED'
      received_at TIMESTAMP DEFAULT NOW(),
      processed_at TIMESTAMP,
      UNIQUE(partner_name, partner_event_id) - Prevents duplicate inserts
    );
    

    Warum die Einschränkung die Hauptarbeit übernimmt

    Weil der Handler lediglich überprüft, einfügt und zurückgibt, reagiert er schnell, und der Sender wartet in der Regel gar nicht erst zu lange. Wenn ein Sender dennoch erneut versucht, auch zehn Mal in Folge, ermöglicht die Eindeutigkeitsbeschränkung es nur einem Einfügen, erfolgreich zu sein. Ihr Handler sollte den daraus resultierenden Fehler wegen Verletzung der Eindeutigkeit (oder ein ON CONFLICT DO NOTHING-Ergebnis) als Erfolg betrachten und dennoch 200 OK zurückgeben; andernfalls wird der Sender weiterhin versuchen, ein Ereignis einzuspeichern, das bereits vorhanden ist. Da nur eine Zeile existiert, wird der Worker die Nebeneffekte nur einmal ausführen.

    Zwei Aspekte sollten unbedingt richtig umgesetzt werden. Erstens hängt die Deduplizierung davon ab, dass der Partner einen stabilen Ereignisidentifikator bereitstellt; die meisten webhook-Betreiber bieten einen solchen an, doch bestätigen Sie dies für jede Integration. Zweitens kann ein Worker nach Ausführung des Nebeneffekts, aber vor der Markierung als verarbeitet, abstürzen. Deshalb sollten Sie den Geschäftsprozesswechsel sowie die Statusaktualisierung möglichst in einer Transaktion durchführen und auch externe Nebeneffekte idempotent gestalten. Für eine ausführlichere Betrachtung der Deduplizierung von Anfragen mithilfe von Schlüsseln siehe Idempotenzschlüssel in Node.js POST-Endpunkten.

    Bearbeitung von Ereignissen, die aus dem richtigen Ordner eintreffen

    Auch wenn Doppelungen unter Kontrolle sind, gibt es keine Garantie dafür, dass die Ereignisse in der Reihenfolge eintreffen, in der sie erzeugt wurden. Ihr Service könnte entity.completed vor entity.started erhalten. Ein Handler, der jedes Ereignis blind anwendet, versucht dann, eine Entität direkt von draft auf completed zu verschieben, was entweder ihren Zustand beschädigt oder mit einem Fehler wie 409 Conflict endet.

    Überprüfung jeder Übergang auf die Zustandsmaschine

    Die Lösung besteht darin, Ereignisse nicht mehr als Befehle zur Änderung des Zustands zu betrachten, sondern als vorgeschlagene Übergänge, die validiert werden müssen. Dies wird manchmal als Zustandssynchronisierungsmechanismus bezeichnet, im Geiste des Event-Sourcing: Der Worker vergleicht das eingehende Ereignis mit dem aktuellen Zustand der Entität und entscheidet, ob der Übergang zulässig ist.

    Der untenstehende Sketch zeigt diese Entscheidung. Wenn ein Abschlussereignis eintrifft, während die Entität noch im Entwurfszustand ist, hat sich die Voraussetzung noch nicht erfüllt, weshalb die Funktion das Ereignis als verschoben melden anstelle dessen, es auszuführen. In den Kommentaren werden zwei Möglichkeiten zur Handhabung einer Verschiebung genannt: Die Zeile im Posteingang belassen und später erneut versuchen, oder einen projizierten Zustand aufzeichnen und auf das fehlende Ereignis warten. Ein Startereignis bei einer Entwurfsentität ist eine gültige Übertragung und wird ausgeführt. Betrachten Sie dies als Pseudocode: return status: 'DEFERRED'; ist kein gültiger JavaScript-Code und sollte stattdessen return { status: 'DEFERRED' }; lauten; eine echte Implementierung würde außerdem die verbleibenden Kombinationen aus Ereignis und Zustand berücksichtigen.

    function processWebhookEvent(event, currentEntityState) {
        if (event.type === 'entity.completed' && currentEntityState === 'draft') {
            // The 'started' event hasn't arrived yet!
            // We cannot transition from 'draft' directly to 'completed'.
    
            // Option A: Leave it in the inbox and retry in 5 minutes.
            // Option B: Store a "Projected State" and wait for the missing piece.
            return status: 'DEFERRED';
        }
    
        if (event.type === 'entity.started' && currentEntityState === 'draft') {
            return transitionTo('started');
        }
    }
    

    Verschiebung als selbstheilender Kreislauf

    Nehmen Sie einen Auftrag, bei dem das Ereignis „versandt“ vor dem Ereignis „bezahlt“ eintrifft. Wenn man sofort „versandt“ anwendet, würde der Auftrag in einen Zustand geraten, den Ihr Modell nicht zulässt. Mit einem auf Zustände achtenenden Prozessor sieht die Abfolge wie folgt aus:

    1. Das Ereignis „versandt“ trifft ein, der Evaluator erkennt, dass die Zahlung fehlt, und das Ereignis wird verschoben.
    2. Das Ereignis „bezahlt“ trifft ein, ist gültig und aktualisiert den Auftrag.
    3. Das verschobene Ereignis „versandt“ wird erneut versucht, findet nun, dass seine Voraussetzung erfüllt ist, und wird angewendet.

    Aufgeschobene Ereignisse können in einer speziellen Wiederholungsqueue abgelegt werden, beispielsweise in Amazon SQS oder einer von Redis unterstützten Queue, wobei ein Hintergrundprozess sie periodisch erneut versucht auszuführen. Dadurch entsteht ein Workflow, der ungültige Übergänge ablehnt, aber letztendlich ohne Verlust eines einzigen Ereignisses zum korrekten Zustand gelangt. Es sollte jedoch eine Obergrenze dafür geben, wie lange ein Ereignis aufgeschoben bleiben darf: Wenn die Voraussetzung niemals eintritt, sollte das Ereignis schließlich als Fehler behandelt werden anstatt endlos wiederholt zu werden, was zum nächsten Abschnitt führt.

    Ausgrenzung problematischer Ereignisse mithilfe von Wiederholungen und einer Dead-Letter-Queue

    Manche Ereignisse werden niemals erfolgreich sein, egal wie oft man sie versucht: ein fehlerhafter Payload oder eine Referenz auf eine ID, die in der Datenbank nicht existiert. Diese werden als „Poison Pills“ bezeichnet. Ein naiver Worker versucht sie endlos erneut, und wenn die Warteschlange in Reihenfolge verarbeitet wird, kann eine fehlerhafte Nachricht alle darunterliegenden gültigen Ereignisse blockieren.

    Die gängige Verteidigungsstrategie besteht aus einer begrenzten Wiederholungspolitik mit zunehmenden Verzögerungen, gefolgt von einer Warteschlange für fehlerhafte Nachrichten (Dead Letter Queue, DLQ). Ein typischer Ablauf sieht wie folgt aus:

    1. Erster Versuch fehlschlägig; warten einen Minute.
    2. Zweiter Versuch fehlschlägig; warten fünf Minuten.
    3. Dritter Versuch fehlschlägig; warten fünfzehn Minuten.
    4. Vierter Versuch fehlschlägig; das Ereignis in die DLQ verschieben.

    Die DLQ kann eine Tabelle in Ihrer eigenen Datenbank sein oder eine Funktion einer verwalteten Warteschlange. Entscheidend ist, was danach geschieht: Ereignisse in der DLQ sollten in einem internen Admin-View angezeigt werden und einen Hochprioritätsalarm auslösen, da jedes davon Daten darstellt, die Ihr System nicht verarbeiten konnte. Ein Ingenieur untersucht den Fall, behebt den Mapping-Fehler oder die fehlerhaften Daten und spielt das Ereignis anschließend erneut ab, damit es den normalen Verarbeitungsweg durchläuft. Planen Sie diese Wiederabspielungsaktion frühzeitig ein; ohne sie wird die Wiederherstellung aus der DLQ zu einer manuellen Datenbankbearbeitung unter Zeitdruck.

    Übertragung des Designs auf AWS, Azure und Google Cloud

    Die Ausgangs- und Eingangswarteschlangen befinden sich in Ihrer relationellen Datenbank, doch die zugehörigen Komponenten (Eingang, Warteschlangen, Verarbeitungskomponenten, DLQs) lassen sich gut auf verwaltete Cloud-Dienste übertragen, was einen großen Teil der Betriebslast beseitigt. Die Struktur ist bei jedem Anbieter gleich; nur die Produktbezeichnungen ändern sich.

    AWS

    • Eingang: Amazon API Gateway nimmt eingehende Webhooks entgegen, wobei ein Lambda-Autorisator die HMAC-Signatur überprüft, bevor der Anfrage der Backend-Zugriff gewährt wird.
    • Datenbank: Amazon Aurora PostgreSQL enthält die Geschäftsdatentabellen zusammen mit webhook_inbox und outbox_events, sodass die transaktionalen Garantien gelten.
    • Warteschlangen und DLQ: Eine SQS-Standardwarteschlange steuert die asynchrone Verarbeitung, und eine konfigurierte SQS-Dead-Letter-Warteschlange empfängt Nachrichten, sobald diese die maximale Anzahl an Empfangen überschreiten. Standardwarteschlangen gewährleisten in der Regel mindestens einen Empfang pro Nachricht, behalten aber die Reihenfolge nicht bei – was ein weiterer Grund dafür ist, warum die oben genannten Idempotenz- und Zustandsprüfungen wichtig sind.
  • Arbeitnehmer: Lambda-Funktionen, die durch SQS eingehende Ereignisse verarbeiten. Der Ausgangskorrespondenz-Relais läuft als geplante Lambda-Funktion oder ein ECS Fargate-Auftrag, der alle paar Sekunden Aurora abfragt, ausstehende Ereignisse sendet und den Wert von processed_at setzt.
  • Azure

    • Eingang: Azure API Management empfängt Webhooks, überprüft Signaturen und leitet Anfragen an den Backend-Server weiter.
    • Datenbank: Azure Database for PostgreSQL Flexible Server speichert den Anwendungsstatus sowie die Tabellen für den Eingangskorb und den Ausgangskorb.
    • Warteschlangen und DLQ: Azure Service Bus vermittelt die Nachrichten und bietet eine integrierte Dead-Letter-Funktion, die eine Nachricht nach einer konfigurierten Anzahl an Zustellversuchen automatisch beiseitelegt.
    • Arbeitsschritte: Azure Functions mit Service Bus-Triggern verarbeiten die Inhalte im Eingangskorb. Der Ausgangskorbs-Relay läuft als Hintergrundschleife in Azure Container Apps oder als Kubernetes CronJob, falls Sie AKS verwenden – er prüft dabei PostgreSQL auf ungesendete Ereignisse und übermittelt sie per HTTP.

    Google Cloud

    • Eingang: Die Google Cloud API Gateway kümmert sich um eingehende HTTP-Webhooks sowie die Authentifizierung.
    • Datenbank: Cloud SQL für PostgreSQL speichert die relationalen Daten, einschließlich beider Tabellen.
    • Warteschlangen und DLQ: Pub/Sub leitet Nachrichten asynchron weiter. Die Hauptabonnementverarbeitung kümmert sich um die Ereignisse, während ein Dead Letter Topic Nachrichten aufnimmt, die nach der konfigurierten maximalen Anzahl an Übertragungsversuchen noch nicht bestätigt wurden.
    • Arbeitnehmer: Cloud Run-Dienste, die zwischen Anfrallwellen auf null Instanzen reduziert werden können, erhalten Pub/Sub-Push-Nachrichten zur Verarbeitung des Posteingangs. Der Ausgangsrelais-Dienst ist entweder eine Cloud Run-Aufgabe oder ein Cloud Run-Dienst, der von Cloud Scheduler nach einem Zeitplan aufgerufen wird, um Cloud SQL abzufragen und ausstehende Ereignisse zu senden.

    Für weitere Muster zur Verbindung von Diensten, wie OAuth und widerstandsfähige API-Aufrufe, siehe sechs Integrationsmuster zur zuverlässigen Verbindung von Node.js-Diensten.

    Kernpunkte

    • Ein zuverlässiges webhook-basiertes System ist ein Ereignisverarbeitungspipeline, nicht nur ein Paar HTTP-Endpunkte.
    • Überprüfen Sie niemals die Datenbank und rufen Sie einen entfernten Dienst als zwei unabhängige Schritte auf; fügen Sie eine Zeile im Ausgangsordner in derselben Transaktion hinzu und lassen Sie ein Relais sie übermitteln.
  • Die Ausgangswarteschlange gewährleistet eine mindestens einmalige Zustellung, sodass jeder Empfänger Duplikate entfernen muss.
  • Auf der Empfangsseite sollte man überprüfen, mit einer Eindeutigkeitsbeschränkung bezüglich der Event-ID des Senders speichern, umgehend bestätigen und die eigentliche Verarbeitung in einem Worker durchführen.
  • Jedes Event sollte anhand der State Machine validiert werden, wobei Events mit fehlenden Voraussetzungen verzögert werden dürfen – allerdings mit einer Obergrenze für die Wartezeit.
  • Die Anzahl der Wiederholungsversuche sollte begrenzt werden, anhaltende Fehler sollten mit Benachrichtigungen in eine DLQ geleitet und die Wiedervorlage zu einer Standardfunktion gemacht werden.
  • Gemanagte Warteschlangen wie SQS, Service Bus und Pub/Sub bieten Funktionen für Wiederholungsversuche und Dead-Letter-Verwaltung, während die Datenbanktabellen die wichtigen Garantien bereitstellen.
  • Weitere Literatur