Una caché es un almacenamiento temporal de datos que evita repetir trabajo costoso, como consultar la misma información en la base de datos para cada petición.
Tenemos un endpoint GET /api/productos que devuelve el catálogo de una tienda:
- La API recibe la petición.
- Abre conexión a SQL Server.
- Ejecuta una query compleja.
- Serializa los datos.
- Devuelve la respuesta.
Esto tarda, digamos, 200ms. No parece mucho. Pero si tienes 1000 usuarios haciendo F5 a la vez, tu base de datos se saturará y la web se caerá. Además, ¿para qué consultar la base de datos mil veces si el catálogo de productos cambia una vez a la semana?
La solución es el Caching: guardar el resultado en la memoria RAM (que es miles de veces más rápida que el disco/SQL) y devolverlo desde ahí.
Caché en memoria
La forma más básica es usar la propia memoria RAM del servidor donde corre tu API. ASP.NET Core trae esto nativo.
Configuración
En Program.cs, simplemente activamos el servicio:
builder.Services.AddMemoryCache();Uso en el servicio
Vamos a modificar un ProductoService para que intente leer de caché antes de molestar a la base de datos.
using Microsoft.Extensions.Caching.Memory;
public class ProductoService
{
private readonly ApplicationDbContext _db;
private readonly IMemoryCache _cache; // 👈 Inyectamos esto
public ProductoService(ApplicationDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<List<Producto>> GetAll()
{
const string cacheKey = "lista_productos";
// 1. Preguntamos: ¿Está en caché?
if (!_cache.TryGetValue(cacheKey, out List<Producto>? productos))
{
// 2. CACHE MISS (No está): Vamos a BBDD
productos = await _db.Productos.ToListAsync();
// 3. Configuramos opciones (Cuándo caduca)
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10)) // Caduca en 10 min
.SetSlidingExpiration(TimeSpan.FromMinutes(2)); // Si no se usa en 2 min, borra
// 4. Guardamos en caché
_cache.Set(cacheKey, productos, cacheOptions);
}
// 5. CACHE HIT: Devolvemos el dato (rápido)
return productos ?? [];
}
}El límite de la memoria local
IMemoryCache es muy útil, pero cada proceso mantiene su propia copia.
Si tienes tu API escalada en 3 servidores (contenedores) detrás de un balanceador:
- El usuario hace una petición y cae en el Servidor A. Se guarda en caché del A.
- El usuario hace otra petición y cae en el Servidor B. ¡El B no sabe nada de la caché del A! Vuelve a consultar la BBDD.
Para solucionar esto, necesitamos una Caché Distribuida.
Caché distribuida con Redis
Redis es una base de datos en memoria ultrarrápida que funciona como un servidor independiente. Todas tus instancias de API (Servidor A, B y C) se conectan al mismo Redis. Si A guarda algo, B puede leerlo.
Instalación
Necesitamos un paquete para hablar con Redis.
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedisTambién necesitamos un servidor Redis. Para desarrollo local podemos levantarlo con Docker:
docker run -d -p 6379:6379 --name mi-redis redis:latestEsto sirve para desarrollo local. En producción, Redis debe tener autenticación, red privada, copias de seguridad si aplica y una política clara de memoria.
Configuración
En Program.cs, cambiamos AddMemoryCache por esto:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379"; // Tu cadena de conexión
options.InstanceName = "MiApi_"; // Prefijo para las claves
});Uso de IDistributedCache
La interfaz cambia ligeramente (IDistributedCache). La gran diferencia es que Redis no guarda objetos C#, guarda bytes. Por tanto, tenemos que serializar a JSON antes de guardar y deserializar al leer.
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
public class ProductoService
{
private readonly IDistributedCache _cache; // 👈 Interfaz distribuida
public async Task<List<Producto>> GetAll()
{
string key = "lista_productos";
// 1. Intentar obtener string del caché
string? cachedJson = await _cache.GetStringAsync(key);
if (!string.IsNullOrEmpty(cachedJson))
{
// CACHE HIT: Deserializamos y devolvemos
return JsonSerializer.Deserialize<List<Producto>>(cachedJson) ?? [];
}
// 2. CACHE MISS: Consultar BBDD
var productos = await _db.Productos.ToListAsync();
// 3. Guardar en Caché (Serializando)
var options = new DistributedCacheEntryOptions
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
string jsonToSave = JsonSerializer.Serialize(productos);
await _cache.SetStringAsync(key, jsonToSave, options);
return productos;
}
}Estrategias de expiración
El caché no puede ser eterno (la memoria es cara y los datos cambian). Tienes dos formas de decidir cuándo borrar datos:
- Absolute Expiration: “Este dato vive 10 minutos. Pase lo que pase, a los 10 minutos muere”.
- Uso: Datos que deben refrescarse periódicamente (ej: Precios, Stock).
- Sliding Expiration: “Este dato vive 10 minutos. PERO si alguien lo lee en el minuto 9, reinicio el contador a 10 minutos más”.
- Uso: Sesiones de usuario. Mientras sigas navegando, no te echo.
Datos obsoletos e invalidación
Introducir caché introduce un nuevo problema: La inconsistencia. Si cambias el precio de un producto en la BBDD, el caché seguirá mostrando el precio viejo durante 10 minutos hasta que caduque.
Lo podemos resolver con invalidación de caché: cuando modificamos un dato, borramos su entrada para que la siguiente lectura vuelva a cargarla.
public async Task ActualizarProducto(Producto p)
{
_db.Productos.Update(p);
await _db.SaveChangesAsync();
// 💣 ¡IMPORTANTE! Borramos el caché viejo para obligar a refrescar
await _cache.RemoveAsync("lista_productos");
}Si muchas peticiones producen el mismo fallo de caché a la vez, todas podrían consultar la base de datos. HybridCache, disponible en versiones modernas de .NET, combina caché local y distribuida y añade protección frente a este problema (cache stampede).