Caching
Kurz gesagt: Ein Cache hält eine Kopie oft gelesener Daten näher am Code; bei Cache-aside schaut das Programm zuerst in den Cache, und bei einer Änderung muss die Kopie verworfen werden, sonst zeigt sie veraltete Daten.
Problem
Die Seite, die die Datenbank immer dasselbe fragt
Der Shop startet eine Kaffee-Aktion. Die Produktseite fragt die Datenbank etwa 40-mal: das Produkt, den Preis, den Lagerbestand, die Bewertungen, die verwandten Produkte. Wird die Seite 2.000-mal pro Minute geöffnet (ein Beispiel, keine Messung), sind das 80.000 Abfragen pro Minute. Und die Antworten sind dieselben, denn der Preis von Kaffee ändert sich nicht jede Sekunde.
Die Datenbank läuft mit 100 % CPU, die Seite lädt 4 Sekunden, und die Käufer gehen. Hier dasselbe im Kleinen, mit einer Datenbank, die ihre Abfragen zählt:
function productPage(products, id) { const p = products.find(id); return `${p.name}: ${p.price.toFixed(2)} €`;}const db = new Database(catalog);const popular = [7, 9, 12];for (let i = 0; i < 1000; i++) { productPage(db, popular[i % 3]);}console.log(`Aufrufe: 1000, Abfragen an die Datenbank: ${db.queries}`);Aufrufe: 1000, Abfragen an die Datenbank: 1000Tausend Aufrufe von drei Produkten: tausend Abfragen für drei verschiedene Antworten.
Die Idee
Eine Kopie vor der Datenbank
Wenn sich die Antwort nicht oft ändert, behalte sie. Das Programm schaut zuerst in den Cache. Gibt es eine Kopie, liefert es sie (ein Treffer). Wenn nicht (ein Fehltreffer), fragt es die Datenbank, legt eine Kopie im Cache ab und gibt die Antwort zurück. Die Kopie hat eine Ablaufzeit, die TTL: Danach vergisst der Cache die Kopie, und der nächste Lesezugriff geht wieder zur Datenbank.
Die Redis-Befehle dafür, TTL und DEL, hast du in der Lektion Key-Value und der Cache gesehen. Hier geht es um eine andere Frage: Wo im Code der Cache lebt, damit der Rest des Codes nichts von ihm weiß.
Die Antwort ist eine Hülle um das Repository. CachedRepository hat dasselbe Interface wie die Datenbank (Repository) und umhüllt die Datenbank wie ein Decorator. Der Service und die Seite bekommen ein Repository und wissen nicht, ob es ein Cache ist. Der Code, der die Objekte zusammensetzt, entscheidet.
Das heißt Cache-aside: Der Cache steht neben der Datenbank, und der Code füllt ihn bei einem Fehltreffer.
Sieh es dir an
Der Cache im Code
Das Produkt, das Repository und die Datenbank, die ihre Abfragen zählt:
// product: { id, name, price }// die Datenbank: zählt ihre Abfragen, damit wir sie sehenclass Database { queries = 0;
constructor(products) { this.rows = new Map(products.map((p) => [p.id, p])); }
find(id) { this.queries++; return this.rows.get(id); }
save(product) { this.queries++; this.rows.set(product.id, product); }}Database und CachedRepository haben einfach dieselben Methoden find und save. In TypeScript würdest du sie mit einem interface beschreiben.Die Zeit ist ein Tick-Zähler. So läuft die TTL bei jedem Durchlauf gleich ab:
// die Zeit ist ein Tick-Zähler, keine Uhrclass Clock { now = 0;}Der Cache: erst die Kopie, bei einem Fehltreffer die Datenbank, und save schreibt in die Datenbank und verwirft die Kopie.
class CachedRepository { cache = new Map(); hits = 0; misses = 0;
constructor(db, clock, ttl) { this.db = db; this.clock = clock; this.ttl = ttl; }
find(id) { const entry = this.cache.get(id); if (entry && this.clock.now < entry.expires) { this.hits++; return entry.product; // eine Kopie, nicht die Wahrheit } this.misses++; const product = this.db.find(id); const expires = this.clock.now + this.ttl; this.cache.set(id, { product, expires }); return product; }
save(product) { this.db.save(product); this.cache.delete(product.id); // die Kopie verwerfen }}Die tausend Aufrufe, aber über den Cache, mit einer TTL von 100 Ticks:
const clock = new Clock();const db = new Database(catalog);const products = new CachedRepository(db, clock, 100);const popular = [7, 9, 12];for (let i = 0; i < 1000; i++) { clock.now = i; // jedes Öffnen ist ein Tick productPage(products, popular[i % 3]);}console.log(`Abfragen an die Datenbank: ${db.queries}`);console.log(`Treffer: ${products.hits}, Fehltreffer: ${products.misses}`);Abfragen an die Datenbank: 30Treffer: 970, Fehltreffer: 30Dreißig Abfragen statt tausend. Jedes der drei Produkte wird alle 100 Ticks einmal gelesen, und die Kopie beantwortet die anderen 970 Aufrufe.
Probier es aus
Sag die Ausgabe voraus
Die Datenbank und die Uhr sind wie im Programm oben, die TTL beträgt 100 Ticks, und die Uhr bleibt bei 0. Was gibt es aus?
const products = new CachedRepository(db, clock, 100);products.find(7);products.find(7);products.find(9);products.save({ id: 7, name: "Kaffee", price: 12.90 });products.find(7);products.find(7);console.log(products.hits, products.misses, db.queries);Das erste find(7) ist ein Fehltreffer (Abfrage 1), das zweite ein Treffer. find(9) ist ein Fehltreffer (Abfrage 2). save schreibt in die Datenbank (Abfrage 3) und verwirft die Kopie von 7, also ist das nächste find(7) wieder ein Fehltreffer (Abfrage 4), und das letzte ein Treffer. Zwei Treffer, drei Fehltreffer, vier Abfragen.
Die Idee
Der alte Preis
Der Kaffee wird von 14,90 € auf 12,90 € billiger. Der nächtliche Preisimport schreibt direkt in die Datenbank, am CachedRepository vorbei:
const clock = new Clock();const db = new Database(catalog);const products = new CachedRepository(db, clock, 5);console.log(`Tick 0: ${productPage(products, 7)}`);// der nächtliche Preisimport schreibt direkt in die Datenbankdb.save({ id: 7, name: "Kaffee", price: 12.90 });for (let t = 1; t <= 6; t++) { clock.now = t; console.log(`Tick ${t}: ${productPage(products, 7)}`);}Tick 0: Kaffee: 14.90 €Tick 1: Kaffee: 14.90 €Tick 2: Kaffee: 14.90 €Tick 3: Kaffee: 14.90 €Tick 4: Kaffee: 14.90 €Tick 5: Kaffee: 12.90 €Tick 6: Kaffee: 12.90 €Die Datenbank sagt 12,90 €, und die Seite zeigt bis Tick 5 noch 14,90 €. Der Cache ist eine Kopie, nicht die Wahrheit. Niemand hat ihm gesagt, dass sich die Wahrheit geändert hat, also antwortet er mit dem alten Wert, bis die TTL abläuft. In einem echten Shop beträgt die TTL Minuten, und der Käufer sieht auf der Seite einen Preis und im Warenkorb einen anderen.
Darum verwirft jeder, der die Daten ändert, auch die Kopie. Das heißt Cache-Invalidierung. Dieselbe Änderung, aber über save:
const clock = new Clock();const db = new Database(catalog);const products = new CachedRepository(db, clock, 5);console.log(`Tick 0: ${productPage(products, 7)}`);// dieselbe Änderung, aber über saveproducts.save({ id: 7, name: "Kaffee", price: 12.90 });clock.now = 1;console.log(`Tick 1: ${productPage(products, 7)}`);console.log(`Abfragen an die Datenbank: ${db.queries}`);Tick 0: Kaffee: 14.90 €Tick 1: Kaffee: 12.90 €Abfragen an die Datenbank: 3Der neue Preis erscheint sofort. Die drei Abfragen sind das Lesen bei Tick 0, das Schreiben und das neue Lesen nach der verworfenen Kopie.
Die TTL bleibt als Sicherheitsnetz: Wenn jemals etwas am Cache vorbeischreibt, lebt die Lüge höchstens eine TTL lang.
Sieh es dir an
Die Kopie, der alte Preis und die Invalidierung
find(id) { const entry = this.cache.get(id); if (entry && this.clock.now < entry.expires) { this.hits++; return entry.product; // eine Kopie, nicht die Wahrheit } this.misses++; const product = this.db.find(id); const expires = this.clock.now + this.ttl; this.cache.set(id, { product, expires }); return product;}
save(product) { this.db.save(product); this.cache.delete(product.id); // die Kopie verwerfen}Schritt für Schritt, in Worten
- Tick 0: Die Seite fragt nach dem Kaffee (Produkt 7). Im Cache gibt es keine Kopie: ein Fehlgriff.
- Bei einem Fehlgriff fragt der Cache die Datenbank. Das ist bisher die einzige Abfrage.
- Der Cache behält eine Kopie bis Tick 5 (TTL von 5 Ticks), und die Seite zeigt 14.90 €.
- Tick 1: ein Treffer. Die Kopie antwortet, und die Datenbank merkt nichts.
- Der Preisimport schreibt 12.90 € direkt in die Datenbank. Bei Tick 2 sagt die Kopie noch 14.90 €: den alten Preis.
- Niemand hat es dem Cache gesagt. Die veraltete Kopie lebt, bis ihre TTL bei Tick 5 abläuft.
- Die Korrektur: Die Änderung läuft über save. Es schreibt in die Datenbank und verwirft sofort die Kopie.
- Der nächste Aufruf findet keine Kopie, fragt die Datenbank und bekommt 12.90 €.
- Der Cache ist eine Kopie. Die Wahrheit liegt in der Datenbank, und wer sie ändert, verwirft die Kopie.
save schreibt den neuen Preis nicht in den Cache, es verwirft nur die Kopie. Der nächste Lesezugriff holt die Wahrheit aus der Datenbank. Warum das sicherer ist, steht im Test am Ende.
Probier es aus
Lücke füllen
Welche Methode verwirft die Kopie, wenn sich der Preis ändert?
save(product) {
this.db.save(product);
this.cache.(product.id);
}this.cache.delete(product.id) verwirft die Kopie. Das nächste find ist ein Fehltreffer und holt den neuen Preis aus der Datenbank.
Die Idee
Wann ein Cache schadet
Ein Cache ist schnell, weil er ein bisschen lügt. Hier steht, wann die Lüge mehr kostet als die Geschwindigkeit bringt:
- Werte, die exakt sein müssen. Der Preis und der Lagerbestand bei der Zahlung werden aus der Datenbank gelesen, nicht aus dem Cache. Sonst verkaufst du den letzten Artikel zweimal oder berechnest den alten Preis.
- Änderungen am Cache vorbei. Ein Import, ein anderes Programm oder ein manuelles
UPDATEin der Datenbank verwerfen die Kopie nicht. Je mehr Wege schreiben, desto leichter vergisst einer sie. - Ein Wettlauf zwischen Lesen und Schreiben. Ein Lesezugriff bei einem Fehltreffer holt 14,90 € aus der Datenbank. Bevor er die Kopie ablegt, schreibt
save12,90 € und verwirft den (noch leeren) Schlüssel. Dann legt der Lesezugriff seine 14,90 € ab, und sie leben eine ganze TTL. Das ist eine Race Condition zwischen zwei Anfragen. Darum ist die TTL nie endlos. - Alles läuft gleichzeitig ab. Wenn tausende Kopien im selben Moment ablaufen oder der Cache leer neu startet, gehen alle Anfragen auf einmal an die Datenbank. Den Ablauf streut man mit etwas Zufall, und die meistgelesenen Daten lädt man im Voraus.
- Jeder Server mit eigenem Cache. Ein Cache im Speicher des Prozesses ist schnell, aber bei drei Kopien des Servers (in der Lektion Skalierung) hat jede Kopie ihren eigenen alten Preis. Darum ist der gemeinsame Cache getrennt (Redis).
- Ein Cache, den niemand gemessen hat. Ein Cache bringt Code, Speicher und Fehler mit. Du fügst ihn hinzu, wenn ein Zähler oder eine Messung zeigt, dass die Datenbank der Engpass ist, nicht auf Verdacht.
Probier es aus
Finde den Fehler
Ein Kollege cached die ganze fertige Produktseite. Die Tests laufen durch. Am nächsten Tag sehen englischsprachige Käufer die Seite auf Deutsch. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Schlüssel ist nur die Produktnummer, aber die Seite hängt auch von der Sprache ab. Wer das Produkt zuerst öffnet, bestimmt die Sprache für alle, bis die Kopie abläuft. Der Schlüssel muss alles enthalten, wovon die Antwort abhängt: `page:${id}:${lang}`. Dasselbe gilt für die Währung, die Berechtigungen und alles andere, wodurch sich zwei Antworten unterscheiden.
Genauer, für Neugierige
Andere Arten zu cachen. Bei Read-through fragt der Cache bei einem Fehltreffer selbst die Datenbank (der Code spricht nur mit dem Cache). Bei Write-through läuft das Schreiben über den Cache, und der Cache schreibt in die Datenbank. Bei Write-behind schreibt der Cache später in die Datenbank, in Stapeln: Das ist schnell, aber ein Absturz verliert Schreibzugriffe. Cache-aside ist am häufigsten, weil der Cache ausfallen kann, ohne den Shop anzuhalten: Dann geht alles an die Datenbank.
Invalidierung per Ereignis. Ändert sich der Preis in einem Service und wird in einem anderen gecacht, kann der erste keinen Schlüssel verwerfen, der ihm nicht gehört. Dann veröffentlicht die Änderung ein Ereignis „der Preis von 7 hat sich geändert“, und jeder, der cacht, verwirft seine Kopie. Das ist die Lektion Ereignisse und Warteschlangen.
Die echten Caches: Redis und Valkey (ein gemeinsamer Cache für alle Server), Memcached. Im Prozess: Caffeine in Java, IMemoryCache und HybridCache in ASP.NET Core, functools.lru_cache in Python (ohne TTL), das Paket lru-cache in Node. Frameworks haben auch eine Hülle wie die hier: @Cacheable und @CacheEvict in Spring, den Django-Cache.
Caches unterwegs. Auch der Browser und das CDN cachen Antworten anhand des Headers Cache-Control. CDN und Proxys einzurichten ist Infrastruktur und wird im DevOps-Kurs ZeroToOps behandelt.
Phil Karlton sagte, es gebe in der Programmierung zwei schwierige Dinge: Cache-Invalidierung und Namen vergeben. Jetzt weißt du, warum das erste auf der Liste steht.
Übung
Der Lieferpreis
Löse sie in deinem Editor.
Der Warenkorb fragt einen externen Anbieter, wie viel die Lieferung in die Stadt des Käufers kostet. Der Anbieter antwortet langsam und zählt die Aufrufe. Die Preise: Sofia 4,90 €, Plovdiv 5,90 €, Varna 6,90 €. Schreibe CachedQuotes: Es behält den Preis jeder Stadt 10 Ticks lang und fragt den Anbieter nur bei einem Fehltreffer. Führe die Anfragen bei den Ticks 0, 1, 2, 5, 11, 12 und 13 für die Städte Sofia, Plovdiv, Sofia, Sofia, Sofia, Plovdiv und Varna aus.
Tick 0, Sofia: 4.90 €Tick 1, Plovdiv: 5.90 €Tick 2, Sofia: 4.90 €Tick 5, Sofia: 4.90 €Tick 11, Sofia: 4.90 €Tick 12, Plovdiv: 5.90 €Tick 13, Varna: 6.90 €Aufrufe beim Anbieter: 5Fünf Aufrufe statt sieben. Sofia bei Tick 11 ist ein Fehltreffer: Die Kopie von Tick 0 lebte bis Tick 10.
Weitere Aufgaben
- Zähle die Treffer. Füge einen Zähler für Treffer und Fehltreffer hinzu und gib sie am Ende aus.
- Der Anbieter ändert einen Preis. Füge eine Methode hinzu, mit der der Anbieter sagt „der Preis für Varna hat sich geändert“, und verwirf nur diese Kopie.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Decorator · Repository · Race Condition · Schichten
- Geht weiter in: Skalierung · ADR: Architekturentscheidungen festhalten
- Aufgaben zum Thema: Softwarearchitektur