Zum Inhalt springen

Caching-Strategien für Webanwendungen

Von HTTP-Headern bis zu Redis-Mustern: ein praktischer Leitfaden zu Caching-Schichten, die Latenz und DB-Last senken, ohne veraltete Daten zu liefern.

3 Min. Lesezeit
Diagramm einer mehrschichtigen Caching-Architektur mit Browser-, CDN-, Anwendungs- und Datenbank-Cache-Ebenen

Jede langsame Anwendung hat dasselbe Problem: Sie holt Daten, die sie bereits hat. Caching ist das wirksamste Performance-Werkzeug im Repertoire eines Backend-Engineers, aber falsches Caching ist schlimmer als gar kein Caching — es liefert veraltete Daten, erzeugt Konsistenz-Bugs und vermittelt ein falsches Gefühl von Performance.

Die Caching-Schichten

Moderne Webanwendungen können auf vier Ebenen cachen. Jede hat unterschiedliche Latenzeigenschaften und Invalidierungskomplexität.

EbeneLatenzInvalidierungGeeignet für
Browser-Cache (HTTP-Header)0msWird über Header gesteuertStatische Assets, API-Antworten
CDN-/Edge-Cache1-50msPurge-API oder TTLÖffentliche, lesehäufige Inhalte
Anwendungs-Cache (Redis/Memory)1-5msAnwendungslogikSitzungsdaten, berechnete Ergebnisse
Datenbank-Query-Cache5-20msAutomatisch (query-abhängig)Wiederholte identische Queries

Beginne von oben — Browser-Caching ist kostenlos und eliminiert Netzwerk-Requests vollständig. Füge tiefere Ebenen nur hinzu, wenn die oberen nicht ausreichen.

HTTP-Caching-Header

Der eingebaute Cache des Browsers ist die schnellste und günstigste Option. Zwei Header steuern das meiste davon.

tstypescript
// ❌ No cache headers — browser re-fetches every time
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  res.json(products);
});
 
// ✅ Cache headers — browser reuses response for 60 seconds
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  res.set("Cache-Control", "public, max-age=60, s-maxage=300");
  res.set("ETag", computeETag(products));
  res.json(products);
});

max-age sagt dem Browser, wie lange er die gecachte Antwort ohne Revalidierung nutzen soll. s-maxage gibt CDNs und Proxys eine separate TTL vor. ETag ermöglicht bedingte Requests — der Browser sendet If-None-Match, und der Server kann 304 Not Modified zurückgeben, ohne den Body zu senden.

tstypescript
// Conditional request handling
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  const etag = computeETag(products);
 
  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end();
  }
 
  res.set("Cache-Control", "public, max-age=60");
  res.set("ETag", etag);
  res.json(products);
});

Caching auf Anwendungsebene mit Redis

Für berechnete oder aggregierte Daten, deren Neuerzeugung teuer ist, ist Redis die Standardwahl. Das Muster heißt Cache-Aside (auch Lazy Loading genannt).

tstypescript
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
async function getProductCatalog(categoryId: string) {
  const cacheKey = `catalog:${categoryId}`;
 
  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  // 2. Cache miss — fetch from database
  const products = await db.products.findMany({
    where: { categoryId, status: "active" },
    include: { pricing: true, inventory: true },
  });
 
  // 3. Store in cache with TTL
  await redis.set(cacheKey, JSON.stringify(products), "EX", 300);
 
  return products;
}

Das Cache-Aside-Muster ist einfach, hat aber ein Thundering-Herd-Problem: Wenn der Cache abläuft, treffen alle gleichzeitigen Requests zeitgleich auf die Datenbank.

Thundering Herds verhindern

Wenn ein populärer Cache-Key abläuft, versuchen möglicherweise Hunderte von Requests gleichzeitig, ihn neu aufzubauen. Ein Mutex (Lock) stellt sicher, dass nur einer die Arbeit erledigt, während die anderen warten.

tstypescript
async function getWithLock<T>(
  key: string,
  ttl: number,
  fetchFn: () => Promise<T>,
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      const data = await fetchFn();
      await redis.set(key, JSON.stringify(data), "EX", ttl);
      return data;
    } finally {
      await redis.del(lockKey);
    }
  }
 
  // Another process holds the lock — wait and retry
  await new Promise((resolve) => setTimeout(resolve, 100));
  return getWithLock(key, ttl, fetchFn);
}

Eine Alternative ist Stale-While-Revalidate: den abgelaufenen gecachten Wert sofort ausliefern, während im Hintergrund aktualisiert wird.

Strategien zur Cache-Invalidierung

Cache-Invalidierung ist wirklich schwierig. Hier sind die praktischen Ansätze:

tstypescript
// Strategy 1: TTL-based — simplest, accept brief staleness
await redis.set("catalog:electronics", data, "EX", 300); // 5 min
 
// Strategy 2: Event-driven — invalidate on write
async function updateProduct(id: string, updates: ProductUpdate) {
  const product = await db.products.update({ where: { id }, data: updates });
 
  // Invalidate related cache keys
  await redis.del(`product:${id}`);
  await redis.del(`catalog:${product.categoryId}`);
 
  return product;
}
 
// Strategy 3: Versioned keys — never invalidate, always fresh
async function getProductV2(id: string) {
  const version = await redis.get(`product-version:${id}`);
  const cacheKey = `product:${id}:v${version}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const product = await db.products.findUnique({ where: { id } });
  await redis.set(cacheKey, JSON.stringify(product), "EX", 3600);
  return product;
}
StrategieKonsistenzKomplexitätGeeignet für
Nur TTLEventual (innerhalb der TTL)NiedrigLesehäufig, Veralten akzeptabel
EreignisgesteuertStarkMittelWrite-after-Read-Muster
Versionierte KeysStarkHochHoher Traffic, muss aktuell sein

Was man nicht cachen sollte

Nicht alles profitiert vom Caching. Vermeide das Cachen von:

  • Nutzerspezifische Daten mit kurzen Sitzungen — die Cache-Trefferquote wird nahe null liegen
  • Sich schnell ändernde Daten — die Kosten der Cache-Invalidierung übersteigen den Nutzen
  • Daten mit strengen Konsistenzanforderungen — Finanztransaktionen, Bestandszahlen während des Checkouts
  • Große, selten abgerufene Payloads — verschwenden Speicher für eine minimale Trefferquote

Die erste Frage vor dem Hinzufügen eines Caches sollte immer sein: Wie hoch wird die Trefferquote sein? Liegt sie unter 50 %, fügt der Cache wahrscheinlich Komplexität hinzu, ohne ausreichenden Nutzen zu bringen.

Die wichtigsten Punkte

  1. Beginne mit HTTP-Caching-Headern — sie sind kostenlos und eliminieren Netzwerk-Requests vollständig
  2. Cache-Aside mit Redis ist das Standardmuster für Caching auf Anwendungsebene
  3. Verhindere Thundering Herds mit Mutex-Locks oder Stale-While-Revalidate
  4. Wähle eine Invalidierungsstrategie basierend auf deinen Konsistenzanforderungen
  5. Miss die Cache-Trefferquoten — niedrige Trefferquoten bedeuten, dass du Komplexität ohne Nutzen hinzufügst
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX