ASP.NET Memory Cache

O memory cache é um recurso usado para otimizar a performance das aplicações, porque evita requisições desnecessárias às fontes de dados. Neste artigo, vamos entender quando vale a pena cachear, como o cache em memória funciona e como implementá-lo no ASP.NET Core 3.1 com a interface IMemoryCache.

O que é cache e por que usar

É comum termos diversos dados nas aplicações que não mudam com frequência. Itens como estado civil, unidade de medida e até algumas categorias de produtos entram nesse grupo.

Tudo que tem baixa mutabilidade pode ser cacheado. Dessa forma, a requisição não precisa chegar até a fonte de dados toda vez.

O cache age entre a aplicação e a fonte de dados. Ou seja, dada uma requisição, guardamos o resultado em algum local e, nas próximas chamadas iguais, lemos esse resultado em vez de consultar o banco.

Segundo a documentação oficial sobre cache em memória, o cache funciona melhor com dados que mudam pouco e que são caros de gerar. Além disso, a aplicação nunca deve depender do valor cacheado para funcionar.

Expiração e renovação do cache

O cache pode ficar em diversos lugares, incluindo a memória do servidor, como veremos com o memory cache. Contudo, todo item cacheado precisa de um tempo de expiração.

Quando esse tempo termina, o item sai do cache. Então, a próxima requisição vai novamente até a fonte de dados e o processo de cacheamento recomeça.

Quando usado com sabedoria, o cache pode melhorar muito a performance das aplicações. Imagine quantos acessos ao banco um bom cache pode evitar em uma página muito visitada.

Em cenários de larga escala, é comum cachear itens por algumas horas ou apenas por alguns minutos. Afinal, o importante é reduzir a carga no banco, mesmo que por pouco tempo.

Portanto, não tenha medo do cache. Se uma informação não precisa estar em tempo real, talvez ela possa ser cacheada de alguma forma.

Para conhecer outras estratégias, como cache de resposta e cache distribuído, vale ler o artigo Cache em APIs: entenda os tipos, vantagens e quando usar. Ele complementa bem o conteúdo deste guia.

Como o memory cache funciona

Um dos caches mais comuns é o memory cache, que armazena as informações na memória do próprio servidor. Na prática, ele funciona como um dicionário de chave e valor dentro do processo da aplicação.

Para guardar um item, usamos uma chave, um tempo de expiração e o valor propriamente dito. Em seguida, verificamos se os dados pedidos pelo usuário já estão na memória e os devolvemos mais rápido.

Caso não exista um item com a chave informada, recorremos ao processo normal de acesso ao banco. Esse caminho alternativo também recebe o nome de fallback.

O memory cache aceita qualquer objeto como valor. Por outro lado, o cache distribuído trabalha apenas com arrays de bytes, o que exige serialização.

Bons candidatos ao memory cache são listas de apoio, configurações lidas do banco e resultados de consultas pesadas que mudam pouco. Por outro lado, saldos bancários, estoques em tempo real e dados de sessão sensíveis costumam ficar de fora.

Antes de cachear, pergunte quanto tempo o usuário pode conviver com um dado levemente desatualizado. Então, use essa resposta para definir o tempo de expiração de cada item.

Cuidados com memória no memory cache

O memory cache tem dois pontos extremamente importantes que você precisa considerar antes de implementar. São eles a memória e a escalabilidade.

Como o nome diz, colocamos os dados em memória, e eles permanecem lá até expirar. Depois disso, eles voltam a ser gerados e ocupam a memória novamente.

Em outras palavras, quanto mais você cacheia, mais memória fica em uso. Por isso, devemos ter cautela para não sobrecarregar o servidor com itens desnecessários.

A documentação deixa um alerta importante aqui. O runtime do ASP.NET Core não limita o tamanho do memory cache com base na pressão de memória, então essa responsabilidade fica com o desenvolvedor.

Para controlar o crescimento, a documentação recomenda três atitudes. Use expirações, defina limites de tamanho com SizeLimit e nunca use entradas externas, como textos digitados pelo usuário, como chave.

Memory cache e escalabilidade horizontal

O segundo ponto se refere à escalabilidade horizontal da aplicação, ou seja, à quantidade de servidores ou contêineres que o app possui. Esse detalhe muda bastante o comportamento do memory cache.

É comum termos aplicações com escalonamento automático. Assim, quando o uso de CPU ou memória atinge certo percentual, a plataforma provisiona uma nova máquina ou um novo contêiner.

Sempre que uma nova instância sobe, ela possui apenas o essencial. Portanto, a memória do servidor anterior não acompanha a nova instância.

Isso significa que tudo que está cacheado no servidor A fica apenas no servidor A. O conteúdo não se estende para os servidores B, C e D que vierem depois.

Nesses novos servidores, a geração do cache acontece de novo, exatamente como aconteceu no servidor A. Além disso, cada instância pode devolver um valor diferente durante algum tempo.

A documentação sugere sessões fixas, conhecidas como sticky sessions, quando o memory cache roda em um conjunto de servidores. Com elas, as requisições de um mesmo cliente chegam sempre à mesma máquina.

Caso precise de um cache compartilhado entre diversas máquinas, você pode optar por um cache distribuído, como o Redis. No entanto, ao agregar serviços, agregamos também complexidade e custo à infraestrutura.

Em poucas palavras, use esses recursos para ampliar seu leque de possibilidades. Muitas vezes o básico funciona bem e custa menos.

Configurando o memory cache no ASP.NET Core 3.1

No ASP.NET Core, o cache em memória é um serviço nativo do framework, que a aplicação consome por injeção de dependência. Vale destacar que ele não é um middleware, e sim um serviço registrado no container.

Como todo serviço, ele fica registrado no método ConfigureServices, dentro do arquivo Startup.cs. Na versão 3.1, essa é a estrutura padrão dos projetos.

public void ConfigureServices(IServiceCollection services)
{
    // Registra o IMemoryCache no container de DI
    services.AddMemoryCache();
}

Essa é toda a configuração necessária para usar o memory cache. Daqui em diante, o serviço IMemoryCache fica disponível para qualquer classe da aplicação.

Na verdade, muitos projetos já recebem o serviço sem essa linha. Segundo a documentação, chamadas como AddMvc, AddControllersWithViews e AddRazorPages também habilitam o IMemoryCache.

Ainda assim, deixar o AddMemoryCache explícito não causa problema algum. Pelo contrário, ele documenta a intenção de usar o recurso.

Utilizando o IMemoryCache

O primeiro passo para trabalhar com o memory cache é recuperar uma instância do serviço IMemoryCache. Podemos fazer isso com a injeção de dependência, diretamente na action.

public IActionResult Index([FromServices] IMemoryCache cache)
{
    // ...
}

Com isso, a variável cache oferece os métodos GetOrCreate e GetOrCreateAsync, que obtêm um item existente ou criam um novo. Use o GetOrCreateAsync quando estiver em um método assíncrono, que retorna Task.

Agora vamos implementar o código que decide qual fonte deve ser lida. Se a chave existir, o valor vem da memória, caso contrário a função informada gera o valor.

public IActionResult Index([FromServices] IMemoryCache cache)
{
    var date = cache.GetOrCreate("MyCacheKey", entry =>
    {
        // Mantém o item enquanto houver acessos a cada 10 segundos
        entry.SlidingExpiration = TimeSpan.FromSeconds(10);
        return DateTime.Now;
    });

    return View("Cache", date);
}

Ao atualizar a página várias vezes, a data exibida permanece a mesma enquanto o item estiver em cache. Depois da expiração, o memory cache gera uma nova data.

Lendo e gravando com TryGetValue e Set

Além do GetOrCreate, a interface permite separar a leitura da gravação. O método TryGetValue informa se a chave existe, enquanto o Set grava um novo item.

if (!_cache.TryGetValue("MyCacheKey", out DateTime cachedDate))
{
    cachedDate = DateTime.Now;

    var options = new MemoryCacheEntryOptions()
        .SetSlidingExpiration(TimeSpan.FromSeconds(10));

    _cache.Set("MyCacheKey", cachedDate, options);
}

Essa forma dá mais controle quando a lógica de geração é longa. Por exemplo, você pode registrar logs ou validar o resultado antes de gravar.

Expiração deslizante e absoluta no memory cache

A opção SlidingExpiration define por quanto tempo um item pode ficar inativo, sem acessos, antes de sair do cache. Cada leitura reinicia essa contagem.

Já a opção AbsoluteExpirationRelativeToNow define um tempo de expiração relativo ao momento atual. Nesse caso, o item expira no prazo definido, mesmo que receba acessos.

Existe um detalhe importante sobre a expiração deslizante. A expiração deslizante não estende a vida do item além do término absoluto, quando ele existe.

A documentação alerta que um item com apenas expiração deslizante corre o risco de nunca expirar. Se ele receber acessos constantes dentro do intervalo, o memory cache nunca o remove.

Por isso, a recomendação é combinar as duas opções. Assim, a expiração absoluta funciona como limite máximo, enquanto a deslizante remove antes os itens pouco usados.

var date = cache.GetOrCreate("MyCacheKey", entry =>
{
    // Sai do cache após 10 segundos sem acesso
    entry.SlidingExpiration = TimeSpan.FromSeconds(10);
    // E nunca dura mais que 1 minuto
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
    return DateTime.Now;
});

Também vale saber que a expiração não acontece em segundo plano. Na verdade, não existe um timer varrendo o cache, e qualquer atividade, como Get ou Set, pode disparar a verificação dos itens expirados.

Cacheando dados complexos

No exemplo anterior, usamos uma simples data e hora como retorno. Porém, podemos guardar dados mais estruturados e até chamar uma função do repositório.

var products = cache.GetOrCreate("ProductsCacheKey", entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
    // Só executa a consulta quando a chave não existe no cache
    return _repository.GetProducts();
});

return products;

Nesse caso, se o memory cache não encontrar a chave ProductsCacheKey, o código chama o método GetProducts do repositório. Em seguida, os produtos ficam guardados por uma hora.

Repare que o memory cache guarda a referência do objeto, sem cópia. Portanto, evite alterar a lista retornada, porque a alteração afeta todas as próximas leituras.

Alterando a prioridade dos itens

Cada item do cache possui uma prioridade, definida pelo enum CacheItemPriority. Os valores disponíveis são Low, Normal, High e NeverRemove.

A prioridade entra em ação quando o cache precisa liberar espaço. Nesse momento, os itens menos prioritários saem primeiro, enquanto os marcados com NeverRemove permanecem.

Para definir a prioridade, usamos o método SetPriority dentro da configuração do item. Veja como fica o exemplo dos produtos com prioridade alta.

var products = cache.GetOrCreate("ProductsCacheKey", entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
    // Itens com prioridade alta saem depois dos demais
    entry.SetPriority(CacheItemPriority.High);
    return _repository.GetProducts();
});

Segundo a documentação, o método Compact remove itens nesta ordem. Primeiro saem os expirados, depois os de menor prioridade, os menos usados recentemente e, por fim, os de expiração mais próxima.

Limitando o tamanho do memory cache

Como o runtime não limita o tamanho automaticamente, a classe MemoryCacheOptions oferece a propriedade SizeLimit. Esse valor não tem unidade definida, então você escolhe o que cada unidade representa.

Uma abordagem simples é atribuir tamanho 1 a cada item. Dessa forma, o SizeLimit passa a representar a quantidade máxima de itens.

public class LimitedMemoryCache
{
    // Instância própria, separada do cache compartilhado
    public MemoryCache Cache { get; } = new MemoryCache(
        new MemoryCacheOptions
        {
            SizeLimit = 1024
        });
}

Quando existe um limite, todos os itens precisam informar o tamanho com SetSize ou com a propriedade Size. Caso contrário, a gravação falha.

Por esse motivo, a documentação recomenda criar uma instância própria, registrada como singleton. Afinal, o cache compartilhado também recebe itens de bibliotecas que não informam tamanho.

Removendo e invalidando itens do memory cache

Nem sempre queremos esperar a expiração para atualizar um item. Por exemplo, quando um administrador altera uma categoria, o valor antigo deve sair do cache imediatamente.

Nesse caso, basta chamar o método Remove com a chave do item. Na próxima leitura, o memory cache não encontra a chave e o fallback busca o dado atualizado.

public IActionResult UpdateCategory(Category category)
{
    _repository.Update(category);

    // Invalida o cache para a próxima leitura buscar o dado novo
    _cache.Remove("CategoriesCacheKey");

    return Ok();
}

Também é possível invalidar vários itens de uma vez com um CancellationTokenSource. Para isso, cada item recebe um CancellationChangeToken associado ao mesmo token.

var tokenSource = new CancellationTokenSource();

var options = new MemoryCacheEntryOptions()
    .AddExpirationToken(new CancellationChangeToken(tokenSource.Token));

_cache.Set("ProductsCacheKey", products, options);
_cache.Set("CategoriesCacheKey", categories, options);

// Remove os dois itens de uma só vez
tokenSource.Cancel();

Quando o código chama Cancel, todos os itens ligados ao token saem do cache. Dessa forma, você agrupa dados relacionados e evita inconsistências entre eles.

Centralizar as chaves em uma classe estática também ajuda bastante. Afinal, um erro de digitação na chave cria um item duplicado e impede a invalidação.

Callbacks de remoção e atualização em segundo plano

O memory cache permite registrar um callback que roda sempre que um item sai da memória. Para isso, usamos o método RegisterPostEvictionCallback, que recebe a chave, o valor e o motivo da remoção.

var options = new MemoryCacheEntryOptions()
    .SetAbsoluteExpiration(TimeSpan.FromMinutes(5))
    .RegisterPostEvictionCallback((key, value, reason, state) =>
    {
        // O callback roda em outra thread
        Console.WriteLine($"Entry {key} was evicted: {reason}");
    });

Outro ponto de atenção aparece quando várias requisições encontram a chave vazia ao mesmo tempo. Nesse caso, mais de uma thread pode gerar o mesmo item.

Para dados muito caros, a documentação sugere atualizar o cache em segundo plano com um IHostedService. Se quiser aprofundar, o artigo sobre background services com C# e .NET mostra como criar esse tipo de serviço.

Conclusão

O memory cache é uma das formas mais simples de ganhar performance no ASP.NET Core 3.1. Com poucas linhas, evitamos consultas repetidas e aliviamos a carga sobre o banco de dados.

Contudo, ele exige cuidado com o consumo de memória e com a escalabilidade horizontal. Por isso, combine expirações, limite o tamanho quando necessário e avalie um cache distribuído quando houver várias instâncias.

Usado com critério, o memory cache resolve a maior parte dos cenários de dados pouco mutáveis. Assim, você entrega respostas mais rápidas sem adicionar novos serviços à infraestrutura.

FAQ

O que é memory cache no ASP.NET Core?

É o cache que guarda dados na memória do próprio servidor web por meio da interface IMemoryCache. Ele evita idas repetidas à fonte de dados.

Preciso instalar algum pacote para usar o IMemoryCache?

Não, o ASP.NET Core 3.1 já inclui o recurso. Basta chamar AddMemoryCache no ConfigureServices ou usar um método que já o habilite, como AddControllersWithViews.

Qual a diferença entre SlidingExpiration e AbsoluteExpirationRelativeToNow?

A expiração deslizante remove o item após um período sem acessos. Já a absoluta remove o item em um prazo fixo, mesmo que ele continue recebendo acessos.

O memory cache funciona com vários servidores?

Cada servidor mantém o próprio cache, então os dados não são compartilhados. Para várias instâncias, use sticky sessions ou um cache distribuído como o Redis.

O ASP.NET Core limpa o cache quando falta memória?

Não, o runtime não limita o tamanho do cache com base na pressão de memória. Use expirações e a propriedade SizeLimit para controlar o crescimento.