English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

Gast
1 / ?

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.

Warum führt die Begründung „es ist OK, wenn zwei Threads dasselbe Ergebnis schreiben“ zu dem Defekt? Was ist daran richtig, und was wird übersehen?

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.

Identifizieren Sie den Defekt, nennen Sie, wann er auftritt, und schreiben Sie ihn mit computeIfAbsent um.