sequenceDiagram
participant P as Producer
participant Q as Queue
participant K1 as Konsument 1
participant K2 as Konsument 2
P->>Q: send "mail an kunde-7"
Q->>K1: Zustellung (Versuch 1)
K1->>K1: Mail verschickt
Note over K1: Absturz vor dem Ack
Note over Q: Visibility Timeout läuft ab
Q->>K2: Zustellung (Versuch 2)
K2->>K2: Mail verschickt (doppelt)
K2->>Q: Ack
Verteilte Systeme: Grundprobleme, Queues und idempotente Konsumenten
Track Konzepte · Messaging und Idempotenz · ca. 45 Min.
Worum es geht
Dein Shop speichert eine Bestellung und schickt danach “Bestellung angelegt” an den Versand-Service. Meistens klappt das. Einmal stürzt der Prozess zwischen den beiden Schritten ab, ein anderes Mal kommt dieselbe Nachricht zweimal an, und der Kunde bekommt zwei Mails oder wird doppelt belastet. Das sind keine seltenen Pannen, sondern der Normalfall, sobald zwei Computer über ein Netzwerk reden. Am Ende von Teil 1 kannst du erklären, warum at-least-once (mindestens einmal) die Standard-Garantie von Queues ist, und wie ein idempotenter Konsument (idempotent consumer) damit sicher umgeht. Outbox-Pattern, Dead Letter Queue und die Wahl zwischen Queue, Pub/Sub und Event-Log folgen in Teil 2.
Du bist hier bei einer bekannten Lücke (Queues und Idempotenz). Darum gehen wir den Weg einmal komplett an der Hand durch (Schritte 1 bis 4), bevor du selbst baust. Nichts hier braucht echte Netzwerke: Zeit, Verlust und Absturz werden simuliert, mit festen Zufallszahlen (Seed), damit jeder Lauf gleich ausgeht.
Von JS/TS her gedacht
Du kennst das Problem schon von HTTP-Retries. Dein fetch läuft in einen Timeout. Hat der Server die Zahlung ausgeführt oder nicht? Du weißt es nicht. Wiederholst du, wird vielleicht doppelt gebucht. Eine Queue hat genau dieses Problem, nur eingebaut.
| Konzept | JS/TS-Welt | Queue-Welt |
|---|---|---|
| Antwort kommt nicht | fetch Timeout |
Ack kommt nicht, Nachricht wird erneut zugestellt |
| Wiederholung | Retry-Schleife im Client | Redelivery durch die Queue |
| Schutz | Idempotency Key im Header | Nachrichten-ID in einer Dedup-Tabelle |
| Absturz mittendrin | await in der Mitte, Prozess stirbt |
Konsument stirbt vor dem Ack |
Dasselbe Muster in TypeScript (der Code wurde als JavaScript mit Node ausgeführt, Ausgabe unten):
const gesehen = new Set<string>();
let stand = 100;
function konsumiere(msg: { id: string; betrag: number }): boolean {
if (gesehen.has(msg.id)) return false; // schon verarbeitet
stand -= msg.betrag;
gesehen.add(msg.id);
return true;
}
const lieferungen = [{ id: "m1", betrag: 30 }, { id: "m1", betrag: 30 }, { id: "m2", betrag: 30 }];
console.log(lieferungen.map(konsumiere), stand);
// [ true, false, true ] 40Die doppelte Nachricht m1 wird erkannt, m2 ist eine echte zweite Zahlung. Aber: Das Set liegt im Arbeitsspeicher. Nach einem Neustart ist es leer, und es ist nicht atomar mit der Buchung. Genau diese Schwäche reparierst du in Übung 3.
Konzept
Schritt 1: Warum verteilte Systeme anders sind
In einem Prozess ist ein Funktionsaufruf entweder erfolgreich oder wirft eine Exception. Zwischen zwei Rechnern gibt es einen dritten Ausgang: keine Antwort. Daraus folgen die Grundprobleme (Quelle, Schicht 7):
- Latenz (latency): Ein Netzwerkaufruf dauert Größenordnungen länger als ein lokaler und schwankt stark.
- Partielle Ausfälle (partial failure): Ein Teil des Systems fällt aus, der Rest läuft weiter. Du weißt nicht, ob der andere tot, langsam oder nur das Netz kaputt ist.
- Keine gemeinsame Uhr (no shared clock): Zeitstempel verschiedener Rechner lassen sich nicht sauber vergleichen (mehr dazu in Lektion 08).
- Fallacies of Distributed Computing (“die acht Trugschlüsse”, allgemeines Fachwissen, nicht Wortlaut der Quelle): das Netzwerk ist zuverlässig, die Latenz ist null, die Bandbreite ist unendlich, das Netzwerk ist sicher, die Topologie ändert sich nie, es gibt einen Administrator, Transport kostet nichts, das Netzwerk ist überall gleich.
Das Fake-Netzwerk unten macht die ersten beiden Trugschlüsse sichtbar. Es verliert Nachrichten, verdoppelt manche und liefert sie mit Verzögerung, also auch in anderer Reihenfolge. FakeNetz(seed) würfelt mit einem festen Seed, darum ist jeder Lauf identisch.
Gesendet wurden bestellung-1 bis bestellung-5. Angekommen ist: ['bestellung-1', 'bestellung-4', 'bestellung-3', 'bestellung-3', 'bestellung-5']. Drei Effekte auf einmal: bestellung-2 ist verloren, bestellung-3 ist doppelt da, und bestellung-4 überholt bestellung-3 (Reihenfolge vertauscht). Ein Sender, der Verlust ausgleichen will, wiederholt. Dann gibt es noch mehr Doppelte. Diese Doppelten sind der Preis dafür, dass nichts verloren geht.
Schritt 2: Die Queue mit Acknowledgement und Visibility Timeout
Eine Message Queue (Nachrichtenwarteschlange) entkoppelt Sender (Producer) und Empfänger (Consumer): Der Producer legt Nachrichten ab, der Consumer holt sie, wann er kann. Ist der Consumer langsam, wartet die Nachricht, statt dass der Producer blockiert.
Wie erfährt die Queue, dass ein Consumer fertig ist? Durch ein Acknowledgement (Ack, Empfangsbestätigung). Erst nach dem Ack wird die Nachricht gelöscht. Holt ein Consumer eine Nachricht, ist sie für alle anderen unsichtbar, aber nur für eine begrenzte Zeit, das Visibility Timeout (Sichtbarkeits-Zeitfenster). Kommt bis dahin kein Ack, geht die Queue davon aus, dass der Consumer tot ist, und macht die Nachricht wieder sichtbar. Ein anderer Consumer holt sie dann ab. So geht bei einem Absturz nichts verloren.
Unsere Simulation: Zeit ist ein Zähler (tick), receive() holt die erste sichtbare Nachricht, ack(id) löscht sie. Das Feld versuche zählt, wie oft eine Nachricht ausgeliefert wurde.
Schritt 3: At-least-once und die Doppelzustellung
Jetzt der kritische Fall. Ein Konsument verschickt eine Mail und stürzt ab, bevor er das Ack schickt:
Ausgabe: None, dann ['mail an kunde-7', 'mail an kunde-7'] 2 0. Der Kunde hat zwei Mails bekommen. Die Queue hat nichts falsch gemacht: Sie konnte nicht wissen, ob die Arbeit vor dem Absturz erledigt war. Sie hat sich für “lieber doppelt als gar nicht” entschieden. Das ist at-least-once.
Dasselbe passiert ohne Absturz, wenn ein Konsument nur länger arbeitet als das Visibility Timeout. Die Queue kann “tot” und “langsam” nicht unterscheiden, der zweite Konsument bekommt die Nachricht trotzdem. Das brauchst du gleich in Übung 1.
Die drei Garantien im Überblick:
| Garantie | Bedeutung | Preis |
|---|---|---|
| at-most-once | Höchstens einmal. Ack vor der Arbeit, bei Absturz ist die Nachricht weg. | Verlust möglich |
| at-least-once | Mindestens einmal. Ack nach der Arbeit, bei Absturz kommt sie erneut. | Duplikate möglich |
| exactly-once | Genau einmal. Über ein unzuverlässiges Netz nicht allgemein erreichbar. In der Praxis: at-least-once plus idempotenter Konsument = effectively-once (die Wirkung tritt genau einmal ein). | Aufwand im Konsumenten |
Die Praxisregel: Baue so, dass Duplikate harmlos sind.
Schritt 4: Der idempotente Konsument
Ein Konsument ist idempotent, wenn mehrfache Verarbeitung derselben Nachricht dieselbe Wirkung hat wie einmalige (Idempotenz-Grundlagen und Idempotency Key stehen in Lektion 01). Hier zählt der Konsument-Fall: Die Nachrichten-ID kommt von der Queue und bleibt bei jeder Wiederzustellung gleich. Der Konsument merkt sich die IDs in einer Dedup-Tabelle (deduplication table, verarbeitet).
Der entscheidende Punkt: Buchung und Merken der ID müssen in derselben Transaktion passieren (Atomicity aus Lektion 01). Merkst du zuerst und die Buchung scheitert, ist die Nachricht für immer “erledigt”, aber nie ausgeführt. Buchst du zuerst und stürzt vor dem Merken ab, wird doppelt gebucht.
Erst die naive Version, die zweimal dieselbe Nachricht bekommt:
Ausgabe: 40 statt 70. Jetzt die idempotente Version. Der PRIMARY KEY auf msg_id ist ein Unique Constraint: Ein zweiter Insert derselben ID wirft IntegrityError, und die Datenbank erledigt die Prüfung atomar, auch bei mehreren Konsumenten.
Ausgabe: True False 70. Das Duplikat wird erkannt und trotzdem bestätigt (Ack), sonst käme es endlos wieder.
Ehrliche Grenze: Das gilt, wenn die Wirkung in derselben Datenbank liegt. Eine verschickte Mail oder ein Aufruf einer fremden API lässt sich nicht in diese Transaktion packen. Dort gibst du dem Fremdsystem die Nachrichten-ID als Idempotency Key mit, oder du akzeptierst die seltene Doppelmail.
Falle
- “Exactly-once” ungeprüft glauben. Ein Produkt, das “exactly-once” verspricht, meint fast immer eine eingeschränkte Form (innerhalb des eigenen Systems). Sobald ein externer Aufruf (Mail, Zahlungsanbieter) beteiligt ist, brauchst du trotzdem Idempotenz.
- Ack vor der Arbeit. Das ist at-most-once: Stürzt der Konsument ab, ist die Nachricht weg (Übung 2).
- Dedup und Wirkung in getrennten Transaktionen. Entweder doppelte Wirkung oder verlorene Nachricht (Übung 3).
Übungen
Übung 1: Visibility Timeout vorhersagen (ca. 5 Min.)
Das Visibility Timeout ist 3. Konsument A holt zahlung-1, ist aber sehr langsam (kein Absturz, nur lange Arbeit) und bestätigt erst ganz zum Schluss. Dazwischen holen sich andere Konsumenten Arbeit. Trage die drei Werte ein, ohne den Code auszuführen:
- Wie oft steht
zahlung-1am Ende inverarbeitet? - Was ist
c["versuche"]? - Was liefert
q.offen()am Ende?
Zeichne eine Zeitachse für t=0, 2 und 4. Ab welchem Tick ist zahlung-1 wieder sichtbar? Was passiert, wenn ein Ack eine ID betrifft, die schon gelöscht ist?
q = Queue(visibility_timeout=3)
q.send("zahlung-1")
q.send("zahlung-2")
verarbeitet = []
a = q.receive()
q.tick(2)
b = q.receive()
verarbeitet.append(b["body"]); q.ack(b["id"])
q.tick(2)
c = q.receive()
verarbeitet.append(c["body"]); q.ack(c["id"])
verarbeitet.append(a["body"]); q.ack(a["id"])
antwort = (2, 2, 0)
antwortBei t=2 ist zahlung-1 noch unsichtbar (sichtbar ab t=3), B bekommt zahlung-2. Bei t=4 ist das Timeout abgelaufen, C bekommt zahlung-1 (Versuch 2). Dann beendet auch A seine Arbeit an derselben Nachricht, zahlung-1 steht zweimal in verarbeitet. A’s Ack betrifft eine schon gelöschte ID und ist wirkungslos. Eine langsame Arbeit erzeugt dieselbe Doppelzustellung wie ein Absturz.
Übung 2: Wann ack’en? Drei Konsumenten stürzen ab (ca. 6 Min.)
Drei Konsumenten-Varianten holen dieselbe Nachricht aus der Queue und stürzen an unterschiedlichen Stellen ab. Danach kommt ein zweiter, korrekter Konsument, der nach Ablauf des Visibility Timeouts arbeitet und erst nach der Arbeit bestätigt. Die Funktion szenario gibt zurück, wie oft die Wirkung insgesamt eingetreten ist und was q.offen() am Ende liefert. Sage für alle drei Varianten das Paar (Wirkungen, offen) voraus.
Trage die Vorhersage ein, ohne den Code auszuführen. Reihenfolge der Paare: ack_zuerst, wirkung_dann_tod, sofort_tod.
Frage bei jeder Variante: Ist die Nachricht nach dem ersten Konsumenten noch in der Queue? Und hat der erste Konsument seine Arbeit schon getan, bevor er starb?
antwort = ((0, 0), (2, 0), (1, 0))
antwortack_zuerst: Die Nachricht ist gelöscht, bevor die Arbeit passiert. Der zweite Konsument findet nichts, die Wirkung tritt nie ein (at-most-once, Verlust). wirkung_dann_tod: Die Arbeit lief schon, das Ack fehlt. Der zweite Konsument bekommt die Nachricht erneut, die Wirkung tritt zweimal ein (at-least-once, Duplikat). sofort_tod: Es ist noch nichts passiert, und die Nachricht wird neu zugestellt, die Wirkung tritt genau einmal ein. Aus der Queue allein lässt sich “tot vor der Arbeit” nicht von “tot nach der Arbeit” unterscheiden.
Übung 3: Idempotenten Konsumenten bauen (ca. 12 Min.)
Du hast in Schritt 4 das Muster gesehen. Jetzt dieselbe Idee für einen neuen Fall. Das Muster ist das aus Schritt 4, neu ist das Verhalten bei Fehlern (Exception weitergeben, ID nicht merken) und der Absturztest. Ein Treuepunkte-Service verarbeitet Nachrichten der Form {"id": 7, "body": {"kunde": "anna", "punkte": 50}}. Die Tabelle punkte hat eine Obergrenze (CHECK (stand <= 1000)), die Tabelle gesehen speichert Nachrichten-IDs.
Schreibe verarbeite(con, msg):
- Neue Nachrichten-ID: Punkte gutschreiben, ID in
gesehenmerken,Truezurückgeben. - Bekannte ID: nichts ändern,
Falsezurückgeben (das Duplikat wird danach normal bestätigt). - Gutschrift und Merken der ID geschehen in einer Transaktion.
- Schlägt die Gutschrift fehl (z. B. Obergrenze überschritten), darf die Exception nicht verschluckt werden und die ID darf nicht gemerkt sein. Dann bleibt die Nachricht unbestätigt und kommt später erneut.
Die Prüfung stellt auch einen Absturz mitten in der Verarbeitung nach (sie bricht nach dem ersten bzw. zweiten Schreibzugriff ab) und prüft, dass danach entweder beides oder nichts passiert ist.
Der Unique Constraint der Datenbank kann die Duplikat-Prüfung übernehmen. Welcher Block sorgt dafür, dass zwei Schreibzugriffe zusammen gelten oder zusammen verworfen werden? Und wann ist ein return False richtig, wann wäre es gefährlich?
def verarbeite(con, msg):
with con:
try:
con.execute("INSERT INTO gesehen (msg_id) VALUES (?)", (msg["id"],))
except sqlite3.IntegrityError:
return False
con.execute("UPDATE punkte SET stand = stand + ? WHERE kunde = ?",
(msg["body"]["punkte"], msg["body"]["kunde"]))
return True
verarbeiteDer INSERT auf die Dedup-Tabelle und das UPDATE stehen in derselben Transaktion (with con). Scheitert das UPDATE, wird auch der INSERT zurückgerollt, und die Exception fliegt weiter. Dann wird die Nachricht nicht bestätigt und später erneut versucht.
Merksatz
Über ein Netzwerk gilt “lieber doppelt als verloren”: Queues liefern mindestens einmal, darum muss jeder Konsument Duplikate vertragen, mit einer Dedup-Tabelle in derselben Transaktion wie die Wirkung.
Prüfstein
Eine Queue liefert at-least-once. Ein Konsument bucht Geld und schickt danach eine Bestätigungsmail. Wo kann hier etwas doppelt passieren, wie sicherst du die Buchung ab, und was kannst du bei der Mail ehrlich garantieren?
Weiter mit Teil 2: Outbox, Dead Letter Queue und Pub/Sub.
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “7. Verteilte Systeme und Betrieb” (Grundprobleme, at-least-once vs. exactly-once); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 7 “Log-basierte Systeme” (idempotente Consumer).
Über die Quelle hinaus (allgemeines Fachwissen, die Quellen sind Stichwortlisten): die acht Fallacies im Wortlaut, die Definition von Visibility Timeout und Acknowledgement, die Dedup-Tabelle mit Unique Constraint, die Aussage, dass exactly-once über ein unzuverlässiges Netz nur als effectively-once erreichbar ist. Die Simulationen (Queue, Fake-Netzwerk) sind didaktische Vereinfachungen und bilden kein konkretes Produkt (z. B. SQS, RabbitMQ, Kafka) ab.