Teil unserer Performance & Scalability-Serie
Den vollständigen Leitfaden lesenRedis-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
| Datentyp | Empfohlene TTL | Invalidierungsstrategie |
|---|---|---|
| Benutzerprofil | 15 Minuten | Bei der Profilaktualisierung |
| Organisationseinstellungen | 1 Stunde | Bei Einstellungsänderung |
| Produktkatalog | 24 Stunden | Beim Produkt erstellen/aktualisieren |
| Liste der Blogbeiträge | 10 Minuten | Auf Post veröffentlichen |
| API-Ratenbegrenzungszähler | Pro Fenster (60s) | Automatischer Ablauf |
| Authentifizierungssitzung | 15 Minuten | Beim Abmelden oder Token-Widerruf |
| Suchergebnisse | 5 Minuten | Nur TTL |
| Aggregierte Metriken | 1 Minute | Nur TTL |
| Einmalige Codes (Authentifizierung) | 60 Sekunden | Nach Gebrauch oder Ablauf |
| Abfrage einer paginierten Liste | 5 Minuten | Bei 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 Wachstumevicted_keys– Schlüssel, die durch die Maxmemory-Richtlinie entfernt werden (sollten für Cache-Aside nahe 0 liegen)connected_clients– Auslastung des Verbindungspoolsinstantaneous_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.
Geschrieben von
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Erweitern Sie Ihr Geschäft mit ECOSIRE
Unternehmenslösungen in den Bereichen ERP, E-Commerce, KI, Analyse und Automatisierung.
Verwandte Artikel
API-Ratenbegrenzung: Muster und Best Practices
Master-API-Ratenbegrenzung mit Token-Bucket, Schiebefenster und festen Zählermustern. Schützen Sie Ihr Backend mit NestJS Throttler, Redis und realen Konfigurationsbeispielen.
k6-Lasttest: Führen Sie vor dem Start einen Stresstest für Ihre APIs durch
Master-K6-Lasttests für Node.js-APIs. Behandelt das Hochfahren virtueller Benutzer, Schwellenwerte, Szenarien, HTTP/2, WebSocket-Tests, Grafana-Dashboards und CI-Integrationsmuster.
NestJS 11 Enterprise API-Muster
Beherrschen Sie NestJS 11-Unternehmensmuster: Guards, Interceptors, Pipes, Mandantenfähigkeit und produktionsbereites API-Design für skalierbare Backend-Systeme.
Mehr aus Performance & Scalability
Webhook-Debugging und -Überwachung: Der vollständige Leitfaden zur Fehlerbehebung
Beherrschen Sie das Webhook-Debugging mit diesem vollständigen Leitfaden, der Fehlermuster, Debugging-Tools, Wiederholungsstrategien, Überwachungs-Dashboards und Best Practices für die Sicherheit abdeckt.
k6-Lasttest: Führen Sie vor dem Start einen Stresstest für Ihre APIs durch
Master-K6-Lasttests für Node.js-APIs. Behandelt das Hochfahren virtueller Benutzer, Schwellenwerte, Szenarien, HTTP/2, WebSocket-Tests, Grafana-Dashboards und CI-Integrationsmuster.
Nginx-Produktionskonfiguration: SSL, Caching und Sicherheit
Nginx-Produktionskonfigurationsleitfaden: SSL-Terminierung, HTTP/2, Caching-Header, Sicherheits-Header, Ratenbegrenzung, Reverse-Proxy-Einrichtung und Cloudflare-Integrationsmuster.
Odoo Performance Tuning: PostgreSQL und Serveroptimierung
Expertenleitfaden zur Leistungsoptimierung von Odoo 19. Behandelt PostgreSQL-Konfiguration, Indizierung, Abfrageoptimierung, Nginx-Caching und Serverdimensionierung für Unternehmensbereitstellungen.
Odoo vs Acumatica: Cloud ERP für wachsende Unternehmen
Odoo vs. Acumatica im Vergleich für 2026: einzigartige Preismodelle, Skalierbarkeit, Fertigungstiefe und welches Cloud-ERP zu Ihrem Wachstumskurs passt.
Testen und Überwachen von KI-Agenten in der Produktion
Eine vollständige Anleitung zum Testen und Überwachen von KI-Agenten in Produktionsumgebungen. Behandelt Bewertungsrahmen, Beobachtbarkeit, Abweichungserkennung und Reaktion auf Vorfälle für OpenClaw-Bereitstellungen.