HybridCache no .NET: o que é e como usar

HybridCache no .NET: o que é e como usar

O HybridCache é a biblioteca de cache do .NET que combina um cache em memória com um cache distribuído em uma única API. Assim, você ganha a velocidade da memória local e o compartilhamento entre servidores sem escrever a coordenação à mão.

Ao longo deste guia, você vai ver como ele funciona por dentro, como configurá-lo em uma API ASP.NET Core e quais cuidados evitam surpresas em produção.

O que é o HybridCache

Lançado no .NET 9, o HybridCache substitui o uso manual de IMemoryCache e IDistributedCache juntos. Além disso, ele cuida da serialização, da proteção contra requisições simultâneas e da invalidação em grupo.

A biblioteca trabalha em dois níveis. O L1 fica na memória do processo, enquanto o L2 usa o IDistributedCache configurado, como Redis, SQL Server ou Postgres.

Antes dele, o padrão comum era consultar o IMemoryCache, depois o IDistributedCache, serializar o objeto e gravar nos dois lugares. Esse código se repetia em cada serviço e, por isso, abria espaço para erros sutis de concorrência.

O pacote também é compatível com runtimes mais antigos. Na prática, você pode usá-lo até em projetos .NET Framework 4.7.2 e .NET Standard 2.0.

HybridCache, IMemoryCache e IDistributedCache

A tabela abaixo resume o que cada API entrega por padrão. Dessa forma, fica mais fácil decidir quando a troca vale a pena.

Recurso IMemoryCache IDistributedCache HybridCache
Cache em memória local Sim Não Sim (L1)
Cache compartilhado entre servidores Não Sim Sim (L2)
Serialização automática Não precisa Não, trabalha com byte[] Sim, com System.Text.Json
Proteção contra stampede Não Não Sim
Invalidação por tags Não Não Sim

Como o HybridCache busca os dados

Toda leitura passa pelo método GetOrCreateAsync. Primeiro, o HybridCache procura a chave no L1; se não encontrar, consulta o L2 e, por fim, executa a factory que busca o dado na origem.

Fluxograma de leitura do HybridCache: GetOrCreateAsync consulta o L1 em memória, depois o L2 distribuído e, por último, a factory que acessa o banco de dados
Cada nível só é consultado quando o anterior não tem a chave, e o resultado volta preenchendo os caches no caminho.

Quando a factory retorna, o valor é gravado nos dois níveis. Dessa forma, a próxima leitura no mesmo servidor sai direto da memória.

A factory nunca roda quando existe um hit no L1 ou no L2. Ou seja, o banco só é consultado quando nenhum dos caches tem o dado.

Configurando o HybridCache no .NET

Primeiro, instale o pacote da biblioteca e o provedor do Redis, que vai servir como L2.

dotnet add package Microsoft.Extensions.Caching.Hybrid
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Em seguida, registre o Redis e chame AddHybridCache no Program.cs. A connection string fica na configuração, e não no código.

using Microsoft.Extensions.Caching.Hybrid;

var builder = WebApplication.CreateBuilder(args);

// Redis como cache distribuído (L2)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});

Depois, injete o HybridCache no serviço e use o GetOrCreateAsync com uma chave única.

public class ProductService(HybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetByIdAsync(int id, CancellationToken token = default)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async cancel => await db.Products.FindAsync([id], cancel),
            tags: ["products"],
            cancellationToken: token);
    }
}

Mesmo sem um IDistributedCache registrado, o HybridCache continua funcionando. Nesse caso, ele usa apenas o L1 e mantém a proteção contra stampede.

Usando o serviço em um endpoint

Com o serviço pronto, o endpoint fica simples. Por exemplo, em uma Minimal API, basta receber o ProductService por injeção de dependência.

builder.Services.AddScoped<ProductService>();

var app = builder.Build();

app.MapGet("/api/products/{id:int}", async (int id, ProductService service, CancellationToken token) =>
{
    var product = await service.GetByIdAsync(id, token);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

app.Run();

O endpoint não sabe nada sobre cache. Assim, você pode ajustar a estratégia de cache no serviço sem tocar nas rotas.

Migrando do IDistributedCache para o HybridCache

Muitos projetos já têm cache em dois níveis escrito à mão. O código abaixo mostra como esse padrão costuma aparecer em um serviço que usa IMemoryCache e IDistributedCache juntos.

public async Task<Product?> GetByIdAsync(int id, CancellationToken token = default)
{
    var key = $"product:{id}";

    // 1. Tenta a memória local
    if (memoryCache.TryGetValue(key, out Product? cached))
        return cached;

    // 2. Tenta o Redis e desserializa à mão
    var bytes = await distributedCache.GetAsync(key, token);
    if (bytes is not null)
    {
        var fromRedis = JsonSerializer.Deserialize<Product>(bytes);
        memoryCache.Set(key, fromRedis, TimeSpan.FromMinutes(5));
        return fromRedis;
    }

    // 3. Busca no banco e grava nos dois níveis
    var product = await db.Products.FindAsync([id], token);
    if (product is not null)
    {
        await distributedCache.SetAsync(
            key,
            JsonSerializer.SerializeToUtf8Bytes(product),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) },
            token);
        memoryCache.Set(key, product, TimeSpan.FromMinutes(5));
    }

    return product;
}

Esse método funciona, mas tem dois problemas. Primeiro, ele repete a mesma lógica em cada serviço; além disso, não impede que várias requisições executem o passo 3 ao mesmo tempo.

Com o HybridCache, o mesmo comportamento cabe na chamada de GetOrCreateAsync que você viu na seção anterior. As durações de 30 e 5 minutos passam para o Expiration e o LocalCacheExpiration, e a serialização deixa de aparecer no código.

Na migração, mantenha o mesmo formato de chave que você já usava. Dessa forma, fica mais fácil comparar o comportamento antes e depois e acompanhar a taxa de hits no Redis.

Por outro lado, não reaproveite os valores antigos que já estão no Redis. O HybridCache grava as entradas com um formato próprio, então o caminho seguro é deixar os dados antigos expirarem ou usar um prefixo novo nas chaves.

Expiração e limites do HybridCache

Cada entrada do HybridCache tem duas durações, definidas no HybridCacheEntryOptions. Enquanto o Expiration controla o tempo de vida total da entrada, o LocalCacheExpiration define quanto tempo ela fica no L1.

Na prática, um LocalCacheExpiration menor reduz o tempo em que cada servidor fica com uma cópia desatualizada. Por outro lado, um valor maior aumenta os hits locais e alivia o Redis.

Imagine uma vitrine de produtos com três servidores atrás de um balanceador de carga. Com Expiration de 30 minutos e LocalCacheExpiration de 5 minutos, o banco é consultado, em geral, uma vez por produto a cada meia hora.

Nesse mesmo cenário, cada servidor pode ficar até cinco minutos com uma versão antiga em memória. Se esse atraso não for aceitável, reduza primeiro o LocalCacheExpiration, porque é o L1 que segura a cópia antiga.

Além do padrão global, você pode sobrescrever as opções em uma entrada específica. Isso ajuda quando alguns dados mudam muito menos do que outros.

var categoryOptions = new HybridCacheEntryOptions
{
    // Categorias mudam pouco, então podem viver mais tempo
    Expiration = TimeSpan.FromHours(6),
    LocalCacheExpiration = TimeSpan.FromMinutes(30)
};

var category = await cache.GetOrCreateAsync(
    $"category:{categoryId}",
    async cancel => await db.Categories.FindAsync([categoryId], cancel),
    categoryOptions,
    cancellationToken: token);

O HybridCache também aplica dois limites globais. O MaximumPayloadBytes vale 1 MB por padrão, e o MaximumKeyLength aceita até 1024 caracteres.

Quando um valor ou uma chave passa desses limites, a biblioteca registra um log e não grava a entrada. Por isso, objetos muito grandes simplesmente deixam de ser cacheados, sem erro aparente.

builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 2 * 1024 * 1024; // 2 MB
    options.MaximumKeyLength = 512;
});

Boas práticas para chaves de cache

A chave precisa identificar o dado de forma única, tanto em relação à origem quanto ao resto da aplicação. Por isso, combine um prefixo com os identificadores, como product:{id} ou orders:{region}:{orderId}.

Em aplicações multi-tenant, inclua o identificador do tenant no início da chave, como tenant:{tenantId}:product:{id}. Assim, dados de clientes diferentes nunca se misturam, e você ainda pode usar o tenant como tag para invalidar tudo de uma vez.

Use sempre um separador entre as partes. Sem ele, $"order{customerId}{orderId}" gera order42123 tanto para o cliente 42 com o pedido 123 quanto para o cliente 421 com o pedido 23.

Além disso, nunca monte a chave com texto livre digitado pelo usuário. Um atacante poderia inundar o cache com chaves aleatórias ou acessar dados de outra entrada.

A documentação também recomenda escrever a string interpolada direto na chamada do GetOrCreateAsync. Com isso, o código fica pronto para otimizações futuras que evitam alocar a string da chave.

Proteção contra cache stampede

O stampede acontece quando várias requisições encontram o cache vazio ao mesmo tempo e todas vão ao banco. Por isso, um item popular que expira pode derrubar a origem de uma só vez.

Comparação lado a lado: sem proteção, cinco requisições simultâneas geram cinco consultas ao banco; com o HybridCache, as mesmas cinco requisições geram uma única consulta
Com a mesma chave, só a primeira requisição executa a factory; as outras aguardam o resultado dela.

Esse problema é comum em dados muito acessados que expiram de uma vez, como a página inicial de um e-commerce. Quando a entrada vence, centenas de requisições chegam no mesmo segundo e todas encontram o cache vazio.

Sem uma coordenação pronta, a solução manual exigiria locks por chave, com cuidado extra para não travar a aplicação. O HybridCache resolve isso internamente, sem nenhum código adicional.

No HybridCache, apenas uma chamada executa a factory e as demais esperam o mesmo resultado. Contudo, essa coordenação vale para a mesma instância, e não entre servidores diferentes.

O cancelamento também é compartilhado. O token que chega à factory só é cancelado quando todas as requisições que aguardam aquele resultado desistem.

Atualizando e removendo entradas

Na maioria dos casos, o GetOrCreateAsync é a única API necessária. Mesmo assim, há cenários em que você já tem o valor novo em mãos e quer gravá-lo sem passar pela factory.

Para isso, existe o SetAsync. Ele grava o valor nos dois níveis, o que é útil logo depois de salvar uma alteração no banco.

public async Task UpdateAsync(Product product, CancellationToken token = default)
{
    db.Products.Update(product);
    await db.SaveChangesAsync(token);

    // Grava o valor atualizado no L1 e no L2
    await cache.SetAsync(
        $"product:{product.Id}",
        product,
        tags: ["products"],
        cancellationToken: token);
}

Quando preferir apenas descartar a entrada, use o RemoveAsync com a chave. Diferente da invalidação por tag, essa remoção apaga o valor do L1 e do L2.

await cache.RemoveAsync($"product:{id}", token);

Há também uma sobrecarga que recebe uma coleção de chaves. Assim, você remove várias entradas em uma única chamada.

Invalidação por tags no HybridCache

As tags agrupam entradas relacionadas, como todos os produtos. Assim, quando o catálogo muda, você invalida o grupo inteiro com uma chamada.

// Invalida todas as entradas marcadas com a tag "products"
await cache.RemoveByTagAsync("products");

// Invalida todo o HybridCache, inclusive entradas sem tag
await cache.RemoveByTagAsync("*");
Linha do tempo da invalidação por tag no HybridCache: entradas criadas antes do RemoveByTagAsync passam a ser tratadas como miss, e as criadas depois voltam a ser hits
A invalidação é lógica: os valores antigos continuam guardados até expirar, mas deixam de ser usados.

Na prática, o RemoveByTagAsync não apaga nada do Redis nem da memória. Em vez disso, ele marca que tudo o que foi criado antes daquele instante deve ser ignorado.

O asterisco é uma tag reservada e não pode ser atribuído a uma entrada. Além disso, a biblioteca não aceita padrões como products*, então cada tag precisa ser informada pelo nome exato.

Não existe limite para a quantidade de tags, mas conjuntos muito grandes podem afetar a performance. Por isso, prefira poucas tags com significado claro, como o nome da entidade ou do tenant.

Existe ainda um ponto importante em ambientes com vários servidores. A invalidação, por chave ou por tag, alcança o servidor atual e o L2, mas não limpa o L1 dos outros servidores, que só se atualizam quando o LocalCacheExpiration vence.

Serialização no HybridCache

O L2 exige que os objetos virem bytes. Por padrão, o HybridCache trata string e byte[] internamente e usa o System.Text.Json para todos os outros tipos.

Se você precisar de outro formato, como protobuf, configure o serializador junto com o registro do serviço. O AddSerializer vale para um tipo específico, enquanto o AddSerializerFactory atende vários tipos de uma vez.

builder.Services.AddHybridCache()
    // ProductProtobufSerializer implementa IHybridCacheSerializer<Product>
    .AddSerializer<Product, ProductProtobufSerializer>();

Na maioria das APIs, o System.Text.Json padrão é suficiente. Por isso, troque o serializador apenas quando o tamanho dos payloads ou a integração com outros sistemas justificar.

Em aplicações com Native AOT, a serialização por reflexão não funciona. Nesse caso, use os source generators do System.Text.Json para os tipos cacheados e garanta que eles não sejam removidos pelo trimming.

Ganhando performance com instâncias imutáveis

Por padrão, cada leitura desserializa um objeto novo, como já acontecia com o IDistributedCache. Esse comportamento é seguro, porque nenhuma requisição altera a instância de outra.

No entanto, quando o tipo é imutável, o HybridCache pode reutilizar a mesma instância e economizar CPU e alocações. Para habilitar isso, marque o tipo como sealed e aplique o atributo [ImmutableObject(true)].

using System.ComponentModel;

[ImmutableObject(true)]
public sealed record ProductSummary(int Id, string Name, decimal Price);

As duas mudanças são necessárias. Sem qualquer uma delas, a biblioteca mantém a cópia por leitura, que é o comportamento mais conservador.

Outra otimização vem dos provedores de L2. As implementações de Redis, SQL Server e Postgres que suportam IBufferDistributedCache evitam alocações de byte[] a cada operação.

Testando código que usa o HybridCache

Como o HybridCache é uma classe abstrata registrada por injeção de dependência, você pode usar a implementação real nos testes. Nesse cenário, basta chamar AddHybridCache sem registrar nenhum IDistributedCache.

O teste abaixo confirma a proteção contra stampede. Ele dispara dez leituras simultâneas da mesma chave e verifica que a factory rodou uma única vez.

[Fact]
public async Task GetOrCreateAsync_ConcurrentCalls_RunFactoryOnce()
{
    var services = new ServiceCollection();
    services.AddLogging();
    services.AddHybridCache();
    var cache = services.BuildServiceProvider().GetRequiredService<HybridCache>();

    var calls = 0;

    async ValueTask<string> Factory(CancellationToken token)
    {
        Interlocked.Increment(ref calls);
        await Task.Delay(100, token);
        return "value";
    }

    var tasks = Enumerable.Range(0, 10)
        .Select(_ => cache.GetOrCreateAsync("test:key", Factory).AsTask());

    await Task.WhenAll(tasks);

    Assert.Equal(1, calls);
}

O mesmo padrão serve para testar a invalidação. Por exemplo, grave um valor, chame RemoveByTagAsync e confira se a próxima leitura executa a factory de novo.

Já nos testes de unidade dos seus serviços, isolar o cache costuma ser desnecessário. Afinal, a versão em memória é rápida, determinística e não depende de infraestrutura externa.

Quando o HybridCache não é a melhor escolha

Para uma aplicação com um único servidor e necessidades simples, o IMemoryCache pode bastar. Da mesma forma, em vários servidores sem necessidade de stampede ou tags, o cache distribuído puro também resolve.

Fique atento a um detalhe de configuração. O HybridCache ignora o AddDistributedMemoryCache como L2, porque ele guarda os dados no mesmo processo e apenas duplicaria o L1.

Ou seja, para compartilhar o cache entre servidores, você precisa de um provedor fora do processo, como o Redis. Caso contrário, cada instância da aplicação terá apenas o seu próprio cache em memória.

Também vale cautela com dados que precisam estar sempre atualizados, como saldo ou estoque em tempo real. Nesses casos, qualquer cópia local pode ficar desatualizada por alguns minutos, então talvez o melhor seja não cachear ou usar durações bem curtas.

Checklist para usar o HybridCache em produção

Antes de publicar, revise os pontos abaixo. Eles reúnem os cuidados que aparecem ao longo do artigo.

  • Registre um L2 fora do processo, como o Redis, se a aplicação roda em mais de um servidor.
  • Defina o Expiration pelo tempo que o dado pode ficar desatualizado no Redis.
  • Mantenha o LocalCacheExpiration curto, porque a invalidação não limpa o L1 dos outros servidores.
  • Monte chaves com prefixo, separador e identificadores gerados pelo sistema.
  • Agrupe entradas relacionadas com poucas tags de significado claro.
  • Confira se nenhum objeto passa do MaximumPayloadBytes, já que a falha só aparece no log.
  • Marque como sealed e [ImmutableObject(true)] os tipos que nunca são alterados depois de lidos.
  • Configure source generators do System.Text.Json se a aplicação usa Native AOT.

Além disso, acompanhe a taxa de hits e o volume de consultas ao banco depois do deploy. Assim, você confirma se as durações escolhidas realmente aliviaram a origem.

Conclusão

O HybridCache simplifica o cache em aplicações .NET com dois níveis, proteção contra stampede e invalidação por tags em uma API só. Com poucas linhas, você troca o código repetitivo de IMemoryCache e IDistributedCache por uma chamada ao GetOrCreateAsync.

Para tirar o melhor dele, separe bem o Expiration do LocalCacheExpiration, monte chaves com separadores e identificadores confiáveis e use tipos imutáveis quando puder. Para detalhes adicionais, consulte a documentação oficial da biblioteca.

Se a sua API também depende de serviços externos instáveis, vale combinar o cache com o Retry Pattern para tratar falhas temporárias.

FAQ

O HybridCache funciona sem Redis?

Sim. Sem um IDistributedCache fora do processo, o HybridCache usa apenas a memória local e mantém a proteção contra stampede.

O RemoveByTagAsync apaga os dados do cache?

Não. Ele invalida as entradas de forma lógica, e os valores antigos só saem do armazenamento quando expiram.

Qual a diferença entre Expiration e LocalCacheExpiration?

O Expiration define o tempo de vida total da entrada. Já o LocalCacheExpiration define quanto tempo ela fica na memória de cada servidor.

O HybridCache funciona no .NET Framework?

Sim. A biblioteca suporta runtimes a partir do .NET Framework 4.7.2 e do .NET Standard 2.0.

Posso usar outra implementação do HybridCache?

Sim. O HybridCache é uma classe abstrata, e bibliotecas como o FusionCache oferecem implementações próprias da mesma API.

Glossário

Termo Significado
L1 Cache em memória, local ao processo da aplicação
L2 Cache distribuído, compartilhado entre servidores, como o Redis
Cache stampede Situação em que muitas requisições simultâneas encontram o cache vazio e sobrecarregam a origem
Factory Função que busca o dado na origem quando ele não está em cache
Tag Rótulo que agrupa entradas para invalidá-las juntas
Invalidação lógica Marcação que faz entradas antigas serem tratadas como miss sem apagá-las do armazenamento
Serializador Componente que converte objetos em bytes para gravar no cache distribuído

Fontes