//JorgenHoc
← Todos los artículos
EF CorePor Jorge Calderón10 min read

Cacheando Consultas EF Core con HybridCache en .NET 10

Cachea resultados de consultas EF Core con HybridCache de .NET 10: protección contra estampidas, invalidación por tags y Redis L2 opcional — con conteos reales de sentencias SQL.

#entity-framework#dotnet#performance#caching

La mayor parte del trabajo de rendimiento en EF Core consiste en abaratar las consultas — corregir patrones N+1, proyectar en lugar de cargar entidades, agregar índices. El siguiente paso es no ejecutar la consulta en absoluto. HybridCache, estabilizado en la ola de .NET 9/10 como Microsoft.Extensions.Caching.Hybrid, es la API de caché que por fin hace eso seguro sin código de bloqueo escrito a mano.

Tres cosas le ganan un lugar en una aplicación EF Core:

  1. Protección contra estampidas — las peticiones concurrentes de la misma clave ejecutan la consulta a la base de datos una vez, no una vez por llamador. Este es el modo de fallo que IMemoryCache deja completamente abierto.
  2. Invalidación por tags — descarta todas las consultas cacheadas que tocan órdenes con una sola llamada, sin contabilidad de claves.
  3. Caché de dos niveles — memoria en proceso (L1) respaldada por una caché distribuida opcional (L2, p. ej. Redis) detrás de la misma API. Puedes empezar solo con L1 y agregar Redis después sin tocar los puntos de llamada.

Cada afirmación de este artículo está respaldada por un conteo de sentencias que puedes reproducir — el programa ejecutable está enlazado al final.

El Problema con IMemoryCache

El patrón estándar parece seguro y no lo es:

// Parece correcto. Oculta una estampida.
var summaries = await memoryCache.GetOrCreateAsync("orders:summaries", async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
    return await db.Orders
        .Select(o => new OrderSummary(o.Reference, o.Customer.Name, o.Lines.Count))
        .ToListAsync();
});

IMemoryCache.GetOrCreateAsync no coalesce a los llamadores concurrentes. Cuando una clave popular expira bajo carga, todas las peticiones en vuelo fallan la caché, y cada una ejecuta la factory. Veinte peticiones concurrentes significan hasta veinte consultas idénticas golpeando la base de datos en el mismo instante — una estampida de caché. La caché que debía proteger la base de datos en cambio sincroniza una avalancha contra ella.

El arreglo clásico es envolver la factory en un SemaphoreSlim, acordarse de liberarlo en un finally, y usar un semáforo por clave para que entradas sin relación no se serialicen entre sí. Todo el mundo escribe ese helper una vez, la mayoría de las versiones tienen un bug sutil, y ninguna necesita existir ya.

💡

Si solo recuerdas una cosa: HybridCache.GetOrCreateAsync garantiza que la factory se ejecuta una vez por clave entre llamadores concurrentes. Esa sola propiedad reemplaza el patrón completo de IMemoryCache + SemaphoreSlim.

Configuración

Un paquete:

dotnet add package Microsoft.Extensions.Caching.Hybrid

Un registro:

builder.Services.AddHybridCache();

Eso es una caché completamente funcional — solo L1 (memoria en proceso). La protección contra estampidas y los tags ya funcionan; nada de lo que sigue requiere Redis.

Para agregar un nivel L2 distribuido, registra cualquier implementación de IDistributedCache antes de AddHybridCache() y HybridCache la detecta automáticamente:

// L2 opcional — Redis, SQL Server o Azure Cache funcionan igual
builder.Services.AddStackExchangeRedisCache(options =>
    options.Configuration = builder.Configuration.GetConnectionString("Redis"));
 
builder.Services.AddHybridCache();

Las lecturas revisan L1 primero, luego L2, luego ejecutan tu factory — y pueblan ambos niveles al regresar. L2 te compra entradas de caché que sobreviven un reinicio de la aplicación y se comparten entre instancias detrás de un balanceador de carga. No cambia nada de la API que llamas.

Cacheando una Consulta EF Core

El dominio es el mismo esquema Order/Customer/OrderLine usado en el artículo de N+1, poblado con 500 órdenes. La consulta que se cachea es una proyección:

public sealed record OrderSummary(string Reference, string CustomerName, int LineCount);
 
public class OrderReadService(HybridCache cache, IServiceScopeFactory scopeFactory)
{
    public async Task<List<OrderSummary>> GetSummariesAsync(CancellationToken ct = default)
    {
        return await cache.GetOrCreateAsync(
            "orders:summaries",                                    // clave de caché
            async token => await LoadSummariesAsync(token),        // solo corre en un miss
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(5),
            },
            tags: ["orders"],                                      // para invalidación masiva
            cancellationToken: ct);
    }
 
    private async Task<List<OrderSummary>> LoadSummariesAsync(CancellationToken ct)
    {
        // Scope nuevo por ejecución: la factory puede correr concurrentemente con otras
        // peticiones, y DbContext no es thread-safe.
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
 
        return await db.Orders
            .Select(o => new OrderSummary(o.Reference, o.Customer.Name, o.Lines.Count))
            .ToListAsync(ct);
    }
}

Dos decisiones en ese fragmento importan más de lo que parecen.

Cachea proyecciones, nunca entidades

Los valores cacheados pasan por el serializador de HybridCache — System.Text.Json por defecto. Un record pequeño e inmutable se serializa limpiamente y se deserializa exactamente en lo que era. Un grafo de entidades rastreadas no: las propiedades de navegación arrastran medio modelo de objetos, los ciclos rompen la serialización directamente, y una entidad que salió de una caché no está siendo rastreada por ningún DbContext, así que mutarla y llamar SaveChangesAsync silenciosamente no hace nada. Proyecta a un DTO en la consulta, cachea el DTO.

⚠️

Por esto mismo el record no tiene un DbContext cerca. Cachea el resultado de la consulta, nunca nada que sostenga una referencia al contexto que lo produjo — el contexto tiene el scope de una petición y será desechado mientras el objeto cacheado sigue vivo.

La factory crea su propio scope

El delegado pasado a GetOrCreateAsync puede ejecutarse en el llamador que resulte dispararlo. Resolver el DbContext desde un scope nuevo dentro de la factory — en lugar de capturar el contexto de la petición — lo mantiene correcto sin importar qué petición gane la carrera.

Cómo Se Ven los Conteos

Ejecutando los escenarios contra 500 órdenes en SQL Server LocalDB y contando las sentencias SQL que EF Core realmente ejecutó:

EstrategiaSentencias SQL
5 lecturas secuenciales, sin caché5
5 lecturas secuenciales, HybridCache1
20 lecturas concurrentes, sin caché20
20 lecturas concurrentes, HybridCache1
1 lectura después de RemoveByTagAsync("orders")1
Salida de consola del sample de HybridCache: 5 lecturas secuenciales cuestan 5 sentencias SQL sin caché y 1 con HybridCache; 20 lecturas concurrentes cuestan 20 sentencias sin caché y 1 con HybridCache; una lectura tras RemoveByTagAsync cuesta 1 sentencia.
La tabla de arriba, directa de la consola — misma ejecución, nada retipeado.

La fila que justifica la migración es la concurrente. Veinte tareas llaman GetOrCreateAsync para la misma clave fría al mismo tiempo; la factory corre una vez, diecinueve llamadores esperan la misma ejecución, y la base de datos ve una consulta. Repite ese escenario con IMemoryCache y el conteo es 20 — su factory corre una vez por llamador concurrente.

💡

Son conteos, no tiempos, y eso es deliberado — son reproducibles en cualquier máquina y cualquier proveedor relacional. Localmente una consulta es casi gratis, que es exactamente por qué el caching parece inútil en desarrollo y luego importa contra una base de datos administrada en otra región, donde cada viaje de ida y vuelta evitado es latencia real que le quitas a una petición.

El programa que los produjo es samples/ef-core-hybridcache — reutiliza los datos de seed del artículo de N+1, corre con o sin Redis, y debería darte exactamente estos números.

Invalidación con Tags

La expiración responde "¿cuánta obsolescencia es aceptable?" — la invalidación responde "los datos acaban de cambiar". Antes de los tags, invalidar significaba rastrear cada clave que tus escrituras pudieran afectar. Con tags, las entradas declaran de qué dependen, y las escrituras descartan por tag:

// Toda consulta cacheada que toca órdenes lleva el tag
tags: ["orders"]
 
// Después de una escritura que cambia órdenes:
await db.SaveChangesAsync(ct);
await cache.RemoveByTagAsync("orders", ct);

La siguiente lectura falla la caché, paga una consulta y repuebla. Esa es la última fila de la tabla de conteos: exactamente 1 sentencia después de un descarte por tag.

Los tags se componen. Una proyección por cliente puede llevar un tag amplio y uno estrecho:

tags: ["orders", $"customer:{customerId}"]

Una escritura que toca un cliente descarta customer:42 y deja calientes las entradas de todos los demás clientes; una importación masiva descarta orders y las limpia todas. Invalida en la granularidad de la escritura, no en la granularidad con la que resultó que definiste las claves.

⚠️

Llama RemoveByTagAsync después de que SaveChangesAsync tenga éxito, no antes. Descarta primero y un lector concurrente puede repoblar la caché con datos previos al guardado que luego viven hasta la expiración — exactamente la obsolescencia que intentabas prevenir. Descartar después reduce la ventana al instante entre el commit y el descarte, y la expiración sigue siendo la red de seguridad.

Eligiendo la Expiración

La protección contra estampidas cambia la economía de las expiraciones cortas. Con IMemoryCache, un TTL corto en una clave caliente significaba una estampida en cada expiración, así que los TTLs subían para compensar. Con HybridCache, una expiración cuesta exactamente una consulta sin importar cuántas peticiones estén en vuelo — así que puedes permitirte expiraciones honestas y cortas y apoyarte en los tags para la corrección:

DatosExpiraciónInvalidación
Datos de referencia (monedas, categorías)HorasDescarte por tag en la escritura ocasional
Consultas de lista/resumen (el caso de este artículo)1–5 minutosDescarte por tag tras escrituras
Datos por usuarioMinutosTag por usuario (user:{id})
Cualquier cosa que alimente una decisión de autorizaciónNo lo cachees

HybridCacheEntryOptions también tiene LocalCacheExpiration para la copia L1 específicamente — útil cuando L2 se comparte entre instancias pero quieres que la copia en proceso de cada instancia se revalide antes.

Cuándo No Usarlo

La misma honestidad que aplica a la optimización de consultas aplica aquí: el caching no es un default, es un intercambio de frescura por carga.

  • No cachees lo que lees una vez. Una caché frente a una consulta que corre una vez por hora es contabilidad sin recompensa.
  • No cachees datos por petición. Si la clave necesitaría el id de la petición, la caché es un diccionario con pasos extra.
  • No cachees insumos de autorización. Un permiso revocado hace cinco minutos que sigue autorizando peticiones es un incidente, no una mejora de rendimiento.
  • Vigila la proporción escritura-lectura. Los datos que se escriben tan seguido como se leen pasan su vida invalidados; pagas serialización en cada miss y no ganas nada.

Checklist

  1. Registra AddHybridCache() — solo L1 es un punto de partida completo y correcto
  2. Cachea proyecciones (records/DTOs), nunca entidades rastreadas ni nada que referencie un DbContext
  3. Crea un scope nuevo dentro de la factory — corre en el llamador que la dispare
  4. Etiqueta cada entrada con los datos de los que depende (orders, customer:{id})
  5. RemoveByTagAsync después de SaveChangesAsync, no antes
  6. Mantén expiraciones cortas — la protección contra estampidas abarata la expiración, y la expiración respalda invalidaciones perdidas
  7. Agrega Redis L2 cuando escales horizontalmente — solo cambia el registro, no los puntos de llamada

Resumen

ProblemaSolución
La misma consulta ejecutada por cada peticiónHybridCache.GetOrCreateAsync con una clave
Estampida de caché al expirar bajo cargaIntegrado — la factory corre una vez por clave
Invalidar sin contabilidad de clavestags: [...] + RemoveByTagAsync
Caché perdida al reiniciar / no compartida entre instanciasRegistra un IDistributedCache (Redis) como L2
Grafos de entidades cacheados comportándose malCachea records de proyección en su lugar
Helpers de caché con SemaphoreSlim escritos a manoBórralos

El camino de migración es incremental: reemplaza el uso de IMemoryCache de una consulta caliente y de lectura intensiva por HybridCache, verifica que los conteos de sentencias bajan como dice la tabla de arriba, y expande desde ahí. La API tiene la misma forma que ya conoces — solo cierra las trampas que la anterior dejaba abiertas.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados