El rendimiento de una aplicación backend depende en gran medida de cuántas veces consulta la base de datos. El patrón Cache-Aside (también conocido como Lazy Loading) es una estrategia que permite reducir drásticamente esas consultas al almacenar temporalmente los datos más accedidos en memoria.
En esta primera parte vemos la implementación con IMemoryCache, la solución de caché integrada en ASP.NET Core. En la segunda parte escalamos la misma estrategia con Redis.
¿Qué es el patrón Cache-Aside?
En el patrón Cache-Aside, la aplicación gestiona explícitamente el caché. A diferencia de otras estrategias donde el caché se llena de forma automática, aquí el desarrollador controla qué se cachea, cuándo y por cuánto tiempo.
El flujo de ejecución típico es:
- La aplicación intenta leer el dato desde el caché.
- Si existe (cache hit), lo devuelve de inmediato.
- Si no existe (cache miss), lo consulta en la base de datos.
- Almacena el resultado en el caché.
- Devuelve el dato al llamador.
Este enfoque garantiza que solo se cachean los datos realmente necesarios, lo que lo hace eficiente y predecible.
¿Por qué usar Cache-Aside?
- Mejor rendimiento: datos frecuentes se sirven desde memoria, sin tocar la base de datos.
- Menor carga en la DB: menos queries = mejor escalabilidad del motor de base de datos.
- Control fino: el desarrollador decide qué cachear y con qué política de expiración.
- Flexibilidad: funciona con caché en memoria (IMemoryCache) y distribuida (Redis).
¿Cuándo usarlo?
Es ideal cuando:
- Los datos se leen con frecuencia pero se actualizan raramente.
- Se tolera una ligera inconsistencia temporal.
- El costo de consultar la base de datos es alto (joins complejos, queries lentas).
Casos de uso típicos: catálogos de productos, perfiles de usuario, datos de configuración, APIs de solo lectura.
Implementación en .NET con IMemoryCache
IMemoryCache es la solución de caché en memoria incluida en ASP.NET Core. Almacena los datos en el proceso de la aplicación y es ideal para instancias únicas.
Configuración en Program.cs
builder.Services.AddMemoryCache();
Servicio con patrón Cache-Aside
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _dbContext;
public ProductService(IMemoryCache cache, AppDbContext dbContext)
{
_cache = cache;
_dbContext = dbContext;
}
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"product_{id}";
if (!_cache.TryGetValue(cacheKey, out Product? product))
{
// Cache miss: consulta la base de datos
product = await _dbContext.Products.FindAsync(id);
if (product != null)
{
var options = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5))
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
_cache.Set(cacheKey, product, options);
}
}
return product;
}
}
Primera request: el caché está vacío, se consulta la DB y se almacena el resultado.
Requests siguientes: el dato se sirve desde memoria, sin tocar la base de datos.
Tras la expiración: la siguiente request vuelve a cargar el dato fresco.
Estrategias de expiración
Sliding Expiration (expiración deslizante)
La entrada expira si no se accede durante un período determinado. Ideal para datos de acceso frecuente: mientras se consulten, se mantienen en caché.
Absolute Expiration (expiración absoluta)
La entrada expira después de un tiempo fijo, independientemente de cuántas veces se haya accedido. Garantiza que los datos se refresquen periódicamente.
Estrategia combinada (recomendada)
Usar ambas juntas es el enfoque más equilibrado: evita que datos calientes sean desalojados prematuramente, pero también previene que datos desactualizados persistan indefinidamente.
Invalidación del caché
Cuando se actualiza un dato en la base de datos, hay que eliminar o actualizar la entrada del caché para evitar inconsistencias:
public async Task UpdateProductAsync(Product product)
{
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
// Invalidar la entrada del caché
var cacheKey = $"product_{product.Id}";
_cache.Remove(cacheKey);
}
La próxima lectura detectará un cache miss y cargará el dato actualizado desde la DB.
Problemas comunes y soluciones
Cache Stampede
Cuando una entrada expira, múltiples requests simultáneas pueden intentar cargar el dato de la base de datos al mismo tiempo, generando un pico de carga. Soluciones:
- Usar
SemaphoreSlimpara serializar el acceso al recurso. - Implementar request coalescing.
- Migrar a un caché distribuido como Redis (ver parte 2).
Datos desactualizados (Stale Data)
Si la invalidación no se implementa correctamente, el caché puede devolver datos obsoletos. Estrategias: tiempos de expiración cortos, invalidación por eventos, versionado de claves.
Presión de memoria
El caché en memoria consume RAM del proceso. Limitá el tamaño del caché con SizeLimit y evitá cachear objetos de gran tamaño. Para escalar horizontalmente, la solución es Redis.
Conclusión
El patrón Cache-Aside con IMemoryCache es una forma simple y efectiva de mejorar el rendimiento en aplicaciones .NET de instancia única. La clave está en elegir bien las estrategias de expiración e implementar una invalidación correcta cuando los datos cambian.
Para entornos con múltiples instancias (load balancers, contenedores escalados), el siguiente paso natural es usar Redis como caché distribuida. El patrón es exactamente el mismo, pero compartido entre todas las instancias. Lo vemos en la segunda parte de esta serie.