Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|19. März 202610 Min. Lesezeit2.1k Wörter|

Teil unserer Performance & Scalability-Serie

Den vollständigen Leitfaden lesen

Redis-Caching-Muster für Webanwendungen

Datenbankabfragen sind der häufigste Leistungsengpass in Webanwendungen. Eine schlecht indizierte Abfrage, die 200 ms dauert und bei jedem Seitenladevorgang für 1.000 gleichzeitige Benutzer aufgerufen wird, generiert 200 Sekunden Datenbank-CPU pro Sekunde – eine Todesspirale. Redis ist das Gegenmittel: ein In-Memory-Speicher von weniger als einer Millisekunde, der die Leselast absorbiert, die Ihre Datenbank andernfalls wiederholt für identische Daten treffen würde.

Beim Caching geht es aber nicht nur darum, „Dinge in Redis zu legen“. Das falsche Muster führt zu veralteten Datenfehlern, donnernden Herdenkatastrophen oder grenzenlosem Speicherwachstum. Dieser Leitfaden behandelt die vier kanonischen Caching-Muster, Cache-Invalidierungsstrategien, Stampede-Prävention und die NestJS-Implementierung in der Produktion, damit Sie von Anfang an richtig zwischenspeichern können.

Wichtige Erkenntnisse

  • Cache-aside (verzögertes Laden) ist die sicherste Standardeinstellung – nur das zwischenspeichern, was tatsächlich angefordert wird
  • Durchschreiben hält Cache und Datenbank synchron, erhöht jedoch die Schreiblatenz – wird für häufig gelesene und selten geschriebene Daten verwendet – Cache-Stampede (donnernde Herde) zerstört Datenbanken, wenn ein beliebter Schlüssel abläuft – verwenden Sie probabilistische frühe Ablauf- oder Mutex-Sperren – TTL muss für jeden Schlüssel festgelegt werden – unbegrenztes Redis-Wachstum ist ein Vorfall, der nur darauf wartet, passiert zu werden – Die Cache-Invalidierung ist schwierig: Bevorzugen Sie kurze TTLs und ereignisgesteuerte Invalidierung gegenüber dem Versuch, eine perfekte Invalidierung durchzuführen – Cache-Schlüssel müssen alle Abfragedimensionen enthalten: contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir} – Verwenden Sie Redis-Namespaces und Schlüsselmuster, um die Masseninvalidierung zu ermöglichen
  • Cache-Trefferrate überwachen; unter 80 % bedeutet, dass Ihre TTLs zu kurz sind oder Ihre Schlüsselstruktur falsch ist

Redis-Setup in NestJS

pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) return null;
    return JSON.parse(value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  // Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
  async invalidatePattern(pattern: string): Promise<void> {
    let cursor = '0';
    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100
      );
      cursor = nextCursor;
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } while (cursor !== '0');
  }

  async remember<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>
  ): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) return cached;

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';

@Global()
@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'single',
        options: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get('REDIS_PASSWORD'),
          db: config.get<number>('REDIS_DB', 0),
          keyPrefix: 'ecosire:',
        },
      }),
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class AppCacheModule {}

Muster 1: Cache-Aside (Lazy Loading)

Das häufigste Muster. Aus Cache lesen; Bei Fehlschlag aus der Datenbank lesen und Cache füllen.

// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
  const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;

  return this.cache.remember(cacheKey, 300, async () => {
    const results = await this.db.query.contacts.findMany({
      where: and(
        eq(contacts.organizationId, orgId),
        search
          ? or(
              ilike(contacts.name, `%${search}%`),
              ilike(contacts.email, `%${search}%`)
            )
          : undefined
      ),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });
    return results;
  });
}

Vorteile: Einfach; Der Cache enthält nur das, was tatsächlich angefordert wird. Nachteile: Die erste Anfrage ist immer langsam (Cache-Fehler); Daten können veraltet sein, bis TTL abläuft.

Wann ungültig machen: Löschen Sie nach dem Erstellen/Aktualisieren/Löschen alle Schlüssel, die mit contacts:{orgId}:* übereinstimmen.


Muster 2: Durchschreiben

Aktualisieren Sie den Cache bei jedem Schreibvorgang gleichzeitig mit der Datenbank. Tauscht die Schreiblatenz zugunsten der Lesekonsistenz aus.

async update(orgId: string, contactId: string, dto: UpdateContactDto) {
  // 1. Update the database
  const [updated] = await this.db
    .update(contacts)
    .set({ ...dto, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, contactId),
        eq(contacts.organizationId, orgId)
      )
    )
    .returning();

  // 2. Update the individual contact cache immediately (write-through)
  const singleKey = `contact:${orgId}:${contactId}`;
  await this.cache.set(singleKey, updated, 3600);

  // 3. Invalidate list caches (they now contain stale order/counts)
  await this.cache.invalidatePattern(`contacts:${orgId}:*`);

  return updated;
}

Verwendungszweck: Häufig gelesene, selten geschriebene Datensätze (Benutzerprofile, Einstellungen, Produktkatalog).


Muster 3: Verhinderung von Cache-Stampedes

Wenn ein beliebter Cache-Schlüssel abläuft, verfehlen Hunderte gleichzeitiger Anfragen gleichzeitig den Cache und treffen alle auf die Datenbank – die „donnernde Herde“. Die Datenbank bricht unter der plötzlichen Belastung zusammen.

Mutex-Sperrmuster

Nur eine Anfrage baut den Cache neu auf; andere warten und versuchen es erneut:

// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>
): Promise<T> {
  // Try cache first
  const cached = await this.get<T>(key);
  if (cached !== null) return cached;

  const lockKey = `lock:${key}`;
  const lockOwner = `${process.pid}-${Date.now()}`;

  // Try to acquire lock: NX = only set if not exists, EX = expire in 30s
  const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);

  if (acquired === 'OK') {
    try {
      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      // Atomic check-and-delete: only release if we still own the lock
      // Uses Lua defined with defineCommand for atomicity
      const currentOwner = await this.redis.get(lockKey);
      if (currentOwner === lockOwner) {
        await this.redis.del(lockKey);
      }
    }
  } else {
    // Another process is rebuilding — wait 100ms and retry
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, 100);
      // Store timer reference so Node.js does not block on it
      timer.unref?.();
    });
    return this.getWithLock(key, ttlSeconds, factory);
  }
}

Probabilistischer früher Ablauf (XFetch)

Beginnen Sie mit der Neuberechnung des Caches, bevor er abläuft, und verteilen Sie so die Neuberechnungslast:

async getWithEarlyExpiration<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>,
  beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
  const metaKey = `${key}:meta`;
  const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
  const value = await this.get<T>(key);

  if (value !== null && meta) {
    const elapsed = Date.now() / 1000 - meta.createdAt;
    const remaining = meta.ttl - elapsed;

    // XFetch: trigger early recomputation probabilistically
    const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
    if (!shouldRecompute) return value;
  }

  const newValue = await factory();
  const now = Date.now() / 1000;
  await this.set(key, newValue, ttlSeconds);
  await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);

  return newValue;
}

Muster 4: Tag-basierte Cache-Ungültigmachung

Gruppieren Sie Cache-Schlüssel nach logischen Tags und machen Sie ein ganzes Tag auf einmal ungültig:

// Tag-based invalidation using Redis sets
async setWithTags(
  key: string,
  value: unknown,
  ttlSeconds: number,
  tags: string[]
): Promise<void> {
  await this.set(key, value, ttlSeconds);

  // Add key to each tag's member set using a pipeline
  const pipeline = this.redis.pipeline();
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
  }
  await pipeline.exec();
}

async invalidateByTag(tag: string): Promise<void> {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = this.redis.pipeline();
    for (const cacheKey of keys) {
      pipeline.del(cacheKey);
    }
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

// Usage
await this.setWithTags(
  `contacts:${orgId}:page:1`,
  contacts,
  300,
  [`org:${orgId}`, 'contacts']
);

// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);

Sitzungs- und Authentifizierungs-Caching

Redis ist ideal für die Speicherung von Authentifizierungssitzungen – eliminieren Sie Datenbanksuchen bei jeder Anfrage:

// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
  const key = `session:${userId}`;
  await this.redis.hset(key,
    'id',             session.id,
    'email',          session.email,
    'role',           session.role,
    'organizationId', session.organizationId,
    'tokenVersion',   String(session.tokenVersion)
  );
  await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}

async getUserSession(userId: string): Promise<UserSession | null> {
  const data = await this.redis.hgetall(`session:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
  return {
    ...data,
    tokenVersion: parseInt(data.tokenVersion, 10),
  } as UserSession;
}

async invalidateUserSession(userId: string): Promise<void> {
  await this.redis.del(`session:${userId}`);
}

TTL-Strategie nach Datentyp

DatentypEmpfohlene TTLInvalidierungsstrategie
Benutzerprofil15 MinutenBei der Profilaktualisierung
Organisationseinstellungen1 StundeBei Einstellungsänderung
Produktkatalog24 StundenBeim Produkt erstellen/aktualisieren
Liste der Blogbeiträge10 MinutenAuf Post veröffentlichen
API-RatenbegrenzungszählerPro Fenster (60s)Automatischer Ablauf
Authentifizierungssitzung15 MinutenBeim Abmelden oder Token-Widerruf
Suchergebnisse5 MinutenNur TTL
Aggregierte Metriken1 MinuteNur TTL
Einmalige Codes (Authentifizierung)60 SekundenNach Gebrauch oder Ablauf
Abfrage einer paginierten Liste5 MinutenBei jeder Mutation in org

Cache-Leistung überwachen

// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
  const value = await this.redis.get(key);
  if (value) {
    await this.redis.incr('metrics:cache_hits');
    return JSON.parse(value) as T;
  }
  await this.redis.incr('metrics:cache_misses');
  return null;
}

// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
  const [hits, misses] = await this.redis.mget(
    'metrics:cache_hits',
    'metrics:cache_misses'
  );
  const h = parseInt(hits ?? '0', 10);
  const m = parseInt(misses ?? '0', 10);
  const total = h + m;
  const hitRate = total === 0 ? 1 : h / total;

  if (hitRate < 0.8) {
    this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
  }

  // Reset counters
  await this.redis.set('metrics:cache_hits', '0');
  await this.redis.set('metrics:cache_misses', '0');
}

Wichtige Redis-Metriken zur Überwachung über redis-cli INFO stats:

  • keyspace_hits / keyspace_misses – globale Trefferquote (Ziel über 80 %)
  • used_memory – achten Sie auf grenzenloses Wachstum
  • evicted_keys – Schlüssel, die durch die Maxmemory-Richtlinie entfernt werden (sollten für Cache-Aside nahe 0 liegen)
  • connected_clients – Auslastung des Verbindungspools
  • instantaneous_ops_per_sec – aktueller Durchsatz

Häufig gestellte Fragen

Welche Maxmemory-Richtlinie sollte ich für einen Cache verwenden?

Verwenden Sie allkeys-lru (zuletzt verwendete Schlüssel von allen Schlüsseln entfernen) oder volatile-lru (LRU-Schlüssel entfernen, für die eine TTL festgelegt ist). Für eine reine Cache-Arbeitslast ist allkeys-lru Standard – Redis entfernt automatisch kalte Schlüssel, wenn der Speicher voll ist. Verwenden Sie niemals noeviction für den Cache – es gibt Fehler zurück, wenn der Speicher voll ist, anstatt alte Daten zu entfernen.

Wie vermeide ich die Speicherung sensibler Daten in Redis?

Speichern Sie niemals unbearbeitete Passwörter, private Schlüssel, Zahlungskartendaten oder SSNs zwischen. Für Authentifizierungssitzungen werden nur minimale Daten zwischengespeichert: Benutzer-ID, Rolle, Organisations-ID, tokenVersion – keine vollständigen JWT-Nutzlasten oder API-Anmeldeinformationen. Aktivieren Sie Redis AUTH und TLS in der Produktion. Wenn Ihre Redis-Instanz kompromittiert ist, werden nur Metadaten offengelegt, wenn Sie diese Disziplin befolgen.

Was ist der Unterschied zwischen SCAN und KEYS zum Löschen von Mustern?

KEYS pattern ist eine blockierende O(n)-Operation, die alle Redis-Befehle während des Scannens anhält – sie kann bei großen Schlüsselbereichen zu Ausfallzeiten von Sekunden führen. SCAN ist nicht blockierend und iteriert in kleinen Abschnitten mit einem Cursor. Verwenden Sie immer SCAN zum Löschen von Produktionsmustern. Der Nachteil besteht darin, dass SCAN möglicherweise nicht alle Schlüssel zurückgibt, wenn sie während des Scans hinzugefügt oder gelöscht werden – akzeptabel für die Cache-Ungültigmachung.

Sollte ich eine separate Redis-Instanz für Caching vs. Ratenbegrenzung vs. Sitzungen verwenden?

Für die meisten Anwendungen reicht eine Redis-Instanz mit unterschiedlichen Schlüsselpräfixen aus. Separate Instanzen sind sinnvoll, wenn: der Cache einen anderen maxmemory-policy benötigt als der Ratenlimitspeicher (Cache: allkeys-lru, Ratenlimits: noeviction) oder wenn Sie unabhängige Skalierung und Failover benötigen. Verwenden Sie im großen Maßstab Redis Cluster mit separaten Clustern pro Workload-Typ.

Wie gehe ich mit der Cache-Ungültigmachung für paginierte Listenabfragen um?

Paginierte Listen-Caches sind knifflig – das Hinzufügen eines Kontakts auf Seite 1 verschiebt alle Kontakte auf Seite 2+. Die pragmatische Lösung: Verwenden Sie kurze TTLs (2–5 Minuten) und entwerten Sie alle Seiten für die Organisation bei jedem Schreibvorgang mithilfe der Musterinvalidierung (contacts:{orgId}:*). Überspringen Sie bei großen Organisationen mit hohem Schreibvolumen die Caching-Paginierung vollständig und verlassen Sie sich stattdessen auf die Optimierung auf Datenbankebene (richtige Indizes, abdeckende Indizes).

Wie teste ich das Cache-Verhalten in Vitest?

Verspotten Sie den CacheService in Unit-Tests, wobei vi.fn() geeignete Werte für Hit/Miss-Szenarien zurückgibt. Verwenden Sie für Integrationstests eine echte Redis-Instanz (Docker) und verwenden Sie redis.flushdb() in beforeEach, um sauber zu starten. Testen Sie explizit sowohl den Cache-Treffer-Pfad (kein Datenbankaufruf) als auch den Cache-Fehltreffer-Pfad (Datenbank aufgerufen + Cache gefüllt).


Nächste Schritte

Redis-Caching ist eine der Performance-Investitionen mit dem höchsten ROI in eine Webanwendung. Der Unterschied zwischen einer 200-ms-Datenbankabfrage und einem 2-ms-Redis-Treffer beträgt das 100-fache – im großen Maßstab führt dies direkt zu Kosteneinsparungen bei der Infrastruktur und einer Verbesserung des Benutzererlebnisses.

ECOSIRE implementiert Redis-Caching mit Cache-Aside, Write-Through-Invalidierung, Stampede-Prävention und Cache-Trefferratenüberwachung für jedes NestJS-Projekt. [Entdecken Sie unsere Backend-Engineering-Services] (/services), um zu erfahren, wie wir leistungsstarke, skalierbare APIs entwerfen.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp