A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

Cache no .NET: IMemoryCache, Redis ou HybridCache?

Cache no .NET: IMemoryCache, Redis ou HybridCache?

Cache no .NET é o ganho de desempenho mais barato que uma API pode ter, e também um dos mais fáceis de errar. Neste guia, você vai entender onde cada tipo de cache fica, como usar IMemoryCache, IDistributedCache com Redis e HybridCache, e quais armadilhas cada um esconde em produção.

Por que o cache no .NET exige cuidado

Considere uma API de rastreamento de remessas com latência p95 de 120 ms. Depois de adicionar o IMemoryCache, a latência cai para 5 ms e todo mundo comemora.

Em seguida, o time escala a API para duas instâncias. Então um cliente atualiza o endereço de entrega, recarrega a página e vê o endereço antigo.

Ele recarrega de novo e vê o endereço novo. Na terceira vez, o endereço antigo volta.

Nada está quebrado, de fato. Cada instância tem a própria memória, e apenas uma delas sabe que o endereço mudou.

Esse comportamento resume o problema central do cache no .NET. Por isso, escolher o tipo certo depende de entender onde cada camada guarda os dados e quem enxerga cada cópia.

Corrija a consulta antes de adicionar cache

A primeira regra é simples: não use cache para esconder consultas lentas no banco de dados. Afinal, isso trata o sintoma e deixa a causa intacta.

Primeiro, corrija a consulta com índices e projeções adequadas. Depois disso, adicione o cache para aumentar a vazão e deixar a API ainda mais rápida.

Na prática, o cache no .NET funciona melhor sobre uma base que já responde bem. Assim, ele multiplica um ganho real em vez de mascarar um gargalo.

Onde cada camada de cache no .NET fica

Todo cache no .NET fica em algum ponto do caminho entre a requisição HTTP e o banco de dados. Alguns ficam na frente do seu código e guardam respostas HTTP inteiras.

Outros ficam dentro dos handlers e guardam objetos .NET. Nesse caso, eles usam a memória de um único processo ou um armazenamento externo compartilhado por todas as instâncias.

Diagrama das camadas de cache no .NET: a requisição HTTP passa pelo Output Cache, pelo endpoint, pelo cache L1 em memória, pelo cache L2 distribuído no Redis e, em caso de miss, chega ao banco de dados
Cada camada só repassa a requisição para a próxima quando não encontra o dado.

Três termos aparecem em todo o restante do artigo, por isso vale defini-los agora:

  • Cache L1: a memória do processo atual. Ele oferece a leitura mais rápida possível, porque não envolve rede nem desserialização.
  • Cache L2: um armazenamento externo, como o Redis, compartilhado por todas as instâncias. Ele responde mais devagar que o L1, mas muito mais rápido que o banco.
  • Cache stampede: acontece quando muitas requisições perdem o cache ao mesmo tempo e todas consultam o banco pela mesma chave.

Além disso, o .NET oferece seis formas de cache: IMemoryCache, IDistributedCache, Redis, HybridCache, FusionCache e Output Cache. Este artigo cobre as quatro primeiras, que atuam dentro do handler.

O cenário de exemplo: uma API de remessas

Os exemplos usam um domínio de remessas com Minimal APIs e EF Core no .NET 10, a versão LTS atual. O endpoint chama um handler, e o handler consulta o banco diretamente:

app.MapGet("/api/shipments/{shipmentNumber}", async (
    string shipmentNumber,
    GetShipmentByNumberHandler handler,
    CancellationToken cancellationToken) =>
{
    var shipment = await handler.HandleAsync(shipmentNumber, cancellationToken);
    return shipment is null ? Results.NotFound() : Results.Ok(shipment);
});

internal sealed class GetShipmentByNumberHandler(ShipmentsDbContext dbContext)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        return await dbContext.Shipments
            .Where(s => s.Number == shipmentNumber)
            .Select(s => s.MapToResponse())
            .FirstOrDefaultAsync(cancellationToken);
    }
}

Essa consulta já responde bem, porque o campo Number tem índice e a projeção traz apenas as colunas necessárias. A partir dela, cada seção adiciona um tipo diferente de cache.

IMemoryCache: o cache no .NET mais rápido

O IMemoryCache é um armazenamento chave-valor que vive na memória do processo da aplicação. Você guarda qualquer objeto .NET e recebe de volta a mesma referência.

Por isso, uma leitura dele não exige serialização nem chamada de rede. O registro no container de injeção de dependência ocupa uma linha:

builder.Services.AddMemoryCache();

Se o conceito de container ainda não estiver claro, o artigo sobre injeção de dependência no balta.io explica o padrão em detalhes.

No handler, o padrão cache-aside envolve a consulta ao banco:

internal sealed class GetShipmentByNumberHandler(
    ShipmentsDbContext dbContext,
    IMemoryCache cache)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        return await cache.GetOrCreateAsync($"shipment:{shipmentNumber}", async entry =>
        {
            // A entrada expira 10 minutos depois de gravada
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SetSize(1);

            return await dbContext.Shipments
                .Where(s => s.Number == shipmentNumber)
                .Select(s => s.MapToResponse())
                .FirstOrDefaultAsync(cancellationToken);
        });
    }
}

O GetOrCreateAsync primeiro procura a chave. Quando não a encontra, ele executa a factory, consulta o banco e guarda o resultado.

Na prática, um acerto equivale a uma busca em dicionário que devolve uma referência. Não há nada para desserializar.

Vantagens e limitações do IMemoryCache

O IMemoryCache tem três vantagens claras:

  • Zero infraestrutura: não exige Redis, string de conexão nem nada para implantar.
  • Leitura mais rápida possível: um acerto custa nanossegundos e não aloca memória.
  • Qualquer objeto: aceita tipos que não são serializáveis, como regex compiladas ou grandes tabelas imutáveis.

Por outro lado, ele traz limitações importantes:

  • Restrito ao processo: duas instâncias da API têm dois caches que nunca conversam entre si.
  • Perdido no restart: toda implantação começa com o cache frio.
  • Divide memória com a aplicação: um cache que cresce sem limite pode causar problemas de memória.

Armadilhas do IMemoryCache

Não existe limite de tamanho por padrão. O SetSize(1) do exemplo não faz nada até que você configure um SizeLimit no próprio cache:

builder.Services.AddMemoryCache(options =>
{
    // Cada remessa conta como 1, então o cache guarda no máximo 10.000 remessas
    options.SizeLimit = 10_000;
});

A unidade de tamanho fica a seu critério. Contudo, depois que você define o SizeLimit, toda entrada precisa chamar SetSize, ou o cache lança exceção ao adicioná-la.

Segundo a documentação da Microsoft, o runtime do ASP.NET Core também não reduz o cache quando a memória do sistema fica baixa. Ou seja, controlar o crescimento é responsabilidade sua.

O cache entrega sempre a mesma referência. Se quem chama altera o objeto recebido, todas as chamadas seguintes enxergam a alteração:

var response = await cache.GetOrCreateAsync(key, ...);
response.Items.Clear(); // todas as próximas requisições recebem a remessa sem itens

Esse é um bug clássico que só aparece sob carga. Para evitá-lo, guarde records imutáveis ou devolva uma cópia.

Não há remoção por prefixo ou tag. O IMemoryCache oferece apenas Remove(key), então remover todas as remessas de um pedido exige guardar as chaves em algum lugar.

O GetOrCreateAsync não protege contra stampede. Se 100 requisições perderem a mesma chave ao mesmo tempo, 100 factories rodam e 100 consultas chegam ao banco.

Afinal, o IMemoryCache não faz lock por chave. Assim, você precisa escrever um SemaphoreSlim por conta própria ou usar outro cache no .NET que resolva isso.

IDistributedCache com Redis: cache distribuído no .NET

Quando a API escala para várias instâncias, ela precisa de um armazenamento que todas compartilhem. O IDistributedCache é a abstração do .NET para esse armazenamento externo, e o Redis é a implementação mais comum.

Primeiro, instale o pacote e registre o Redis:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    // Prefixo aplicado a todas as chaves desta aplicação
    options.InstanceName = "shipments:";
});

O InstanceName adiciona um prefixo a cada chave. Dessa forma, várias aplicações dividem o mesmo Redis sem colisão de chaves.

A interface trabalha apenas com byte[] e strings, então a serialização fica por sua conta:

internal sealed class GetShipmentByNumberHandler(
    ShipmentsDbContext dbContext,
    IDistributedCache cache)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        var key = $"shipment:{shipmentNumber}";

        var cached = await cache.GetStringAsync(key, cancellationToken);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<ShipmentResponse>(cached);
        }

        var shipment = await dbContext.Shipments
            .Where(s => s.Number == shipmentNumber)
            .Select(s => s.MapToResponse())
            .FirstOrDefaultAsync(cancellationToken);

        if (shipment is not null)
        {
            await cache.SetStringAsync(key, JsonSerializer.Serialize(shipment),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                },
                cancellationToken);
        }

        return shipment;
    }
}

Todas as instâncias leem e gravam as mesmas chaves no Redis. Quando a instância A guarda uma remessa, a instância B a encontra no Redis.

Do mesmo modo, quando a instância A remove a chave, a instância B percebe na próxima leitura. Com isso, o cache no .NET deixa de divergir entre instâncias.

Vantagens e limitações do cache distribuído no .NET

O IDistributedCache resolve os problemas que o IMemoryCache deixa abertos:

  • Compartilhado por todas as instâncias: um único cache e uma única fonte de verdade.
  • Sobrevive a restarts: uma nova implantação já começa com o cache quente.
  • Memória fora da aplicação: o Redis guarda gigabytes sem tocar no seu processo e pode rodar em outro servidor.

No entanto, esse modelo de cache no .NET cobra um preço em cada leitura:

  • Toda leitura passa pela rede: um acerto custa um milissegundo ou mais, em vez de nanossegundos.
  • Toda leitura desserializa: você paga o parse do JSON em cada acerto, além das alocações que vêm junto.

Armadilhas do IDistributedCache

Redis fora do ar vira problema seu. O GetStringAsync lança exceção quando o Redis não responde, e essa exceção chega direto ao handler.

Um cache no .NET nunca deve derrubar a API. Por isso, proteja a leitura e caia para o banco quando o Redis falhar:

string? cached = null;
try
{
    cached = await cache.GetStringAsync(key, cancellationToken);
}
catch (RedisConnectionException ex)
{
    // O cache falhou, mas a requisição segue para o banco
    logger.LogWarning(ex, "Redis unavailable, falling back to database");
}

Continua sem proteção contra stampede. O código de verificar e depois gravar deixa cada miss concorrente consultar o banco, exatamente como no IMemoryCache.

Em uma chave muito acessada, um simples restart do Redis transforma o cache no .NET em um pico de carga no banco.

A serialização se desalinha após implantações. Se você renomear uma propriedade de ShipmentResponse e implantar, o Redis ainda guarda o JSON antigo.

Nesse caso, a desserialização devolve silenciosamente o valor padrão para a propriedade renomeada. A solução é colocar uma versão na chave, como shipment:v2:{number}, e atualizá-la sempre que o formato mudar.

HybridCache: L1 e L2 em uma única API de cache no .NET

O HybridCache chegou com o .NET 9 para substituir o uso direto de IMemoryCache e IDistributedCache. Ele combina um L1 em memória, para velocidade, com um L2 opcional, para compartilhamento.

Além disso, ele resolve o stampede, porque requisições concorrentes pela mesma chave dividem uma única chamada da factory. A configuração começa pelo pacote e pelo registro:

dotnet add package Microsoft.Extensions.Caching.Hybrid
builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024;
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),
        LocalCacheExpiration = TimeSpan.FromMinutes(10)
    };
});

Sem nenhum outro registro, o HybridCache roda apenas com o L1. Porém, assim que você registra um IDistributedCache, ele passa a usá-lo como L2 automaticamente:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

Com isso, você começa o cache no .NET só com memória e adiciona o Redis depois, sem mudar o código que chama o cache. O handler fica assim:

internal sealed class GetShipmentByNumberHandler(
    ShipmentsDbContext dbContext,
    HybridCache cache)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        return await cache.GetOrCreateAsync(
            $"shipment:{shipmentNumber}",
            (dbContext, shipmentNumber),
            // Lambda static: evita capturar variáveis e alocar uma closure por chamada
            static async (state, ct) => await state.dbContext.Shipments
                .Where(s => s.Number == state.shipmentNumber)
                .Select(s => s.MapToResponse())
                .FirstOrDefaultAsync(ct),
            tags: ["shipments"],
            cancellationToken: cancellationToken);
    }
}

Em uma leitura, o HybridCache verifica primeiro o L1, depois o L2 e só então executa a factory. Quando a factory consulta o banco, o resultado vai para o L2 e para o L1.

Enquanto isso, as requisições concorrentes pela mesma chave aguardam a factory que já está rodando. A documentação oficial do HybridCache detalha esse fluxo e todas as opções disponíveis.

Tags e invalidação em grupo

O parâmetro tags traz o que falta ao IMemoryCache. Uma tag agrupa chaves, então você remove todas as remessas de uma só vez.

O RemoveAsync remove um item, enquanto o RemoveByTagAsync("shipments") invalida todos. Esse é o comportamento ideal depois de uma importação em massa.

Segundo a Microsoft, a invalidação por tag é lógica, e não física. Em outras palavras, o HybridCache passa a tratar como miss tudo que nasceu antes da invalidação, e os valores antigos expiram no tempo normal.

Vantagens e limitações do HybridCache

O HybridCache reúne o melhor das abordagens anteriores:

  • L1 e L2 em uma API: velocidade de memória nos acertos e Redis para compartilhar entre instâncias.
  • Proteção contra stampede embutida: uma chamada da factory por chave, não importa quantas requisições percam o cache.
  • Tags: invalidação de grupos de chaves em uma chamada.
  • Evolução sem atrito: começa só com L1 e ganha o Redis com uma linha no container.
  • Parte do .NET: sem dependência de terceiros, com manutenção da Microsoft.

Ainda assim, ele tem limitações que você precisa conhecer:

  • Os L1 não sincronizam entre instâncias, e essa é a armadilha que mais importa.
  • Por padrão, cada leitura devolve uma nova instância, para evitar bugs de concorrência. Para reutilizar a mesma instância, marque o tipo como sealed e aplique [ImmutableObject(true)].
  • Não há expiração deslizante, fail-safe nem atualização em segundo plano.

Armadilhas do HybridCache em produção

As quatro armadilhas a seguir aparecem com frequência quando o HybridCache sai do ambiente local e roda em produção.

O L1 não sincroniza entre instâncias no cache no .NET

Registre o Redis como L2, rode duas instâncias e observe o fluxo. A instância A recebe um PUT que altera o status da remessa e chama RemoveAsync para a chave.

Esse comando limpa o L1 da instância A e o L2 compartilhado. Contudo, a instância B ainda tem o valor antigo no próprio L1.

Diagrama de sequência do HybridCache com duas instâncias: a instância A remove a chave do seu L1 e do Redis, mas a instância B responde ao GET seguinte com o status antigo guardado no próprio L1
A remoção na instância A não chega ao L1 da instância B, que segue servindo o valor antigo.

Assim, a instância B continua servindo o status antigo até o LocalCacheExpiration vencer. Afinal, o HybridCache não tem backplane, ou seja, nenhuma mensagem avisa as outras instâncias para descartar a chave.

A única defesa disponível é um LocalCacheExpiration curto:

options.DefaultEntryOptions = new HybridCacheEntryOptions
{
    // O L2 guarda a entrada por 30 minutos
    Expiration = TimeSpan.FromMinutes(30),
    // Cada instância volta ao Redis a cada 30 segundos
    LocalCacheExpiration = TimeSpan.FromSeconds(30)
};

Nesse caso, a defasagem fica limitada a 30 segundos por instância. Mesmo assim, ela não desaparece, e o tempo escolhido define quanto dado antigo o negócio tolera.

Uma factory que retorna null vira acerto

O HybridCache não trata null nem lista vazia como miss. Então, quando a remessa não existe, o null fica guardado pelo TTL inteiro.

Como resultado, a remessa continua invisível mesmo depois de criada. A correção é remover a entrada quando a factory volta vazia:

var shipment = await cache.GetOrCreateAsync(key, state, factory, cancellationToken: cancellationToken);

if (shipment is null)
{
    // Não deixa o "não encontrado" ocupar o cache pelo TTL inteiro
    await cache.RemoveAsync(key, cancellationToken);
}

return shipment;

Remover depois, em vez de evitar a gravação, mantém a proteção contra stampede para as requisições que já aguardavam a mesma chave.

Opções por chamada se mesclam com o padrão

Esta armadilha é sutil. Suponha que o LocalCacheExpiration padrão seja de 5 minutos e que você escreva o código abaixo:

await cache.SetAsync(key, value, new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromHours(1)
});

Você pediu uma hora, mas no modo apenas L1 recebe cinco minutos. Isso acontece porque o LocalCacheExpiration ficou nulo, e uma propriedade nula herda o valor padrão.

Sem L2, a expiração do L1 é a única que vale, então a entrada morre em 5 minutos. Portanto, defina sempre as duas propriedades e centralize isso em um helper:

public static class HybridCacheEntry
{
    // Garante que L1 e L2 recebam o mesmo tempo de vida
    public static HybridCacheEntryOptions Ttl(TimeSpan ttl) => new()
    {
        Expiration = ttl,
        LocalCacheExpiration = ttl
    };
}

Não existe expiração deslizante

Toda entrada do HybridCache tem um TTL absoluto, contado a partir da gravação. Se você usava a expiração deslizante do IMemoryCache para dados parecidos com sessão, o comportamento muda.

Logo, revise esses fluxos antes de migrar o cache no .NET. Caso contrário, dados que deveriam continuar vivos enquanto alguém os acessa vão expirar no meio do uso.

Como escolher o cache no .NET para o seu cenário

A escolha do cache no .NET depende de quantas instâncias rodam e de quanta defasagem o negócio aceita. A tabela abaixo resume os critérios vistos até aqui:

Critério IMemoryCache IDistributedCache (Redis) HybridCache
Velocidade do acerto Nanossegundos Milissegundos Nanossegundos no L1
Compartilhado entre instâncias Não Sim Apenas o L2
Sobrevive a restart Não Sim Apenas o L2
Proteção contra stampede Não Não Sim, por instância
Invalidação por tag Não Não Sim
Serialização Não exige Manual Automática
Expiração deslizante Sim Sim Não

Em resumo, o IMemoryCache serve bem a uma única instância com dados que não serializam. Já o IDistributedCache atende várias instâncias que precisam enxergar exatamente o mesmo dado.

Para código novo, o HybridCache costuma ser o melhor ponto de partida, porque entrega L1, L2, tags e proteção contra stampede em uma API. Mesmo assim, ajuste o LocalCacheExpiration à tolerância de defasagem do negócio.

Conclusão

O cache no .NET reduz a latência de forma drástica, mas cada tipo resolve um problema e cria outro. Enquanto o IMemoryCache é imbatível em velocidade, ele fica preso a um processo.

Já o IDistributedCache com Redis garante consistência entre instâncias, só que paga rede e desserialização em cada leitura. Por fim, o HybridCache combina os dois e protege contra stampede, embora não sincronize o L1 entre instâncias.

Portanto, corrija as consultas primeiro, escolha a camada pelo número de instâncias e conheça as armadilhas antes de ir para produção. Com isso, o cache no .NET entrega o ganho prometido sem devolver dados antigos aos seus usuários.

FAQ

O HybridCache substitui o IMemoryCache?

Para código novo, na maioria dos casos, sim. Ainda assim, o IMemoryCache continua útil para objetos não serializáveis e para quem depende de expiração deslizante.

Preciso de Redis para usar o HybridCache?

Não. Sem um IDistributedCache registrado, o HybridCache funciona apenas com o L1 em memória e mantém a proteção contra stampede.

O HybridCache sincroniza o L1 entre instâncias?

Não, porque ele não tem backplane. A defasagem entre instâncias fica limitada pelo valor de LocalCacheExpiration.

Por que o SetSize não limita o meu IMemoryCache?

Porque o SetSize só tem efeito quando você define SizeLimit no registro do cache. Depois disso, toda entrada precisa informar o tamanho.

Como evitar que um null fique em cache no HybridCache?

Verifique o resultado do GetOrCreateAsync e chame RemoveAsync quando ele vier nulo. Assim, a próxima requisição consulta o banco de novo.