.NET y backend

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.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# que usa HybridCache.GetOrCreateAsync con expiración

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:

  1. 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).
  2. 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.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿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