Caching in .NET: Cache-Aside, Stampede-Schutz und HybridCache
Ein Cache wirkt wie der einfachste Weg, eine langsame Abfrage zu beschleunigen: das Ergebnis irgendwo ablegen und bei erneuter Anfrage von dort zurückgeben. Der Haken: Cache-Code ist leicht geschrieben, aber schwer korrekt umgesetzt. Bildschirme mit veralteten Daten, eine Datenbank, die von Tausenden gleichzeitigen Anfragen überrannt wird, und Fehler nach dem Muster „nach dem Leeren funktioniert es wieder" entstehen meist genau durch diese Details.
In diesem Beitrag gehe ich Caching in .NET über drei Fragen an: Was soll ich wie lange speichern? Was passiert, wenn sich die Daten ändern? Was passiert, wenn viele Anfragen gleichzeitig denselben fehlenden Schlüssel berechnen wollen? Danach betrachte ich HybridCache, das einen großen Teil davon von Haus aus löst.
Zuerst: Braucht man überhaupt einen Cache?
Ein Cache ist eine Optimierung und sollte wie jede Optimierung mit einer Messung beginnen. Prüfen Sie, ob die Abfrage wirklich langsam ist, ob sie oft aufgerufen wird und ob ein kurzzeitig veraltetes Ergebnis akzeptabel ist. Manchmal ist ein fehlender Index oder eine unnötige N+1-Abfrage die eigentliche Ursache des Problems, das der Cache nur verdecken würde; sie zu beheben bringt deutlich weniger Komplexität mit sich als ein Cache.
Gute Kandidaten sind selten veränderte Referenzdaten, teure Zusammenfassungen, die für alle gleich sind, und Antworten externer Dienste mit Ratenbegrenzung. Schlechte Kandidaten sind nutzerspezifische, sich schnell ändernde oder bei Fehlern teure Daten wie Kontostände oder Lagerbestände.
Das Cache-Aside-Muster
Das häufigste Muster ist Cache-Aside: zuerst im Cache nachsehen; fehlt der Eintrag, aus der Quelle lesen und das Ergebnis in den Cache schreiben. Mit IMemoryCache sieht das so aus:
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
if (cache.TryGetValue(key, out ProductDto? cached))
return cached;
var product = await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
if (product is not null)
cache.Set(key, product, TimeSpan.FromMinutes(5));
return product;
}
Das funktioniert, hat aber zwei stille Probleme. Erstens werden nicht gefundene Datensätze nie gecacht, sodass jede Anfrage nach einer nicht existierenden ID die Datenbank erreicht. Zweitens gehen gleichzeitige Anfragen, die eintreffen, während der Schlüssel fehlt, alle gemeinsam an die Datenbank. Letzteres nennt man Stampede.
Schlüssel- und Ablaufdesign
Bilden Sie den Schlüssel aus jeder Eingabe, die das Ergebnis bestimmt. Wenn Sprache, Mandant, Seitennummer oder ein Filter das Ergebnis verändern, muss das Teil des Schlüssels sein. Sonst können die Daten eines Nutzers einem anderen angezeigt werden; in mandantenfähigen Systemen ist das ein direkter Sicherheitsfehler. Die Schlüsselbildung in einer einzigen Hilfsmethode zu bündeln, statt verstreute String-Verkettungen zu verwenden, verringert Fehler.
Es gibt keine einzig richtige Ablaufzeit, aber zwei Prinzipien helfen. Wählen Sie sie danach, wie veraltet die Daten sein dürfen, nicht danach, wie schnell es sein soll. Außerdem glättet ein kleiner Zufallsanteil (Jitter) in der Dauer Wellen gleichzeitiger Aktualisierungen, sodass nicht alle Schlüssel im selben Moment ablaufen.
Invalidierung: der schwierige Teil
Wie Sie den Cache bei Datenänderungen aktualisieren, ist wichtiger als die Lebensdauer der Einträge. Es gibt drei Ansätze:
- Nur auf den Ablauf vertrauen: Am einfachsten. Daten können höchstens so lange veraltet sein wie die Dauer; ist das für das Produkt akzeptabel, reicht es oft aus.
- Beim Schreiben löschen: Der Code, der die Daten ändert, entfernt den zugehörigen Schlüssel aus dem Cache. Der nächste Lesezugriff lädt frische Daten. Achten Sie darauf, dass der Code, der den Schlüssel schreibt, und der, der ihn löscht, nah beieinander liegen.
- Massenlöschung per Tag: Wenn Sie alle Produktlisten einer Kategorie in einem Schritt invalidieren möchten, nutzen Sie ein gemeinsames Tag, statt jeden Schlüssel zu kennen.
Behalten Sie in jedem Fall den Ablauf als Sicherheitsnetz, damit ein Fehler in der Invalidierung nur zu „etwas veralteten Daten" führt.
Stampede: gleichzeitig fehlende Schlüssel
Läuft ein beliebter Schlüssel ab, verfehlen Hunderte Anfragen in derselben Millisekunde den Cache und führen alle dieselbe teure Abfrage aus. Die Datenbank wird in diesem Moment zum am stärksten belasteten Punkt; die langsamer werdende Abfrage lässt weitere Anfragen warten, und das System kann in eine sich selbst verstärkende Schleife geraten.
Der Kern der Lösung: Nur eine Anfrage berechnet den Wert für einen Schlüssel, die anderen warten auf ihr Ergebnis. Man kann das von Hand schreiben (ein SemaphoreSlim pro Schlüssel), aber Details wie das Aufräumen des Lock-Dictionarys, Abbruch und Fehlerbehandlung sind leicht falsch umgesetzt. Deshalb ist eine fertige Lösung sinnvoll.
Eine einfache Lösung mit HybridCache
HybridCache aus dem Paket Microsoft.Extensions.Caching.Hybrid vereint einen In-Memory-Cache (L1) und einen verteilten Cache (L2) unter einer API und fasst gleichzeitige Aufrufe für denselben Schlüssel zu einer Berechnung zusammen. Die Registrierung ist einfach:
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
};
});
Die Verwendung reduziert den gesamten Cache-Aside-Code auf einen einzigen Aufruf:
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(token),
tags: ["products"],
cancellationToken: ct);
}
Bei gleichzeitigen Aufrufen mit demselben Schlüssel läuft die Factory-Funktion nur einmal. Nach einer Änderung können Sie mit RemoveAsync($"product:{id}") einen einzelnen Eintrag oder mit RemoveByTagAsync("products") alle getaggten Einträge invalidieren. Ist eine IDistributedCache-Implementierung (zum Beispiel Redis) registriert, nutzt HybridCache sie automatisch für die verteilte Ebene; andernfalls arbeitet nur der lokale Cache.
In einem verteilten Cache werden Werte serialisiert, daher sollten die gecachten Typen einfache, serialisierbare DTOs sein. EF-Core-Entitäten direkt zu speichern verursacht wegen verknüpfter Objekte und Change-Tracking-Informationen Probleme.
Den Cache beobachten
Ob ein Cache hilft, wissen Sie nur, wenn Sie ihn messen. Veröffentlichen Sie Trefferquote und Fehlversuche (Misses), die Dauer der Factory-Funktion und cachebedingte Fehler als Metriken. Ist die Trefferquote niedrig, stimmt entweder das Schlüsseldesign nicht oder die Dauer ist zu kurz. Ist sie hoch, Nutzer klagen aber über veraltete Daten, gibt es eine Lücke im Invalidierungsablauf.
Ersetzen Sie den Cache in Tests nicht durch eine Attrappe, sondern prüfen Sie das Verhalten im gefüllten wie im leeren Zustand: Der zweite Aufruf darf die Factory nicht ausführen, und ein Aufruf nach einer Änderung muss die neuen Daten sehen.
Fazit
Ein Cache tauscht Komplexität gegen Geschwindigkeit; damit sich der Tausch lohnt, braucht es einen gemessenen Bedarf und klare Regeln. Verbessern Sie zuerst die Abfrage selbst, legen Sie die akzeptable Veralterung der gecachten Daten gemeinsam mit dem Produktteam fest, bilden Sie den Schlüssel aus allen Eingaben, die das Ergebnis beeinflussen, und stützen Sie die Invalidierung durch einen Ablauf. Versuchen Sie nicht, Stampedes von Hand zu lösen, sondern nutzen Sie einen erprobten Baustein wie HybridCache. Und weil der Cache ein unsichtbarer, aber das Verhalten prägender Teil der Anwendung ist, messen und testen Sie ihn wie jeden anderen Teil.