un

guest
1 / ?
back to lessons

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.

Warum führt das Denken 'Es ist in Ordnung, wenn zwei Threads denselben Wert schreiben' zum Defekt? Was hat es richtig erkannt und was hat es verpasst?

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.

Identifiziere die Fehlerquelle, nenne, wann sie abläuft, und verwende computeIfAbsent.