Cache prêt à l'emploi pour ASP.NET Core : IDistributedCache et HybridCache sur Zaris
La plupart des raisons de recourir à un cache distribué dans une application .NET ne mentionnent jamais un cache par son nom. Vous ajoutez du cache de sortie et il réclame un IDistributedCache. Vous activez les sessions et l'intergiciel réclame un store de secours. Vous activez le partage des clés de protection des données entre instances et, là encore, il réclame un endroit où mettre des octets que chaque nœud peut voir. Le code de l'application est écrit contre une abstraction ; la seule question est quelle implémentation vous enregistrez au démarrage.
Zaris répond à cette question avec trois petits paquets NuGet, chacun ciblant une couture ASP.NET Core spécifique : un IDistributedCache nu, un HybridCache à deux niveaux et un store de session. Vous enregistrez une ligne au démarrage et le reste de votre application — y compris les bibliothèques tierces qui ne connaissent que l'abstraction — met en cache sur un cluster Zaris sans un seul changement dans le code qui utilise le cache. Cet article passe en revue les trois, et est délibéré sur l'endroit où chacun s'arrête honnêtement.
Les trois paquets
| Paquet | Implémente | À utiliser pour |
|---|---|---|
Clustron.Zaris.DistributedCache | IDistributedCache | Toute bibliothèque ou code qui réclame l'abstraction de cache distribué standard |
Clustron.Zaris.HybridCache | HybridCache (de Microsoft.Extensions.Caching.Hybrid) | Un chemin chaud qui veut un L1 en cours de processus devant le cache partagé, avec protection contre les ruées |
Clustron.Zaris.AspNetCore.Session | ISessionStore | Sessions ASP.NET Core avec un timeout d'inactivité glissant |
Les trois reposent sur le même client Zaris .NET. Ils n'inventent pas de canal parallèle — ils résolvent un client Zaris normal à partir d'une chaîne de connexion et traduisent Get/Set/Remove en GetAsync/PutAsync/DeleteAsync du client. Si vous avez lu Une chaîne de connexion, n'importe quelle plateforme, le câblage vous paraîtra familier : le cache n'est qu'un autre consommateur d'un store nommé.
1. Le IDistributedCache standard
C'est le prêt-à-l'emploi. IDistributedCache est l'interface que Microsoft.Extensions.Caching.Abstractions expose depuis des années, et une grande partie de l'écosystème .NET — session, cache de sortie, stores de tickets d'authentification, bibliothèques tierces — dépend exactement de cette interface et de rien de plus. Enregistrez Zaris comme implémentation et tout cela est adossé à votre cluster.
builder.Services.AddClustronZaris("app-cache", "zaris://cache-host:7861/app-cache");
builder.Services.AddClustronDistributedCache(
storeName: "app-cache",
configure: o => o.KeyPrefix = "myapp:");
La première ligne enregistre un client Zaris sous la clé DI app-cache ; la seconde enregistre un IDistributedCache qui l'utilise. À partir de là, tout ce qui prend un IDistributedCache — y compris du code que vous n'avez pas écrit — fonctionne :
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;
}
}
Sous le capot, GetAsync est un client.GetAsync<byte[]> avec le préfixe configuré appliqué, et un défaut de cache (la clé n'existe pas) renvoie null — exactement le contrat que l'interface promet. SetAsync avec AbsoluteExpirationRelativeToNow fait correspondre cette fenêtre directement à un TTL Zaris, de sorte que le store expire l'entrée pour vous.
Deux limites honnêtes
Seule l'expiration absolue relative est prise en charge. Le IDistributedCache de Zaris lit AbsoluteExpirationRelativeToNow et le transforme en TTL. Si vous ne définissez aucune expiration, l'entrée est écrite sans TTL et vit jusqu'à être évincée ou écrasée. SlidingExpiration sur ce cache polyvalent n'est pas honoré — son Refresh/RefreshAsync est une opération nulle. C'est un choix de périmètre délibéré, pas un oubli : l'expiration glissante est une préoccupation de session, et le paquet de session ci-dessous l'implémente correctement plutôt que de la demi-implémenter ici.
Vous pouvez en enregistrer plusieurs, et le premier l'emporte par défaut. Chaque appel à AddClustronDistributedCache enregistre une instance de cache nommée liée à un store spécifique. Le premier enregistré devient le IDistributedCache à l'échelle de l'application ; passez setAsDefault: true pour faire d'un enregistrement ultérieur le défaut à la place. Si vous voulez plusieurs caches sur différents stores, résolvez-les explicitement via IClustronCacheProvider.GetCache("other-store") plutôt que de vous reposer sur le défaut ambiant.
2. HybridCache — un niveau en cours de processus devant le cluster
L'abstraction plus récente HybridCache de .NET résout un problème que IDistributedCache vous laisse : la combinaison d'un cache local rapide (L1) et d'un cache distribué partagé (L2), plus la protection contre les ruées — s'assurer que, lorsqu'une clé populaire expire, une horde de requêtes concurrentes n'exécute pas toutes la fabrique coûteuse en même temps.
Zaris implémente directement la classe abstraite HybridCache. La particularité est que les deux niveaux sont des stores Zaris que vous nommez — vous pointez le L1 vers un store embarqué en cours de processus (aucun réseau, aucun saut de sérialisation hors machine) et le L2 vers le cluster distribué :
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");
Le chemin de lecture est la recherche classique à deux niveaux, et il vaut la peine d'être vu en entier car chaque garantie ci-dessous en découle :
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" });
Le L1 est vérifié en premier sans verrou. En cas de défaut, l'appelant prend un verrou par clé, revérifie le L1 (au cas où un autre appelant viendrait de le remplir), puis le L2 ; ce n'est que si les deux échouent qu'il exécute la fabrique, écrit le L2, puis écrit le L1. Les TTL proviennent de HybridCacheEntryOptions — Expiration pour le L2, LocalCacheExpiration pour le L1 — et l'implémentation borne le L1 pour qu'il ne soit jamais plus long que le L2, de sorte que vous ne pouvez jamais avoir une copie locale qui survit à la copie partagée. Omettez les options et les deux prennent par défaut cinq minutes.
Protection contre les ruées, mesurée
Le verrou par clé est la protection contre les ruées. Le propre test du paquet déclenche vingt appels GetOrCreateAsync concurrents sur une seule clé froide, chacun avec une fabrique qui dort 100 ms, et vérifie que la fabrique s'est exécutée exactement une fois — dix-neuf appelants attendent sur le verrou, et celui qui a rempli le cache les sert tous. Cela transforme un marteau potentiel à vingt sur votre base de données en un unique chargement.
La limite honnête : ce verrou vit dans un ConcurrentDictionary<string, SemaphoreSlim> à l'intérieur du processus. Il effondre la ruée au sein d'une instance d'application. Avec dix instances d'application toutes froides sur la même clé au même instant, vous pouvez encore voir jusqu'à dix exécutions de fabrique, une par processus — pas une à l'échelle du cluster. C'est la forme normale de la protection contre les ruées côté client, et pour le cas courant (une poignée d'instances, une fabrique coûteuse mais pas catastrophique à exécuter quelques fois) c'est exactement ce que vous voulez. Si vous avez besoin d'un chargement strictement unique sur toute la flotte, c'est un problème de verrou distribué, pas une fonctionnalité de cache — et une tâche mieux modélisée avec le motif de revendication par comparaison-et-échange de Coordination sans course.
Invalidation par étiquette, et pourquoi elle est paresseuse
HybridCache vous laisse étiqueter des entrées et invalider tout un groupe d'un coup :
await cache.RemoveByTagAsync("catalog"); // every entry tagged "catalog" is now stale
Zaris implémente cela avec des horodatages plutôt qu'avec un balayage actif. Chaque entrée est écrite avec deux étiquettes de métadonnées : l'instant où elle a été créée, et ses étiquettes. RemoveByTagAsync("catalog") écrit simplement une clé enregistrant « catalog a été invalidé à l'instant T. » À la prochaine lecture d'une entrée étiquetée, le cache compare : si l'une des étiquettes de l'entrée a été invalidée après la création de l'entrée, l'entrée est traitée comme un défaut et la fabrique se réexécute. Le test le confirme — étiquetez une entrée, invalidez l'étiquette, relisez, et la fabrique est appelée une deuxième fois.
Le compromis de conception à connaître : l'invalidation est paresseuse et déclenchée par la lecture, pas une purge immédiate. Appeler RemoveByTagAsync est en O(1) — il écrit un marqueur et retourne — mais les entrées périmées elles-mêmes restent résidentes dans le store jusqu'à ce que quelque chose les lise (et obtienne une valeur fraîche) ou que leur TTL expire. Vous obtenez une invalidation logique bon marché et instantanée ; vous n'obtenez pas une récupération immédiate de la mémoire que ces entrées détiennent. Pour des données de cache, c'est exactement le bon marché. Ne vous appuyez simplement pas dessus comme moyen de forcer la suppression de données pour des raisons de correction — pour cela, RemoveAsync(key) supprime les deux niveaux d'emblée.
3. Sessions, avec une vraie expiration glissante
Les sessions ASP.NET Core ont besoin d'un store qui fait quelque chose que le cache général ne fait délibérément pas : faire glisser l'expiration à chaque requête qui touche la session, de sorte que la session d'un utilisateur actif n'expire jamais sous ses pieds tandis qu'une session inactive finit par le faire. Le paquet de session est un IDistributedCache conçu spécialement qui honore exactement cette sémantique, câblé en un seul appel :
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();
Donnez-lui une chaîne de connexion et il enregistre le store Zaris de secours pour vous ; omettez-la et il se lie à un store que vous avez déjà enregistré sous StoreName. Dans les deux cas, vous utilisez ensuite HttpContext.Session exactement comme vous l'avez toujours fait.
La pièce qui compte est Refresh. Lorsque l'intergiciel de session charge une session lue mais non modifiée, il appelle RefreshAsync pour la maintenir en vie. Le cache de session Zaris implémente cela comme une lecture-puis-réécriture avec un TTL frais égal au timeout d'inactivité — de sorte que chaque requête qui touche la session fait glisser la fenêtre de 30 minutes vers l'avant. Une écriture (SetAsync) résout son TTL à partir de l'expiration glissante ou absolue de l'entrée, en se rabattant sur le timeout d'inactivité configuré. Le résultat est un comportement de session ASP.NET Core standard, adossé à un cluster Zaris, survivant à un redémarrage d'instance d'application parce que les données de session vivent dans le store et non dans la mémoire de l'instance.
Une note d'implémentation délibérée issue du code lui-même : Refresh est une lecture-plus-réécriture plutôt qu'un appel natif « réinitialise simplement le TTL ». Cela coûte un aller-retour supplémentaire, mais cela garde le glissement correct sur chaque déploiement Zaris — y compris celui embarqué en cours de processus — sans dépendre d'une primitive serveur de réinitialisation de TTL. La correction sur chaque topologie l'a emporté sur l'économie d'un aller-retour sur l'une d'elles.
Choisir le bon
- Recourez à
IDistributedCachequand quelque chose le réclame déjà, ou quand vous voulez le cache partagé le plus simple et qu'une bibliothèque va le consommer pour vous. C'est la couture de compatibilité maximale. - Recourez à
HybridCachesur un chemin de lecture véritablement chaud où un L1 en cours de processus gagne son pain et où une ruée sur une clé populaire froide ferait mal. Vous payez une pièce mobile supplémentaire (deux stores, mémoire locale pour le L1) ; vous obtenez des lectures répétées à latence quasi nulle et une horde effondrée à un chargement par instance. - Recourez à
AddZarisSessionpour les sessions ASP.NET Core spécifiquement — c'est celui avec une vraie expiration glissante, et il reste à l'écart du propreIDistributedCachede votre application.
Aucun des trois ne vous demande d'apprendre une API spécifique au cache. Ils rencontrent le framework là où il définit déjà l'abstraction, s'enregistrent en une ligne, et mettent les octets sur un cluster que vous pouvez mettre à l'échelle, sécuriser et exécuter sur Kubernetes indépendamment de l'application qui est devant. La meilleure intégration est celle que le code de votre application n'a jamais à remarquer — et, ce qui est agréable, c'est souvent exactement celle que vous obtenez.