Das Vier-Schritt-Muster
Das Defekt befindet sich in vier sequenziellen, nicht-atomen Schritten:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Schritt 1: Prüfen des Caches. Schritt 2: Verfehlen. Schritt 3: Berechnen. Schritt 4: Speichern. Alle vier Schritte: nicht atomar. Zwischen Schritt 1 und Schritt 4 können beliebig viele Threads Schritt 1 ausführen und sehen alle 'null'.
Der Idempotenzfall
Die Argumentation, die diesen Pattern schützt: 'Es ist in Ordnung, wenn zwei Threads berechnen und speichern. Der Resultat ist idempotent. Es tritt keine Datenverfälschung auf.'
Diese Argumentation: richtig über Korrektheit. Fatal über Kosten.
Bei 1.000 Threads bei einem Cache-Verfehlen: Jeder Thread führt expensiveCompute(key) aus. Wenn expensiveCompute eine Datenbank abfragt, werden gleichzeitig 1.000 Datenbankabfragen gestartet. Wenn es eine externe Dienst aufruft, werden gleichzeitig 1.000 HTTP-Anfragen gestartet. Das System, das korrekte Ergebnisse produziert, bricht unter dem Kosten der Produktion zusammen.
Drei Auslöser
Ein Donnerndes Rudel entfaltet sich, wenn eine Cache-Schlüssel von warm auf kalt gleichzeitig über viele Threads überspringt:
Kaltstart: Dienst startet mit einem leeren Cache neu. Erste Anfrage-Welle: jeder Schlüssel verfehlt. Alle berechnen gleichzeitig.
Dienstneustart: rollender Neustart setzt Cache über Instanzen fort. Der Verkehr verteilt sich auf kalt laufende Instanzen.
TTL-Ablauf: ein hochbelasteter Schlüssel erlischt. N Threads verfehlen alle, berechnen, bevor der erste Thread das Ergebnis speichert.
Alle drei Auslöser: korreliert mit Verkeersspitzen. Das Rudel entfaltet sich, wenn der Verkehr ansteigt und der Cache kalt ist. Die schlimmstmögliche Zeit.
Das Elasticsearch EnrichCache Beispiel
Elasticsearch EnrichCache: dokumentierter Kommentar lautet 'intentionally non-locking for simplicity...OK, wenn wir denselben Schlüssel/Wert in einer Race-Condition erneut einfügen.' Bei 10.000 Dokumenten pro Sekunde mit einem kalten Enrich-Cache: Alle 10.000 Anfragen greifen gleichzeitig den Enrich-Index ab. Der Enrich-Index, der für gelegentliche Abfragen entworfen wurde, muss 10.000 parallele Abfragen bewältigen. Der Cluster destabilisiert sich.
Die Idempotenz-Argumentation: richtig im Code-Kommentar. Katastrophal bei 10.000 Dokumenten pro Sekunde.
Kopplung zu MOAD-0001
MOAD-0001 (Sedimentäres Defekt) erzeugt eine O(N²)-Engstelle in Hochdurchsatzsystemen. Das Beheben von MOAD-0001 (O(N²) zu O(N)) entlastet diese Arbeitsstation. Der schnellere Durchsatz sendet mehr Anfragen in Richtung der Downstream-Komponenten. Die Downstream-Caches, die zuvor durch die MOAD-0001-Engstelle geschützt waren, erhalten jetzt korrelierte Verkehrsspitzen. MOAD-0005 feuert in Caches, die es vorher noch nie getriggert haben. Fixe ein MOAD; stelle den anderen auf die Bühne.
Was der Idempotenz-Fall vermissen lässt
Der Elasticsearch-Kommentar zeigt sorgfältiges Ingenieurdenken, das jedoch die falsche Frage anwendet. Idempotenz: ein echtes Merkmal, über das nachgedacht werden sollte. Der Fall: das Aufhören der Analyse an der Richtigkeit, ohne auf den Kostenaspekt einzugehen.
computeIfAbsent & singleflight
Der Fix: Die Prüfung und Berechnung atomar ausführen. Ein Thread berechnet. Alle anderen Threads warten auf das Ergebnis.
Java: computeIfAbsent
// DEFECT: Vier nicht atomare Schritte
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// FIX: Atomare Prüfung und Berechnung
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent: wenn Schlüssel fehlt, wird genau einmal berechnet, gespeichert und zurückerstattet. Alle anderen Threads, die computeIfAbsent für denselben Schlüssel aufrufen, warten auf die erste Berechnung. Keine N-fache Berechnung. Keine Sturmfront.
Go: singleflight.Group
var g singleflight.Group
func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}
singleflight: Wenn eine Berechnung für den Schlüssel bereits läuft, warten alle Aufrufer für denselben Schlüssel & teilen sich das einzelne Ergebnis. Eine Berechnung, N Wartende, ein geteilter Resultat. Die 'flight'-Abstraktion: Entdoppeln im Flug.
Lock vs singleflight
Ein naiver pro-Schlüssel-Schließstoff serialisiert: Thread 1 berechnet, Thread 2 wartet, Thread 3 wartet. Nachdem Thread 1 fertig ist, tritt Thread 2 ein & prüft den Cache (Treffer). Thread 3 tritt ein & prüft den Cache (Treffer). N-1 Schließstoffakquisitionen & Cachelesereihenfolgen.
singleflight entdoppelt: Thread 1 berechnet, Threads 2 bis N warten auf das Ergebnis von Thread 1. Keine getrennten Schließstoffakquisitionen. Keine getrennten Cachelesereihenfolgen. Eine Berechnung, ein Resultat, verteilt auf N Wartende. Weniger Operationen als ein pro-Schlüssel-Schließstoff.
Beide verhindern die Sturmfront. singleflight verhindert die redundante Arbeit vollständiger.
Das Muster erneut schreiben
Wende die Reparatur an einem konkreten Szenario an.
// Ein Benutzerprofil-Cache in einem Java-Dienst mit hohem Traffic
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // teuer: 50ms DB-Anfrage
profileCache.put(userId, profile);
}
return profile;
}
Dienst wird jeden Morgen um 2 Uhr wiederbelebt. Um 8 Uhr bitten 10.000 Benutzer gleichzeitig nach ihren Profilen.