A maior parte do trabalho de performance no EF Core consiste em baratear as consultas — corrigir padrões N+1, projetar em vez de carregar entidades, adicionar índices. O próximo passo é não executar a consulta de forma alguma. O HybridCache, estabilizado na onda do .NET 9/10 como Microsoft.Extensions.Caching.Hybrid, é a API de cache que finalmente torna isso seguro sem código de lock escrito à mão.
Três coisas garantem seu lugar em uma aplicação EF Core:
- Proteção contra stampede — requisições concorrentes pela mesma chave executam a consulta ao banco uma vez, não uma vez por chamador. Esse é o modo de falha que o
IMemoryCachedeixa completamente aberto. - Invalidação por tags — descarte todas as consultas cacheadas que tocam pedidos com uma única chamada, sem contabilidade de chaves.
- Cache em dois níveis — memória no processo (L1) apoiada por um cache distribuído opcional (L2, p. ex. Redis) atrás da mesma API. Você pode começar só com L1 e adicionar Redis depois sem tocar nos pontos de chamada.
Cada afirmação deste artigo é sustentada por uma contagem de instruções que você pode reproduzir — o programa executável está no link ao final.
O Problema com IMemoryCache
O padrão comum parece seguro e não é:
// Parece correto. Esconde um stampede.
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();
});O IMemoryCache.GetOrCreateAsync não coalesce chamadores concorrentes. Quando uma chave popular expira sob carga, todas as requisições em voo erram o cache, e cada uma executa a factory. Vinte requisições concorrentes significam até vinte consultas idênticas atingindo o banco no mesmo instante — um cache stampede. O cache que deveria proteger o banco em vez disso sincroniza uma avalanche contra ele.
A correção clássica é envolver a factory em um SemaphoreSlim, lembrar de liberá-lo em um finally, e usar um semáforo por chave para que entradas sem relação não se serializem entre si. Todo mundo escreve esse helper uma vez, a maioria das versões tem um bug sutil, e nenhuma precisa mais existir.
Se você lembrar de uma única coisa: HybridCache.GetOrCreateAsync garante que a factory
executa uma vez por chave entre chamadores concorrentes. Essa única propriedade substitui o
padrão inteiro de IMemoryCache + SemaphoreSlim.
Configuração
Um pacote:
dotnet add package Microsoft.Extensions.Caching.HybridUm registro:
builder.Services.AddHybridCache();Isso é um cache totalmente funcional — apenas L1 (memória no processo). Proteção contra stampede e tags já funcionam; nada abaixo requer Redis.
Para adicionar um nível L2 distribuído, registre qualquer implementação de IDistributedCache antes de AddHybridCache() e o HybridCache a detecta automaticamente:
// L2 opcional — Redis, SQL Server ou Azure Cache funcionam da mesma forma
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache();As leituras verificam L1 primeiro, depois L2, depois executam sua factory — e populam os dois níveis na volta. O L2 compra entradas de cache que sobrevivem a um reinício da aplicação e são compartilhadas entre instâncias atrás de um load balancer. Não muda nada na API que você chama.
Cacheando uma Consulta EF Core
O domínio é o mesmo esquema Order/Customer/OrderLine usado no artigo sobre N+1, populado com 500 pedidos. A consulta cacheada é uma projeção:
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", // chave do cache
async token => await LoadSummariesAsync(token), // só roda em um miss
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
},
tags: ["orders"], // para invalidação em massa
cancellationToken: ct);
}
private async Task<List<OrderSummary>> LoadSummariesAsync(CancellationToken ct)
{
// Scope novo por execução: a factory pode rodar concorrentemente com outras
// requisições, e o DbContext não é 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);
}
}Duas decisões nesse trecho importam mais do que parecem.
Cacheie projeções, nunca entidades
Valores cacheados passam pelo serializador do HybridCache — System.Text.Json por padrão. Um record pequeno e imutável serializa limpo e desserializa exatamente no que era. Um grafo de entidades rastreadas não: propriedades de navegação arrastam metade do modelo de objetos, ciclos quebram a serialização de vez, e uma entidade que saiu de um cache não está sendo rastreada por nenhum DbContext, então mutá-la e chamar SaveChangesAsync silenciosamente não faz nada. Projete para um DTO na consulta, cacheie o DTO.
É também por isso que o record não tem um DbContext por perto. Cacheie o resultado da
consulta, nunca nada que segure uma referência ao contexto que o produziu — o contexto tem
o escopo de uma requisição e será descartado enquanto o objeto cacheado continua vivo.
A factory cria seu próprio scope
O delegate passado ao GetOrCreateAsync pode executar em qualquer chamador que o dispare. Resolver o DbContext a partir de um scope novo dentro da factory — em vez de capturar o contexto da requisição — o mantém correto não importa qual requisição vença a corrida.
Como Ficam as Contagens
Executando os cenários contra 500 pedidos no SQL Server LocalDB e contando as instruções SQL que o EF Core realmente executou:
| Estratégia | Instruções SQL |
|---|---|
| 5 leituras sequenciais, sem cache | 5 |
| 5 leituras sequenciais, HybridCache | 1 |
| 20 leituras concorrentes, sem cache | 20 |
| 20 leituras concorrentes, HybridCache | 1 |
1 leitura após RemoveByTagAsync("orders") | 1 |

A linha que justifica a migração é a concorrente. Vinte tasks chamam GetOrCreateAsync pela mesma chave fria ao mesmo tempo; a factory roda uma vez, dezenove chamadores aguardam a mesma execução, e o banco vê uma consulta. Repita esse cenário com IMemoryCache e a contagem é 20 — a factory dele roda uma vez por chamador concorrente.
São contagens, não tempos, e isso é deliberado — são reproduzíveis em qualquer máquina e qualquer provedor relacional. Localmente uma consulta é quase de graça, e é exatamente por isso que caching parece inútil em desenvolvimento e depois importa contra um banco gerenciado em outra região, onde cada ida e volta evitada é latência real tirada de uma requisição.
O programa que as produziu é o
samples/ef-core-hybridcache
— ele reutiliza os dados de seed do artigo sobre N+1, roda com ou sem Redis, e deve te dar
exatamente esses números.
Invalidação com Tags
Expiração responde "quanta desatualização é aceitável?" — invalidação responde "os dados acabaram de mudar". Antes das tags, invalidar significava rastrear cada chave que suas escritas pudessem afetar. Com tags, as entradas declaram do que dependem, e as escritas descartam por tag:
// Toda consulta cacheada que toca pedidos carrega a tag
tags: ["orders"]
// Após uma escrita que altera pedidos:
await db.SaveChangesAsync(ct);
await cache.RemoveByTagAsync("orders", ct);A próxima leitura erra o cache, paga uma consulta e repopula. Essa é a última linha da tabela de contagens: exatamente 1 instrução após um descarte por tag.
Tags se compõem. Uma projeção por cliente pode carregar uma tag ampla e uma estreita:
tags: ["orders", $"customer:{customerId}"]Uma escrita que toca um cliente descarta customer:42 e deixa aquecidas as entradas de todos os outros clientes; uma importação em massa descarta orders e limpa todas. Invalide na granularidade da escrita, não na granularidade com que você por acaso definiu as chaves.
Chame RemoveByTagAsync depois que SaveChangesAsync tiver sucesso, não antes.
Descarte primeiro e um leitor concorrente pode repopular o cache com dados anteriores ao
save que então vivem até a expiração — exatamente a desatualização que você tentava
prevenir. Descartar depois reduz a janela ao instante entre o commit e o descarte, e a
expiração continua sendo a rede de segurança.
Escolhendo a Expiração
A proteção contra stampede muda a economia das expirações curtas. Com IMemoryCache, um TTL curto em uma chave quente significava um stampede a cada expiração, então os TTLs subiam para compensar. Com HybridCache, uma expiração custa exatamente uma consulta não importa quantas requisições estejam em voo — então você pode se permitir expirações honestas e curtas e se apoiar nas tags para a correção:
| Dados | Expiração | Invalidação |
|---|---|---|
| Dados de referência (moedas, categorias) | Horas | Descarte por tag na escrita rara |
| Consultas de lista/resumo (o caso deste artigo) | 1–5 minutos | Descarte por tag após escritas |
| Dados por usuário | Minutos | Tag por usuário (user:{id}) |
| Qualquer coisa que alimente uma decisão de autorização | Não cacheie | — |
HybridCacheEntryOptions também tem LocalCacheExpiration para a cópia L1 especificamente — útil quando o L2 é compartilhado entre instâncias mas você quer que a cópia em processo de cada instância revalide antes.
Quando Não Usar
A mesma honestidade que vale para a otimização de consultas vale aqui: caching não é um padrão, é uma troca de frescor por carga.
- Não cacheie o que você lê uma vez. Um cache na frente de uma consulta que roda uma vez por hora é contabilidade sem retorno.
- Não cacheie dados por requisição. Se a chave precisaria do id da requisição, o cache é um dicionário com passos extras.
- Não cacheie insumos de autorização. Uma permissão revogada há cinco minutos que ainda autoriza requisições é um incidente, não um ganho de performance.
- Observe a proporção escrita-leitura. Dados escritos com a mesma frequência com que são lidos passam a vida invalidados; você paga serialização em cada miss e não ganha nada.
Checklist
- Registre
AddHybridCache()— só L1 é um ponto de partida completo e correto - Cacheie projeções (records/DTOs), nunca entidades rastreadas nem nada que referencie um
DbContext - Crie um scope novo dentro da factory — ela roda no chamador que a disparar
- Marque cada entrada com tags dos dados de que ela depende (
orders,customer:{id}) RemoveByTagAsyncdepois deSaveChangesAsync, não antes- Mantenha expirações curtas — a proteção contra stampede barateia a expiração, e a expiração cobre invalidações perdidas
- Adicione Redis L2 quando escalar horizontalmente — muda só o registro, não os pontos de chamada
Resumo
| Problema | Solução |
|---|---|
| A mesma consulta executada por cada requisição | HybridCache.GetOrCreateAsync com uma chave |
| Cache stampede na expiração sob carga | Embutido — a factory roda uma vez por chave |
| Invalidar sem contabilidade de chaves | tags: [...] + RemoveByTagAsync |
| Cache perdido no reinício / não compartilhado entre instâncias | Registre um IDistributedCache (Redis) como L2 |
| Grafos de entidades cacheados se comportando mal | Cacheie records de projeção no lugar |
Helpers de cache com SemaphoreSlim escritos à mão | Apague-os |
O caminho de migração é incremental: substitua o uso de IMemoryCache de uma consulta quente e de leitura intensa por HybridCache, verifique que as contagens de instruções caem como a tabela acima diz, e expanda a partir daí. A API tem o mesmo formato que você já conhece — ela só fecha as armadilhas que a antiga deixava abertas.
Leituras adicionais
- Biblioteca HybridCache no ASP.NET Core (Microsoft Learn)
- Caching distribuído no ASP.NET Core (Microsoft Learn)
- Resolvendo o problema de consultas N+1 — barateie a consulta antes de cacheá-la