Blog
Teil 3 von 13 Backend Patterns

Cache Stampede

3 min Lesezeit
patterns caching performance

Montag, 9:15. Dein Grafana-Dashboard zeigt etwas Seltsames: alle 60 Sekunden springt die P99-Latenz auf über 3 Sekunden. Exakt alle 60 Sekunden. Wie ein Herzschlag.

Dein erster Instinkt: ein Cronjob? Ein Health Check der amok läuft? Nein. Die Spikes korrelieren mit nichts in deinem Scheduler. Dann schaust du dir die DB-Metriken an. Und da ist es: alle 60 Sekunden explodiert die Anzahl der Queries. Hunderte gleichzeitige Requests treffen die Datenbank im selben Moment. Dann Ruhe. Bis zur nächsten Minute.

Das ist kein Bug. Das ist dein Cache.

Du hast einen Hot Key — vielleicht die Produktliste deiner Startseite, vielleicht ein Nutzerprofil das tausende Male pro Sekunde gelesen wird. Dein TTL ist 60 Sekunden. Und jede Minute passiert das Gleiche: TTL läuft ab, Cache ist leer, hunderte Requests gleichzeitig: Cache Miss. Alle gehen zur DB. DB choked. Latenz explodiert. Irgendwann kommt ein Request durch, schreibt zurück in den Cache. Alles wieder gut. Für 60 Sekunden.

Starte den Traffic und beobachte das Muster:

Redis Cache
TTL60s
CACHE HIT RATE99%
DB QUERIES/S3
AVG LATENCY12ms
CONN POOL2/20

Siehst du den Rhythmus? Das ist kein einmaliger Ausfall. Das passiert jede Minute. Solange dein Service läuft. Für immer.

Der Begriff “Stampede” beschreibt es gut: hunderte Requests stürmen gleichzeitig zur Datenbank. Du findest das auch unter “Thundering Herd Problem” — selbes Phänomen, anderer Name.

Warum TTL allein nicht reicht

Das Problem ist nicht der Cache. Caching ist großartig. Das Problem ist, dass alle Einträge für denselben Key im selben Moment ablaufen. Bei einem Hot Key lesen hunderte Clients gleichzeitig. Wenn der Cache leer ist, machen alle dasselbe: ab zur Datenbank.

Die DB sieht plötzlich das 300-fache der normalen Last. Connection Pool voll. Queries queuen sich. Timeouts. Und das Perverse daran: je populärer der Key, desto härter der Stampede.

Drei Strategien

Es gibt drei Ansätze, die sich fundamental unterscheiden. Lass den Cache ablaufen und schau dir an, wie jeder damit umgeht:

Kein Schutz
DB LOAD3/s
LATENCY12ms
OK
Mutex Lock
DB LOAD3/s
LATENCY12ms
OK
Early Refresh
DB LOAD3/s
LATENCY12ms
OK

Gleicher Trigger. Komplett andere Ergebnisse.

Mutex Lock

Wenn der Cache leer ist, bekommt ein Request den Lock. Alle anderen warten.

async function getWithMutex(key: string): Promise<Data> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const acquired = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 5);

  if (acquired) {
    const data = await db.query('SELECT ...');
    await redis.set(key, JSON.stringify(data), 'EX', 60);
    await redis.del(`lock:${key}`);
    return data;
  }

  // Jemand anders rebuildet — kurz warten, retry
  await new Promise(r => setTimeout(r, 50));
  return getWithMutex(key);
}

Kein Stampede, aber die wartenden Requests haben erhöhte Latenz. Du tauschst einen DB-Stampede gegen einen Latenz-Spike. Wenn der Lock-Holder abstürzt? Dafür ist das EX 5 da: der Lock expired automatisch.

Probabilistic Early Expiration

Mein Favorit. Requests refreshen den Cache vor dem TTL, mit einer Wahrscheinlichkeit die steigt, je näher das Ablaufdatum rückt.

async function getWithEarlyRefresh(key: string, ttl: number): Promise<Data> {
  const cached = await redis.get(key);
  const remaining = await redis.ttl(key);

  if (cached && !shouldRefresh(remaining, ttl)) {
    return JSON.parse(cached);
  }

  const data = await db.query('SELECT ...');
  await redis.set(key, JSON.stringify(data), 'EX', ttl);
  return data;
}

function shouldRefresh(remaining: number, ttl: number): boolean {
  const beta = 1;
  return Math.random() < Math.exp(-beta * remaining / (ttl - remaining + 1));
}

Das ist der XFetch-Algorithmus aus “Optimal Probabilistic Cache Stampede Prevention” (Vattani et al., 2015). Der beta-Parameter kontrolliert, wie aggressiv früh refresht wird.

Kein Lock. Kein Warten. Die DB sieht nie den Spike, weil einzelne Requests den Cache erneuern bevor er abläuft, verteilt über die letzten Sekunden vor dem TTL.

External Refresh

Die dritte Option: ein Background-Job refresht den Cache bevor er abläuft. Requests lesen nur.

// Läuft als Cronjob alle 45s (TTL ist 60s)
async function refreshCache() {
  const data = await db.query('SELECT ...');
  await redis.set('products:featured', JSON.stringify(data), 'EX', 60);
}

Simpel. Aber du brauchst einen separaten Prozess, und wenn der stirbt, merkst du es erst wenn der Cache expired. Gut für wenige, vorhersagbare Keys. Schlecht für tausende dynamische.

Erkennung

Wie findest du eine Cache Stampede in der Wildnis?

  1. Periodische Latenz-Spikes: wenn dein P99 ein regelmäßiges Muster zeigt, check ob die Periode deinem TTL entspricht.
  2. DB Query Bursts: identische Queries die in einem Fenster von < 100ms gehäuft auftauchen.
  3. Cache Hit Rate Drops: periodische Einbrüche von 99% auf 0%.

Ehrlich: Probabilistic Early Expiration sollte der Default sein, nicht das Upgrade. Dass die meisten Cache-Libraries immer noch mit nacktem TTL ausliefern, ohne auch nur eine Warnung — naja.