Das Vier-Schritt-Muster
Der Defekt liegt in vier sequenziellen, nicht-atomaren Schritten:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Schritt 1: Cache prüfen. Schritt 2: Fehlschlag. 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 alle sehen null.
Die Idempotenz-Falle
Die Begründung, die dieses Muster schützt: „Es ist in Ordnung, wenn zwei Threads denselben Wert berechnen und speichern. Das Ergebnis ist idempotent. Es tritt keine Datenkorruption auf.“
Diese Begründung: korrekt in Bezug auf Korrektheit. Fatal in Bezug auf Kosten.
Bei 1.000 Threads bei einem Cache-Miss: 1.000 Threads führen jeweils expensiveCompute(key) aus. Wenn expensiveCompute eine Datenbank abfragt, werden 1.000 Datenbankabfragen gleichzeitig ausgelöst. Wenn es einen externen Dienst aufruft, werden 1.000 HTTP-Anfragen gleichzeitig ausgelöst. Das System, das korrekte Ergebnisse liefert, bricht unter den Kosten der Erzeugung dieser Ergebnisse zusammen.
Drei Auslöser
Eine Thundering Herd tritt auf, wenn ein Cache-Key gleichzeitig über viele Threads von warm auf kalt wechselt:
Kaltstart: Der Dienst startet neu mit einem leeren Cache. Erste Anfragewelle: jeder Key verursacht einen Miss. Alle Berechnungen erfolgen gleichzeitig.
Dienstneustart: Ein Rolling-Restart setzt den Cache über alle Instanzen zurück. Der Traffic wird auf kalte Instanzen umverteilt.
TTL-Ablauf: Ein Key mit hohem Traffic läuft ab. N Threads prüfen gleichzeitig, alle verursachen einen Miss, alle berechnen, bevor der erste Thread das Ergebnis speichert.
Alle drei Trigger: korreliert mit Traffic-Spitzen. Die Herde feuert, wenn der Traffic seinen Höhepunkt erreicht und der Cache kalt ist. Der schlechteste Zeitpunkt überhaupt.
Das Elasticsearch EnrichCache-Beispiel
Elasticsearch EnrichCache: der dokumentierte Kommentar lautet „absichtlich nicht sperrend, um die Einfachheit zu wahren … OK, wenn wir denselben Schlüssel/Wert bei einer Race Condition erneut setzen“. Bei 10.000 Dokumenten pro Sekunde mit einem kalten Enrich-Cache treffen alle 10.000 Anfragen gleichzeitig auf den Enrich-Index. Der Enrich-Index, der für gelegentliche Lookups ausgelegt ist, muss 10.000 gleichzeitige Abfragen verarbeiten. Der Cluster wird instabil.
Die Idempotenz-Überlegung: im Code-Kommentar korrekt. Katastrophal bei 10.000 Dokumenten pro Sekunde.
Kopplung an MOAD-0001
MOAD-0001 (Sedimentary Defect) erzeugt einen O(N²)-Engpass in Hochdurchsatz-Systemen. Die Behebung von MOAD-0001 (O(N²) → O(N)) entlastet diese Workstation. Der höhere Durchsatz sendet mehr Anfragen downstream. Downstream-Caches, die zuvor durch den MOAD-0001-Engpass geschützt waren, erhalten nun korrelierte Traffic-Spitzen. MOAD-0005 tritt in Caches auf, die ihn zuvor nie ausgelöst haben. Behebe einen MOAD; bereite den nächsten vor.
Was die Idempotenz-Falle falsch macht
Der Elasticsearch-Kommentar steht für sorgfältige technische Überlegungen, die auf die falsche Frage angewendet wurden. Idempotenz: eine reale Eigenschaft, über die es sich lohnt nachzudenken. Die Falle: die Analyse bei der Korrektheit zu stoppen, ohne die Kosten weiter zu betrachten.
computeIfAbsent & singleflight
Die Lösung: mache die Prüfung-und-Berechnung atomar. Ein Thread berechnet. Alle anderen Threads warten auf dieses 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 der Schlüssel fehlt, wird er genau einmal berechnet, gespeichert und zurückgegeben. Alle anderen Threads, die computeIfAbsent für denselben Schlüssel aufrufen, warten auf die erste Berechnung. Keine N-fache Berechnung. Kein Stampede.
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: Läuft für einen Schlüssel bereits eine Berechnung, warten alle Aufrufer desselben Schlüssels und teilen sich das einzige Ergebnis. Eine Berechnung, N Wartende, ein geteiltes Ergebnis. Die „Flight“-Abstraktion: dedupliziert laufende Anfragen.
Lock vs singleflight
Ein naives pro-Schlüssel-Lock serialisiert: Thread 1 berechnet, Thread 2 wartet, Thread 3 wartet. Nachdem Thread 1 fertig ist, betritt Thread 2 und prüft den Cache (Treffer). Thread 3 betritt und prüft den Cache (Treffer). N-1 Lock-Akquisitionen & Cache-Lesevorgänge.
singleflight dedupliziert: Thread 1 berechnet, Threads 2 bis N warten alle auf das Ergebnis von Thread 1. Keine separaten Lock-Akquisitionen. Keine separaten Cache-Lesevorgänge. Eine Berechnung, ein Ergebnis, an N Wartende verteilt. Weniger Operationen als ein pro-Schlüssel-Lock.
Beide verhindern den Stampede. singleflight verhindert redundante Arbeit vollständiger.
Rewrite the Pattern
Apply the fix to a concrete scenario.
// Ein Benutzerprofil-Cache in einem hochfrequentierten Java-Dienst
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // teuer: 50ms DB-Abfrage
profileCache.put(userId, profile);
}
return profile;
}
Der Dienst wird jeden Morgen um 2 Uhr neu gestartet. Um 8 Uhr rufen 10.000 Benutzer gleichzeitig ihre Profile ab.