Caching en .NET: MemoryCache vs caché distribuida
La caché acelera todo hasta que muestra datos viejos o se desincroniza. Cuándo usar IMemoryCache, IDistributedCache o HybridCache y cómo invalidar.
Hay una frase conocida en programación: las dos cosas más difíciles son nombrar cosas e invalidar cachés. Agregar caché es fácil: tres líneas y el endpoint responde en 2 ms en lugar de 200. Lo difícil viene después, cuando un cliente ve un precio que cambió hace una hora o cuando el segundo servidor muestra datos distintos al primero.
Elegir el tipo de caché correcto depende menos del rendimiento y más de una pregunta: ¿qué pasa si el dato está desactualizado?
IMemoryCache: en la memoria del proceso
builder.Services.AddMemoryCache();
public class CategoryService(IMemoryCache cache, AppDbContext db)
{
public Task<List<CategoryDto>?> GetAllAsync(CancellationToken ct) =>
cache.GetOrCreateAsync("categories:all", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.Size = 1; // necesario si configuras SizeLimit
return db.Categories.Select(c => new CategoryDto(c.Id, c.Name)).ToListAsync(ct);
});
}
Ventajas: acceso en nanosegundos, sin red ni serialización.
Limitaciones:
- Cada servidor tiene su propia copia. Con tres instancias detrás de un balanceador, invalidar en una no invalida en las otras. Un usuario puede ver el dato nuevo en una petición y el viejo en la siguiente.
- Se pierde al reiniciar o desplegar, y todas las instancias arrancan "frías" al mismo tiempo.
- Compite por memoria con la aplicación. Sin
SizeLimit, una caché que crece sin control termina en presión de memoria y recolecciones de basura largas.
IDistributedCache: compartida entre servidores
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "api:";
});
Todas las instancias leen y escriben en el mismo lugar (Redis, SQL Server u otro proveedor). Invalidar una vez sirve para todos y la caché sobrevive a los despliegues.
El costo: cada acceso es una llamada de red (normalmente uno o pocos milisegundos) más la serialización. Y la interfaz trabaja con byte[], así que tienes que serializar tú mismo.
HybridCache (.NET 9): lo mejor de los dos
HybridCache combina una caché en memoria (nivel 1) con una distribuida opcional (nivel 2), y resuelve un problema que las otras dos dejan abierto:
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // nivel 2 (distribuido)
LocalCacheExpiration = TimeSpan.FromMinutes(1) // nivel 1 (memoria)
};
});
public async Task<ProductDto?> GetAsync(int id, CancellationToken ct) =>
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);
- Protección contra la estampida. Si 500 peticiones piden la misma clave cuando expira, solo una ejecuta la consulta; las demás esperan ese resultado. Con
IMemoryCache.GetOrCreateAsync, las 500 irían a la base de datos. - Serialización integrada para el nivel distribuido.
- Invalidación por etiquetas:
await cache.RemoveByTagAsync("products").
Si registras un IDistributedCache (por ejemplo, Redis), HybridCache lo usa como segundo nivel automáticamente. Sin él, funciona solo en memoria, pero con la protección contra estampidas.
Invalidación: la parte difícil
Hay dos estrategias, y conviene elegir una a propósito:
- Expiración por tiempo. Simple y predecible: el dato puede estar desactualizado hasta N minutos. Funciona cuando eso es aceptable (categorías, configuración, catálogos).
- Invalidación explícita al escribir. Cuando el dato cambia, se elimina de la caché:
public async Task UpdatePriceAsync(int id, decimal price, CancellationToken ct)
{
await db.Products.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
await cache.RemoveAsync($"product:{id}", ct);
}
El riesgo de la segunda: cada lugar que modifica el dato tiene que acordarse de invalidar. Un proceso nocturno, un script manual o una actualización desde otro sistema que no lo haga deja la caché desincronizada hasta que expire. Por eso conviene combinarla con una expiración, aunque sea larga, como red de seguridad.
Qué no cachear
- Datos por usuario con alta variación, si la tasa de aciertos es baja: ocupan memoria y casi nunca se reutilizan.
- Datos que deben ser exactos en el momento: saldo disponible, stock al confirmar una compra, permisos recién revocados.
- Respuestas de error: si cacheas un "no encontrado" por 10 minutos, el producto recién creado no aparecerá en ese tiempo. Si lo haces a propósito para proteger la base de datos, que sea por pocos segundos.
El trade-off
| IMemoryCache | IDistributedCache | HybridCache | |
|---|---|---|---|
| Velocidad | Máxima | Red + serialización | Máxima en nivel 1 |
| Coherencia entre servidores | No | Sí | Nivel 2 sí; nivel 1 hasta que expira |
| Sobrevive despliegues | No | Sí | Nivel 2 sí |
| Protección contra estampida | No | No | Sí |
| Infraestructura extra | Ninguna | Redis u otro | Opcional |
Cuándo no agregar caché
- Cuando la consulta es lenta por un problema que se puede corregir (un índice que falta, un N+1). La caché esconde el problema y lo traslada al momento en que expira.
- Cuando el sistema tiene poco tráfico: la caché agrega complejidad y casos de datos viejos sin un beneficio visible.
- Cuando no puedes responder qué pasa si el dato está desactualizado.
Conclusión
Una aplicación en un solo servidor con datos que toleran unos minutos de retraso está bien con IMemoryCache. Con varias instancias, la caché distribuida evita inconsistencias. Y en .NET 9 o posterior, HybridCache es un buen punto de partida para casi todo, sobre todo por su protección contra estampidas. Pero antes de cachear, revisa si la consulta se puede optimizar desde EF Core.
¿Necesitas un equipo .NET para tu proyecto?
Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.
Ver desarrollo a medida