Zurück zum Blog
Architektur
FortgeschrittenFürBackend EngineersPlatform Engineers
8 min

Wie man 1.000.000 Benachrichtigungen versendet, ohne abzustürzen

Ein anbieterneutraler System-Design-Durchgang durch die Message-Queue-Architektur, die eine Million Benachrichtigungen versendet, ohne die API lahmzulegen: Fan-out-Worker, Back-Pressure, Retries und Dead-Letter-Queues.

message-queueevent-driven-architecturefan-outnotification-systemback-pressuredead-letter-queue
Titelbild: Wie man 1.000.000 Benachrichtigungen versendet, ohne abzustürzen
Inhalt

Eine Benachrichtigung zu senden ist ein Funktionsaufruf. Eine Million zu senden ist ein Problem verteilter Systeme im Gewand eines Funktionsaufrufs.

Die naive Variante wirkt harmlos: Ein Nutzer löst ein Event aus, du iterierst über eine Million Empfänger und rufst für jeden den Push-, E-Mail- oder SMS-Provider auf. Im Demo funktioniert es. Bei tausend Nutzern funktioniert es. Dann fächert ein Produkt-Launch ein einziges Event über die gesamte Nutzerbasis auf, jeder Send wartet auf eine langsame Drittanbieter-API, und der Request-Thread, der in 50 ms hätte antworten sollen, hält jetzt eine Million offene Aufrufe. Die API stürzt ab — nicht weil der Code falsch ist, sondern weil die Form falsch ist.

Das hier ist ein anbieterneutraler Durchgang durch die Form, die hält: eine Message-Queue-Architektur mit Fan-out-Workern, Back-Pressure und den langweiligen Zuverlässigkeitsteilen, die verhindern, dass ein „Send“ zum Ausfall wird. Die Beispiele sind cloud-agnostisch — dasselbe Muster läuft auf RabbitMQ, Kafka, SQS oder Pub/Sub.

Der Fehler: Die Arbeit im Request erledigen

Als Erstes muss man reparieren, wo die Arbeit passiert. Wenn du im Request-Handler sendest, ist der Thread, der den HTTP-Aufruf angenommen hat, derselbe Thread, der auf einen langsamen externen Provider wartet — millionenfach.

Der Versand im Request blockiert den Thread und flutet bei einer Spitze; Annehmen-und-Antworten hält die API schnell, während Worker die Queue abbauen.

Zwei Dinge gehen gleichzeitig schief. Erstens stapelt sich die Latenz: Die Antwortzeit deiner API ist jetzt die Summe aller Provider-Aufrufe, also zieht ein langsamer Anbieter den ganzen Request nach unten. Zweitens explodiert die Nebenläufigkeit: Eine Spitze reiht sich nirgends ein, sie öffnet eine Million gleichzeitige Sends gegen Provider, die dich bereitwillig rate-limiten oder abweisen, während dein eigener Connection-Pool und Speicher austrocknen.

Die Faustregel ist einfach:

Der Request soll den Auftrag annehmen, nicht ausführen.

Nimm das Event an, schreib es in eine dauerhafte Queue und antworte in Millisekunden mit 202 Accepted. Die schwere Arbeit wandert zu Hintergrund-Workern, die du unabhängig vom Request-Pfad skalieren, drosseln und wiederholen kannst. Die Spitze ist immer noch da — aber jetzt landet sie in einer Queue, wo ein Rückstau eine Unannehmlichkeit ist, kein Ausfall.

In „schreib es in eine dauerhafte Queue“ versteckt sich eine Feinheit. Schreibst du in deine Datenbank und publizierst in die Queue als zwei getrennte Schritte, verliert ein Absturz dazwischen das Event oder sendet ein Phantom. Die Lösung ist das transaktionale Outbox-Pattern: Schreib das Event in derselben Datenbank-Transaktion wie deinen fachlichen Write in eine outbox-Tabelle, und ein separater Relay befördert diese Zeilen in die Queue. Ein atomarer Write, keine verlorenen Events, keine doppelten Sends.

Der Kern: Eine Fan-out-Pipeline

Sobald die Arbeit vom Request-Pfad weg ist, wird die Architektur zur Pipeline. Ein Event kommt herein; kanalspezifische Benachrichtigungen gehen hinaus. Der Baustein, der sie skalieren lässt, ist Fan-out: eine einzige Ingress-Queue, aufgeteilt in je eine isolierte Spur pro Kanal.

Ein Producer publiziert in ein Ingress-Topic; ein Fan-out-Schritt teilt die Arbeit in isolierte Push-, E-Mail- und SMS-Queues mit je eigenen Workern und Providern.

Geh den Pfad durch:

  1. Producer → Ingress-Queue. Die API (oder ein vorgelagertes Event) publiziert pro logischem Event eine Nachricht in ein einziges dauerhaftes Topic. Das ist das Einzige, was der Request-Pfad berührt, und es ist schnell und append-only.
  2. Fan-out. Ein leichtgewichtiger Schritt liest das Ingress-Event, entscheidet welche Kanäle gelten (dieser Nutzer will Push + E-Mail, keine SMS) und publiziert eine Nachricht in die Queue jedes Kanals. Fan-out ist, wo aus „einem Event“ „drei Sends“ werden.
  3. Kanalspezifische Queues + Worker. Jeder Kanal — Push, E-Mail, SMS — hat seine eigene Queue und seinen eigenen Worker-Pool. Diese Isolation ist der ganze Punkt: Ein langsames SMS-Gateway staut nur die SMS-Queue. Push und E-Mail fließen weiter.
  4. Provider. Jeder Worker-Pool spricht mit seinem Provider (APNs/FCM, SMTP/SES, ein SMS-Gateway) in einem Tempo, das dieser Provider verkraften kann.

Die Isolation bringt drei Dinge, die ein einzelner geteilter Worker-Pool nicht kann: unabhängiges Skalieren (mehr E-Mail-Worker hochfahren, ohne SMS anzufassen), unabhängiges Scheitern (ein toter Provider staut die anderen nicht) und unabhängiges Tuning (E-Mail verträgt Minuten Verzögerung; Push oft nicht).

Zwei Hebel machen aus dem Diagramm ein Produktionssystem. Batche zum Provider: Die meisten Push-, E-Mail- und SMS-APIs akzeptieren Hunderte Empfänger pro Aufruf, also sendet ein Worker, der seine Queue zu Batches bündelt, weit mehr pro Sekunde als einer, der einzeln sendet. Und trenne nach Priorität, nicht nur nach Kanal: Ein transaktionaler OTP und eine Marketing-Blast dürfen sich nie eine Queue teilen — gib latenzkritischem Verkehr seine eigene Spur, damit eine Kampagne über eine Million Nachrichten keinen Login-Code verzögert. Wo die Reihenfolge pro Nutzer zählt, schlüssle diese Nachrichten auf dieselbe Partition, damit ein einzelner Worker sie der Reihe nach abarbeitet.

Anhand der Queue-Tiefe skalieren, nicht der Request-Rate

Die Worker sind der Ort, an dem eine Million Events tatsächlich versendet werden, und der Trick ist, worauf man skaliert. Skaliere nicht auf CPU oder Request-Rate — skaliere auf Queue-Tiefe und Nachrichtenalter. Wächst der Rückstau über einen Schwellenwert, füge Worker-Replicas hinzu; leert er sich, skaliere zurück.

Genau das entkoppelt Ankunftstempo von Sendetempo. Eine Million Events können in einer Minute eintreffen; deine Worker brauchen vielleicht eine Stunde zum Senden, gedrosselt auf das, was die Provider erlauben. Das ist in Ordnung — die Queue federt die Differenz ab. Die Aufgabe des Systems ist nicht, alles sofort zu senden. Sie ist, nie ein Event zu verlieren und nie abzustürzen, während so schnell abgebaut wird, wie die nachgelagerten Provider es zulassen.

Diese Worker-Pools laufen meist auf einer autoskalierenden Container-Plattform, und ist diese Plattform Kubernetes, zählt die Basis, auf der sie läuft, ebenso viel wie die Pipeline selbst — siehe Secure-by-Default GKE: Eine Referenzarchitektur für 2026.

Das ist dasselbe event-getriebene Rückgrat hinter jedem entkoppelten System. Baust du es speziell auf GCP, lohnt sich als Nächstes der Abgleich der Trade-offs zwischen Transport und Router: Pub/Sub oder Eventarc? Event-getriebenes GCP ohne Spaghetti.

Unter Last oben bleiben: Die langweiligen Teile, die zählen

Eine Queue und ein paar Worker bringen dich durchs Demo. Was einen Lauf über eine Million Nachrichten um 3 Uhr morgens am Leben hält, ist die Zuverlässigkeitsschicht — die Teile, die alle überspringen, bis die Produktion sie lehrt. Es sind vier, und sie funktionieren nur, weil du eine Queue hast.

Vier Schutzmechanismen halten es oben: Retry mit Backoff, eine Dead-Letter-Queue, Idempotenz-Schlüssel und Back-Pressure pro Provider.

Retry mit Backoff. Provider scheitern vorübergehend — ein Timeout, ein 503, ein kurzes Rate-Limit. Wiederhole, aber mit exponentiellem Backoff und Jitter, nie in einer engen Schleife. Eine enge Retry-Schleife über Tausende Worker ist von einem Denial-of-Service-Angriff nicht zu unterscheiden — und das Ziel ist dein eigener Provider. Backoff verteilt die Retries, sodass ein Aussetzer sich erholt, statt sich zu verstärken.

Dead-Letter-Queue (DLQ). Manche Nachrichten gelingen nie: ein fehlerhaftes Payload, eine dauerhaft ungültige Adresse, ein Bug. Verschiebe die Nachricht nach N Versuchen in eine Dead-Letter-Queue, statt sie endlos zu wiederholen. Die schlechte Nachricht wird zur Prüfung geparkt — nie verloren, nie die Linie blockierend — und der gesunde Verkehr dahinter läuft weiter. Ohne DLQ wiederholt sich eine Giftnachricht, bis sie die Queue aushungert.

Idempotenz. Jede dauerhafte Queue stellt mindestens einmal zu. Eine erneute Zustellung, ein langsames Ack, ein Worker, der nach dem Senden aber vor dem Ack abstürzt — jedes davon bedeutet, dass dieselbe Nachricht zweimal verarbeitet wird. Bedeutet „zweimal“ eine zweite E-Mail oder eine Doppelbelastung, ist das ein Bug, den du geschrieben hast. Dedupliziere auf einer stabilen Message-ID: notiere „Nachricht abc123 bereits gesendet“ und behandle einen erneuten Lauf als No-op. Halte diesen Eintrag in einem schnellen Store mit einer TTL, die dein Retry-Fenster abdeckt — du musst nur jüngste IDs merken, nicht die ganze Historie. Die erneute Zustellung wird harmlos.

Back-Pressure. Das ist der Schutz, der das gesamte System vom Kippen abhält. Jeder Worker respektiert ein Concurrency- und Rate-Limit pro Provider. Wird ein Provider langsamer, werden die Worker mit ihm langsamer — die Queue wächst, aber nichts stürzt ab. Back-Pressure verwandelt „der Provider hat Mühe“ in „der Rückstau ist heute tiefer“, statt „wir haben ihn gehämmert, bis er uns blockiert hat“.

Zusammengenommen sind diese vier der Grund, warum das Design seinen eigenen Erfolg überlebt. Die Queue macht sie möglich; überspringst du sie, wird deine Retry-Logik zu einem selbstverschuldeten DDoS gegen genau die Provider, von denen du abhängst.

Alles zusammengesetzt

Verfolge einen Launch durch die fertige Form. Eine Kampagne feuert ein Event. Die API schreibt es in die Ingress-Queue und antwortet in Millisekunden — der nutzerseitige Pfad weiß nicht einmal, dass eine Million Sends kommen. Fan-out teilt das Event in Push-, E-Mail- und SMS-Arbeit auf drei isolierten Queues. Jeder Worker-Pool skaliert auf seinem eigenen Rückstau, sendet im Tempo seines Providers, wiederholt vorübergehende Fehler mit Backoff, verschiebt die hoffnungslosen in die DLQ und dedupliziert erneute Zustellungen auf einer Message-ID. Die Spitze wird zu drei abbauenden Queues. Nichts stürzt ab.

Nichts davon ist exotisch. Es ist eine Queue, ein Fan-out-Schritt, Worker-Pools, die auf Tiefe skalieren, und vier Zuverlässigkeits-Schutzmechanismen. Der Fehler waren nie die Tools — es war, eine Aufgabe verteilter Systeme in einem einzigen Request-Thread zu erledigen. Nimm die Arbeit von diesem Thread, isoliere die Kanäle und lass Back-Pressure die Spitze abfedern — und „eine Million Benachrichtigungen senden“ hört auf, eine beängstigende Zahl zu sein, und wird zu einem Dienstag.

Die Feldregel

Nimm den Auftrag an, führ ihn nicht aus. Fan-out auf je eine Queue pro Kanal, damit Fehler lokal bleiben. Skaliere Worker auf Queue-Tiefe, nicht auf Request-Rate. Und behandle Retries, Dead-Letter-Queues, Idempotenz und Back-Pressure als tragend, nicht optional — sie sind der Unterschied zwischen einem Rückstau und einem Ausfall.

Häufig gestellte Fragen

Warum stürzt der Versand ab, wenn er im Request-Handler läuft?

Weil der Request-Thread jetzt für jeden Empfänger langsame I/O zu einem externen Provider ausführt. Ein langsamer Provider blockiert den Thread, eine Lastspitze öffnet Tausende gleichzeitige Sends auf einmal, Speicher und Verbindungen gehen aus, und die gesamte API läuft in Timeouts. Die Lösung: den Auftrag annehmen, einreihen und sofort antworten — und dann Hintergrund-Worker in einem für die Provider verträglichen Tempo senden lassen.

Was ist Fan-out in einem Benachrichtigungssystem?

Fan-out nimmt ein eingehendes Event und teilt es in kanalspezifische Arbeit auf: eine Push-Nachricht, eine E-Mail und eine SMS landen jeweils auf ihrer eigenen Queue mit eigenem Worker-Pool. Diese Isolation ist der Kern — jeder Kanal skaliert und scheitert unabhängig, sodass ein langsames SMS-Gateway die Push-Zustellung nie blockiert und man einen Kanal drosseln oder erneut versuchen kann, ohne die anderen anzutasten.

Wie skaliert man die Worker, die Benachrichtigungen versenden?

Anhand der Queue-Tiefe und des Nachrichtenalters skalieren, nicht anhand der eingehenden Request-Rate. Wächst der Rückstau oder werden Nachrichten älter als das Ziel, mehr Worker-Replicas hinzufügen; leert sich die Queue, wieder herunterskalieren. Das entkoppelt, wie schnell Events eintreffen, davon, wie schnell man sendet — eine Spitze aus einer Million Events wird zum Rückstau, den Worker stetig abbauen, statt zur Überlast, die das System lahmlegt.

Was hält Retries davon ab, die Provider zu überlasten?

Back-Pressure. Jeder Worker respektiert ein Concurrency- und Rate-Limit pro Provider, Retries nutzen exponentielles Backoff mit Jitter statt einer engen Schleife, und Nachrichten, die nach N Versuchen weiter scheitern, wandern in eine Dead-Letter-Queue statt endlos wiederholt zu werden. Ohne diese Schutzmechanismen wird die Retry-Logik zu einem selbstverschuldeten Denial-of-Service gegen die eigenen Provider.

Warum müssen Benachrichtigungs-Handler idempotent sein?

Weil jede dauerhafte Queue mindestens einmal zustellt — dieselbe Nachricht kann nach einer erneuten Zustellung, einem langsamen Ack oder einem Worker-Neustart zweimal verarbeitet werden. Sendet ein Duplikat eine zweite E-Mail oder belastet eine Karte doppelt, ist das ein Bug. Auf einer stabilen Message-ID deduplizieren und einen erneuten Lauf als No-op behandeln — dann wird die erneute Zustellung harmlos statt gefährlich.

Aus der Community

Diskussion im Fediverse

Antworten von Mastodon und Bluesky — direkt aus dem offenen Netz, ohne Tracking.

Antworten werden geladen …

ENDE