//JorgenHoc
← Todos os artigos
EF CorePor Jorge Calderón10 min read

Cacheando Consultas EF Core com HybridCache no .NET 10

Cacheie resultados de consultas EF Core com o HybridCache do .NET 10: proteção contra stampede, invalidação por tags e Redis L2 opcional — com contagens reais de instruções SQL.

#entity-framework#dotnet#performance#caching

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:

  1. 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 IMemoryCache deixa completamente aberto.
  2. Invalidação por tags — descarte todas as consultas cacheadas que tocam pedidos com uma única chamada, sem contabilidade de chaves.
  3. 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.Hybrid

Um 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égiaInstruções SQL
5 leituras sequenciais, sem cache5
5 leituras sequenciais, HybridCache1
20 leituras concorrentes, sem cache20
20 leituras concorrentes, HybridCache1
1 leitura após RemoveByTagAsync("orders")1
Saída de console do sample de HybridCache: 5 leituras sequenciais custam 5 instruções SQL sem cache e 1 com HybridCache; 20 leituras concorrentes custam 20 instruções sem cache e 1 com HybridCache; uma leitura após RemoveByTagAsync custa 1 instrução.
A tabela acima, direto do console — mesma execução, nada redigitado.

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:

DadosExpiraçãoInvalidação
Dados de referência (moedas, categorias)HorasDescarte por tag na escrita rara
Consultas de lista/resumo (o caso deste artigo)1–5 minutosDescarte por tag após escritas
Dados por usuárioMinutosTag por usuário (user:{id})
Qualquer coisa que alimente uma decisão de autorizaçãoNã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

  1. Registre AddHybridCache() — só L1 é um ponto de partida completo e correto
  2. Cacheie projeções (records/DTOs), nunca entidades rastreadas nem nada que referencie um DbContext
  3. Crie um scope novo dentro da factory — ela roda no chamador que a disparar
  4. Marque cada entrada com tags dos dados de que ela depende (orders, customer:{id})
  5. RemoveByTagAsync depois de SaveChangesAsync, não antes
  6. Mantenha expirações curtas — a proteção contra stampede barateia a expiração, e a expiração cobre invalidações perdidas
  7. Adicione Redis L2 quando escalar horizontalmente — muda só o registro, não os pontos de chamada

Resumo

ProblemaSolução
A mesma consulta executada por cada requisiçãoHybridCache.GetOrCreateAsync com uma chave
Cache stampede na expiração sob cargaEmbutido — a factory roda uma vez por chave
Invalidar sem contabilidade de chavestags: [...] + RemoveByTagAsync
Cache perdido no reinício / não compartilhado entre instânciasRegistre um IDistributedCache (Redis) como L2
Grafos de entidades cacheados se comportando malCacheie records de projeção no lugar
Helpers de cache com SemaphoreSlim escritos à mãoApague-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

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados