← maurobernal.com.ar

Cache-Aside Pattern con Redis en .NET: Cache Distribuida para Entornos Escalables

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:42 en lugar de solo 42.
  • 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 stats o RedisInsight.
  • Usá IDistributedCache si querés poder cambiar de proveedor sin tocar el servicio; usá StackExchange.Redis directamente 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.