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:
- 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
IMemoryCachedeja completamente abierto. - Invalidación por tags — descarta todas las consultas cacheadas que tocan órdenes con una sola llamada, sin contabilidad de claves.
- 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.HybridUn 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ó:
| Estrategia | Sentencias SQL |
|---|---|
| 5 lecturas secuenciales, sin caché | 5 |
| 5 lecturas secuenciales, HybridCache | 1 |
| 20 lecturas concurrentes, sin caché | 20 |
| 20 lecturas concurrentes, HybridCache | 1 |
1 lectura después de RemoveByTagAsync("orders") | 1 |

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:
| Datos | Expiración | Invalidación |
|---|---|---|
| Datos de referencia (monedas, categorías) | Horas | Descarte por tag en la escritura ocasional |
| Consultas de lista/resumen (el caso de este artículo) | 1–5 minutos | Descarte por tag tras escrituras |
| Datos por usuario | Minutos | Tag por usuario (user:{id}) |
| Cualquier cosa que alimente una decisión de autorización | No 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
- Registra
AddHybridCache()— solo L1 es un punto de partida completo y correcto - Cachea proyecciones (records/DTOs), nunca entidades rastreadas ni nada que referencie un
DbContext - Crea un scope nuevo dentro de la factory — corre en el llamador que la dispare
- Etiqueta cada entrada con los datos de los que depende (
orders,customer:{id}) RemoveByTagAsyncdespués deSaveChangesAsync, no antes- Mantén expiraciones cortas — la protección contra estampidas abarata la expiración, y la expiración respalda invalidaciones perdidas
- Agrega Redis L2 cuando escales horizontalmente — solo cambia el registro, no los puntos de llamada
Resumen
| Problema | Solución |
|---|---|
| La misma consulta ejecutada por cada petición | HybridCache.GetOrCreateAsync con una clave |
| Estampida de caché al expirar bajo carga | Integrado — la factory corre una vez por clave |
| Invalidar sin contabilidad de claves | tags: [...] + RemoveByTagAsync |
| Caché perdida al reiniciar / no compartida entre instancias | Registra un IDistributedCache (Redis) como L2 |
| Grafos de entidades cacheados comportándose mal | Cachea records de proyección en su lugar |
Helpers de caché con SemaphoreSlim escritos a mano | Bó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
- Biblioteca HybridCache en ASP.NET Core (Microsoft Learn)
- Caching distribuido en ASP.NET Core (Microsoft Learn)
- Resolviendo el problema de consultas N+1 — abarata la consulta antes de cachearla