Het Vier-Stappenpatroon
Het defect zit in vier opeenvolgende, niet-atomaire stappen:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Stap 1: controleer cache. Stap 2: miss. Stap 3: bereken. Stap 4: opslaan. Alle vier stappen: niet-atomair. Tussen stap 1 en stap 4 kunnen meerdere threads stap 1 uitvoeren en allemaal null zien.
De Idempotentie-val
De redenering die dit patroon beschermt: 'het is OK als twee threads dezelfde waarde berekenen en opslaan. Het resultaat is idempotent. Er treedt geen datacorruptie op.'
Deze redenering: correct wat betreft juistheid. Fataal wat betreft kosten.
Bij 1.000 threads bij een cache-miss: 1.000 threads voeren elk expensiveCompute(key) uit. Als expensiveCompute een database opvraagt, worden 1.000 database-query's tegelijkertijd uitgevoerd. Als het een externe service aanroept, worden 1.000 HTTP-verzoeken tegelijkertijd uitgevoerd. Het systeem dat correcte resultaten produceert, bezwijkt onder de kosten van het produceren ervan.
Drie triggers
Een Thundering Herd treedt op wanneer een cache-sleutel gelijktijdig van warm naar koud overgaat bij veel threads:
Cold start: service herstart met een lege cache. Eerste verzoekgolf: elke sleutel mist. Alle berekeningen gebeuren tegelijkertijd.
Service herstart: rolling restart reset de cache over alle instanties. Verkeer wordt herverdeeld naar koude instanties.
TTL-verval: een sleutel met veel verkeer verloopt. N threads controleren allemaal, missen allemaal, en berekenen allemaal voordat de eerste thread het resultaat opslaat.
Alle drie de triggers: gecorreleerd met verkeerspieken. De kudde vuurt wanneer het verkeer piekt & de cache koud is. Slechtst mogelijke timing.
Het Elasticsearch EnrichCache-voorbeeld
Elasticsearch EnrichCache: de gedocumenteerde opmerking luidt 'opzettelijk niet-vergrendelend voor eenvoud... OK als we dezelfde sleutel/waarde opnieuw plaatsen bij een raceconditie.' Bij 10.000 documenten per seconde met een koude verrijkingscache: alle 10.000 verzoeken raken de verrijkingsindex tegelijkertijd. De verrijkingsindex, ontworpen voor incidentele lookups, krijgt te maken met 10.000 gelijktijdige query's. Het cluster destabiliseert.
De idempotentieredenatie: correct in de codecommentaar. Catastrofaal bij 10.000 documenten per seconde.
Koppeling aan MOAD-0001
MOAD-0001 (Sedimentary Defect) creëert een O(N²)-knelpunt in systemen met hoge doorvoer. Het oplossen van MOAD-0001 (O(N²) naar O(N)) ontgrendelt dat werkstation. De snellere doorvoer stuurt meer verzoeken stroomafwaarts. Stroomafwaartse caches, eerder beschermd door het MOAD-0001-knelpunt, ontvangen nu gecorreleerde verkeerspieken. MOAD-0005 vuurt in caches die het eerder nooit triggerden. Los één MOAD op; bereid de andere voor.
Wat de Idempotentieval Misloopt
De Elasticsearch-commentaar vertegenwoordigt zorgvuldige engineering-redenering toegepast op de verkeerde vraag. Idempotentie: een echte eigenschap die het waard is om over na te denken. De val: stoppen met de analyse bij correctheid zonder door te gaan naar kosten.
computeIfAbsent & singleflight
De oplossing: maak de check-and-compute atomair. Eén thread voert de berekening uit. Alle andere threads wachten op dat resultaat.
Java: computeIfAbsent
// DEFECT: vier niet-atomische stappen
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// FIX: atomische check-en-compute
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent: als de sleutel ontbreekt, wordt de waarde exact één keer berekend, opgeslagen en geretourneerd. Alle andere threads die computeIfAbsent voor dezelfde sleutel aanroepen, wachten op de eerste berekening. Geen N-voudige berekening. Geen stormloop.
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: als een berekening voor een sleutel al loopt, wachten alle aanroepers voor dezelfde sleutel en delen ze het enkele resultaat. Eén berekening, N wachtenden, één resultaat gedeeld. De 'flight'-abstractie: dedupliceer in-flight verzoeken.
Lock vs singleflight
Een naïeve per-sleutel lock serialiseert: thread 1 berekent, thread 2 wacht, thread 3 wacht. Nadat thread 1 klaar is, gaat thread 2 binnen & controleert de cache (hit). Thread 3 gaat binnen & controleert de cache (hit). N-1 lock-acquisities & cache-lezen.
singleflight dedupliceert: thread 1 berekent, threads 2 tot en met N wachten allemaal op het resultaat van thread 1. Geen aparte lock-acquisities. Geen aparte cache-lezen. Eén berekening, één resultaat, verdeeld over N wachtenden. Minder operaties dan een per-sleutel lock.
Beide voorkomen de stormloop. singleflight voorkomt overbodig werk nog vollediger.
Herschrijf het patroon
Pas de fix toe op een concreet scenario.
// Een gebruikersprofiel-cache in een Java-service met hoge belasting
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // duur: 50ms DB-query
profileCache.put(userId, profile);
}
return profile;
}
Service herstart elke ochtend om 2 uur 's nachts. Om 8 uur 's ochtends vragen 10.000 gebruikers tegelijkertijd hun profielen op.