Redis-Caching-Strategien, die in der Produktion tatsächlich funktionieren
Cache-Aside, Write-Through, Cache-Stampede-Prävention, TTL-Strategien und Invalidierungs-Patterns. Die Redis-Patterns, die ich in Produktions-Node.js-Apps mit echten Code-Beispielen einsetze.
Jeder sagt dir „füg einfach Redis hinzu", wenn deine API langsam ist. Niemand sagt dir, was sechs Monate später passiert, wenn dein Cache veraltete Daten liefert, deine Invalidierungslogik über 40 Dateien verstreut ist und ein Deploy eine Cache-Stampede auslöst, die deine Datenbank härter trifft, als wenn du nie gecacht hättest.
Ich betreibe Redis seit Jahren in der Produktion. Nicht als Spielzeug, nicht in einem Tutorial — in Systemen, die echten Traffic verarbeiten, wo falsches Caching Pager-Alarme um 3 Uhr morgens bedeutet. Was folgt, ist alles, was ich gelernt habe, wie man es richtig macht.
Warum Caching?#
Fangen wir mit dem Offensichtlichen an: Datenbanken sind langsam im Vergleich zum Arbeitsspeicher. Eine PostgreSQL-Abfrage, die 15ms dauert, ist schnell nach Datenbank-Maßstäben. Aber wenn diese Abfrage bei jedem einzelnen API-Request läuft und du 1.000 Requests pro Sekunde verarbeitest, sind das 15.000ms kumulative Datenbank-Zeit pro Sekunde. Dein Connection Pool ist erschöpft. Deine p99-Latenz geht durch die Decke. Benutzer starren auf Spinner.
Redis liefert die meisten Reads in unter 1ms. Dieselben Daten, gecacht, verwandeln eine 15ms-Operation in eine 0,3ms-Operation. Das ist keine Mikro-Optimierung. Das ist der Unterschied zwischen 4 Datenbank-Replicas brauchen und null brauchen.
Aber Caching ist nicht umsonst. Es fügt Komplexität hinzu, führt Konsistenzprobleme ein und schafft eine ganz neue Klasse von Fehlermodi. Bevor du irgendetwas cachst, frag dich:
Wann Caching hilft:
- Daten werden weit öfter gelesen als geschrieben (10:1-Verhältnis oder höher)
- Die zugrunde liegende Abfrage ist teuer (Joins, Aggregationen, externe API-Aufrufe)
- Leichte Veralterung ist akzeptabel (Produktkatalog, Benutzerprofile, Konfiguration)
- Du hast vorhersagbare Zugriffsmuster (dieselben Keys werden wiederholt abgefragt)
Wann Caching schadet:
- Daten ändern sich ständig und müssen frisch sein (Echtzeit-Aktienkurse, Live-Ergebnisse)
- Jeder Request ist einzigartig (Suchanfragen mit vielen Parametern)
- Dein Datensatz ist winzig (wenn das Ganze in den App-Speicher passt, überspringe Redis)
- Du hast nicht die operative Reife, Cache-Probleme zu überwachen und zu debuggen
Phil Karlton sagte bekanntlich, es gibt nur zwei schwere Dinge in der Informatik: Cache-Invalidierung und Namensgebung. Er hatte bei beidem recht, aber Cache-Invalidierung ist das, was dich nachts aufweckt.
ioredis einrichten#
Bevor wir in die Patterns eintauchen, richten wir die Verbindung ein. Ich verwende überall ioredis — es ist der ausgereifteste Redis-Client für Node.js, mit ordentlichem TypeScript-Support, Cluster-Modus, Sentinel-Support und Lua-Scripting.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;Ein paar Dinge sind erwähnenswert. lazyConnect: true bedeutet, die Verbindung wird erst aufgebaut, wenn du tatsächlich einen Befehl ausführst, was nützlich beim Testen und bei der Initialisierung ist. retryStrategy implementiert exponentielles Backoff mit einer Obergrenze von 5 Sekunden — ohne das spammt ein Redis-Ausfall Reconnect-Versuche. Und maxRetriesPerRequest: 3 stellt sicher, dass einzelne Befehle schnell fehlschlagen statt endlos zu hängen.
Cache-Aside-Pattern#
Das ist das Pattern, das du in 80% der Fälle verwenden wirst. Es wird auch „Lazy Loading" oder „Look-Aside" genannt. Der Ablauf ist einfach:
- Anwendung empfängt einen Request
- Redis nach dem gecachten Wert prüfen
- Falls gefunden (Cache Hit), zurückgeben
- Falls nicht gefunden (Cache Miss), Datenbank abfragen
- Ergebnis in Redis speichern
- Ergebnis zurückgeben
Hier ist eine typisierte Implementierung:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Schritt 1: Aus Cache lesen
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Beschädigter Cache-Eintrag, löschen und durchfallen
await redis.del(cacheKey);
}
}
// Schritt 2: Cache Miss — von der Quelle holen
const result = await fetcher();
// Schritt 3: Im Cache speichern (nicht awaiten — Fire and Forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}Die Verwendung sieht so aus:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 Minuten
);
}Beachte, dass ich den redis.set-Aufruf per Fire-and-Forget mache. Das ist Absicht. Wenn Redis down oder langsam ist, wird der Request trotzdem abgeschlossen. Der Cache ist eine Optimierung, keine Anforderung. Wenn das Schreiben in den Cache fehlschlägt, trifft der nächste Request einfach wieder die Datenbank. Kein Drama.
Es gibt einen subtilen Bug in vielen Cache-Aside-Implementierungen, den Leute übersehen: Null-Werte cachen. Wenn ein Benutzer nicht existiert und du das nicht cachst, trifft jeder Request für diesen Benutzer die Datenbank. Ein Angreifer kann das ausnutzen, indem er zufällige User-IDs anfragt und deinen Cache zum No-Op macht. Cache immer auch das negative Ergebnis — nur mit kürzerer TTL.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Kürzere TTL für Null-Ergebnisse, um Speicherverbrauch zu begrenzen,
// aber lang genug, um wiederholte Misses abzufangen
ttl: row ? 1800 : 300,
}
);
}Eigentlich lass mich das umstrukturieren, damit die dynamische TTL richtig funktioniert:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Existierende Ergebnisse 30 Min cachen, Null-Ergebnisse 5 Min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through und Write-Behind#
Cache-Aside funktioniert gut für lesintensive Workloads, hat aber ein Konsistenzproblem: Wenn ein anderer Dienst oder Prozess die Datenbank direkt aktualisiert, ist dein Cache veraltet, bis die TTL abläuft. Hier kommen Write-Through- und Write-Behind-Patterns ins Spiel.
Write-Through#
Bei Write-Through geht jeder Schreibvorgang durch die Cache-Schicht. Der Cache wird zuerst aktualisiert, dann die Datenbank. Das garantiert, dass der Cache immer konsistent mit der Datenbank ist (vorausgesetzt, Schreibvorgänge gehen immer durch deine Anwendung).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Schritt 1: Datenbank aktualisieren
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Schritt 2: Cache sofort aktualisieren
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}Der wesentliche Unterschied zu Cache-Aside: Wir schreiben bei jedem Schreibvorgang in den Cache, nicht nur bei Reads. Das bedeutet, der Cache ist immer warm für kürzlich aktualisierte Daten.
Der Kompromiss: Die Schreib-Latenz steigt, weil jeder Schreibvorgang jetzt sowohl die Datenbank als auch Redis berührt. Wenn Redis langsam ist, sind deine Writes langsam. In den meisten Anwendungen überwiegen Reads die Writes bei weitem, also ist dieser Kompromiss es wert.
Write-Behind (Write-Back)#
Write-Behind dreht das Ganze um: Writes gehen zuerst an Redis, und die Datenbank wird asynchron aktualisiert. Das gibt dir extrem schnelle Writes auf Kosten von potenziellem Datenverlust, wenn Redis ausfällt, bevor die Daten persistiert wurden.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Aktuellen Zustand lesen
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Cache sofort aktualisieren
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Datenbank-Write für asynchrone Verarbeitung in die Queue
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}Du brauchst dann einen separaten Worker, der diese Queue abarbeitet:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Bei Fehler erneut in die Queue mit Retry-Count
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}Ich verwende Write-Behind in der Praxis selten. Das Datenverlust-Risiko ist real — wenn Redis abstürzt, bevor der Worker die Queue verarbeitet hat, sind diese Writes weg. Verwende das nur für Daten, bei denen Eventual Consistency wirklich akzeptabel ist, wie Aufrufzähler, Analytics-Events oder unkritische Benutzereinstellungen.
TTL-Strategie#
TTL richtig hinzubekommen ist nuancierter als es aussieht. Eine feste 1-Stunden-TTL auf alles ist einfach zu implementieren und fast immer falsch.
Daten-Volatilitätsstufen#
Ich kategorisiere Daten in drei Stufen und weise entsprechend TTLs zu:
const TTL = {
// Stufe 1: Ändert sich selten, teuer zu berechnen
// Beispiele: Produktkatalog, Seitenkonfiguration, Feature Flags
STATIC: 86400, // 24 Stunden
// Stufe 2: Ändert sich gelegentlich, moderate Kosten
// Beispiele: Benutzerprofile, Team-Einstellungen, Berechtigungen
MODERATE: 1800, // 30 Minuten
// Stufe 3: Ändert sich häufig, günstig zu berechnen aber oft aufgerufen
// Beispiele: Feed-Daten, Benachrichtigungszähler, Session-Info
VOLATILE: 300, // 5 Minuten
// Stufe 4: Kurzlebig, verwendet für Rate Limiting und Locks
EPHEMERAL: 60, // 1 Minute
// Null-Ergebnisse: immer kurzlebig
NOT_FOUND: 120, // 2 Minuten
} as const;TTL-Jitter: Den Thundering Herd verhindern#
Hier ein Szenario, das mich gebissen hat: Du deployst deine App, der Cache ist leer, und 10.000 Requests cachen alle dieselben Daten mit einer 1-Stunden-TTL. Eine Stunde später verfallen alle 10.000 Keys gleichzeitig. Alle 10.000 Requests treffen die Datenbank auf einmal. Die Datenbank geht in die Knie. Ich habe gesehen, wie das eine Produktions-Postgres-Instanz lahmgelegt hat.
Der Fix ist Jitter — Zufälligkeit zu TTL-Werten hinzufügen:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Statt: redis.set(key, value, "EX", 3600)
// Verwende: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = Zufallswert zwischen 3240 und 3960Das verteilt das Verfallen über ein Fenster, sodass statt 10.000 Keys, die in derselben Sekunde verfallen, sie über ein 12-Minuten-Fenster verfallen. Die Datenbank sieht einen allmählichen Anstieg des Traffics, keine Klippe.
Für kritische Pfade gehe ich weiter und verwende 20% Jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 SekundenSliding Expiry#
Für Session-ähnliche Daten, bei denen die TTL bei jedem Zugriff zurückgesetzt werden soll, verwende GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX holt den Wert atomar UND setzt die TTL zurück
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}Falls du auf einer älteren Redis-Version bist, verwende eine Pipeline:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
TTL-Jitter hilft bei Massenablauf, löst aber nicht die Einzel-Key-Stampede: wenn ein populärer Key abläuft und hunderte gleichzeitige Requests alle versuchen, ihn gleichzeitig zu regenerieren.
Stell dir vor, du cachst deinen Homepage-Feed mit einer 5-Minuten-TTL. Er läuft ab. Fünfzig gleichzeitige Requests sehen den Cache Miss. Alle fünfzig treffen die Datenbank mit derselben teuren Abfrage. Du hast dich effektiv selbst DDoS'd.
Lösung 1: Mutex Lock#
Nur ein Request regeneriert den Cache. Alle anderen warten.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Erst Cache prüfen
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Versuche Lock zu erwerben (NX = nur wenn nicht existiert, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// Wir haben den Lock — holen und cachen
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Lock freigeben
await redis.del(lockKey);
}
}
// Ein anderer Request hält den Lock — warten und retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Immer noch kein Cache — zur Datenbank durchfallen
// (behandelt den Fall, dass der Lock-Halter fehlgeschlagen ist)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Es gibt eine subtile Race Condition in der Lock-Freigabe oben. Wenn der Lock-Halter länger als 10 Sekunden braucht (die Lock-TTL), erwirbt ein anderer Request den Lock, und dann löscht der erste Request den Lock des zweiten Requests. Der richtige Fix ist ein einzigartiger Token:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua-Script stellt atomisches Check-and-Delete sicher
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}Das ist im Wesentlichen ein vereinfachter Redlock. Für Single-Instance-Redis reicht es. Für Redis Cluster oder Sentinel-Setups schau dir den vollständigen Redlock-Algorithmus an — aber ehrlich gesagt, für Cache-Stampede-Prävention funktioniert diese einfache Version bestens.
Lösung 2: Probabilistische frühe Erneuerung#
Das ist mein Lieblingsansatz. Statt zu warten, bis der Key abläuft, regeneriere ihn zufällig kurz vor dem Ablauf. Die Idee stammt aus einem Paper von Vattani, Chierichetti und Lowenstein.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch-Algorithmus: probabilistisch regenerieren, wenn der Ablauf näher rückt
// beta * Math.log(Math.random()) produziert eine negative Zahl,
// die größer (negativer) wird, wenn der Ablauf näher rückt
const beta = 1; // Tuning-Parameter, 1 funktioniert gut
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Zur Regenerierung durchfallen
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Mit extra Puffer setzen, damit Redis nicht abläuft, bevor wir regenerieren können
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}Die Schönheit dieses Ansatzes: Je kürzer die verbleibende TTL des Keys, desto höher die Wahrscheinlichkeit der Regenerierung. Bei 1.000 gleichzeitigen Requests werden vielleicht ein oder zwei die Regenerierung auslösen, während der Rest weiterhin gecachte Daten liefert. Keine Locks, keine Koordination, kein Warten.
Lösung 3: Stale-While-Revalidate#
Den veralteten Wert liefern, während im Hintergrund regeneriert wird. Das gibt die beste Latenz, weil kein Request jemals auf den Fetcher wartet.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // wie lange die Daten "frisch" sind
staleTtl: number; // wie lange veraltete Daten geliefert werden können
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Daten sind veraltet — liefern, aber Hintergrund-Refresh auslösen
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Kompletter Cache Miss — muss synchron fetchen
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Lock verwenden, um mehrfache Hintergrund-Refreshes zu verhindern
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Verwendung:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 Minuten frisch
staleTtl: 3600, // bis zu 1 Stunde veraltete Daten liefern, während revalidiert wird
});Ich verwende dieses Pattern für alles, was benutzerseitig ist und wo Latenz wichtiger ist als absolute Frische. Dashboard-Daten, Profilseiten, Produktlisten — alles perfekte Kandidaten.
Cache-Invalidierung#
Phil Karlton hat nicht gescherzt. Invalidierung ist der Punkt, an dem Caching von „einfacher Optimierung" zu „Distributed-Systems-Problem" wird.
Einfache Key-basierte Invalidierung#
Der einfachste Fall: Wenn du einen Benutzer aktualisierst, lösche seinen Cache-Key.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Cache invalidieren
await redis.del(`cache:user:${userId}`);
return user[0];
}Das funktioniert, bis die Benutzerdaten in anderen gecachten Ergebnissen auftauchen. Vielleicht sind sie in einer Team-Mitglieder-Liste eingebettet. Vielleicht in einem Suchergebnis. Vielleicht in 14 verschiedenen gecachten API-Responses. Jetzt musst du tracken, welche Cache-Keys welche Entitäten enthalten.
Tag-basierte Invalidierung#
Tagge deine Cache-Einträge mit den Entitäten, die sie enthalten, dann invalidiere nach Tag.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Wert speichern
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Den Key zu jedem Tag-Set hinzufügen
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag-Sets leben länger als Werte
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Verwendung:
// Beim Cachen von Team-Daten mit allen Member-IDs taggen
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// Wenn User 42 sein Profil aktualisiert, alles invalidieren, das ihn enthält
await invalidateByTag("entity:user:42");Event-gesteuerte Invalidierung#
Für größere Systeme verwende Redis Pub/Sub, um Invalidierungsereignisse zu verbreiten:
// Publisher (in deinem API-Service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in jeder App-Instanz)
const subscriber = new Redis(/* gleiche Konfiguration */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});Das ist kritisch bei Multi-Instanz-Deployments. Wenn du 4 App-Server hinter einem Load Balancer hast, muss eine Invalidierung auf Server 1 an alle Server weitergegeben werden. Pub/Sub erledigt das automatisch.
Pattern-basierte Invalidierung (vorsichtig)#
Manchmal musst du alle Keys invalidieren, die einem Pattern entsprechen. Verwende niemals KEYS in der Produktion. Es blockiert den Redis-Server beim Scannen des gesamten Keyspace. Bei Millionen von Keys kann das sekundenlang blockieren — eine Ewigkeit in Redis-Begriffen.
Verwende stattdessen SCAN:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Alle gecachten Daten für ein bestimmtes Team invalidieren
await invalidateByPattern("cache:team:42:*");SCAN iteriert inkrementell — es blockiert den Server nie. Der COUNT-Hint schlägt vor, wie viele Keys pro Iteration zurückgegeben werden sollen (es ist ein Hint, keine Garantie). Für große Keyspaces ist das der einzig sichere Ansatz.
Allerdings ist Pattern-basierte Invalidierung ein Code Smell. Wenn du dich häufig beim Scannen ertappst, redesigne deine Key-Struktur oder verwende Tags. SCAN ist O(N) über den Keyspace und ist für Wartungsoperationen gedacht, nicht für Hot Paths.
Datenstrukturen jenseits von Strings#
Die meisten Entwickler behandeln Redis als Key-Value-Store für JSON-Strings. Das ist, als würdest du ein Schweizer Taschenmesser kaufen und nur den Flaschenöffner benutzen. Redis hat reichhaltige Datenstrukturen, und die richtige Wahl kann ganze Kategorien von Komplexität eliminieren.
Hashes für Objekte#
Statt ein ganzes Objekt als JSON zu serialisieren, speichere es als Redis Hash. Das erlaubt dir, einzelne Felder zu lesen und zu aktualisieren, ohne das Ganze zu deserialisieren.
// User als Hash speichern
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Bestimmte Felder lesen
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Einzelnes Feld aktualisieren
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Ganzen Hash als Objekt lesen
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Hashes sind speichereffizient für kleine Objekte (Redis verwendet eine kompakte Ziplist-Kodierung unter der Haube) und vermeiden den Serialisierungs-/Deserialisierungs-Overhead. Der Kompromiss: Du verlierst die Möglichkeit, verschachtelte Objekte zu speichern, ohne sie vorher zu flachen.
Sorted Sets für Bestenlisten und Rate Limiting#
Sorted Sets sind Redis' am meisten unterschätzte Datenstruktur. Jedes Mitglied hat einen Score, und das Set ist immer nach Score sortiert. Das macht sie perfekt für Bestenlisten, Rankings und Sliding-Window-Rate-Limiting.
// Bestenliste
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indiziert zu 1-indiziert
}Für Sliding-Window-Rate-Limiting:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Einträge außerhalb des Fensters entfernen
pipeline.zremrangebyscore(key, 0, windowStart);
// Aktuellen Request hinzufügen
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Requests im Fenster zählen
pipeline.zcard(key);
// Ablauf auf dem ganzen Key setzen
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}Das ist genauer als der Fixed-Window-Counter-Ansatz und leidet nicht unter dem Grenzproblem, bei dem ein Burst am Ende eines Fensters und am Anfang des nächsten dein Rate Limit effektiv verdoppelt.
Lists für Queues#
Redis-Listen mit LPUSH/BRPOP ergeben exzellente leichtgewichtige Job-Queues:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumer (blockiert bis ein Job verfügbar ist)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}Für alles Komplexeres als einfaches Queuing (Retries, Dead Letter Queues, Prioritäten, verzögerte Jobs) verwende BullMQ, das auf Redis aufbaut, aber alle Randfälle abdeckt.
Sets für eindeutige Verfolgung#
Musst du eindeutige Besucher tracken, Events deduplizieren oder Mitgliedschaften prüfen? Sets sind O(1) für Hinzufügen, Entfernen und Mitgliedschaftsprüfungen.
// Eindeutige Besucher pro Tag tracken
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Automatisch nach 48 Stunden ablaufen
await redis.expire(key, 172800);
return isNew === 1; // 1 = neues Mitglied, 0 = existierte bereits
}
// Eindeutige Besucherzahl abrufen
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Prüfen, ob ein Benutzer bereits abgestimmt hat
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Für sehr große Sets (Millionen von Mitgliedern) erwäge stattdessen HyperLogLog. Es verbraucht nur 12KB Speicher unabhängig von der Kardinalität, auf Kosten von ~0,81% Standardfehler:
// HyperLogLog für approximative eindeutige Zählungen
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Serialisierung: JSON vs MessagePack#
JSON ist die Standardwahl für Redis-Serialisierung. Es ist lesbar, universell und gut genug für die meisten Fälle. Aber für hochdurchsatzige Systeme summiert sich der Serialisierungs-/Deserialisierungs-Overhead.
Das Problem mit JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 Bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse auf einem Hot Path: ~0,02ms pro Aufruf
// Bei 10.000 Requests/Sek: 200ms gesamte CPU-Zeit pro SekundeMessagePack-Alternative#
MessagePack ist ein binäres Serialisierungsformat, das kleiner und schneller als JSON ist:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 Bytes (25% kleiner)
const packed = pack(user);
console.log(packed.length); // ~140
// Als Buffer speichern
await redis.set("user:123", packed);
// Als Buffer lesen
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Beachte die Verwendung von getBuffer statt get — das ist entscheidend. get gibt einen String zurück und würde Binärdaten korrumpieren.
Kompression für große Werte#
Für große gecachte Werte (API-Responses mit hunderten von Elementen, gerendertes HTML) füge Kompression hinzu:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Nur komprimieren, wenn größer als 1KB (Kompressionsoverhead lohnt sich nicht für kleine Werte)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Erst versuchen zu dekomprimieren
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Nicht komprimiert, als reguläres JSON parsen
return JSON.parse(raw.toString()) as T;
}
}In meinen Tests reduziert gzip-Kompression die JSON-Payload-Größe typischerweise um 70-85%. Eine 50KB-API-Response wird zu 8KB. Das ist relevant, wenn du für Redis-Speicher bezahlst — weniger Speicher pro Key bedeutet mehr Keys in derselben Instanz.
Der Kompromiss: Kompression fügt 1-3ms CPU-Zeit pro Operation hinzu. Für die meisten Anwendungen ist das vernachlässigbar. Für Ultra-Low-Latency-Pfade überspringe es.
Meine Empfehlung#
Verwende JSON, es sei denn, Profiling zeigt, dass es ein Engpass ist. Die Lesbarkeit und Debugbarkeit von JSON in Redis (du kannst redis-cli GET key ausführen und den Wert tatsächlich lesen) überwiegt den Performancegewinn von MessagePack für 95% der Anwendungen. Füge Kompression nur für Werte größer als 1KB hinzu.
Redis in Next.js#
Next.js hat seine eigene Caching-Story (Data Cache, Full Route Cache usw.), aber Redis füllt Lücken, die das eingebaute Caching nicht abdecken kann — besonders wenn du den Cache über mehrere Instanzen teilen oder über Deployments hinweg persistieren musst.
API-Route-Responses cachen#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Cache prüfen
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Von Datenbank holen
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// 5 Minuten cachen mit Jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}Der X-Cache-Header ist beim Debugging unbezahlbar. Wenn die Latenz steigt, sagt dir ein schnelles curl -I, ob der Cache funktioniert.
Session-Speicherung#
Next.js mit Redis für Sessions schlägt JWT für zustandsbehaftete Anwendungen:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 Stunden
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// GETEX verwenden, um TTL bei jedem Zugriff zu erneuern (Sliding Expiry)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Alle Sessions eines Benutzers zerstören (nützlich für „überall abmelden")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// Das erfordert die Pflege eines User->Sessions-Index
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Rate-Limiting-Middleware#
// middleware.ts (oder ein Helper, der von Middleware verwendet wird)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua-Script für atomisches Rate Limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}Das Lua-Script ist hier wichtig. Ohne es ist die ZREMRANGEBYSCORE + ZADD + ZCARD-Sequenz nicht atomar, und bei hoher Konkurrenz könnte die Zählung ungenau sein. Lua-Scripts werden in Redis atomar ausgeführt — sie können nicht mit anderen Befehlen verschachtelt werden.
Verteilte Locks für Next.js#
Wenn du mehrere Next.js-Instanzen hast und sicherstellen musst, dass nur eine einen Task verarbeitet (wie das Versenden einer geplanten E-Mail oder das Ausführen eines Bereinigungsjobs):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Versuche Lock zu erwerben
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Lock automatisch für lang laufende Tasks verlängern
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Lock nur freigeben, wenn wir ihn noch besitzen
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Warten vor dem Retry
await new Promise((r) => setTimeout(r, retryDelay));
}
// Lock konnte nach allen Versuchen nicht erworben werden
return null;
}Verwendung:
// In einer Cron-ausgelösten API-Route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Nur eine Instanz führt das aus
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}Das Lock-Verlängerungsintervall bei ttl/3 ist wichtig. Ohne es läuft der Lock ab, wenn dein Task länger als die Lock-TTL dauert, und eine andere Instanz greift ihn. Der Extender hält den Lock am Leben, solange der Task läuft.
Monitoring und Debugging#
Redis ist schnell, bis es das nicht mehr ist. Wenn Probleme auftreten, brauchst du Sichtbarkeit.
Cache-Hit-Rate#
Die wichtigste Einzelmetrik. Tracke sie in deiner Anwendung:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Metriken täglich zurücksetzen
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Eine gesunde Cache-Hit-Rate liegt über 90%. Wenn du unter 80% bist, sind entweder deine TTLs zu kurz, deine Cache-Keys zu spezifisch, oder deine Zugriffsmuster zufälliger als gedacht.
INFO-Befehl#
Der INFO-Befehl ist Redis' eingebautes Health-Dashboard:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Wichtige Metriken zum Überwachen:
- used_memory vs maxmemory: Näherst du dich dem Limit?
- mem_fragmentation_ratio: Über 1,5 bedeutet, Redis verwendet deutlich mehr RSS als logischen Speicher. Erwäge einen Neustart.
- evicted_keys: Wenn das nicht null ist und du kein Eviction beabsichtigt hast, ist dir der Speicher ausgegangen.
redis-cli INFO statsAchte auf:
- keyspace_hits / keyspace_misses: Hit-Rate auf Serverebene
- total_commands_processed: Durchsatz
- instantaneous_ops_per_sec: Aktueller Durchsatz
MONITOR (mit äußerster Vorsicht verwenden)#
MONITOR streamt jeden auf dem Redis-Server ausgeführten Befehl in Echtzeit. Es ist unglaublich nützlich zum Debuggen und unglaublich gefährlich in der Produktion.
# NIEMALS in der Produktion laufen lassen
# Es erzeugt erheblichen Overhead und kann sensible Daten loggen
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
Ich verwende MONITOR für genau zwei Dinge: Key-Naming-Probleme während der Entwicklung debuggen und verifizieren, dass ein bestimmter Code-Pfad Redis wie erwartet trifft. Nie länger als 30 Sekunden. Nie in der Produktion, es sei denn, du hast andere Debugging-Optionen bereits ausgeschöpft.
Keyspace-Benachrichtigungen#
Willst du wissen, wann Keys ablaufen oder gelöscht werden? Redis kann Events publishen:
# Keyspace-Benachrichtigungen für abgelaufene und ausgeschlossene Events aktivieren
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* Konfiguration */);
// Auf Key-Ablauf-Events hören
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Wichtige Keys proaktiv regenerieren
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});Das ist nützlich für proaktives Cache-Warming — statt darauf zu warten, dass ein Benutzer einen Cache Miss auslöst, regenerierst du kritische Einträge in dem Moment, in dem sie ablaufen.
Speicheranalyse#
Wenn Redis-Speicher unerwartet wächst, musst du herausfinden, welche Keys am meisten verbrauchen:
# Die 10 größten Keys stichprobenartig prüfen
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
Für detailliertere Analyse:
# Speicherverbrauch eines bestimmten Keys (in Bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatische Speicheranalyse
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Nach Größe absteigend sortieren
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Eviction-Policies#
Wenn deine Redis-Instanz ein maxmemory-Limit hat (sollte sie), konfiguriere eine Eviction-Policy:
# In redis.conf oder via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruVerfügbare Policies:
- noeviction: Gibt Fehler zurück, wenn der Speicher voll ist (Standard, am schlechtesten fürs Caching)
- allkeys-lru: Am wenigsten kürzlich verwendeten Key evicten (beste Allzweck-Wahl fürs Caching)
- allkeys-lfu: Am wenigsten häufig verwendeten Key evicten (besser wenn manche Keys in Bursts zugegriffen werden)
- volatile-lru: Nur Keys mit gesetzter TTL evicten (nützlich wenn du Cache und persistente Daten mischt)
- allkeys-random: Zufälliges Eviction (überraschend ordentlich, kein Overhead)
Für reine Caching-Workloads ist allkeys-lfu normalerweise die beste Wahl. Es hält häufig zugegriffene Keys im Speicher, auch wenn sie kürzlich nicht zugegriffen wurden.
Alles zusammensetzen: Ein Produktions-Cache-Modul#
Hier ist das vollständige Cache-Modul, das ich in der Produktion verwende, und das alles kombiniert, was wir besprochen haben:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL-Stufen
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Kern-Cache-Aside mit Stampede-Schutz
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Cache prüfen
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Lock erwerben, um Stampede zu verhindern
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Ein anderer Prozess fetcht — kurz warten und Cache erneut prüfen
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Tag-Zuordnungen speichern
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidierung
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Metriken
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Verwendung in der gesamten Anwendung:
import { cache } from "@/lib/cache";
// Einfaches Cache-Aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// Mit benutzerdefinierter TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 Stunden
});
// Nach dem Aktualisieren eines Produkts
await cache.invalidateByTag("entity:products");
// Gesundheit prüfen
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Häufige Fehler, die ich gemacht habe (damit du sie nicht machen musst)#
1. Kein maxmemory gesetzt. Redis wird fröhlich den gesamten verfügbaren Speicher verbrauchen, bis das OS es killt. Setze immer ein Limit.
2. KEYS in der Produktion verwendet. Es blockiert den Server. Verwende SCAN. Das habe ich gelernt, als ein KEYS *-Aufruf von einem Monitoring-Script 3 Sekunden Downtime verursachte.
3. Zu aggressiv gecacht. Nicht alles muss gecacht werden. Wenn deine Datenbankabfrage 2ms dauert und 10 Mal pro Minute aufgerufen wird, fügt Caching Komplexität für vernachlässigbaren Nutzen hinzu.
4. Serialisierungskosten ignoriert. Ich habe einmal einen 2MB-JSON-Blob gecacht und mich gewundert, warum Cache-Reads langsam waren. Der Serialisierungs-Overhead war größer als die Datenbankabfrage, die er einsparen sollte.
5. Keine Graceful Degradation. Wenn Redis ausfällt, sollte deine App weiterhin funktionieren — nur langsamer. Wickle jeden Cache-Aufruf in ein try/catch, das auf die Datenbank zurückfällt. Lass einen Cache-Fehler nie zu einem benutzerseitigen Fehler werden.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Cache komplett umgehen
}
}6. Evictions nicht überwacht. Wenn Redis Keys evictet, bist du entweder unterdimensioniert oder cachst zu viel. In jedem Fall musst du es wissen.
7. Eine Redis-Instanz zwischen Caching und persistenten Daten geteilt. Verwende separate Instanzen (oder mindestens separate Datenbanken). Eine Cache-Eviction-Policy, die deine Job-Queue-Einträge löscht, ist ein schlechter Tag für alle.
Zusammenfassung#
Redis-Caching ist nicht schwer, aber es ist leicht, es falsch zu machen. Starte mit Cache-Aside, füge TTL-Jitter von Tag eins hinzu, überwache deine Hit-Rate und widerstehe dem Drang, alles zu cachen.
Die beste Caching-Strategie ist die, über die du um 3 Uhr morgens nachdenken kannst, wenn etwas kaputtgeht. Halte es einfach, halte es beobachtbar und denk daran, dass jeder gecachte Wert eine Lüge ist, die du deinen Benutzern über den Zustand deiner Daten erzählt hast — dein Job ist es, diese Lüge so klein und kurzlebig wie möglich zu halten.