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: FusionCache, Output Cache e qual escolher

Cache no .NET: FusionCache, Output Cache e qual escolher

Escolher o cache no .NET deixou de ser uma decisão simples, porque hoje existem seis caminhos: IMemoryCache, IDistributedCache, Redis, HybridCache, FusionCache e Output Cache. Este artigo aprofunda o FusionCache e o Output Cache, e termina com um roteiro objetivo para decidir qual camada usar em cada cenário.

Onde o FusionCache entra no cache no .NET

O FusionCache é uma biblioteca open source de cache híbrido que existia anos antes do HybridCache da Microsoft. Ele usa o mesmo formato em dois níveis: o L1 fica na memória do processo e o L2 fica em um armazenamento distribuído, como o Redis.

Além disso, ele oferece a mesma proteção contra cache stampede do HybridCache no .NET, que impede várias requisições de recalcularem a mesma chave ao mesmo tempo. No entanto, o FusionCache vai além e entrega recursos que o HybridCache ainda não tem.

O principal deles é o backplane, que sincroniza o L1 entre as instâncias da aplicação. Também entram na lista o fail-safe, os timeouts de factory e o eager refresh.

Na prática, o projeto já roda em produção há anos e soma mais de 100 milhões de downloads no NuGet. Inclusive, a própria Microsoft usa o FusionCache em produtos como o Data API Builder.

Por isso, o autor do material de referência considera o FusionCache a opção de cache no .NET mais completa disponível hoje. Contudo, como toda escolha de arquitetura, ela tem custos, e eles aparecem mais adiante.

Configurando o cache no .NET com FusionCache e Redis

Para começar, adicione os pacotes do núcleo, do serializador, do backplane e do cache distribuído do Redis:

dotnet add package ZiggyCreatures.FusionCache
dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson
dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Em seguida, registre o FusionCache no container de injeção de dependência do Program.cs:

var redisConnection = builder.Configuration.GetConnectionString("Redis")!;

builder.Services.AddFusionCache()
    .WithDefaultEntryOptions(new FusionCacheEntryOptions
    {
        Duration = TimeSpan.FromMinutes(10),
        IsFailSafeEnabled = true,
        FailSafeMaxDuration = TimeSpan.FromHours(1),
        FactorySoftTimeout = TimeSpan.FromMilliseconds(300)
    })
    .WithSerializer(new FusionCacheSystemTextJsonSerializer())
    .WithDistributedCache(new RedisCache(new RedisCacheOptions
    {
        Configuration = redisConnection
    }))
    .WithBackplane(new RedisBackplane(new RedisBackplaneOptions
    {
        Configuration = redisConnection
    }));

Esse registro monta três blocos. Primeiro, o serializador, que converte o valor para o L2 com System.Text.Json.

Depois vem o cache distribuído, que é o mesmo RedisCache usado pela interface IDistributedCache. Por fim, o backplane avisa as outras instâncias sobre mudanças usando o pub/sub do Redis.

Com isso pronto, o handler recebe a interface IFusionCache e usa o método GetOrSetAsync:

internal sealed class GetShipmentByNumberHandler(
    ShipmentsDbContext dbContext,
    IFusionCache cache)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        // Busca no L1, depois no L2 e só então no banco
        return await cache.GetOrSetAsync<ShipmentResponse?>(
            $"shipment:{shipmentNumber}",
            async (_, ct) => await dbContext.Shipments
                .Where(s => s.Number == shipmentNumber)
                .Select(s => s.MapToResponse())
                .FirstOrDefaultAsync(ct),
            tags: ["shipments"],
            token: cancellationToken);
    }
}

Para remover um item, você usa a chave ou uma tag, exatamente como no HybridCache:

await cache.RemoveAsync($"shipment:{shipmentNumber}", token: cancellationToken);

Como o backplane sincroniza as instâncias

A diferença real aparece no que acontece depois dessa remoção nas outras instâncias da aplicação. Para entender, considere uma API com duas instâncias, A e B, atrás de um balanceador.

Primeiro, um cliente envia um PUT para atualizar o status de um envio, e a instância A processa a requisição. Em seguida, A remove a entrada do L2 no Redis e publica uma mensagem no backplane.

Então, o Redis entrega essa mensagem para a instância B, que descarta a cópia que guardava no L1. Assim, a próxima leitura em B não encontra nada na memória, consulta o L2 e, sem resultado, executa a factory no banco.

Diagrama de sequência do cache no .NET com FusionCache: a instância A remove a entrada no Redis e publica no backplane, e a instância B descarta o L1 e lê o valor atualizado do banco
O backplane leva apenas a chave e a ação, e cada instância descarta a própria cópia em memória.

Vale notar que a mensagem do backplane carrega apenas a chave e a ação, sem o valor. Por isso, o tráfego entre as instâncias continua pequeno.

Esse mecanismo resolve a maior armadilha do HybridCache no .NET quando há várias instâncias com cache. Segundo a documentação oficial do HybridCache, invalidar uma chave ou tag afeta o servidor atual e o L2, mas não toca no L1 dos outros servidores.

Usando o FusionCache como HybridCache

Se os seus handlers já injetam HybridCache, você não precisa reescrevê-los. O FusionCache pode se registrar como a implementação da abstração da Microsoft com o método AsHybridCache:

builder.Services.AddFusionCache()
    .WithSerializer(new FusionCacheSystemTextJsonSerializer())
    .WithDistributedCache(new RedisCache(new RedisCacheOptions { Configuration = redisConnection }))
    .WithBackplane(new RedisBackplane(new RedisBackplaneOptions { Configuration = redisConnection }))
    // Expõe o FusionCache por trás da abstração HybridCache
    .AsHybridCache();

Dessa forma, as suas classes continuam dependendo de HybridCache, mas o motor de cache por baixo no .NET passa a ser o FusionCache. Além disso, a mesma instância continua disponível como IFusionCache para quem quiser a API completa.

Contudo, existe um limite importante nesse adaptador. Pela abstração, cada chamada aceita apenas HybridCacheEntryOptions, então fail-safe, timeouts e eager refresh ficam configurados uma vez, nas opções padrão do registro.

Por outro lado, o adaptador supera limitações da implementação da Microsoft. Por exemplo, ele permite registrar vários caches nomeados e expô-los como keyed services com AsKeyedHybridCache.

Recursos de resiliência do FusionCache

O primeiro recurso de resiliência do cache no .NET com FusionCache é o fail-safe. Quando o banco cai e a factory lança uma exceção, o FusionCache devolve o último valor conhecido em vez de um erro.

O segundo são os timeouts soft e hard. Se a factory demora mais que o timeout soft e existe um valor antigo, a biblioteca devolve esse valor e continua a factory em segundo plano.

Já o timeout hard define um limite absoluto. Assim, mesmo sem valor antigo, a requisição não fica esperando indefinidamente.

O terceiro é o eager refresh, que atualiza uma entrada em segundo plano antes que ela expire. Com isso, as chaves mais acessadas praticamente nunca sofrem um miss.

Por fim, o adaptive caching permite que a factory defina o tempo de vida de cada entrada com base no que carregou. Esses recursos tornam o cache no .NET uma camada de resiliência, e não apenas de desempenho.

Vantagens e desvantagens do FusionCache

Entre as vantagens, o backplane mantém o L1 sincronizado entre as instâncias. Também pesam a favor os recursos de resiliência, o adaptador para HybridCache e a maturidade do projeto, que tem documentação extensa e um mantenedor ativo.

Por outro lado, há três desvantagens claras. A primeira é a dependência de terceiros, que exige acompanhar atualizações e mudanças de versão.

Em segundo lugar, a superfície de configuração é maior, porque mais opções significam mais formas de configurar errado. Por último, o backplane depende do pub/sub do Redis, e alguns planos gerenciados não oferecem esse recurso.

Output cache no .NET: cacheando a resposta inteira

O Output Cache é um middleware que guarda a resposta HTTP completa e a devolve na próxima requisição equivalente. Em um acerto, nem o handler nem o banco de dados executam.

Por isso, ele é o maior ganho por linha de código entre as opções de cache no .NET. Enquanto o FusionCache e o HybridCache guardam objetos, o Output Cache guarda o resultado final do endpoint.

O middleware funciona em Minimal APIs, controllers, MVC e Razor Pages. Se você ainda não conhece o modelo enxuto de endpoints, o artigo sobre ASP.NET Minimal APIs cobre a base antes de avançar.

Configurando políticas e tags

Durante o registro, você define políticas nomeadas e depois habilita o middleware no pipeline:

builder.Services.AddOutputCache(options =>
{
    // Uma entrada por número de envio, expirando em 5 minutos
    options.AddPolicy("shipment-by-number", policy => policy
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByRouteValue("shipmentNumber")
        .Tag("shipments"));
});

var app = builder.Build();

app.UseOutputCache();

Em seguida, o endpoint usa a política pelo nome com o método CacheOutput:

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);
})
.CacheOutput("shipment-by-number");

O método SetVaryByRouteValue transforma cada número de envio em uma entrada própria. Para endpoints de listagem, SetVaryByQuery("carrier", "status") faz o mesmo com os parâmetros da query string.

Já o método Tag agrupa as entradas para remoção rápida. Então, basta injetar IOutputCacheStore no fluxo de escrita e remover pela tag:

internal sealed class UpdateShipmentStatusHandler(
    ShipmentsDbContext dbContext,
    IOutputCacheStore outputCache)
{
    public async Task HandleAsync(
        string shipmentNumber,
        ShipmentStatus status,
        CancellationToken cancellationToken)
    {
        var shipment = await dbContext.Shipments
            .FirstAsync(s => s.Number == shipmentNumber, cancellationToken);

        shipment.UpdateStatus(status);
        await dbContext.SaveChangesAsync(cancellationToken);

        // Remove todas as respostas marcadas com a tag
        await outputCache.EvictByTagAsync("shipments", cancellationToken);
    }
}

Atenção à ordem do pipeline. Segundo a documentação do Output Cache, UseOutputCache deve vir depois de UseCors, UseAuthentication e UseAuthorization.

Output cache no .NET com Redis

Por padrão, o Output Cache no .NET guarda as respostas em memória, o que traz de volta o problema das duas instâncias. Nesse caso, cada servidor tem o próprio cache, e ele se perde quando o processo reinicia.

A saída é usar o armazenamento em Redis com o pacote oficial:

dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

Assim, todas as instâncias servem as mesmas respostas, e EvictByTagAsync em uma delas remove a entrada para todas. Repare que o método é AddStackExchangeRedisOutputCache, e não AddStackExchangeRedisCache.

A Microsoft também não recomenda usar IDistributedCache como armazenamento do Output Cache. O motivo é que essa interface não tem operações atômicas, que as tags exigem.

Vale um alerta sobre o middleware antigo de Response Caching, ativado com UseResponseCaching. Ele depende dos cabeçalhos Cache-Control enviados pelo cliente, que pode simplesmente ignorá-lo, além de não remover por tag nem suportar Redis.

Armadilhas do output cache no .NET

Antes das armadilhas, vale conhecer a política padrão. Ela só guarda respostas 200 de requisições GET ou HEAD, ignora respostas que definem cookies e não guarda requisições autenticadas.

Além disso, a granularidade é a resposta inteira. Ou seja, um único campo alterado invalida todo o payload.

A primeira armadilha é guardar dados por usuário sem variar pelo usuário. Se você sobrescrever o padrão e cachear respostas autenticadas, o usuário A pode receber os envios do usuário B.

Nesse caso, varie a política pelo cabeçalho Authorization:

options.AddPolicy("my-shipments", policy => policy
    .Expire(TimeSpan.FromMinutes(1))
    .SetVaryByHeader("Authorization")
    .Tag("shipments"));

Isso funciona, mas cada token vira uma entrada própria no cache do .NET. Portanto, a taxa de acerto cai conforme a base de usuários cresce.

A segunda armadilha é que a remoção mora no fluxo de escrita. Se algum handler esquecer de remover a tag, respostas antigas ficam disponíveis até expirar.

Por isso, centralize a remoção em um único lugar, como um handler de evento de domínio. Dessa forma, nenhum fluxo novo de escrita esquece a invalidação.

Como escolher o cache no .NET

A escolha do cache no .NET depende de três perguntas: quantas instâncias você executa, quanto tempo um dado pode ficar desatualizado e o que exatamente você vai guardar.

O fluxo de decisão começa pelo tipo de conteúdo. Se é uma resposta HTTP inteira e igual para todos, o caminho é o Output Cache, com Redis quando houver várias instâncias.

Caso contrário, a próxima pergunta para o cache no .NET é se o objeto pode ser serializado. Se não pode, o destino é o IMemoryCache.

Se pode, avalie se a aplicação roda em uma única instância. Nesse caso, o HybridCache apenas com L1 resolve.

Com várias instâncias, a pergunta final é se alguns segundos de dado desatualizado são aceitáveis. Se forem, use HybridCache com Redis e um LocalCacheExpiration curto; se não forem, use FusionCache com backplane.

Fluxograma para escolher o cache no .NET entre Output Cache, IMemoryCache, HybridCache só com L1, HybridCache com Redis e FusionCache com backplane
Quatro perguntas levam a cinco destinos possíveis.

Repare que o IDistributedCache não aparece no fluxo. Isso acontece porque ele é o L2 por trás do HybridCache e do FusionCache, e é assim que você deve acessá-lo.

Quando usar IMemoryCache

O IMemoryCache é a opção de cache mais rápida no .NET, com leituras em nanossegundos e sem serialização. Ele serve bem para objetos que não podem ser serializados e para dados imutáveis muito grandes, lidos milhares de vezes.

Também atende ferramentas pequenas e aplicações de instância única. O artigo sobre ASP.NET Memory Cache mostra o uso básico dessa interface.

Escolha essa opção quando desserializar o objeto custaria mais que a consulta evitada. Além disso, limite a quantidade de chaves com SizeLimit e aceite que cada instância tenha a própria cópia.

Quando usar IDistributedCache

Use o IDistributedCache diretamente como cache no .NET apenas em casos específicos. Por exemplo, quando você precisa de controle total sobre os bytes gravados no Redis.

Ele também faz sentido para compartilhar valores pequenos entre aplicações diferentes pelo mesmo Redis. Outro cenário é um projeto preso ao .NET 8 ou anterior que não pode adicionar o HybridCache.

Quando usar HybridCache

O HybridCache é o padrão para novas aplicações a partir do .NET 9, e continua sendo no .NET 10, a versão LTS atual. Ele combina L1 e L2, protege contra stampede, suporta tags e adiciona Redis com uma linha.

Escolha essa opção quando quiser uma biblioteca oficial, sem dependência de terceiros. Ela também funciona bem quando você pretende começar sem Redis e adicioná-lo depois.

No entanto, conheça as armadilhas citadas pelo autor: o L1 não sincroniza entre instâncias, um null cacheado conta como acerto e as opções se mesclam de formas pouco óbvias. Portanto, alguns segundos de L1 desatualizado precisam ser aceitáveis para os seus dados.

Quando usar FusionCache

O FusionCache é a escolha de cache no .NET para várias instâncias que precisam de consistência quase imediata após uma escrita. Também atende APIs que precisam continuar respondendo quando o banco está lento ou fora do ar.

Ele serve ainda para times que superaram o HybridCache e querem manter os handlers como estão. Nesse cenário, o AsHybridCache faz a troca sem reescrever código.

Escolha essa opção quando um LocalCacheExpiration curto não bastar, quando precisar de fail-safe, timeouts ou eager refresh, e quando o seu Redis suportar pub/sub.

Quando usar output cache no .NET

O Output Cache é o cache no .NET ideal para endpoints públicos de leitura, com respostas idênticas para todos os usuários. Também combina com respostas que mudam pouco e podem ser removidas por tag.

Além disso, ele funciona como uma segunda camada sobre um cache de objetos. Escolha essa opção quando pular o handler inteiro for um ganho real e a resposta mudar como um todo.

Recomendação prática por cenário de cache no .NET

Em um projeto novo, comece pelo FusionCache se você planeja usar Redis e várias instâncias. Isso vale desde que a dependência de terceiros não seja um problema para o time.

Já para melhorar um projeto existente, comece pelo HybridCache apenas com L1. Ele é oficial, evita stampede e recebe o Redis com uma linha quando a aplicação escalar.

Mais tarde, se precisar, o FusionCache pode assumir o lugar do HybridCache com uma única linha de código. A partir daí, o roteiro fica assim:

  • Uma única instância: HybridCache apenas com L1, e pronto.
  • Várias instâncias com alguns segundos de atraso aceitáveis: HybridCache com Redis no L2 e um LocalCacheExpiration curto.
  • Várias instâncias com escrita visível na hora: FusionCache com backplane no Redis, usando AsHybridCache para manter os handlers.
  • Endpoints públicos com muita leitura: Output Cache com Redis por cima do cache de objetos escolhido.
  • Objeto imutável enorme lido o tempo todo: IMemoryCache, de propósito, com o motivo documentado.

Em outras palavras, as camadas de cache no .NET se somam. O Output Cache fica na frente, o cache híbrido fica dentro dos handlers e o IMemoryCache entra só para objetos que nunca devem ser serializados.

Conclusão

Não existe um único cache no .NET correto para todos os casos. O IMemoryCache é o mais rápido, mas vive em um processo só, enquanto o IDistributedCache compartilha dados ao custo de rede e desserialização em cada leitura.

O HybridCache é o padrão oficial e cobre a maioria dos cenários, desde que você entenda que o L1 não sincroniza. Quando essa limitação incomoda, o FusionCache resolve com backplane e ainda acrescenta fail-safe para manter a API respondendo.

Por fim, o Output Cache pula o seu código inteiro e brilha em endpoints públicos de leitura. Assim, escolha pelo número de instâncias, pela tolerância a dados desatualizados e pelo formato do que você guarda, e esteja pronto para adicionar uma segunda camada quando a primeira não bastar.

FAQ

O FusionCache substitui o HybridCache?

Não necessariamente, porque ele pode atuar como implementação do HybridCache com AsHybridCache. Assim, o código continua dependendo da abstração da Microsoft, mas ganha backplane e fail-safe.

O backplane do FusionCache envia o valor atualizado para as outras instâncias?

Não, a mensagem carrega apenas a chave e a ação. Cada instância descarta a própria cópia em memória e busca o valor de novo no L2 ou no banco.

O Output Cache guarda respostas de usuários autenticados?

Não por padrão, porque requisições com cabeçalho Authorization não entram no cache. Se você mudar esse comportamento, varie a política pelo usuário para não vazar dados.

Ainda vale usar o middleware de Response Caching?

Para APIs, não. O Output Cache é controlado pelo servidor, remove por tag e suporta Redis, o que o Response Caching não faz.

Posso usar Output Cache e FusionCache juntos?

Sim, e essa é a combinação recomendada para leituras públicas intensas. O Output Cache atende na frente e o FusionCache cuida dos objetos dentro dos handlers.