Warum überhaupt cachen?
Ein Cache ist eine Kopie von Daten, die an einem Ort gespeichert wird, der schneller zu erreichen ist als die ursprüngliche Quelle. Jede Entscheidung für einen Cache ist im Grunde eine Wette: dass derselbe Wert erneut angefragt wird, bevor er sich ändert, und dass es akzeptabel ist, eine geringfügig veraltete Kopie auszuliefern. Wenn beides zutrifft, ist der Gewinn enorm.
Ein einziger Cache-Hit hat drei Auswirkungen:
- Latenz. Eine Redis-Abfrage antwortet in deutlich weniger als einer Millisekunde; dieselbe Zeile aus PostgreSQL kostet mehrere Millisekunden für Netzwerk, Planung und I/O. Auf einer Seite, die zwanzig Dinge ausliest, macht dieser Unterschied das gesamte Nutzererlebnis aus.
- Last. Wenn 95 % der Lesezugriffe aus dem Cache bedient werden, sieht die Datenbank nur eine Abfrage, wo sie früher zwanzig gesehen hätte. So können Sie Traffic-Spitzen überstehen oder eine kleinere Instanz betreiben, ohne eine einzige Zeile der Query-Logik ändern zu müssen.
- Kosten. Weniger Datenbank-Reads bedeuten kleinere Instanzen, weniger Read-Replicas und weniger Cross-Region-Traffic. Bei entsprechender Skalierung ist Caching eine der wenigen Optimierungen, die sich finanziell direkt auszahlt.
Der Preis dafür ist die Komplexität. Ein Cache ist eine zweite Kopie der Wahrheit, und jede Kopie kann von der anderen abweichen. Ein Großteil dieses Guides befasst sich damit, diese Abweichungen gering und vorübergehend zu halten.
Wo ein Cache liegen kann
Caching ist kein einzelnes System, sondern ein Stack aus verschiedenen Schichten. Jede Schicht liegt näher am Leser und hat eine eigene Logik für die Invalidierung.
- Der Browser. HTTP-Antworten, die als
Cache-Control: max-agemarkiert sind, werden ohne erneute Anfrage wiederverwendet. Dies ist kostenlos, privat und liegt größtenteils in der Kontrolle des Clients. - Das CDN oder die Edge. Ein Shared Cache vor Ihrem Origin absorbiert den Traffic für öffentliche Antworten. Er ist schnell und global, aber ein Fehler an dieser Stelle ist für jeden sichtbar.
- Die Anwendung. Ein In-Process-Cache (ein
Map, ein LRU) ist die schnellste Ebene, da er das Netzwerk komplett vermeidet. Er ist zudem instanzspezifisch und muss daher klein und austauschbar sein. - Ein Shared Store. Redis oder Memcached sitzen neben der Anwendung und bedienen jede Instanz aus einer konsistenten Kopie. Hier findet der Großteil des Application-Caching statt.
- Die Datenbank. Buffer Pools, Materialized Views, Prepared Plans und Query-Result-Caches halten die Datenquelle an sich schnell. Sie sind die letzte Instanz vor dem Festplattenzugriff.
Eine Anfrage kann auf jeder dieser Ebenen beantwortet werden. Je näher die Antwort liegt, desto schneller ist sie – aber desto schwieriger ist sie zu invalidieren. Das ist das zentrale Spannungsfeld dieses gesamten Themas.
Cache Hit Rate: Die entscheidende Metrik
Die Gesundheit eines Caches wird an seiner Hit Rate gemessen: Treffer (Hits) geteilt durch die Summe aus Treffern und Fehlschlägen (Misses). Dies ist die einzige Kennzahl, die Ihnen verrät, ob der Cache überhaupt einen Nutzen bringt.
hit rate = hits / (hits + misses)
Die Mathematik dahinter ist drastisch. Bei einer Hit Rate von 90 % sieht die Datenbank nur jede zehnte Anfrage – eine 10-fache Reduzierung. Bei 50 % sieht sie jede zweite, was nur einer 2-fachen Reduzierung entspricht, während der Cache für die Hälfte des Traffics einen zusätzlichen Netzwerk-Hop hinzugefügt hat. Ein Cache mit einer niedrigen Hit Rate kann langsamer sein, als wenn gar kein Cache vorhanden wäre.
Messen Sie diesen Wert direkt am Store. Redis meldet keyspace_hits und keyspace_misses in INFO stats, und auch Client-Bibliotheken stellen dieselben Zähler bereit. Beobachten Sie die Hit Rate nach jeder Änderung an einem Key, einem TTL oder einer Query; eine kleine Änderung am Key kann eine Cache-Rate von 95 % unbemerkt auf 40 % sinken lassen.
Die logische Schlussfolgerung daraus ist, dass Sie nur Dinge cachen dürfen, die tatsächlich wiederholt angefragt werden. Ein Key, der pro Request eindeutig ist, hat konstruktionsbedingt eine Hit Rate von 0 % und verschwendet lediglich Arbeitsspeicher.
Die vier Kernmuster
Fast jede Cache-Implementierung folgt einem von vier Mustern. Sie unterscheiden sich darin, wer den Cache befüllt und wann der Cache aktualisiert wird.
Cache-aside (Lazy Loading)
Die Anwendung steuert die Logik. Bei einem Lesezugriff wird der Cache geprüft; bei einem Cache-Miss werden die Daten aus der Quelle geladen und das Ergebnis zurück in den Cache geschrieben. Dies ist das Standardmuster, da es einfach ist, mit jedem Store funktioniert und nur Daten cached, die tatsächlich angefordert werden.
async function getUser(id: string) {
const key = `user:${id}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const user = await db.user.findUniqueOrThrow({ where: { id } });
await redis.set(key, JSON.stringify(user), "EX", 600);
return user;
}
Der Nachteil ist, dass der erste Leser immer die volle Latenz trägt und der Cache veraltete Daten enthalten kann, bis die TTL abläuft oder ein Schreibvorgang diese entfernt.
Read-through
Bei Read-through wird der Fallback direkt in die Cache-Schicht verschoben: Die Anwendung fragt immer den Cache ab, und der Cache ist mit einem Loader konfiguriert, der bei einem Miss ausgeführt wird. Einige Bibliotheken und Proxies implementieren dies, sodass der Anwendungscode keinen expliziten Pfad für Cache-Misses benötigt. Das Verhalten ist identisch mit Cache-aside; nur der Ort der Logik ändert sich.
Write-through
Write-through aktualisiert den Cache und die Quelle in derselben Operation. Lesezugriffe sind immer „hot“ und sehen niemals Daten, die älter sind als der letzte erfolgreiche Schreibvorgang.
async function updateUser(id: string, patch: Partial<User>) {
const user = await db.user.update({ where: { id }, data: patch });
await redis.set(`user:${id}`, JSON.stringify(user), "EX", 600);
return user;
}
Dies geht zu Lasten der Schreiblatenz und cached Daten, die möglicherweise nie gelesen werden. Es hält den Cache jedoch konsistent mit dem Schreibpfad, was genau das ist, was benutzerorientierte Updates benötigen.
Write-behind (Write-back)
Write-behind schreibt sofort in den Cache und spült die Daten asynchron in die Quelle. Schreibvorgänge sind sehr schnell und können gebündelt werden, was besonders wertvoll für Counter und Telemetrie ist. Das Risiko ist jedoch erheblich: Wenn der Cache abstürzt, bevor der Flush erfolgt, ist der Schreibvorgang verloren. Verwenden Sie dieses Muster nur dort, wo der Verlust der letzten Schreibvorgänge akzeptabel ist – niemals bei Finanztransaktionen oder offiziellen Datensätzen.
TTLs und Freshness
Jeder gecachte Wert sollte eine Time to Live (TTL) haben. Die TTL ist quasi ein Budget für die Veralterung: Sie definiert die maximale Zeitspanne, in der ein Leser einen Wert sieht, der in der Quelle nicht mehr aktuell ist. Die Wahl der TTL ist ebenso eine Produktentscheidung wie eine technische.
- Preise und Lagerbestände: Sekunden. Ein falscher Preis führt direkt zu einem Support-Ticket.
- Profile, Feeds und Listings: Minuten. Geringfügige Abweichungen fallen nicht auf.
- Referenzdaten, Feature Flags und Konfigurationen: Minuten bis Stunden.
- Statische Assets und selten geänderte Inhalte: Tage, versioniert über den Dateinamen.
Fügen Sie der TTL einen Jitter hinzu. Wenn tausend Keys im selben Moment mit der identischen TTL geschrieben werden, laufen sie gleichzeitig ab und verursachen einen Lastspitzen-Peak. Eine Randomisierung um einige Prozent verteilt die Reloads gleichmäßiger.
const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(value), "EX", ttl);
Behalten Sie die TTL auch bei expliziter Invalidierung als Sicherheitsnetz bei. Ein Bug, der das Löschen eines Keys vergisst, sollte Sie nur einige Minuten an veralteten Daten kosten und nicht zu einer permanenten Fehlermeldung führen.
Invalidation: der schwierige Teil
Es gibt einen Grund, warum Cache-Invalidierung die Pointe des ältesten Witzes der Informatik ist. Einen Wert zu speichern ist trivial, aber ihn im richtigen Moment, aus jeder Schicht und ohne Race Conditions zu entfernen, ist wirklich schwierig.
Drei Techniken decken fast jeden Anwendungsfall ab.
Key Versioning. Anstatt zu löschen, ändern Sie den Key. Präfixieren Sie Keys mit einer Version, die Sie erhöhen, wenn sich die zugrunde liegenden Daten ändern. So werden alte Einträge unerreichbar und laufen von selbst ab.
const version = (await redis.get(`user:${id}:v`)) ?? "1";
const key = `user:${id}:v${version}`;
Dies ist frei von Race Conditions und funktioniert über verschiedene Instanzen hinweg, weshalb es der bevorzugte Ansatz für alles ist, was gemeinsam genutzt wird.
Explicit Bust. Löschen Sie den Key beim Schreiben. Dies ist der offensichtlichste Ansatz und gleichzeitig am einfachsten falsch zu implementieren, da jeder Schreibpfad daran denken muss, dies zu tun.
await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`);
Event-driven Invalidation. Veröffentlichen Sie ein Change-Event und lassen Sie jede Instanz und jede Schicht darauf reagieren. So wird ein CDN Purge oder ein Multi-Service-Cache konsistent gehalten; zudem lässt sich dies auf Systeme skalieren, die Sie nicht selbst besitzen.
Egal für welche Methode Sie sich entscheiden: Achten Sie auf eine konsistente Reihenfolge und bevorzugen Sie beim Update das Löschen gegenüber dem Überschreiben. Löschen ist idempotent; Überschreiben kann einen Wert wiederbeleben, der von einem gleichzeitigen Writer bereits ersetzt wurde.
Cache Stampede und Thundering Herd
Wenn ein populärer Key abläuft, kommt es bei jeder laufenden Anfrage im selben Moment zu einem Cache-Miss. Wenn tausend Anfragen pro Sekunde eingehen und der Key 200 ms zum Neuaufbau benötigt, treffen hunderte identische Abfragen gleichzeitig auf die Datenbank. Das ist ein Cache Stampede, auch Thundering Herd genannt, und kann einen Service, der einen Moment zuvor noch stabil lief, komplett zum Absturz bringen.
Vier Lösungsansätze, in grober Reihenfolge der Empfehlung:
- Stale-while-revalidate. Liefern Sie den veralteten Wert sofort aus und aktualisieren Sie ihn im Hintergrund. Leser müssen nie warten und die Datenquelle sieht nur eine einzige Aktualisierung. HTTP hat genau dafür einen eigenen Header.
- Locking oder Single-Flight. Nur der erste Aufrufer berechnet den Wert neu; alle anderen warten kurz oder erhalten veraltete Daten. Redis
SET key value NX EXist ein einfacher verteilter Lock. - Jittered TTLs. Verteilen Sie die Ablaufzeiten, sodass nicht alle Keys gleichzeitig ablaufen.
- Probabilistische frühzeitige Ablösung. Jeder Lesezugriff hat eine kleine, steigende Chance, den Wert bereits vor dem eigentlichen Ablauf zu aktualisieren, sodass die Aktualisierung auf viele Anfragen verteilt wird.
Der Lock muss immer eine Ablaufzeit haben, da ein abgestürzter Worker den Key sonst für immer gesperrt lässt.
Was man cachen sollte und was auf keinen Fall
Cache Daten, deren Erzeugung teuer ist, die weitaus häufiger gelesen als geändert werden, bei denen eine geringfügige Veralterung (Staleness) akzeptabel ist und die von vielen Lesern gemeinsam genutzt werden. Produktlisten, öffentliche Profile, Konfigurationen, gerenderte Fragmente und rechenintensive Aggregate sind allesamt gute Kandidaten.
Cache niemals:
- Autorisierungsentscheidungen in einem Shared Cache. Eine Rollenänderung oder ein Logout muss sofort wirksam werden, und ein CDN darf niemals die private Antwort eines Benutzers an einen anderen ausliefern.
- Benutzerspezifische sensible Daten unter einem gemeinsamen Key. Jeder benutzerspezifische Wert benötigt einen Key, der den Benutzer einschließt, sowie eine
privateCache-Direktive, falls die Daten jemals einen Edge-Server erreichen. - Secrets und Credentials. Ein Cache ist ein weiterer Ort, an dem diese durchsickern können.
- Daten, die du nicht invalidieren kannst. Wenn ein Wert keinen natürlichen Key und kein entsprechendes Event hat, wird der Cache irgendwann falsche Daten ausliefern, ohne dass es eine Möglichkeit gibt, dies zu korrigieren.
Ein nützlicher Test: Wenn du nicht beschreiben kannst, wie ein Wert den Cache wieder verlässt, schreibe ihn nicht hinein.
HTTP-Caching am Edge
Der günstigste Cache ist der, der Ihren Server gar nicht erst erreicht. HTTP bietet Ihnen hierfür eine präzise Steuerung.
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Vary: Accept-Encoding
max-age legt fest, wie lange der Browser die Antwort wiederverwenden darf. s-maxage überschreibt dies für Shared Caches, wie zum Beispiel ein CDN. stale-while-revalidate ermöglicht es dem Edge, eine veraltete Kopie auszuliefern, während im Hintergrund eine aktuelle Version abgerufen wird, was Cache-Stampedes bei öffentlichen Inhalten verhindert. private und no-store halten sensible Antworten vollständig aus Shared Caches fern.
Ein ETag ermöglicht bedingte Anfragen (Conditional Requests). Der Client sendet If-None-Match, und wenn der Tag noch übereinstimmt, antworten Sie mit 304 Not Modified ohne Body, was Bandbreite spart und gleichzeitig die Aktualität garantiert.
app.get("/posts", async (req, res) => {
const body = JSON.stringify(await listPosts());
const tag = `"${createHash("sha256").update(body).digest("hex")}"`;
res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
res.set("ETag", tag);
if (req.headers["if-none-match"] === tag) return res.status(304).end();
res.type("application/json").send(body);
});
Vary wird leicht vergessen, ist aber wichtig: Wenn eine Antwort von Accept-Encoding oder Accept-Language abhängt, geben Sie dies an, ansonsten liefert ein Cache eventuell die falsche Variante an den falschen Client aus.
In-memory vs Redis vs CDN
Die drei Shared-Layer sind keine Konkurrenten, sondern bilden eine Hierarchie.
Ein In-Process-Cache ermöglicht den schnellstmöglichen Abruf, da er niemals das Netzwerk überqueren muss. Er ist ideal für kleine, häufig genutzte und primär gelesene Daten, wie etwa Konfigurationen oder eine Permission-Map. Die Einschränkungen liegen darin, dass jede Instanz eine eigene Kopie besitzt – wodurch der Speicherbedarf multipliziert wird und Werte divergieren können – sowie darin, dass der Cache bei einem Deployment verschwindet.
Redis fungiert als gemeinsamer Application-Cache. Eine einzige logische Kopie bedient alle Instanzen; der Cache übersteht Neustarts und unterstützt TTLs, atomare Operationen sowie Datenstrukturen, die über einfache Strings hinausgehen. Der Preis dafür ist ein Netzwerk-Roundtrip, der im selben Netzwerk in der Regel deutlich unter einer Millisekunde liegt.
Ein CDN ist die äußerste Schicht. Es cached öffentliche Antworten nah bei den Nutzern weltweit und kann enorme Traffic-Spitzen abfangen, bevor diese überhaupt bei Ihnen ankommen. Es funktioniert nur für Antworten, die für viele Nutzer identisch sind, und das Leeren (Purging) des Caches ist ein bewusster und manchmal langsamer Vorgang.
Ein gängiges Setup für die Produktion ist ein winziger In-Process-Cache vor Redis für die am häufigsten benötigten Keys, kombiniert mit einem CDN vor der gesamten API für öffentliche GET-Requests.
Negative Caching
Caches werden normalerweise so beschrieben, dass sie Werte speichern, aber es ist ebenso nützlich, das Fehlen eines Wertes zu speichern. Wenn eine Abfrage nach einem nicht vorhandenen Datensatz rechenintensiv ist und wiederholt ausgeführt wird, sollten Sie diesen „Miss“ cachen.
const hit = await redis.get(key);
if (hit === "__miss__") return null;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
await redis.set(key, "__miss__", "EX", 60);
return null;
}
Halten Sie negative TTLs kurz, da ein Datensatz, der vor einer Minute noch nicht existierte, jetzt vorhanden sein könnte. Dieses Muster schützt vor Cache Penetration, bei der eine Flut von Anfragen für nicht existierende Keys den Cache umgeht und jedes Mal die Datenbank belastet. Schützen Sie sich vor Angreifern, die endlos viele einzigartige, nicht vorhandene Keys generieren, indem Sie die Eingaben validieren und ein Rate Limiting vor dem Cache implementieren.
Konsistenz und die zwei schwierigen Dinge
Ein Cache macht ein System eventually consistent: Für ein kurzes Zeitfenster können verschiedene Leser unterschiedliche Werte sehen. Das ist normalerweise akzeptabel, aber in einigen Situationen nicht.
- Read-your-writes. Ein Nutzer, der gerade sein Profil aktualisiert hat, erwartet, die Änderung sofort zu sehen. Invalidiere den Cache beim Schreiben oder lies für einen kurzen Zeitraum nach einem Schreibvorgang desselben Nutzers direkt aus der Quelle.
- Replication lag. Wenn Lesezugriffe an eine Replica gehen, kann ein aus dieser Replica befüllter Cache hinter dem Primary zurückbleiben. Invalidiere den Cache direkt über den Schreibpfad des Primary.
- Cross-region caches. Ein Purge in einer Region erreicht nicht sofort alle anderen Regionen. Nutze Version Keys oder akzeptiere die Propagationsverzögerung.
Um es ehrlich zu formulieren: Caching tauscht Konsistenz gegen Geschwindigkeit ein. Der einzige Weg, dies sicher zu tun, besteht darin, explizit zu entscheiden, welche Veralterung (Staleness) akzeptabel ist, und die Invalidation direkt in den Schreibpfad zu integrieren, anstatt sie nachträglich dranzuhängen.
Cache-Keys sind eine API
Ein Cache-Key wirkt wie ein Implementierungsdetail, verhält sich jedoch wie ein öffentliches Interface. Er ist die gemeinsame Basis, auf die sich jeder Reader und Writer einigen müssen, und er ist in Redis, in Slow-Logs und auf Dashboards sichtbar. Entwerfen Sie Keys daher bewusst.
Drei Regeln sorgen für Konsistenz:
- Namespacing nach Version und Umgebung.
v1:user:42ermöglicht es Ihnen, die Struktur später zu ändern und verhindert, dass Staging-Umgebungen Keys mit der Production teilen. - Seien Sie deterministisch. Derselbe logische Lookup muss immer denselben String erzeugen. Normalisieren Sie die Groß-/Kleinschreibung, trimmen Sie Inputs und fügen Sie niemals Werte hinzu, die sich pro Aufruf ändern.
- Setzen Sie niemals Secrets oder personenbezogene Daten in einen Key. Keys werden geloggt, exportiert und für Operatoren angezeigt.
export const cacheKeys = {
user: (id: string) => `v1:user:${id}`,
userPosts: (id: string, cursor: string) => `v1:user:${id}:posts:${cursor}`,
productBySku: (sku: string) => `v1:product:sku:${sku.trim().toLowerCase()}`,
};
Die Zentralisierung der Key-Erstellung in einem einzigen Modul ist das, was die Invalidation erst ermöglicht. Wenn ein Schreibvorgang einen Key löschen muss, ruft er dieselbe Funktion auf, die auch der Lesevorgang verwendet hat; wenn sich die Struktur ändert, gibt es nur eine einzige Stelle, an der die Version erhöht werden muss.
Eviction Policies
Ein Cache, der unbegrenzt wachsen darf, ist im Grunde ein Memory Leak mit zusätzlichen Schritten. Sowohl Redis als auch In-Process-Caches benötigen eine Obergrenze und eine Strategie (Policy), welche Daten entfernt werden sollen, sobald diese Grenze erreicht ist.
Redis bietet maxmemory und maxmemory-policy an. Die gängige Wahl für einen reinen Cache ist allkeys-lru, bei dem der am wenigsten kürzlich verwendete Key entfernt wird, oder allkeys-lfu, was bei ungleichmäßigen Zugriffsmustern häufig genutzte Keys bevorzugt.
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
Verwenden Sie noeviction niemals für einen Cache: Sobald der Speicher voll ist, schlagen Schreibvorgänge fehl und Ihre Anwendung wirft Fehler, anstatt einfach nur einen Cache-Miss zu verzeichnen. Nutzen Sie für In-Process-Caches eine bounded LRU-Library anstelle eines einfachen Map und legen Sie sowohl eine maximale Anzahl an Einträgen als auch eine maximale Größe fest.
Evictions sind keine Fehler, aber eine steigende Eviction-Rate ist ein Signal. Sie bedeutet, dass das Working Set nicht mehr in den Speicher passt und die Hit-Rate sinken wird. Geben Sie dem Cache entweder mehr Speicher oder cachen Sie weniger.
Cache Warming
Ein Cache ist in dem Moment am kältesten, in dem er am gefährlichsten ist: direkt nach einem Deploy, einem Neustart oder einem Scale-out. Jede Instanz startet leer, der Traffic trifft mit voller Intensität ein und die Datenquelle spürt die gesamte Last, bis der Cache gefüllt ist. Das ist ein „Stampede“, verursacht durch dein eigenes Deployment.
Hierfür gibt es zwei Ansätze. Lazy Warming akzeptiert das Zeitfenster des kalten Caches und verlässt sich auf Locking und stale-while-revalidate, um diesen Zeitraum zu überstehen. Es ist simpel und benötigt keine zusätzliche Infrastruktur. Proactive Warming führt einen Job aus, der die bekannten Hot Keys lädt, bevor die neue Version den Traffic übernimmt.
async function warmCache() {
const popular = await db.product.findMany({
orderBy: { views: "desc" },
take: 500,
select: { id: true },
});
for (const { id } of popular) {
await cached(`product:${id}`, 300, () =>
db.product.findUniqueOrThrow({ where: { id } }),
);
}
}
Warme nur das auf, von dem du weißt, dass es häufig angefragt wird. Alles aufzuwärmen ist lediglich eine langsame, teure Kopie der Datenbank, die größtenteils ungenutzt wieder aus dem Cache verdrängt wird.
Observability für Caches
Ein Cache, den man nicht misst, ist ein Cache, dem man nicht vertrauen kann. Vier Kennzahlen erzählen die ganze Geschichte:
- Hit rate — werden Lesezugriffe tatsächlich aus dem Cache bedient?
- Latency — ist ein Hit signifikant günstiger als die Quelle, inklusive des Netzwerk-Hops?
- Evictions — wächst das Working Set über den verfügbaren Speicher hinaus?
- Errors and timeouts — wird der Cache zu einer Fehlerquelle?
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
redis-cli INFO memory | grep used_memory_human
Senden Sie zusätzlich aus der Anwendung einen Counter für Hits und Misses aus, getaggt mit dem Cache-Namen. Nur so lassen sich Einbrüche der Hit-Rate mit einem Deploy korrelieren – und es ist das Erste, was man prüfen sollte, wenn die Datenbanklast ohne ersichtlichen Grund ansteigt.
Graceful Degradation bei Cache-Ausfällen
Wenn der Ausfall des Caches die gesamte API lahmlegt, ist der Cache zu einem Single Point of Failure für ein System geworden, dessen eigentlicher Zweck es ist, optional zu sein. Die Source of Truth existiert weiterhin; die Anwendung muss in der Lage sein, diese zu erreichen.
Kapseln Sie jede Cache-Operation so ein, dass ein Fehler als Cache-Miss und nicht als Error gewertet wird. Halten Sie Timeouts kurz und ziehen Sie einen Circuit Breaker in Betracht, der die Aufrufe eines fehlerhaften Caches für eine Abkühlphase stoppt.
async function safeGet(key: string) {
try {
return await redis.get(key);
} catch (err) {
logger.warn({ err, key }, "cache_read_failed");
return null;
}
}
Das Gleiche gilt für Schreibvorgänge in den Cache: Ein fehlgeschlagener set sollte niemals die gesamte Anfrage scheitern lassen. Die einzige Ausnahme ist Write-Behind, bei dem der Cache Teil des Schreibpfads ist; dies ist ein weiterer Grund, ihn nur für Daten zu verwenden, deren Verlust verschmerzbar ist.
Teure Abfragen und Aggregate cachen
Nicht alles, was es sich zu cachen lohnt, ist eine einzelne Zeile. Teure Joins, Dashboard-Aggregate und Suchergebnisse sind oft die besten Kandidaten, da sie rechenintensiv sind und sich nur langsam ändern.
Eine Materialized View ist ein Cache, der direkt in der Datenbank lebt: Sie speichert das Ergebnis einer Abfrage und wird nach einem Zeitplan oder bei Bedarf aktualisiert. Sie bietet die Aktualität eines Batch-Jobs kombiniert mit der Lesegeschwindigkeit einer Tabelle.
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
sum(total_cents) AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
Für Ergebnisse, die zu dynamisch für eine View sind, cachen Sie die serialisierte Antwort in Redis unter einem Key, der jeden Einflussfaktor enthält – also Filter, Sortierreihenfolge und Seite. Zwei Anfragen für unterschiedliche Seiten ergeben unterschiedliche Werte und müssen daher unter verschiedenen Keys gespeichert werden.
Einen Cache testen
Cache-Bugs sind genau die Art von Fehlern, die die Tests bestehen, aber in der Produktion scheitern. Testen Sie daher die relevanten Verhaltensweisen explizit: den Miss-Pfad, den Hit-Pfad, die Invalidierung und Fehlerfälle.
test("loads once and serves the second read from cache", async () => {
let calls = 0;
const load = async () => {
calls += 1;
return { id: "1" };
};
await cached("user:1", 60, load);
await cached("user:1", 60, load);
expect(calls).toBe(1);
});
test("falls back to the source when the cache is unavailable", async () => {
redis.get = async () => {
throw new Error("connection_refused");
};
await expect(cached("user:1", 60, load)).resolves.toEqual({ id: "1" });
});
Verwenden Sie für Integrationstests ein echtes Redis in einem Container oder einen In-Memory-Fake, der get, set und del implementiert. Testen Sie zudem die Negativfälle: ein Update, das eine Invalidierung auslösen sollte, eine TTL, die ablaufen sollte, und einen Cache-Ausfall, bei dem das System kontrolliert degradieren sollte, anstatt komplett auszufallen.
Best Practices
- Cachen Sie erst nach einer Messung; fügen Sie einen Cache nur bei langsamen, wiederholten Lesezugriffen hinzu, nicht standardmäßig.
- Geben Sie jedem Key eine TTL, selbst wenn Sie diesen auch explizit invalidieren.
- Verwenden Sie deterministische, namespaced Keys wie
user:42:profileund versionieren Sie diese bei gemeinsam genutzten Daten. - Bevorzugen Sie das Löschen oder Versionieren von Keys gegenüber dem Überschreiben bei einem Update.
- Fügen Sie TTLs einen Jitter hinzu und nutzen Sie stale-while-revalidate, um Cache Stampedes zu überstehen.
- Halten Sie benutzerbezogene Daten und Autorisierungsdaten aus gemeinsam genutzten Caches fern; markieren Sie Antworten als
private. - Behandeln Sie den Cache im Code als optional, sodass ein Ausfall zu einer Performance-Degradierung führt, anstatt das System komplett zum Absturz zu bringen.
- Beobachten Sie die Hit-Rate und die Eviction-Count, nicht nur die Latenz.
- Cachen Sie sowohl Cache-Hits als auch Cache-Misses, wenn die Abfrage nicht vorhandener Daten teuer ist.
Häufige Fehler
- Caching ohne TTL und das Vertrauen auf einen manuellen Purge, der niemals erfolgt.
- Verwendung eines einzigen Keys für alle Benutzer, wodurch Daten einer Person an eine andere geleakt werden.
- Invalidierung in einigen Schreibpfaden, aber nicht in anderen.
- Festlegen derselben TTL für jeden Key, sodass alle gleichzeitig ablaufen.
- Caching einer Autorisierungsentscheidung, die durch eine Rollenänderung hätte widerrufen werden müssen.
- Nutzung eines Caches als Datenbank, wodurch Schreibvorgänge bei einem Neustart verloren gehen.
- Vergessen von
Varyund das Ausliefern einer falschen Content-Encoding vom CDN. - Fehlendes Monitoring, wodurch eine Änderung an einem Key die Hit-Rate stillschweigend sinken lässt.
- Caching von Inhalten, die pro Request eindeutig sind, was eine Hit-Rate von 0 % garantiert.
Wie geht es weiter?
Caching ist ein Werkzeug zur Steuerung der Last; seine natürlichen Ergänzungen sind alle anderen Methoden, die den Arbeitsaufwand begrenzen. Redis befasst sich eingehend mit dem Store, auf dem die meisten Caches aufbauen, und Pagination ist die Variante desselben Konzepts auf Request-Ebene: Erledige niemals mehr Arbeit, als unbedingt nötig ist. Wenn die Datenbank hinter dem Cache der Flaschenhals ist, reduziert Connection Pooling die Kosten für jede einzelne Verbindung, und PostgreSQL deckt die Source of Truth ab, die du schützt.