Zum Inhalt springen

Verteiltes Caching: Konsistenz, Invalidierung, Fehler

Verteilte Caching-Muster im Detail: Cache-Aside, Write-Through, Read-Through und Invalidierungsstrategien für echte Konsistenzanforderungen.

5 Min. Lesezeit
Architekturdiagramm, das Cache-Ebenen zwischen Anwendungsservern und Datenbank mit Invalidierungsflüssen zeigt

Caching ist die leistungsstärkste Performance-Optimierung in verteilten Systemen und die gefährlichste Fehlerquelle. Ein gut implementierter Cache reduziert die Datenbanklast um 90%. Ein schlecht implementierter liefert stundenlang veraltete Daten, während Entwickler sich fragen, warum Nutzer die Preise von gestern sehen.

Der Unterschied zwischen diesen Ergebnissen liegt nicht in der Cache-Technologie, sondern im Caching-Muster. Jedes Muster trifft unterschiedliche Kompromisse zwischen Konsistenz, Performance und Komplexität.

Cache-Aside: Das Standardmuster

Cache-Aside (Lazy Loading) ist das gängigste Muster. Die Anwendung verwaltet den Cache explizit: Cache prüfen, bei einem Miss die Datenbank abfragen, den Cache befüllen.

tstypescript
// ❌ Naive cache-aside with race condition
async function getUser(id: string): Promise<User> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
 
  const user = await db.users.findById(id);
  await redis.set(`user:${id}`, JSON.stringify(user));
  // Race: another request might have updated the DB between
  // our read and our cache write, caching stale data
  return user;
}
tstypescript
// ✅ Cache-aside with TTL and stampede protection
import { Redis } from "ioredis";
 
class CacheAside<T> {
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number = 300
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cacheKey = `${this.prefix}:${key}`;
 
    // Try cache first
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached) as T;
    }
 
    // Stampede protection: only one request populates cache
    const lockKey = `${cacheKey}:lock`;
    const acquired = await this.redis.set(
      lockKey, "1", "EX", 10, "NX"
    );
 
    if (!acquired) {
      // Another request is fetching; wait and retry cache
      await new Promise(r => setTimeout(r, 100));
      const retried = await this.redis.get(cacheKey);
      if (retried) return JSON.parse(retried) as T;
    }
 
    // Fetch from source
    const value = await fetcher();
 
    // Cache with TTL
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.ttlSeconds
    );
 
    // Release lock
    await this.redis.del(lockKey);
 
    return value;
  }
 
  async invalidate(key: string): Promise<void> {
    await this.redis.del(`${this.prefix}:${key}`);
  }
}
 
// Usage
const userCache = new CacheAside<User>(redis, "user", 300);
const user = await userCache.get("user-123", () =>
  db.users.findById("user-123")
);

Das Lock verhindert einen Cache Stampede: Wenn Tausende Requests gleichzeitig auf einen abgelaufenen Cache-Key treffen, fragt nur einer die Datenbank ab. Die anderen warten kurz und erhalten das frisch gecachte Ergebnis.

Write-Through: Konsistenz auf Kosten der Latenz

Write-Through-Caching aktualisiert den Cache synchron mit jeder Datenbank-Schreiboperation. Lesen ist immer schnell, aber Schreiben zahlt den Preis für beide Speicheroperationen.

tstypescript
// ❌ Cache and DB can get out of sync
async function updateUser(id: string, data: Partial<User>) {
  await db.users.update(id, data);    // DB updated
  await redis.del(`user:${id}`);       // Cache invalidated
  // If the app crashes between these lines, cache is stale
}
tstypescript
// ✅ Write-through with atomic-like guarantees
class WriteThroughCache<T> {
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number
  ) {}
 
  async write(
    key: string,
    value: T,
    dbWriter: (value: T) => Promise<void>
  ): Promise<void> {
    const cacheKey = `${this.prefix}:${key}`;
 
    // Write to DB first (source of truth)
    await dbWriter(value);
 
    // Then update cache
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.ttlSeconds
    );
  }
 
  async read(key: string): Promise<T | null> {
    const cacheKey = `${this.prefix}:${key}`;
    const cached = await this.redis.get(cacheKey);
    return cached ? (JSON.parse(cached) as T) : null;
  }
}
 
// Usage
const productCache = new WriteThroughCache<Product>(redis, "product", 600);
 
await productCache.write(
  "prod-456",
  updatedProduct,
  async (product) => {
    await db.products.update(product.id, product);
  }
);

Write-Through garantiert, dass der Cache nach einer Schreiboperation immer frische Daten enthält. Der Kompromiss ist eine höhere Schreiblatenz (zwei Operationen statt einer) und das Risiko, Daten für selten gelesene Keys zu cachen.

Read-Through mit Stale-While-Revalidate

Stale-While-Revalidate liefert sofort leicht veraltete Daten und aktualisiert den Cache im Hintergrund. Damit entfällt die Cache-Miss-Latenz für Nutzer, während die Daten vernünftig aktuell bleiben.

tstypescript
interface CacheEntry<T> {
  value: T;
  cachedAt: number;
  staleAfter: number;
  expireAfter: number;
}
 
class StaleWhileRevalidateCache<T> {
  private refreshing = new Set<string>();
 
  constructor(
    private redis: Redis,
    private prefix: string,
    private freshSeconds: number = 60,
    private staleSeconds: number = 300
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cacheKey = `${this.prefix}:${key}`;
    const raw = await this.redis.get(cacheKey);
 
    if (raw) {
      const entry: CacheEntry<T> = JSON.parse(raw);
      const now = Date.now();
 
      if (now < entry.staleAfter) {
        // Fresh: return immediately
        return entry.value;
      }
 
      if (now < entry.expireAfter) {
        // Stale but usable: return immediately, refresh in background
        this.refreshInBackground(key, cacheKey, fetcher);
        return entry.value;
      }
    }
 
    // Expired or missing: fetch synchronously
    return this.fetchAndCache(key, cacheKey, fetcher);
  }
 
  private async fetchAndCache(
    key: string,
    cacheKey: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const value = await fetcher();
    const now = Date.now();
 
    const entry: CacheEntry<T> = {
      value,
      cachedAt: now,
      staleAfter: now + this.freshSeconds * 1000,
      expireAfter: now + this.staleSeconds * 1000,
    };
 
    await this.redis.set(
      cacheKey,
      JSON.stringify(entry),
      "EX",
      this.staleSeconds
    );
 
    return value;
  }
 
  private refreshInBackground(
    key: string,
    cacheKey: string,
    fetcher: () => Promise<T>
  ): void {
    if (this.refreshing.has(key)) return; // Already refreshing
    this.refreshing.add(key);
 
    this.fetchAndCache(key, cacheKey, fetcher)
      .finally(() => this.refreshing.delete(key));
  }
}

Dieses Muster eignet sich hervorragend für Daten, die sich regelmäßig ändern, aber keine Echtzeitgenauigkeit benötigen – Produktkataloge, Nutzerprofile, Konfigurationseinstellungen. Nutzer erhalten immer eine schnelle Antwort, und die Daten sind nie älter als staleSeconds.

Multi-Level-Caching

Produktionsanwendungen nutzen oft mehrere Cache-Ebenen: In-Process-Speicher, verteilter Cache (Redis) und CDN. Jede Ebene dient unterschiedlichen Zugriffsmustern.

tstypescript
class MultiLevelCache<T> {
  private l1: Map<string, { value: T; expires: number }> = new Map();
  private l1MaxSize: number;
 
  constructor(
    private redis: Redis,
    private prefix: string,
    l1MaxSize: number = 1000,
    private l1TtlMs: number = 10000,
    private l2TtlSeconds: number = 300
  ) {
    this.l1MaxSize = l1MaxSize;
  }
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    // L1: In-process memory (microseconds)
    const l1Entry = this.l1.get(key);
    if (l1Entry && l1Entry.expires > Date.now()) {
      return l1Entry.value;
    }
 
    // L2: Redis (milliseconds)
    const cacheKey = `${this.prefix}:${key}`;
    const l2Value = await this.redis.get(cacheKey);
 
    if (l2Value) {
      const parsed = JSON.parse(l2Value) as T;
      this.setL1(key, parsed);
      return parsed;
    }
 
    // L3: Database (tens of milliseconds)
    const value = await fetcher();
    this.setL1(key, value);
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.l2TtlSeconds
    );
 
    return value;
  }
 
  private setL1(key: string, value: T): void {
    // Simple eviction: remove oldest when full
    if (this.l1.size >= this.l1MaxSize) {
      const oldest = this.l1.keys().next().value;
      if (oldest !== undefined) {
        this.l1.delete(oldest);
      }
    }
 
    this.l1.set(key, {
      value,
      expires: Date.now() + this.l1TtlMs,
    });
  }
 
  async invalidate(key: string): Promise<void> {
    this.l1.delete(key);
    await this.redis.del(`${this.prefix}:${key}`);
    // Note: other application instances still have L1 cache
    // Publish invalidation event for cluster-wide L1 flush
    await this.redis.publish(
      `${this.prefix}:invalidate`,
      key
    );
  }
}

Der L1-Cache (In-Process) hat eine sehr kurze TTL, weil er nicht über Instanzen hinweg invalidiert werden kann. Der L2-Cache (Redis) hat eine längere TTL, weil er geteilt ist. Dieser geschichtete Ansatz verarbeitet Hot-Path-Lesevorgänge in Mikrosekunden und behält dabei eine vernünftige Konsistenz bei.

Cache-Fehler resilient behandeln

Cache-Fehler sollten nicht in Anwendungsfehler kaskadieren. Wenn Redis ausfällt, sollte die Anwendung auf direkte Datenbank-Lesevorgänge heruntergestuft werden, nicht abstürzen.

tstypescript
class ResilientCache<T> {
  private circuitOpen = false;
  private failureCount = 0;
  private lastFailure = 0;
 
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number,
    private failureThreshold: number = 5,
    private resetTimeMs: number = 30000
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    // Check circuit breaker
    if (this.circuitOpen) {
      if (Date.now() - this.lastFailure > this.resetTimeMs) {
        this.circuitOpen = false;
        this.failureCount = 0;
      } else {
        // Circuit open: skip cache entirely
        return fetcher();
      }
    }
 
    try {
      const cacheKey = `${this.prefix}:${key}`;
      const cached = await this.redis.get(cacheKey);
 
      if (cached) {
        this.failureCount = 0;
        return JSON.parse(cached) as T;
      }
 
      const value = await fetcher();
 
      // Best-effort cache write
      this.redis
        .set(cacheKey, JSON.stringify(value), "EX", this.ttlSeconds)
        .catch(() => this.recordFailure());
 
      return value;
    } catch {
      this.recordFailure();
      return fetcher();
    }
  }
 
  private recordFailure(): void {
    this.failureCount++;
    this.lastFailure = Date.now();
 
    if (this.failureCount >= this.failureThreshold) {
      this.circuitOpen = true;
    }
  }
}

Der Circuit Breaker verhindert, dass wiederholte Cache-Fehler jeder Request zusätzliche Latenz hinzufügen. Nach fünf Redis-Fehlern öffnet sich der Circuit, und die Anwendung geht direkt zur Datenbank, bis Redis sich erholt.

Wichtige Erkenntnisse

Die Wahl eines Caching-Musters geht nicht darum, das „beste“ auszuwählen, sondern das Muster an deine Konsistenzanforderungen anzupassen. Cache-Aside funktioniert für die meisten leseintensiven Workloads. Write-Through funktioniert, wenn Lesevorgänge immer den neuesten Schreibvorgang sehen müssen. Stale-While-Revalidate funktioniert, wenn Near-Realtime ausreicht und Latenz kritisch ist.

Die Muster, die die meisten Produktionsvorfälle verursachen, sind diejenigen, die Fehlermodi ignorieren. Implementiere immer Stampede-Schutz für Cache-Aside, behandle Cache-Fehler immer mit Circuit Breakern und gehe immer davon aus, dass Multi-Instance-Deployments eine übergreifende Invalidierung benötigen. Ein Cache, der einwandfrei performt, aber nach einem Update veraltete Daten liefert, ist schlimmer als gar kein Cache.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX