En la primera parte de esta serie implementamos el patrón Cache-Aside con IMemoryCache en ASP.NET Core. Funciona perfecto para una sola instancia, pero en producción con múltiples replicas la historia cambia.
El problema con IMemoryCache en entornos distribuidos
Cuando una aplicación corre en múltiples instancias (detrás de un load balancer, en Kubernetes, etc.), cada instancia mantiene su propio caché independiente. Esto genera:
- Inconsistencias: distintas instancias pueden tener versiones distintas del mismo dato.
- Duplicación: el mismo dato se cachea N veces en N instancias.
- Mayor presión de memoria: cada proceso consume RAM por separado.
- Invalidación incompleta: borrar el caché en una instancia no afecta a las demás.
La solución: un caché distribuido compartido. El candidato más popular en el ecosistema .NET es Redis.
¿Por qué Redis?
Redis (Remote Dictionary Server) es un almacén de datos en memoria de código abierto, extremadamente rápido y con soporte nativo para estructuras de datos, TTL, pub/sub y más.
Ventajas clave sobre IMemoryCache en producción:
- Caché compartido entre todas las instancias de la aplicación.
- Persistencia opcional ante reinicios del servicio.
- Escalabilidad horizontal con Redis Cluster.
- Monitoreo dedicado con herramientas como RedisInsight.
- Soporte nativo de TTL (expiración automática por clave).
Opción 1: Cache-Aside con IDistributedCache + Redis
ASP.NET Core incluye la abstracción IDistributedCache, compatible con Redis mediante el paquete oficial de Microsoft.
Instalación
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
Configuración en Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
Y en appsettings.json:
{
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}
Implementación del servicio con Cache-Aside
public class ProductService
{
private readonly IDistributedCache _cache;
private readonly AppDbContext _dbContext;
public ProductService(IDistributedCache cache, AppDbContext dbContext)
{
_cache = cache;
_dbContext = dbContext;
}
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"product_{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
return JsonSerializer.Deserialize<Product>(cached);
// Cache miss: consulta la base de datos
var product = await _dbContext.Products.FindAsync(id);
if (product != null)
{
var options = new DistributedCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5))
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(product),
options
);
}
return product;
}
public async Task UpdateProductAsync(Product product)
{
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
await _cache.RemoveAsync($"product_{product.Id}");
}
}
El patrón es idéntico al de IMemoryCache, con la diferencia de que el dato se serializa a JSON (Redis trabaja con bytes/strings) y el caché es compartido entre todas las instancias.
Opción 2: Cache-Aside con StackExchange.Redis directamente
Si necesitás más control (pipelines, transacciones, estructuras avanzadas como hashes o sorted sets), podés usar el cliente StackExchange.Redis directamente sin pasar por la abstracción.
Instalación
dotnet add package StackExchange.Redis
Configuración en Program.cs
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")!
)
);
Implementación
public class ProductService
{
private readonly IDatabase _redis;
private readonly AppDbContext _dbContext;
public ProductService(IConnectionMultiplexer redis, AppDbContext dbContext)
{
_redis = redis.GetDatabase();
_dbContext = dbContext;
}
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"products:{id}";
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<Product>(cached!);
// Cache miss: consulta la base de datos
var product = await _dbContext.Products.FindAsync(id);
if (product != null)
{
await _redis.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(product),
TimeSpan.FromMinutes(30)
);
}
return product;
}
public async Task UpdateProductAsync(Product product)
{
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
await _redis.KeyDeleteAsync($"products:{product.Id}");
}
}
IMemoryCache vs Redis: ¿cuál elegir?
| Característica | IMemoryCache | Redis (IDistributedCache) |
|---|---|---|
| Velocidad | ⚡ Muy rápido (memoria local) | 🚀 Muy rápido (red local) |
| Instancias múltiples | ❌ Caché por instancia | ✅ Caché compartido |
| Persistencia | ❌ Se pierde al reiniciar | ✅ Opcional (AOF/RDB) |
| Escalabilidad | Limitada a una instancia | Alta (Redis Cluster) |
| Serialización | No requerida | Requerida (JSON/protobuf) |
| Complejidad operacional | Ninguna | Requiere servidor Redis |
| Caso de uso ideal | Desarrollo, instancia única | Producción multi-instancia |
Buenas prácticas con Redis
- Usá claves descriptivas con prefijo:
myapp:products:42en lugar de solo42. - Siempre definí un TTL; sin expiración, las claves persisten indefinidamente.
- No cachees objetos de gran tamaño (idealmente menos de unos pocos KB por entrada).
- Monitoreá el hit/miss rate con
redis-cli INFO statso RedisInsight. - Usá
IDistributedCachesi querés poder cambiar de proveedor sin tocar el servicio; usáStackExchange.Redisdirectamente si necesitás features avanzados (pub/sub, Lua scripts, pipelines). - En entornos de alta concurrencia, considerá un lock distribuido para evitar cache stampede.
Conclusión
Redis lleva el patrón Cache-Aside al siguiente nivel: permite que todas las instancias de tu aplicación compartan el mismo caché de forma eficiente y consistente. El patrón en sí no cambia: verificar el caché primero, cargar desde la base de datos solo si es necesario.
La elección entre IMemoryCache y Redis depende de la escala y los requisitos de tu sistema. Para desarrollo local y aplicaciones de instancia única, IMemoryCache es más que suficiente. Para producción con múltiples réplicas o contenedores, Redis es la opción natural.
Implementar ambas estrategias con el mismo patrón Cache-Aside demuestra una de las grandes fortalezas de ASP.NET Core: la abstracción correcta permite cambiar la implementación sin reescribir la lógica de negocio.