Verteilte Systeme
Sobald zwei Rechner über ein Netz zusammenarbeiten, wird Selbstverständliches schwer: Wurde die Überweisung ausgeführt oder nicht? Wer hat das Sagen, wenn der Chef ausfällt? Und warum macht ein gut gemeinter Neuversuch den Ausfall manchmal schlimmer?
Ein verteiltes System besteht aus mehreren Rechnern, die über ein Netz zusammenarbeiten – heute also fast jede größere Anwendung. Das Grundproblem: Nachrichten können verloren gehen, sich verspäten oder doppelt ankommen, und ein Rechner kann ausfallen, während die anderen weiterlaufen. Ein Absender, der keine Antwort bekommt, weiß nicht, ob seine Anfrage ankam.
Daraus folgen die Grundregeln: Jeder Aufruf braucht ein Timeout. Neuversuche sind nur sicher, wenn Operationen idempotent sind. „Genau einmal zustellen“ gibt es nicht – nur „mindestens einmal“ plus Erkennen von Doppelten. Und wenn mehrere Rechner sich einigen müssen, wer das Sagen hat, braucht man ein Konsensverfahren wie Raft, das mit einer Mehrheit arbeitet.
Acht falsche Annahmen
Schon in den 1990er-Jahren schrieben Ingenieure bei Sun Microsystems die Annahmen auf, die Entwickler verteilter Systeme stillschweigend treffen – und die alle falsch sind:
| Die Annahme | Die Wirklichkeit |
|---|---|
| Das Netz ist zuverlässig. | Pakete gehen verloren, Verbindungen brechen ab, Router fallen aus. |
| Die Latenz ist null. | Jeder Aufruf kostet Millisekunden – über Kontinente hinweg Hunderte. |
| Die Bandbreite ist unbegrenzt. | Große Antworten und viele kleine Aufrufe summieren sich. |
| Das Netz ist sicher. | Alles, was übers Netz geht, muss verschlüsselt und geprüft werden. |
| Die Topologie ändert sich nicht. | Container kommen und gehen, IP-Adressen wechseln. |
| Es gibt einen Administrator. | Viele Teams, viele Zuständigkeiten, niemand sieht alles. |
| Transport kostet nichts. | Serialisierung, Verschlüsselung und Datenverkehr kosten Rechenzeit und Geld. |
| Das Netz ist einheitlich. | Unterschiedliche Hardware, Protokolle und Versionen. |
Dazu kommt das Kernproblem: Teilausfälle. Auf einem einzelnen Rechner läuft ein Programm oder es stürzt ab. In einem verteilten System kann ein Teil ausfallen, während der Rest weitermacht – und von außen sieht ein ausgefallener Rechner genauso aus wie ein langsamer oder ein schlecht erreichbarer.
Wurde es ausgeführt oder nicht?
Ein Bestelldienst schickt eine Überweisung an den Zahlungsdienst. Die Antwort bleibt aus. Was tun?
Aus Sicht des Absenders sehen „Anfrage verloren“ und „Antwort verloren“ identisch aus: Es kam nichts zurück. Deshalb gibt es nur zwei Zustellgarantien, die man tatsächlich bauen kann:
Höchstens einmal
Nie wiederholen. Nichts wird doppelt ausgeführt, aber manches gar nicht. Gut für Dinge, die man verlieren darf – Messwerte, Statistiken.
Mindestens einmal
Wiederholen, bis eine Bestätigung kommt. Nichts geht verloren, aber manches kommt doppelt an. Der Standard bei Nachrichtenvermittlern.
Effektiv einmal
Mindestens einmal zustellen, beim Empfänger Doppelte erkennen und ignorieren. Das ist alles, was „exactly once“ in der Praxis je bedeutet.
Eine Operation ist idempotent, wenn mehrfaches Ausführen dasselbe bewirkt wie einmaliges. „Setze den Status auf versandt“ ist idempotent, „Buche 100 € ab“ nicht. Nicht-idempotente Operationen macht man idempotent, indem der Absender jeder Anfrage eine eindeutige Kennung mitgibt und der Empfänger sich merkt, welche er schon ausgeführt hat.
Konsens: Wer hat das Sagen?
Viele Systeme brauchen genau einen Rechner, der Entscheidungen trifft – einen Anführer (Leader). Er ordnet alle Schreibvorgänge, damit keine Widersprüche entstehen. Fällt er aus, muss ein neuer gewählt werden, und zwar so, dass es nie zwei gleichzeitig gibt. Das leistet ein Konsensverfahren. Das bekannteste ist Raft (2014), entworfen ausdrücklich, um verständlicher zu sein als sein Vorgänger Paxos. Raft steckt in etcd, dem Gedächtnis von Kubernetes, und in vielen Datenbanken. Die Kernidee: Alles braucht eine Mehrheit.
Weil jede Entscheidung eine Mehrheit braucht und es nur eine Mehrheit geben kann, kann es nie zwei Anführer geben, die beide etwas durchsetzen – auch nicht, wenn das Netz sich teilt. Die Minderheit bleibt einfach stehen; das ist die Entscheidung für Konsistenz im CAP-Theorem. Daraus folgt eine wichtige Faustregel: Ein Cluster aus drei Rechnern verkraftet einen Ausfall, einer aus fünf zwei. Ein vierter Rechner bringt nichts – für eine Mehrheit von vier braucht man drei, er verkraftet also ebenfalls nur einen Ausfall.
Timeouts, Neuversuche und der Ansturm danach
Ohne Timeout wartet ein Aufruf im Zweifel ewig – und mit ihm ein Thread, eine Verbindung, ein Nutzer. Also: immer ein Timeout. Nach einem Timeout liegt ein Neuversuch nahe. Aber Vorsicht: Wenn ein Dienst wegen Überlastung ausfällt und alle Aufrufer im selben Takt neu versuchen, trifft ihn bei der Rückkehr eine Welle, die ihn gleich wieder umwirft.
Dazu gehören zwei weitere Muster: Ein Circuit Breaker (Sicherung) zählt Fehler und hört nach zu vielen ganz auf, einen kranken Dienst anzurufen – er antwortet sofort mit einem Fehler oder einem Ersatzwert und probiert erst nach einer Weile wieder vorsichtig. Und ein Bulkhead (Schott) begrenzt, wie viele Threads oder Verbindungen ein einzelner Abhängiger belegen darf, damit ein langsamer Dienst nicht die ganze Anwendung blockiert.
Uhren lügen
Jeder Rechner hat seine eigene Uhr, und keine geht genau. Über NTP werden sie abgeglichen, aber Abweichungen von Millisekunden sind normal, manchmal mehr – und eine Uhr kann bei einer Korrektur sogar rückwärts springen. „Was zuerst passierte“ lässt sich deshalb über Rechnergrenzen hinweg nicht an Zeitstempeln ablesen. Verteilte Systeme benutzen stattdessen logische Uhren: Zähler, die mit jeder Nachricht weitergegeben werden und eine Reihenfolge garantieren, die zur Ursache-Wirkung-Beziehung passt (Lamport-Uhren, Versionsvektoren). Googles Datenbank Spanner geht einen anderen Weg: Atomuhren und GPS in jedem Rechenzentrum, und jede Zeitangabe hat ein ausdrückliches Unsicherheitsintervall.
Für Entwickler übersetzt
// Idempotenter Endpunkt: gleiche Kennung, gleiche Antwort – keine zweite Ausführung
@PostMapping("/ueberweisungen")
Ergebnis ueberweise(@RequestHeader("Idempotency-Key") UUID key, @RequestBody Auftrag a) {
return ergebnisse.findById(key) // schon verarbeitet?
.orElseGet(() -> service.fuehreAus(key, a)); // Buchung + Ergebnis mit Key in EINER Transaktion,
} // Key mit UNIQUE-Constraint gegen Gleichzeitiges
// Resilience4j: Neuversuch mit exponentiellem Backoff und Zufall, plus Sicherung
Retry retry = Retry.of("zahlung", RetryConfig.custom()
.maxAttempts(4)
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(Duration.ofMillis(200), 2.0))
.build());
CircuitBreaker cb = CircuitBreaker.ofDefaults("zahlung");
Supplier<Ergebnis> aufruf = Decorators.ofSupplier(() -> zahlung.belaste(auftrag))
.withCircuitBreaker(cb).withRetry(retry).decorate();| Muster | Wofür |
|---|---|
| Transactional Outbox | Datenbank ändern und ein Ereignis senden – zuverlässig: Das Ereignis wird in derselben Transaktion in eine Tabelle geschrieben und von dort verschickt. Sonst gibt es entweder Ereignisse ohne Änderung oder Änderungen ohne Ereignis. |
| Saga | Ein Ablauf über mehrere Dienste, bei dem jeder Schritt eine Ausgleichsaktion hat – statt einer verteilten Transaktion, die alle Dienste sperrt. |
| Timeouts überall | HTTP-Client, Datenbank, Nachrichtenvermittler – jeder Aufruf über das Netz. Und das Timeout des Aufrufers muss größer sein als die Summe dessen, was dahinter passiert. |
| Zeit | System.currentTimeMillis() verschiedener Rechner nicht vergleichen, um eine Reihenfolge festzustellen. Für Dauern auf einem Rechner System.nanoTime() – das springt nicht. |