Drop-in-Cache für ASP.NET Core: IDistributedCache und HybridCache auf Zaris
Die meisten Gründe, in einer .NET-App zu einem verteilten Cache zu greifen, nennen den Cache gar nicht beim Namen. Sie aktivieren Output Caching, und es verlangt ein IDistributedCache. Sie aktivieren Sessions, und die Middleware will einen Backing-Store. Sie schalten das Teilen von Data-Protection-Schlüsseln über Instanzen hinweg ein, und wieder braucht es einen Ort für Bytes, den jeder Node sieht. Der Anwendungscode ist gegen eine Abstraktion geschrieben; offen ist nur, welche Implementierung Sie beim Start registrieren.
Zaris beantwortet diese Frage mit drei kleinen NuGet-Paketen, die jeweils eine bestimmte ASP.NET-Core-Nahtstelle bedienen: ein schlichtes IDistributedCache, ein zweistufiges HybridCache und ein Session-Store. Sie registrieren beim Start eine Zeile, und der Rest Ihrer App — einschließlich Drittanbieter-Bibliotheken, die nur die Abstraktion kennen — cacht auf einem Zaris-Cluster, ohne dass sich der Code ändert, der den Cache nutzt. Dieser Beitrag geht alle drei durch und ist dabei bewusst klar darüber, wo jedes ehrlicherweise endet.
Die drei Pakete
| Paket | Implementiert | Verwenden für |
|---|---|---|
Clustron.Zaris.DistributedCache | IDistributedCache | Jede Bibliothek oder jeden Code, der die Standard-Abstraktion für verteilte Caches anfordert |
Clustron.Zaris.HybridCache | HybridCache (aus Microsoft.Extensions.Caching.Hybrid) | Einen Hot Path, der ein prozessinternes L1 vor dem gemeinsamen Cache wünscht, mit Stampede-Schutz |
Clustron.Zaris.AspNetCore.Session | ISessionStore | ASP.NET-Core-Sessions mit gleitendem Idle-Timeout |
Alle drei setzen auf demselben Zaris-.NET-Client auf. Sie erfinden keinen Seitenkanal — sie lösen aus einer Connection-String einen normalen Zaris-Client auf und übersetzen Get/Set/Remove in GetAsync/PutAsync/DeleteAsync des Clients. Wenn Sie Ein Connection-String, jede Plattform gelesen haben, wirkt die Verdrahtung vertraut: Der Cache ist nur ein weiterer Consumer eines benannten Stores.
1. Das Standard-IDistributedCache
Das ist das Drop-in. IDistributedCache ist die Schnittstelle, die Microsoft.Extensions.Caching.Abstractions seit Jahren bereitstellt, und ein großer Teil des .NET-Ökosystems — Session, Output Caching, Auth-Ticket-Stores, Drittanbieter-Bibliotheken — hängt genau von dieser Schnittstelle ab und von nichts weiterem. Registrieren Sie Zaris als Implementierung, und all das wird von Ihrem Cluster getragen.
builder.Services.AddClustronZaris("app-cache", "zaris://cache-host:7861/app-cache");
builder.Services.AddClustronDistributedCache(
storeName: "app-cache",
configure: o => o.KeyPrefix = "myapp:");
Die erste Zeile registriert einen Zaris-Client unter dem DI-Schlüssel app-cache; die zweite registriert ein IDistributedCache, das ihn nutzt. Von da an funktioniert alles, was ein IDistributedCache entgegennimmt — auch Code, den Sie nicht geschrieben haben:
public sealed class WeatherController(IDistributedCache cache)
{
public async Task<Forecast> Get(string city)
{
var cached = await cache.GetAsync($"forecast:{city}");
if (cached is not null)
return Deserialize(cached);
var fresh = await FetchForecastAsync(city);
await cache.SetAsync(
$"forecast:{city}",
Serialize(fresh),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
return fresh;
}
}
Intern ist GetAsync ein client.GetAsync<byte[]> mit angewendetem konfiguriertem Präfix, und ein Cache-Miss (der Schlüssel existiert nicht) liefert null — genau den Vertrag, den die Schnittstelle verspricht. SetAsync mit AbsoluteExpirationRelativeToNow bildet dieses Fenster direkt auf ein Zaris-TTL ab, sodass der Store den Eintrag für Sie ablaufen lässt.
Zwei ehrliche Grenzen
Nur die relative absolute Ablaufzeit wird abgebildet. Das Zaris-IDistributedCache liest AbsoluteExpirationRelativeToNow und macht daraus ein TTL. Setzen Sie keine Ablaufzeit, wird der Eintrag ohne TTL geschrieben und lebt, bis er per Eviction entfernt oder überschrieben wird. SlidingExpiration wird in diesem Allzweck-Cache nicht berücksichtigt — sein Refresh/RefreshAsync ist ein No-op. Das ist eine bewusste Abgrenzung, kein Versehen: Sliding Expiration ist ein Session-Thema, und das unten beschriebene Session-Paket implementiert sie ordentlich, statt sie hier halb zu implementieren.
Sie können mehr als eines registrieren, und standardmäßig gewinnt das erste. Jeder Aufruf von AddClustronDistributedCache registriert eine benannte Cache-Instanz, die an einen bestimmten Store gebunden ist. Die zuerst registrierte wird zum appweiten IDistributedCache; mit setAsDefault: true machen Sie stattdessen eine spätere Registrierung zum Default. Wenn Sie mehrere Caches auf verschiedenen Stores wollen, lösen Sie diese explizit über IClustronCacheProvider.GetCache("other-store") auf, statt sich auf den ambienten Default zu verlassen.
2. HybridCache — eine prozessinterne Stufe vor dem Cluster
Die neuere HybridCache-Abstraktion von .NET löst ein Problem, das IDistributedCache Ihnen überlässt: die Kombination aus einem schnellen lokalen (L1) Cache und einem gemeinsamen verteilten (L2) Cache, plus Stampede-Schutz — sicherzustellen, dass nicht eine Thundering Herd gleichzeitiger Requests die teure Factory auf einmal ausführt, wenn ein beliebter Schlüssel abläuft.
Zaris implementiert die abstrakte Klasse HybridCache direkt. Der Kniff: Beide Stufen sind Zaris-Stores, die Sie benennen — L1 zeigen Sie auf einen prozessinternen Embedded-Store (kein Netzwerk, kein Serialisierungs-Hop aus der Maschine heraus) und L2 auf den verteilten Cluster:
builder.Services.AddClustronZaris("l1", "zaris://inproc/l1"); // embedded, in-process
builder.Services.AddClustronZaris("l2", "zaris://cache-host:7861/l2"); // the cluster
builder.Services.AddClustronHybridCache(l1Store: "l1", l2Store: "l2");
Der Lesepfad ist die klassische zweistufige Suche, und es lohnt sich, ihn vollständig zu sehen, denn jede Garantie unten ergibt sich daraus:
var product = await cache.GetOrCreateAsync(
$"product:{id}",
state: id,
factory: async (pid, ct) => await db.LoadProductAsync(pid, ct),
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // L2 TTL
LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 TTL (shorter)
},
tags: new[] { "catalog" });
L1 wird zuerst ohne Lock geprüft. Bei einem Miss nimmt der Aufrufer ein Lock pro Schlüssel, prüft L1 erneut (falls ein anderer Aufrufer es gerade gefüllt hat), dann L2; nur wenn beide verfehlen, führt er die Factory aus, schreibt L2 und dann L1. Die TTLs stammen aus HybridCacheEntryOptions — Expiration für L2, LocalCacheExpiration für L1 —, und die Implementierung begrenzt L1 auf höchstens L2, sodass eine lokale Kopie die gemeinsame nie überleben kann. Ohne Optionen betragen beide standardmäßig fünf Minuten.
Stampede-Schutz, gemessen
Das Lock pro Schlüssel ist der Stampede-Schutz. Der Test des Pakets feuert zwanzig gleichzeitige GetOrCreateAsync-Aufrufe auf einen kalten Schlüssel ab, jeweils mit einer Factory, die 100 ms schläft, und prüft, dass die Factory genau einmal lief — neunzehn Aufrufer warten auf das Lock, und der, der den Cache gefüllt hat, bedient alle. So wird ein potenzieller zwanzigfacher Hammerschlag auf Ihre Datenbank zu einem einzigen Ladevorgang.
Die ehrliche Grenze: Dieses Lock lebt in einem ConcurrentDictionary<string, SemaphoreSlim> innerhalb des Prozesses. Es bricht den Stampede innerhalb einer App-Instanz. Sind zehn App-Instanzen im selben Augenblick auf demselben Schlüssel kalt, können Sie weiterhin bis zu zehn Factory-Läufe sehen, einen je Prozess — nicht einen clusterweit. Das ist die normale Form des clientseitigen Stampede-Schutzes, und für den üblichen Fall (eine Handvoll Instanzen, eine Factory, die teuer, aber nicht katastrophal ist, ein paar Mal auszuführen) genau das, was Sie wollen. Brauchen Sie strikt einen Ladevorgang für die gesamte Flotte, ist das ein Problem für verteilte Locks, kein Cache-Feature — und eine Aufgabe, die sich besser mit dem Compare-and-Swap-Claim-Muster aus Race-freie Koordination modellieren lässt.
Tag-Invalidierung, und warum sie lazy ist
HybridCache erlaubt es, Einträge zu taggen und eine ganze Gruppe auf einmal zu invalidieren:
await cache.RemoveByTagAsync("catalog"); // every entry tagged "catalog" is now stale
Zaris implementiert das mit Zeitstempeln statt mit einem aktiven Sweep. Jeder Eintrag wird mit zwei Metadaten-Labels geschrieben: dem Zeitpunkt der Erstellung und seinen Tags. RemoveByTagAsync("catalog") schreibt einfach einen Schlüssel, der festhält, „catalog wurde zum Zeitpunkt T invalidiert." Beim nächsten Lesen eines getaggten Eintrags vergleicht der Cache: Wurde irgendein Tag des Eintrags nach dessen Erstellung invalidiert, wird der Eintrag als Miss behandelt, und die Factory läuft erneut. Der Test bestätigt es — Eintrag taggen, Tag invalidieren, erneut lesen, und die Factory wird ein zweites Mal aufgerufen.
Der Zielkonflikt, den Sie kennen sollten: Die Invalidierung ist lazy und lesegetriggert, kein eifriges Löschen. RemoveByTagAsync aufzurufen ist O(1) — es schreibt einen Marker und kehrt zurück —, aber die veralteten Einträge selbst bleiben im Store, bis sie jemand liest (und einen frischen Wert erhält) oder ihr TTL abläuft. Sie erhalten billige, sofortige logische Invalidierung; Sie erhalten keine sofortige Rückgewinnung des Speichers, den diese Einträge belegen. Für Cache-Daten ist das genau der richtige Handel. Verlassen Sie sich nur nicht darauf, um Daten aus Korrektheitsgründen zwangsweise zu löschen — dafür löscht RemoveAsync(key) beide Stufen ausdrücklich.
3. Sessions, mit echter Sliding Expiration
ASP.NET-Core-Sessions brauchen einen Store, der etwas tut, was der allgemeine Cache bewusst nicht tut: die Ablaufzeit bei jedem Request, der die Session berührt, gleiten zu lassen, sodass die Session eines aktiven Benutzers ihm nie unter den Händen ablaufen, die eines inaktiven aber irgendwann schon. Das Session-Paket ist ein eigens gebautes IDistributedCache, das genau diese Semantik berücksichtigt und mit einem Aufruf eingebunden wird:
builder.Services.AddZarisSession(o =>
{
o.StoreName = "sessions";
o.ConnectionString = "zaris://cache-host:7861/sessions";
o.IdleTimeout = TimeSpan.FromMinutes(30);
o.CookieName = ".MyApp.Session";
});
// ... later in the pipeline, as usual:
app.UseSession();
Geben Sie eine Connection-String an, registriert es den Backing-Zaris-Store für Sie; lassen Sie sie weg, bindet es sich an einen Store, den Sie bereits unter StoreName registriert haben. In beiden Fällen nutzen Sie HttpContext.Session danach genau wie immer.
Entscheidend ist Refresh. Lädt die Session-Middleware eine Session, die gelesen, aber nicht verändert wurde, ruft sie RefreshAsync auf, um sie am Leben zu halten. Der Zaris-Session-Cache implementiert das als Read-then-Re-put mit einem frischen TTL in Höhe des Idle-Timeouts — jeder Request, der die Session berührt, schiebt also das 30-Minuten-Fenster weiter. Ein Schreibvorgang (SetAsync) leitet sein TTL aus der Sliding- oder absoluten Ablaufzeit des Eintrags ab und fällt auf den konfigurierten Idle-Timeout zurück. Das Ergebnis ist das übliche ASP.NET-Core-Session-Verhalten, getragen von einem Zaris-Cluster, das den Neustart einer App-Instanz übersteht, weil die Session-Daten im Store liegen und nicht im Speicher der Instanz.
Ein bewusster Implementierungshinweis aus dem Code selbst: Refresh ist ein Read-plus-Re-put statt eines nativen „setze einfach das TTL zurück"-Aufrufs. Das kostet einen zusätzlichen Roundtrip, hält das Sliding aber auf jedem Zaris-Deployment korrekt — auch dem eingebetteten prozessinternen — ohne von einem serverseitigen TTL-Reset-Primitiv abzuhängen. Korrektheit über jede Topologie hinweg schlug das Einsparen eines Roundtrips bei einer davon.
Das Richtige auswählen
- Greifen Sie zu
IDistributedCache, wenn etwas es bereits anfordert oder wenn Sie den einfachsten gemeinsamen Cache wollen und eine Bibliothek ihn für Sie nutzt. Es ist die Nahtstelle mit maximaler Kompatibilität. - Greifen Sie zu
HybridCacheauf einem wirklich heißen Lesepfad, auf dem sich ein prozessinternes L1 lohnt und ein Stampede auf einen kalten, beliebten Schlüssel wehtäte. Sie zahlen mit einem zusätzlichen beweglichen Teil (zwei Stores, lokaler Speicher für L1); Sie erhalten wiederholte Lesevorgänge mit nahezu null Latenz und eine auf einen Ladevorgang je Instanz zusammengefallene Herde. - Greifen Sie zu
AddZarisSessionspeziell für ASP.NET-Core-Sessions — es ist das mit echter Sliding Expiration und kommt demIDistributedCacheIhrer App nicht in die Quere.
Keines der drei verlangt, dass Sie eine cachespezifische API lernen. Sie treffen das Framework dort, wo es die Abstraktion ohnehin definiert, registrieren sich in einer Zeile und legen die Bytes auf einen Cluster, den Sie unabhängig von der App davor skalieren, absichern und auf Kubernetes betreiben können. Die beste Integration ist die, die Ihr Anwendungscode nie bemerken muss — und erfreulich oft ist das genau die, die Sie bekommen.