Entwurf eines duplikatfreien Benachrichtigungs-Pipelines mit SQS, SES und Redis
Wie ein Design mit zwei Warteschlangen in SQS, ein Fallback von SES zu SendGrid sowie ein atomarer Redis-Lock dafür sorgen, dass Geschäftsunterlagen auch unter Last genau einmal bei den Empfängern ankommen.
Quellen: How I Built a Notification Engine Handling 5M+ Events
Zwei Mitarbeiter erhalten zum gleichen Zeitpunkt denselben Ereignisbericht. Jeder prüft das E-Mail-Log, findet keine Einträge und sendet erneut. Ein Lieferant erhält schließlich denselben Bestellauftrag zweimal, und das Team erfährt davon erst über ein Support-Ticket statt durch Überwachung. Dieser Artikel beschreibt einen Benachrichtigungsmechanismus für eine B2B-Lieferkettenplattform, die es schaffte, mehr als fünf Millionen Ereignisse pro Monat zu verarbeiten, sowie die drei Produktionsfehler, die ihn prägten: verlorene E-Mails, E-Mails, die bei der PDF-Generierung blockiert wurden, und Duplikate.
Was der Mechanismus liefern muss
In einer SaaS-Lösung für die Lebensmittelversorgungskette müssen Geschäftsereignisse wie das Erstellen oder Bearbeiten einer Bestellung oder die Durchführung einer Lagerverwaltungstätigkeit Dokumente für die richtigen Personen erzeugen: Einkaufsbestellungen, Verkaufsbestellungen, Lieferbescheinigungen, Wareneingangsbelege und Rechnungen. Ein einzelnes Ereignis kann daher in mehrere Schritte aufgeteilt werden: Erstellung eines PDFs, Speicherung desselben sowie Versand per E-Mail an mehrere Empfänger. Derselbe Mechanismus sendet außerdem SMS- und WhatsApp-Nachrichten für einige weitere Anwendungsfälle.
Die Konsequenzen sind konkret. Eine nie ankommende Benachrichtigung ist kein rein kosmetisches Problem; eine fehlende Einkaufsbestellung kann den tatsächlichen Warentransport zu einem Kunden verzögern.
Zwei Warteschlangen: eine entscheidet, eine liefert
Die Architektur basiert auf zwei Amazon SQS-Warteschlangen mit klar getrennten Aufgaben.
Die Hauptwarteschlange ist der Ort, an dem Entscheidungen getroffen werden. Ein einziger Verarbeiter liest jedes eingehende Ereignis und bestimmt, was daraus folgt: Welches Dokument erzeugt werden soll, welches Kanal verwendet werden muss und wer es erhalten sollte.
Die Zweittwarteschlange übernimmt lediglich die Zustellung. Die Zustellprozesse wenden keine Geschäftsregeln an; sie verschieben die Nachrichten an die jeweiligen Empfänger.
Eine Nachricht pro Empfänger
Die wichtigste Entscheidung in dieser Struktur ist, dass jeder Empfänger in der Zweittwarteschlange seine eigene Nachricht erhält. Eine Bestellung, die zehn Personen erreichen muss, wird somit zu zehn unabhängigen Nachrichten. Wenn die Zustellung an eine Adresse fehlschlägt, werden die anderen neun dennoch versandt, und ein erneuter Versuch richtet sich nur an den fehlgeschlagenen Empfänger, anstatt an alle erneut gesendet zu werden.
Diese Trennung isoliert auch Änderungen. Der Wechsel auf einen neuen Anbieter oder ein neues Kanal betrifft nur die Übertragungsebene; der Code, der entscheidet, was gesendet werden soll, muss niemals wissen, wie eine Nachricht transportiert wird.
Warum nicht eine Warteschlange pro Kanal?
Eine separate Warteschlange für E-Mails, SMS und WhatsApp würde jedem Kanal eine unabhängige Durchsatzleistung sowie Fehlerbehandlung bieten. Bei diesem Volumen war das nicht notwendig, und der Zusatz einer Warteschlange, wenn es erforderlich ist, ist günstiger als die Verwaltung von vier ab dem Anfang.
Fehler eins: E-Mails, die nie ankamen
E-Mail-Anbieter versagen manchmal offensichtlich mit einem Fehler und manchmal heimlich durch eine Antwort, die fehlende Felder enthält. Amazon SES ist hier der Hauptanbieter, und eine Antwort ohne Message-ID wird genauso wie eine Ausnahme als Fehler behandelt. In jedem Fall wird die gleiche E-Mail über SendGrid gesendet:
// simplified
try {
const res = await ses.send(email);
if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
await sendgrid.send(email); // fallback
}
Achten Sie auf den expliziten Throw, wenn MessageId fehlt: Er leitet den unklaren Fall auf denselben Fallback-Pfad wie einen schwerwiegenden Fehler um. Der Kompromiss ist verständlich. Wenn SES die E-Mail akzeptiert hat, aber die Antwort verloren ging, kann der Fallback sie ein zweites Mal liefern – daher bevorzugt diese Schicht „mindestens einmal zugestellt“ gegenüber „höchstens einmal zugestellt“. Bei Geschäftsunterlagen ist das in der Regel die richtige Vorgehensweise, doch es ist auch ein weiterer Grund, warum der unten beschriebene Deduplizierungs-Schritt wichtig ist.
Falls beide Anbieter versagen, gelangt die Nachricht in eine Dead-Letter-Queue und wird nach einer gewissen Zeit erneut versendet. Darüber hinaus übernimmt ein separates Wiederversand-Verfahren die als verloren gemeldeten E-Mails, sodass es auch für alles, was durchgerutscht ist, einen Rückweg gibt.
Fehler Nummer zwei: PDFs, die unter Last fehlschlagen
Dokument-E-Mails unterliegen einer strengen Regel: Nichts wird gesendet, solange das PDF nicht vorhanden ist. Die Empfänger benötigen die eigentliche Bestellbestätigung, nicht einen Link, der einen Fehler zurückgibt. Der Kunde ruft eine Lambda-Funktion zur PDF-Generierung auf, speichert die Datei in S3 und sendet erst danach das E-Mail mit einem Link dazu.
Die Regel ist korrekt, hat aber eine versteckte Abhängigkeit geschaffen. Wenn das System aussetzte und die PDF-Generierung fehlschlug, zeigte sich der Fehler im E-Mail-Versand nicht sichtbar; es geschah einfach nichts, weil darauf gewartet wurde, dass eine Datei erstellt wird, die niemals entstand. Die Lösung bestand darin, der PDF-Generierung eigene Wiederholungsversuche zu geben, sodass ein vorübergehender Renderingsfehler nicht mehr stillschweigend die anschließende Benachrichtigung stört.
Fehler drei: Dasselbe E-Mail, zweimal
Duplikate entstanden aus drei verschiedenen Quellen, und jede erforderte eine andere Reaktion:
- gewöhnliche Logikfehler, die als solche behoben wurden;
Warum eine Überprüfung des Logs nicht ausreicht
Von Anfang an prüfte das Pipeline-System vor dem Versand den E-Mail-Log: Wenn ein Eintrag anzeigte, dass die Nachricht bereits versendet wurde, wurde der Wiederholungsversuch übersprungen. Das funktioniert gut bei sequenziellen Wiederholungsversuchen. Es kann den Wettlauf jedoch nicht stoppen, da das Lesen des Logs und das Schreiben darin zwei getrennte Vorgänge sind. Zwei Verarbeiter können beide „nicht versendet“ lesen, bevor einer von ihnen schreibt, und anschließend weitermachen. SQS-Standardwarteschlangen liefern mindestens einmal, sodass es sich bei derselben Nachricht, die bei zwei Verarbeitern ankommt, um ein erwartetes Verhalten und nicht um einen seltenen Fehler handelt.
Eine atomare Sperre in Redis
Die Lösung bestand darin, vor der Überprüfung des Logs einen atomaren Schutzmechanismus in Redis einzuführen. Der Schlüssel kombiniert die Bestellnummer, die E-Mail-Adresse des Empfängers sowie den Dokumenttyp und läuft nach etwa fünf Minuten ab:
// simplified
const key = `notif:${orderId}:${email}:${docType}`;
const locked = await redis.set(key, "1", "NX", "EX", 300);
if (!locked) return; // someone else already has it
SET mit der NX-Option schreibt den Schlüssel nur dann, wenn er noch nicht existiert, und EX 300 legt in derselben Anweisung eine Ablaufzeit von 300 Sekunden fest. Da die Existenzprüfung und das Schreiben als einzige Operation auf dem Redis-Server erfolgen, erhält genau ein Worker ein wahrheitswertiges Ergebnis; alle anderen Workers erhalten null und kehren frühzeitig zurück. Dadurch wird die Lücke geschlossen, die durch die Log-Überprüfung entstanden war.
Mehrere Eigenschaften dieses Ansatzes sind zu berücksichtigen:
- Die Granularität des Schlüssels ist wichtig. Durch die Einbeziehung des Dokumenttyps werden eine Bestellbestätigung und eine Rechnung für dieselbe Bestellung und denselben Empfänger nicht fälschlicherweise als Duplikate angesehen.
Dieselbe Idee kommt in HTTP-APIs in Form von Idempotenzschlüsseln vor; der Artikel zu Idempotenzschlüsseln in Node.js POST-Endpunkten behandelt diesen Aspekt.
Wo das System jetzt steht
Nach diesen Änderungen verarbeitet der Engine mehr als fünf Millionen Ereignisse pro Monat durch Auslösungen im Bereich Bestellungen, Lager und Dokumente – mit einer Erfolgsrate von 98,9 % und einer p95-Latenzzeit von unter 1,2 Sekunden.
Was man beim nächsten Mal anders machen sollte
- Erkennen Sie Ausfälle vor den Kunden. Zu viele Probleme tauchten zunächst als Beschwerden auf und erst später in den Protokollen. Warnungen bei Lieferausfällen sowie zur Tiefe der Dead-Letter-Queue sollten bereits in der ersten Version enthalten sein, nicht in einer späteren.
- Verhindern Sie das Verschwinden von Ereignissen. Einige Ereignisse verschwanden aus Gründen, die nie vollständig erklärt wurden. Ein transaktionaler Outbox, der das Ereignis zusammen mit der Geschäftsoperation aufzeichnet und es anschließend weiterleitet, macht verlorene Ereignisse deutlich seltener. Der Artikel zu Outbox- und Inbox-Tabellen erläutert die Funktionsweise.
- Behalten Sie die einzige Unterqueue, solange es geht. Die gemeinsame Lieferqueue hat sich bewährt; sie muss nur aufgeteilt werden, wenn ein Kanal die anderen zu stark belastet.
Checkliste für den Aufbau Ihrer eigenen Queue
- Bewahren Sie die Routing-Entscheidungen an einem Ort auf und die Zustellung an einem anderen.
- Senden Sie eine Nachricht pro Empfänger, damit ein einziger Fehler den Rest nicht blockieren kann.
- Verlassen Sie sich nicht auf einen einzigen Anbieter und betrachten Sie das Fehlen einer Nachrichten-ID als Fehler.
- Weisen Sie fehlgeschlagene Nachrichten eine Zieladresse zu: eine Dead-Letter-Queue mit Wiederholungsversuchen.
- Senden Sie niemals eine E-Mail mit einem Dokument, bevor dieses existiert, und wiederholen Sie den Schritt, der es erzeugt.
- Machen Sie die Deduplizierung atomar, denn ein Prüfen-und-Dann-Schreiben lässt einen Zeitraum offen.
- Warnen Sie bei Fehlern, damit die Kunden nicht Ihr Überwachungssystem sind.
Zusammenfassung
Niemand dieser Schutzmechanismen wurde von Anfang an entworfen; jeder wurde hinzugefügt, nachdem in der Produktion etwas schiefgelaufen war – so reifen die meisten Lieferketten tatsächlich heran. Die Lektion betrifft weniger eine einzelne Lösung als vielmehr die Denkweise: Man sollte von einer mindestens einmaligen Lieferung ausgehen, davon, dass Anbieter und Abhängigkeiten stillschweigend versagen können, und jeden Schritt entweder idempotent gestalten oder durch etwas Atomares schützen.