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 Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.
Verwandte Artikel
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Mehr aus Performance & Scalability
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.