Implementando Cache em memória com Output Cache em APIs .NET

O output cache é o recurso nativo do ASP.NET Core que guarda a resposta HTTP inteira em memória e a devolve nas próximas requisições, sem executar o endpoint de novo. Em APIs .NET, os primeiros problemas de performance costumam aparecer justamente em endpoints de listagem, quando muitos usuários pedem os mesmos dados o tempo todo.

Neste artigo, você vai implementar o output cache em uma Minimal API, aplicar cache no endpoint de listagem e cuidar da invalidação sempre que os dados mudarem.

O que é o output cache no ASP.NET Core

O output cache é um middleware que chegou ao ASP.NET Core no .NET 7. Ele intercepta a requisição, verifica se já existe uma resposta guardada para aquela chave e, nesse caso, devolve o conteúdo direto da memória.

Dessa forma, o banco de dados não recebe a consulta e o handler do endpoint nem chega a rodar. Na prática, uma listagem que levava dezenas de milissegundos passa a responder quase instantaneamente.

Diferente do antigo response caching, o servidor controla o output cache por completo. Ou seja, ele não depende dos cabeçalhos HTTP enviados pelo cliente para decidir se usa ou não a resposta guardada.

Além disso, o middleware funciona em Minimal APIs, em APIs com controllers, em MVC e em Razor Pages. Por isso, o que você aprender aqui vale para praticamente qualquer projeto ASP.NET Core.

Se quiser entender os tipos de cache disponíveis antes de seguir, vale ler o artigo sobre cache em APIs, seus tipos, vantagens e quando usar.

Quando usar cache em memória

O cache em memória é indicado quando o endpoint retorna dados compartilhados entre vários usuários. Também faz sentido quando as informações não mudam a todo instante.

Outro sinal claro aparece quando o banco de dados começa a virar gargalo. Nesse caso, o cache reduz a carga de leitura sem exigir nenhuma infraestrutura extra.

Em resumo, o cenário ideal reúne estas características:

  • O endpoint retorna dados compartilhados entre vários usuários.
  • As informações não mudam a cada segundo.
  • O banco de dados já mostra sinais de sobrecarga.
  • Você quer ganhar performance sem adicionar um servidor de cache.

Na maioria dos projetos, o primeiro uso de cache acontece nos GETs de listagem. Afinal, são eles que recebem mais tráfego e repetem a mesma consulta inúmeras vezes.

Por outro lado, dados personalizados por usuário raramente se beneficiam desse tipo de cache. Para esses casos, o próprio output cache tem regras que evitam guardar respostas de requisições autenticadas, como veremos adiante.

Cenário do exemplo

O exemplo usa uma API simples de catálogo de filmes chamada Filmflix. Ela foi escrita com ASP.NET Core Minimal API, usa SQL Server como banco de dados e oferece um CRUD básico.

O ponto central é o endpoint GET /movies, que lista todo o catálogo. Mantivemos o projeto simples de propósito, para que o foco fique apenas no cache.

Caso você ainda não tenha familiaridade com esse modelo de API, o artigo sobre Minimal APIs no ASP.NET mostra os fundamentos. Em seguida, volte aqui para adicionar o cache.

Como configurar o output cache em uma Minimal API

A configuração do output cache acontece em três etapas curtas. Primeiro, você importa o namespace; depois, registra o serviço; por fim, ativa o middleware no pipeline.

Vale destacar um detalhe da documentação oficial. Chamar AddOutputCache e UseOutputCache não começa a cachear nada sozinho, apenas torna o recurso disponível para os endpoints.

Adicionando o using do output cache

O primeiro passo é importar o namespace que contém os tipos do recurso:

using Microsoft.AspNetCore.OutputCaching;

Sem esse using, tipos como IOutputCacheStore e o atributo [OutputCache] não ficam visíveis no arquivo. Por isso, adicione a linha no topo do Program.cs.

Registrando o serviço no builder

Em seguida, registre o serviço no container de injeção de dependência:

builder.Services.AddOutputCache();

Esse passo cria o armazenamento padrão e as opções do recurso. Assim, a aplicação passa a ter tudo o que precisa para guardar respostas em memória.

Ativando o middleware no pipeline

Além do registro no builder, o middleware precisa entrar no pipeline da aplicação:

app.UseOutputCache();

Sem esse passo, o cache não funciona, mesmo que o endpoint esteja configurado. Portanto, nunca esqueça essa linha.

A ordem também importa. Segundo a documentação, quando a aplicação usa CORS, a chamada vem depois de UseCors; quando usa autenticação e autorização, vem depois de UseAuthentication e UseAuthorization.

Caso contrário, o middleware pode servir para um usuário uma resposta gerada para outro. Em apps com controllers ou Razor Pages, ele também precisa vir depois de UseRouting.

Aplicando o output cache no endpoint de listagem

A forma mais rápida de usar o output cache é aplicá-lo direto no endpoint de listagem com o método CacheOutput:

app.MapGet("/movies", GetMovies)
   .CacheOutput(options =>
       options.Expire(TimeSpan.FromSeconds(5))
              .Tag("movies"));

Esse código aplica cache apenas no endpoint /movies. Além disso, ele define um tempo de expiração de cinco segundos e associa a tag movies, que vamos usar depois para a invalidação manual.

Esse modelo é ótimo para aprendizado, testes e cenários simples. Contudo, quando a aplicação cresce, ele espalha configuração pelos endpoints, e é por isso que mais adiante vamos usar políticas.

Também existe uma alternativa por atributo, útil quando você prefere declarar o comportamento junto do handler:

app.MapGet("/movies", [OutputCache(Duration = 5, Tags = new[] { "movies" })] GetMovies);

Nos dois casos, o output cache usa como chave padrão a URL completa. Isto é, esquema, host, porta, caminho e query string entram na composição da chave.

O efeito do cache: dados desatualizados

Depois de aplicar o cache, você vai notar um comportamento esperado. Ao cadastrar um novo filme, a listagem continua retornando os dados antigos por algum tempo.

Isso acontece porque o middleware devolve a resposta guardada até ela expirar. Só então o handler roda de novo e busca os dados atualizados no banco.

Se esse atraso for aceitável para o negócio, o cache já resolve o problema. Por exemplo, um catálogo que tolera alguns segundos de defasagem não precisa de nenhum ajuste extra.

Caso contrário, você precisa invalidar o cache manualmente sempre que os dados mudarem. Felizmente, o output cache oferece um mecanismo simples para isso: as tags.

Invalidando o output cache por tag

Quando os dados mudam por meio de um POST, PUT ou DELETE, o cache da listagem precisa sair da memória. Para isso, usamos a interface IOutputCacheStore e removemos as entradas pela tag associada.

O IOutputCacheStore chega ao handler por injeção de dependência, como qualquer outro serviço. Então, basta declará-lo como parâmetro:

async Task<Ok<Movie>> AddMovie(
    FilmflixContext context,
    IOutputCacheStore outputCacheStore,
    [FromBody] Movie movie,
    CancellationToken cancellationToken)
{
    // Persiste o novo filme no banco
    context.Movies.Add(movie);
    await context.SaveChangesAsync(cancellationToken);

    // Remove do cache todas as respostas marcadas com a tag "movies"
    await outputCacheStore.EvictByTagAsync("movies", cancellationToken);

    return TypedResults.Ok(movie);
}

O método EvictByTagAsync apaga todas as respostas guardadas com aquela tag. Consequentemente, a próxima chamada ao GET /movies executa o handler e devolve a lista já com o novo filme.

Repare que o código recebe um CancellationToken da requisição. Assim, se o cliente cancelar a chamada, a operação também é interrompida, em vez de usar CancellationToken.None.

A ordem certa da invalidação

A invalidação do cache deve acontecer sempre depois da persistência dos dados no banco. Essa regra parece simples, mas evita um bug difícil de reproduzir.

Se você limpar o cache antes do SaveChangesAsync, outra requisição pode chegar nesse intervalo. Nesse caso, ela consulta o banco ainda sem o novo registro e guarda de novo uma lista desatualizada.

Por isso, persista primeiro e invalide depois. Dessa forma, a listagem nunca fica inconsistente por causa da ordem das operações.

Aplique o mesmo padrão nos endpoints de atualização e exclusão. Em outras palavras, toda operação que altera filmes termina com a chamada a EvictByTagAsync("movies").

Organizando o output cache com políticas

À medida que a aplicação cresce, configurar o cache direto em cada endpoint deixa o código menos organizado. Uma forma mais limpa é usar políticas nomeadas.

Uma política reúne em um só lugar a expiração, as tags e as regras de chave. Depois, cada endpoint apenas referencia o nome dela.

Criando a política

A política nasce no registro do serviço, dentro de AddOutputCache:

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("MoviesPolicy", policy =>
    {
        // Mantém a listagem em cache por dois minutos
        policy.Expire(TimeSpan.FromSeconds(120));
        policy.Tag("movies");
    });
});

Aqui, a política MoviesPolicy guarda as respostas por 120 segundos e aplica a tag movies. Ou seja, a invalidação que já escrevemos continua funcionando sem nenhuma mudança.

Aplicando a política no endpoint

Com a política criada, o endpoint fica bem mais enxuto:

app.MapGet("/movies", GetMovies)
   .CacheOutput("MoviesPolicy");

Com isso, a configuração fica centralizada e o endpoint fica mais limpo. Além disso, futuras mudanças de expiração exigem alteração em um único ponto.

Se vários endpoints compartilham o mesmo prefixo, você também pode aplicar o cache em um grupo com MapGroup. Assim, todas as rotas do grupo herdam a mesma configuração.

Regras padrão e chave do cache

Por padrão, o output cache segue algumas regras de segurança. Ele só guarda respostas HTTP 200 e só atende requisições GET ou HEAD.

Além disso, ele ignora respostas que definem cookies e requisições autenticadas. Por isso, endpoints que dependem do usuário logado não entram no cache sem uma política personalizada.

A chave padrão inclui toda a URL. No entanto, você pode controlar a chave com métodos como SetVaryByQuery, SetVaryByHeader e VaryByValue.

Por exemplo, uma listagem paginada pode variar apenas pelos parâmetros page e pageSize:

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("PagedMovies", policy => policy
        .Expire(TimeSpan.FromSeconds(60))
        .SetVaryByQuery("page", "pageSize")
        .Tag("movies"));
});

Dessa forma, cada combinação de página gera uma entrada própria. Mesmo assim, todas compartilham a tag movies e saem juntas da memória na invalidação.

Limites e proteção contra cache stampede

O armazenamento padrão do output cache fica em memória, dentro do processo da aplicação. Para não consumir memória sem controle, a classe OutputCacheOptions oferece três limites.

O SizeLimit define o tamanho máximo do armazenamento, com padrão de 100 MB. Já o MaximumBodySize define o tamanho máximo de cada resposta, com padrão de 64 MB.

Por fim, o DefaultExpirationTimeSpan define a expiração usada quando a política não informa nenhuma. Nesse caso, o valor padrão é de 60 segundos.

Outro recurso importante é o resource locking, ativo por padrão. Quando muitas requisições chegam ao mesmo tempo para uma entrada expirada, apenas uma executa o handler, enquanto as demais aguardam o resultado.

Esse comportamento protege o banco contra o chamado cache stampede. Se precisar, você pode desligá-lo com SetLocking(false) em uma política.

Output cache em memória ou cache distribuído

Como o armazenamento padrão vive dentro do processo, cada servidor tem o seu próprio cache. Além disso, todo o conteúdo se perde quando a aplicação reinicia.

Para uma única instância, isso não é problema. Contudo, com várias instâncias atrás de um balanceador, uma invalidação por tag limpa apenas o servidor que recebeu o POST.

Nesse cenário, a documentação recomenda o pacote Microsoft.AspNetCore.OutputCaching.StackExchangeRedis, disponível a partir do .NET 8. Com ele, você chama AddStackExchangeRedisOutputCache e todas as instâncias passam a compartilhar o mesmo armazenamento.

A documentação também desaconselha usar IDistributedCache com esse recurso. Afinal, essa interface não tem as operações atômicas que as tags exigem.

Se a dúvida for entre guardar a resposta inteira ou apenas objetos, o artigo sobre memory cache no ASP.NET mostra a abordagem com IMemoryCache. As duas técnicas se complementam.

Boas práticas ao usar output cache em APIs .NET

Algumas práticas simples evitam a maioria dos problemas com o output cache:

  • Cacheie apenas operações de leitura.
  • Comece com cache em memória antes de pensar em cache distribuído.
  • Use tags para controlar a invalidação.
  • Evite tempos de expiração exagerados.
  • Invalide sempre depois de persistir os dados.
  • Respeite a ordem do middleware em relação a CORS e autenticação.

Acima de tudo, o cache deve resolver um problema real, e não entrar no projeto apenas por garantia. Portanto, meça o tempo de resposta antes e depois para confirmar o ganho.

Para se aprofundar nas opções avançadas, consulte a documentação oficial do middleware de output caching. Lá você encontra políticas personalizadas, revalidação com ETag e todos os métodos de OutputCachePolicyBuilder.

Conclusão

O output cache oferece uma maneira simples e eficiente de melhorar a performance de APIs .NET, especialmente em endpoints de listagem. Com três linhas de configuração, a aplicação já consegue guardar respostas inteiras em memória.

Na maioria dos projetos, o cache em memória já resolve o problema inicial. Ele reduz a carga no banco, melhora o tempo de resposta e não exige infraestrutura adicional.

No entanto, mais importante do que aplicar o cache é saber quando e como invalidar. Com tags, políticas e a ordem certa entre persistência e invalidação, você ganha performance sem perder consistência.

FAQ

Em qual versão do .NET o output cache está disponível?

O middleware de output caching chegou no .NET 7 e continua disponível nas versões seguintes. O suporte nativo ao Redis como armazenamento chegou no .NET 8.

O output cache funciona com controllers?

Sim, basta aplicar o atributo [OutputCache] na action ou no controller. Em apps com controllers, chame UseOutputCache depois de UseRouting.

Qual é a expiração padrão do output cache?

Quando nenhuma política define o tempo, a expiração padrão é de 60 segundos. Você pode mudar esse valor em DefaultExpirationTimeSpan.

Por que minha resposta não está sendo cacheada?

As causas mais comuns são requisições autenticadas, respostas com cookie, status diferente de 200 ou ausência de UseOutputCache. Verifique também a ordem do middleware no pipeline.

Qual a diferença entre output cache e response caching?

O response caching segue os cabeçalhos HTTP enviados pelo cliente. Já o output cache é controlado pelo servidor e permite invalidar entradas por tag.

Glossário

Termo Significado
Output cache Middleware do ASP.NET Core que guarda a resposta HTTP inteira e a reaproveita em requisições seguintes.
Tag Rótulo associado a entradas do cache para removê-las em grupo.
IOutputCacheStore Interface que representa o armazenamento do output cache e expõe o método EvictByTagAsync.
Política Conjunto nomeado de regras de cache, como expiração, tags e chave.
Invalidação Remoção de uma entrada do cache para forçar a geração de uma resposta atualizada.
Cache stampede Situação em que muitas requisições simultâneas recalculam a mesma entrada expirada.
Resource locking Mecanismo que deixa apenas uma requisição gerar a resposta enquanto as outras aguardam.
Minimal API Modelo do ASP.NET Core para criar endpoints HTTP com pouco código e sem controllers.

Fontes