Caché lista para usar en ASP.NET Core: IDistributedCache y HybridCache sobre Zaris
La mayoría de las razones para recurrir a una caché distribuida en una aplicación .NET nunca mencionan una caché por su nombre. Añades caché de salida (output caching) y quiere un IDistributedCache. Habilitas sesiones y el middleware quiere un almacén de respaldo. Activas el uso compartido de claves de protección de datos entre instancias y, de nuevo, quiere un lugar donde poner bytes que todos los nodos puedan ver. El código de la aplicación está escrito contra una abstracción; la única pregunta es qué implementación registras al arrancar.
Zaris responde a esa pregunta con tres pequeños paquetes NuGet, cada uno apuntando a una costura específica de ASP.NET Core: un IDistributedCache simple, un HybridCache de dos niveles y un almacén de sesiones. Registras una línea al arrancar y el resto de tu aplicación —incluidas las bibliotecas de terceros que solo conocen la abstracción— cachea en un clúster Zaris sin un solo cambio en el código que usa la caché. Esta entrada recorre los tres, y es deliberada sobre dónde se detiene honestamente cada uno.
Los tres paquetes
| Paquete | Implementa | Úsalo para |
|---|---|---|
Clustron.Zaris.DistributedCache | IDistributedCache | Cualquier biblioteca o código que pida la abstracción estándar de caché distribuida |
Clustron.Zaris.HybridCache | HybridCache (de Microsoft.Extensions.Caching.Hybrid) | Un camino caliente que quiere un L1 en proceso delante de la caché compartida, con protección contra estampidas |
Clustron.Zaris.AspNetCore.Session | ISessionStore | Sesiones de ASP.NET Core con un tiempo de espera de inactividad deslizante |
Los tres se apoyan en el mismo cliente .NET de Zaris. No inventan un canal lateral: resuelven un cliente Zaris normal a partir de una cadena de conexión y traducen Get/Set/Remove a los GetAsync/PutAsync/DeleteAsync del cliente. Si has leído Una cadena de conexión, cualquier plataforma, el cableado te resultará familiar: la caché es simplemente otro consumidor de un almacén con nombre.
1. El IDistributedCache estándar
Este es el de usar y listo. IDistributedCache es la interfaz que Microsoft.Extensions.Caching.Abstractions ha expuesto durante años, y una gran parte del ecosistema .NET —sesiones, caché de salida, almacenes de tickets de autenticación, bibliotecas de terceros— depende exactamente de esa interfaz y de nada más. Registra Zaris como la implementación y todo eso queda respaldado por tu clúster.
builder.Services.AddClustronZaris("app-cache", "zaris://cache-host:7861/app-cache");
builder.Services.AddClustronDistributedCache(
storeName: "app-cache",
configure: o => o.KeyPrefix = "myapp:");
La primera línea registra un cliente Zaris bajo la clave de DI app-cache; la segunda registra un IDistributedCache que lo usa. A partir de aquí, cualquier cosa que acepte un IDistributedCache —incluido código que no escribiste tú— funciona:
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;
}
}
Por debajo, GetAsync es un client.GetAsync<byte[]> con el prefijo configurado aplicado, y un fallo de caché (la clave no existe) devuelve null, exactamente el contrato que la interfaz promete. SetAsync con AbsoluteExpirationRelativeToNow mapea esa ventana directamente a un TTL de Zaris, de modo que el almacén expira la entrada por ti.
Dos bordes honestos
Solo se mapea la expiración absoluta relativa. El IDistributedCache de Zaris lee AbsoluteExpirationRelativeToNow y lo convierte en un TTL. Si no estableces expiración, la entrada se escribe sin TTL y vive hasta que se desaloja o se sobrescribe. SlidingExpiration en esta caché de propósito general no se respeta; su Refresh/RefreshAsync es una operación nula. Esa es una elección deliberada de alcance, no un descuido: la expiración deslizante es un asunto de sesiones, y el paquete de sesiones de más abajo la implementa correctamente en lugar de implementarla a medias aquí.
Puedes registrar más de uno, y el primero gana por defecto. Cada llamada a AddClustronDistributedCache registra una instancia de caché con nombre vinculada a un almacén específico. El primero registrado se convierte en el IDistributedCache de toda la aplicación; pasa setAsDefault: true para hacer que un registro posterior sea el predeterminado en su lugar. Si quieres varias cachés en almacenes distintos, resuélvelas explícitamente a través de IClustronCacheProvider.GetCache("other-store") en lugar de depender del predeterminado ambiental.
2. HybridCache — un nivel en proceso delante del clúster
La abstracción más reciente de .NET, HybridCache, resuelve un problema que IDistributedCache te deja a ti: la combinación de una caché local rápida (L1) y una caché distribuida compartida (L2), más la protección contra estampidas, asegurando que cuando una clave popular expira, una estampida de solicitudes concurrentes no ejecute todas a la vez la costosa fábrica (factory).
Zaris implementa directamente la clase abstracta HybridCache. El giro es que ambos niveles son almacenes Zaris que tú nombras: apuntas L1 a un almacén embebido en proceso (sin red, sin salto de serialización fuera de la máquina) y L2 al clúster distribuido:
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");
El camino de lectura es la clásica búsqueda de dos niveles, y vale la pena verlo completo porque cada garantía de más abajo se desprende de él:
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 se comprueba primero sin bloqueo. Ante un fallo, el llamante toma un bloqueo por clave, vuelve a comprobar L1 (por si otro llamante acaba de rellenarlo), luego L2; solo si ambos fallan ejecuta la fábrica, escribe L2 y luego escribe L1. Los TTL vienen de HybridCacheEntryOptions —Expiration para L2, LocalCacheExpiration para L1— y la implementación limita L1 a no ser más largo que L2, de modo que nunca puedes tener una copia local que sobreviva a la compartida. Omite las opciones y ambos tienen por defecto cinco minutos.
Protección contra estampidas, medida
El bloqueo por clave es la protección contra estampidas. La propia prueba del paquete dispara veinte llamadas concurrentes a GetOrCreateAsync sobre una clave fría, cada una con una fábrica que duerme 100 ms, y comprueba que la fábrica se ejecutó exactamente una vez: diecinueve llamantes esperan en el bloqueo, y el que rellenó la caché los sirve a todos. Eso convierte un potencial martilleo de veinte vías sobre tu base de datos en una sola carga.
El borde honesto: este bloqueo vive en un ConcurrentDictionary<string, SemaphoreSlim> dentro del proceso. Colapsa la estampida dentro de una instancia de aplicación. Con diez instancias de aplicación todas frías sobre la misma clave en el mismo instante, todavía puedes ver hasta diez ejecuciones de fábrica, una por proceso, no una a nivel de todo el clúster. Esa es la forma normal de la protección contra estampidas del lado del cliente, y para el caso común (un puñado de instancias, una fábrica que es costosa pero no catastrófica de ejecutar unas pocas veces) es exactamente lo que quieres. Si necesitas estrictamente una sola carga en toda la flota, eso es un problema de bloqueo distribuido, no una característica de caché, y un trabajo mejor modelado con el patrón de reclamación por comparación e intercambio (compare-and-swap) de Coordinación sin condiciones de carrera.
Invalidación por etiquetas, y por qué es perezosa
HybridCache te permite etiquetar entradas e invalidar un grupo entero de una vez:
await cache.RemoveByTagAsync("catalog"); // every entry tagged "catalog" is now stale
Zaris implementa esto con marcas de tiempo en lugar de un barrido activo. Cada entrada se escribe con dos etiquetas de metadatos: la hora en que se creó y sus etiquetas. RemoveByTagAsync("catalog") simplemente escribe una clave que registra "catalog fue invalidado en el instante T." En la siguiente lectura de una entrada etiquetada, la caché compara: si alguna de las etiquetas de la entrada fue invalidada después de que se creara la entrada, la entrada se trata como un fallo y la fábrica se vuelve a ejecutar. La prueba lo confirma: etiqueta una entrada, invalida la etiqueta, lee de nuevo, y la fábrica se llama por segunda vez.
La disyuntiva de diseño que hay que conocer: la invalidación es perezosa y disparada por lectura, no una purga ávida. Llamar a RemoveByTagAsync es O(1) —escribe un marcador y retorna— pero las entradas obsoletas en sí permanecen residentes en el almacén hasta que algo las lee (y obtiene un valor fresco) o su TTL expira. Obtienes una invalidación lógica barata e instantánea; no obtienes una recuperación inmediata de la memoria que esas entradas ocupan. Para datos de caché ese es exactamente el trato correcto. Simplemente no te apoyes en ello como una forma de forzar el borrado de datos por razones de corrección; para eso, RemoveAsync(key) elimina ambos niveles de plano.
3. Sesiones, con expiración deslizante real
Las sesiones de ASP.NET Core necesitan un almacén que haga algo que la caché general deliberadamente no hace: deslizar la expiración en cada solicitud que toca la sesión, de modo que la sesión de un usuario activo nunca expire por debajo de él mientras que una inactiva eventualmente sí. El paquete de sesiones es un IDistributedCache hecho a propósito que respeta exactamente esa semántica, cableado con una llamada:
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();
Dale una cadena de conexión y registra el almacén Zaris de respaldo por ti; omítela y se vincula a un almacén que ya hayas registrado bajo StoreName. En cualquier caso, luego usas HttpContext.Session exactamente como siempre lo has hecho.
La pieza que importa es Refresh. Cuando el middleware de sesión carga una sesión que fue leída pero no modificada, llama a RefreshAsync para mantenerla viva. La caché de sesión de Zaris implementa eso como una lectura-y-luego-reescritura con un TTL fresco igual al tiempo de espera de inactividad, de modo que cada solicitud que toca la sesión desliza la ventana de 30 minutos hacia delante. Una escritura (SetAsync) resuelve su TTL a partir de la expiración deslizante o absoluta de la entrada, recurriendo al tiempo de espera de inactividad configurado. El resultado es el comportamiento estándar de sesiones de ASP.NET Core, respaldado por un clúster Zaris, sobreviviendo a un reinicio de instancia de aplicación porque los datos de sesión viven en el almacén y no en la memoria de la instancia.
Una nota deliberada de implementación tomada del propio código: Refresh es una lectura-más-reescritura en lugar de una llamada nativa de "simplemente reiniciar el TTL". Eso cuesta una ida y vuelta extra, pero mantiene el deslizamiento correcto en cada despliegue de Zaris —incluido el embebido en proceso— sin depender de una primitiva de reinicio-de-TTL del lado del servidor. La corrección en cada topología superó a ahorrar una ida y vuelta en una de ellas.
Elegir el correcto
- Recurre a
IDistributedCachecuando algo ya lo pide, o cuando quieres la caché compartida más simple y una biblioteca va a consumirla por ti. Es la costura de máxima compatibilidad. - Recurre a
HybridCacheen un camino de lectura genuinamente caliente donde un L1 en proceso se gana su sitio y una estampida sobre una clave popular fría dolería. Pagas por una pieza móvil extra (dos almacenes, memoria local para L1); obtienes lecturas repetidas de latencia casi nula y una estampida colapsada a una carga por instancia. - Recurre a
AddZarisSessionespecíficamente para sesiones de ASP.NET Core; es la que tiene expiración deslizante real, y se mantiene al margen del propioIDistributedCachede tu aplicación.
Ninguno de los tres te pide aprender una API específica de caché. Se encuentran con el framework allí donde ya define la abstracción, se registran en una línea y ponen los bytes en un clúster que puedes escalar, asegurar y ejecutar en Kubernetes independientemente de la aplicación que tiene delante. La mejor integración es la que el código de tu aplicación nunca tiene que notar, y, con grata frecuencia, esa es exactamente la que obtienes.