Caching em APIs .NET com MemoryCache

Caching em APIs .NET com MemoryCache

Em um mundo onde a velocidade e a eficiência são cruciais para a satisfação do usuário, otimizar o desempenho de suas APIs é fundamental. O caching surge como uma solução eficaz para reduzir a carga nos servidores e melhorar o tempo de resposta das aplicações. Neste post, vamos aprender como implementar o MemoryCache em APIs .NET, permitindo armazenar em cache dados frequentemente acessados e, consequentemente, aumentar a performance da sua aplicação.


Mas o que é Caching?

O caching é uma técnica utilizada para armazenar temporariamente dados que são frequentemente acessados, com o objetivo de melhorar o desempenho e a velocidade de uma aplicação. Em vez de buscar informações no banco de dados ou recalcular resultados a cada solicitação, a aplicação verifica se esses dados já estão disponíveis no cache, reduzindo o tempo de resposta e a carga nos recursos do sistema.

Principais tipos de Caching:

  1. Cache em Memória (In-Memory Cache):
    • Descrição: Armazena os dados na memória RAM do servidor local.
    • Vantagens: Acesso extremamente rápido aos dados.
    • Desvantagens: Dados são voláteis e serão perdidos se o servidor for reiniciado. Não é compartilhado entre múltiplos servidores.
  2. Cache Distribuído (Distributed Cache):
    • Descrição: Os dados são armazenados em um sistema de cache externo, como Redis ou Memcached.
    • Vantagens: Permite que múltiplos servidores acessem o mesmo cache, ideal para aplicações escaláveis.
    • Desvantagens: Latência ligeiramente maior em comparação com o cache em memória local.
  3. Cache Persistente (Persistent Cache):
    • Descrição: Armazena dados em disco ou em um banco de dados, permitindo que o cache sobreviva a reinicializações do servidor.
    • Vantagens: Dados persistem após reinícios e podem ser compartilhados entre servidores.
    • Desvantagens: Acesso mais lento em comparação com o cache em memória.

Mas então, o que é o MemoryCache?

O MemoryCache é uma implementação de cache em memória fornecida pelo .NET Framework. Ele permite que você armazene dados temporariamente na memória do servidor, associando cada item a uma chave única para facilitar o acesso rápido. Por ser armazenado na RAM, o acesso aos dados é extremamente veloz, tornando o MemoryCache uma excelente opção para melhorar o desempenho de aplicações que lidam com informações que não mudam com frequência.

Características do MemoryCache:

  • Rapidez: Acesso imediato aos dados armazenados.
  • Controle de Expiração: Possibilidade de definir políticas de expiração, como tempo absoluto ou relativo.
  • Facilidade de Uso: Integração simples com aplicações .NET, sem a necessidade de dependências externas.
  • Limitado ao Servidor Local: Os dados são armazenados apenas no servidor onde a aplicação está rodando, não sendo compartilhados entre múltiplos servidores.

Utilizar o MemoryCache é especialmente útil em cenários onde:

  • Desempenho é Crítico: Necessidade de respostas rápidas aos usuários.
  • Dados são Frequentes e Reutilizáveis: Informações que são solicitadas repetidamente e não mudam com frequência.
  • Ambiente de Servidor Único: Aplicações que rodam em um único servidor ou onde o compartilhamento de cache não é necessário.

No entanto, é importante estar ciente de que, por ser um cache em memória:

  • Volatilidade dos Dados: Os dados serão perdidos em caso de reinicialização do servidor ou da aplicação.
  • Escalabilidade Limitada: Não é adequado para aplicações distribuídas sem uma estratégia adicional para compartilhar o cache entre servidores.

Ao entender o que é o MemoryCache e como ele funciona, você pode tomar decisões informadas sobre quando e como implementá-lo em suas aplicações .NET para obter melhorias significativas no desempenho.

Implementando em .net

Para começar a utilizar o MemoryCache em sua aplicação ASP.NET Core 8, é necessário registrar o serviço de caching no contêiner de injeção de dependência. A configuração é simples e envolve adicionar o serviço de caching no arquivo Program.cs.

No arquivo Program.cs, adicione o serviço de caching da seguinte forma:

var builder = WebApplication.CreateBuilder(args);

// Adiciona o serviço de caching em memória
builder.Services.AddMemoryCache();

var app = builder.Build();

// Configurações adicionais...

app.Run();

Ao chamar AddMemoryCache(), você registra o serviço de cache em memória no contêiner de serviços da aplicação, permitindo que ele seja injetado nos locais onde for necessário.

Exemplo prático de implementação em um endpoint

Vamos supor que você tenha um endpoint que retorna uma lista de produtos de um banco de dados. O objetivo é armazenar essa lista em cache para evitar consultas desnecessárias ao banco de dados e melhorar o tempo de resposta da API.

Primeiro, crie um controlador chamado ProdutosController:

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;

[ApiController]
[Route("api/[controller]")]
public class ProdutosController : ControllerBase
{
    private readonly IMemoryCache _cache;

    public ProdutosController(IMemoryCache cache)
    {
        _cache = cache;
    }

    [HttpGet]
    public async Task<IActionResult> GetProdutos()
    {
        const string cacheKey = "listaProdutos";

        // Tenta obter a lista de produtos do cache
        if (!_cache.TryGetValue(cacheKey, out List<Produto> produtos))
        {
            // Caso não esteja no cache, busca no banco de dados (simulado aqui)
            produtos = await ObterProdutosDoBancoDeDados();

            // Define as opções de cache
            var cacheOptions = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
                SlidingExpiration = TimeSpan.FromMinutes(2)
            };

            // Armazena a lista de produtos no cache
            _cache.Set(cacheKey, produtos, cacheOptions);
        }

        return Ok(produtos);
    }

    private Task<List<Produto>> ObterProdutosDoBancoDeDados()
    {
        // Simulação de acesso ao banco de dados
        var produtos = new List<Produto>
        {
            new Produto { Id = 1, Nome = "Produto A", Preco = 10.0 },
            new Produto { Id = 2, Nome = "Produto B", Preco = 20.0 },
            new Produto { Id = 3, Nome = "Produto C", Preco = 30.0 },
        };

        return Task.FromResult(produtos);
    }
}

public class Produto
{
    public int Id { get; set; }
    public string Nome { get; set; }
    public double Preco { get; set; }
}

Explicação do código:

  • Injeção do IMemoryCache:
    • No construtor do ProdutosController, o IMemoryCache é injetado, permitindo acesso aos métodos de caching.
  • Tentativa de obter do cache:
    • O método _cache.TryGetValue verifica se a lista de produtos já está armazenada no cache sob a chave "listaProdutos".
    • Se não estiver, a aplicação busca os dados (neste exemplo, uma função simulada ObterProdutosDoBancoDeDados()).
  • Definição das opções de cache:
    • AbsoluteExpirationRelativeToNow: define o tempo absoluto de expiração a partir do momento atual (5 minutos).
    • SlidingExpiration: renova o tempo de expiração se o cache for acessado dentro do período especificado (2 minutos).
  • Armazenamento no cache:
    • O método _cache.Set armazena a lista de produtos no cache com as opções definidas.
  • Retorno dos produtos:
    • Independentemente de os dados terem sido obtidos do cache ou do banco de dados, a lista de produtos é retornada ao cliente.

Benefícios desta implementação:

  • Desempenho Melhorado:
    • Reduz o número de consultas ao banco de dados, diminuindo a latência e aumentando a capacidade de resposta da API.
  • Eficiência de Recursos:
    • Menor carga sobre o banco de dados e outros serviços backend, liberando recursos para outras operações.

Considerações Importantes:

  • Validade dos Dados:
    • Certifique-se de que o tempo de cache é adequado para os dados em questão. Dados que mudam com frequência podem exigir um tempo de expiração menor.
  • Ambientes Distribuídos:
    • O MemoryCache é local ao servidor. Em ambientes com múltiplos servidores ou instâncias, considere utilizar um cache distribuído, como Redis, para compartilhar o cache entre todas as instâncias. (vamos ter um post sobre em breve)
  • Limpeza Manual do Cache:
    • Se os dados forem atualizados ou excluídos, pode ser necessário remover manualmente o item do cache usando _cache.Remove(cacheKey);.

Exemplo de remoção manual do cache após uma atualização:

[HttpPost]
public async Task<IActionResult> AtualizarProduto(Produto produto)
{
    // Atualiza o produto no banco de dados
    await AtualizarProdutoNoBancoDeDados(produto);

    // Remove o item do cache para garantir que os dados atualizados sejam carregados na próxima vez
    _cache.Remove("listaProdutos");

    return NoContent();
}

private Task AtualizarProdutoNoBancoDeDados(Produto produto)
{
    // Simulação de atualização no banco de dados
    return Task.CompletedTask;
}

Ao remover o item do cache após uma atualização, você garante que os usuários receberão os dados mais recentes na próxima solicitação.

Nem tudo são flores

Embora o uso do MemoryCache traga benefícios significativos para o desempenho da aplicação, a invalidação de cache é um dos desafios mais complexos que os desenvolvedores enfrentam. Determinar quando e como invalidar o cache é crucial para garantir que os usuários recebam informações atualizadas e precisas.

O desafio da invalidação de cache

  • Dados Desatualizados: Se o cache não for invalidado no momento correto, os usuários podem receber informações antigas, o que pode levar a inconsistências e problemas de confiança nos dados apresentados pela aplicação.
  • Complexidade na Sincronização: Em aplicações onde os dados são atualizados com frequência, manter o cache sincronizado com o estado atual do banco de dados torna-se uma tarefa complexa.
  • Balanceamento entre Performance e Atualização: É necessário encontrar um equilíbrio entre o tempo que os dados permanecem em cache (para maximizar a performance) e a necessidade de apresentar informações atualizadas aos usuários.

Estratégias para invalidação de cache

  1. Expiração por Tempo (Time-to-Live - TTL):
    • Descrição: Define um período após o qual o item em cache expira automaticamente.
    • Desafios: Definir o TTL ideal é complicado; um período muito longo pode servir dados obsoletos, enquanto um muito curto pode anular os benefícios do cache.
  2. Invalidação Manual:
    • Descrição: O cache é explicitamente invalidado ou atualizado quando ocorre uma alteração nos dados subjacentes.
    • Desafios: Requer que todas as partes do código que modificam os dados também gerenciem a invalidação do cache, aumentando a complexidade e a chance de erros.
  3. Eventos e Notificações:
    • Descrição: Utiliza eventos ou mensagens para notificar quando os dados mudam, permitindo que o cache seja invalidado ou atualizado em resposta.
    • Desafios: Implementação mais complexa que pode introduzir latência e exigir uma arquitetura de eventos robusta.
  4. Cache Dependente (Cache Dependencies):
    • Descrição: O cache é configurado para depender de certos recursos ou entradas, sendo invalidado automaticamente quando esses recursos mudam.
    • Desafios: Nem sempre é suportado nativamente e pode ser difícil de implementar corretamente.

Considerações importantes

  • Consistência dos Dados: Sempre avalie o impacto que dados desatualizados podem ter na experiência do usuário e na integridade da aplicação.
  • Monitoramento do Cache: Implemente logs e métricas para monitorar o desempenho do cache e identificar rapidamente quando a invalidação não está ocorrendo como esperado.
  • Testes Rigorosos: Teste diferentes cenários de invalidação para garantir que o cache seja atualizado corretamente em todas as situações.
  • Documentação: Mantenha uma documentação clara sobre como o caching e a invalidação são implementados na aplicação, facilitando a manutenção e futuras alterações.

Exemplo Prático de Invalidação de Cache

Para ilustrar como a invalidação de cache funciona na prática, vamos considerar um cenário comum em aplicações web:

Cenário: Você tem uma API que retorna detalhes de um produto. Quando esse produto é atualizado (por exemplo, alteração de preço ou descrição), é fundamental que os usuários recebam as informações mais recentes. Isso requer a invalidação do cache para evitar servir dados desatualizados.

Como implementar a invalidação de cache

  1. Armazenamento inicial no cache:
    • Quando um usuário solicita os detalhes de um produto pela primeira vez, a aplicação verifica se os dados estão no cache.
    • Como não estão, os dados são obtidos no banco de dados.
    • Os dados do produto são então armazenados no cache usando uma chave única, geralmente baseada no ID do produto (por exemplo, "produto_123").
  2. Servindo dados do cache:
    • Em solicitações subsequentes para o mesmo produto, a aplicação verifica o cache e encontra os dados armazenados.
    • Os dados são retornados rapidamente ao usuário sem necessidade de consultar o banco de dados novamente.
  3. Atualização do produto:
    • Quando o produto é atualizado (por exemplo, o preço muda), a aplicação realiza a atualização no banco de dados.
  4. Invalidação do cache:
    • Após a atualização bem-sucedida, a aplicação remove o item correspondente do cache.
    • Isso é feito usando a mesma chave utilizada para armazenar o produto no cache (por exemplo, _cache.Remove("produto_123")).
  5. Atualização do cache com dados recentes:
    • Na próxima vez que os detalhes do produto forem solicitados, o cache não conterá mais os dados desatualizados.
    • A aplicação busca os dados atualizados do banco de dados, armazena-os no cache novamente e os retorna ao usuário.

Código para o Exemplo de Invalidação de Cache

Vamos agora fornecer código para ilustrar o exemplo prático de invalidação de cache em uma aplicação ASP.NET Core 8.

Lembrando que o código proposto tem o objetivo de exemplificar o uso e invalidação de cache, e uma aplicação real vai conter mais complexidades que serão omitidas.

Configurando o Cache no Endpoint de Obtenção de Produto

Primeiro, vamos criar um controlador ProdutosController com endpoints para obter e atualizar um produto. Usaremos o IMemoryCache para armazenar os detalhes do produto em cache

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;

[ApiController]
[Route("api/[controller]")]
public class ProdutosController : ControllerBase
{
    private readonly IMemoryCache _cache;

    public ProdutosController(IMemoryCache cache)
    {
        _cache = cache;
    }

    // Endpoint para obter detalhes de um produto por ID
    [HttpGet("{id}")]
    public async Task<IActionResult> GetProduto(int id)
    {
        string cacheKey = $"produto_{id}";

        // Tenta obter o produto do cache
        if (!_cache.TryGetValue(cacheKey, out Produto produto))
        {
            // Caso não esteja no cache, busca no banco de dados (simulado aqui)
            produto = await ObterProdutoDoBancoDeDados(id);

            if (produto == null)
            {
                return NotFound();
            }

            // Define as opções de cache
            var cacheOptions = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration = TimeSpan.FromMinutes(5)
            };

            // Armazena o produto no cache
            _cache.Set(cacheKey, produto, cacheOptions);
        }

        return Ok(produto);
    }

    // Endpoint para atualizar um produto
    [HttpPut("{id}")]
    public async Task<IActionResult> AtualizarProduto(int id, [FromBody] Produto produtoAtualizado)
    {
        if (id != produtoAtualizado.Id)
        {
            return BadRequest("O ID do produto não corresponde ao ID da URL.");
        }

        // Atualiza o produto no banco de dados (simulado aqui)
        bool atualizado = await AtualizarProdutoNoBancoDeDados(produtoAtualizado);

        if (!atualizado)
        {
            return NotFound();
        }

        // Invalida o cache removendo o item correspondente
        string cacheKey = $"produto_{id}";
        _cache.Remove(cacheKey);

        return NoContent();
    }

    // Métodos simulados de acesso ao banco de dados
    private Task<Produto> ObterProdutoDoBancoDeDados(int id)
    {
        // Simulação de acesso ao banco de dados
        var produtos = new List<Produto>
        {
            new Produto { Id = 1, Nome = "Produto A", Preco = 10.0 },
            new Produto { Id = 2, Nome = "Produto B", Preco = 20.0 },
            new Produto { Id = 3, Nome = "Produto C", Preco = 30.0 },
        };

        var produto = produtos.FirstOrDefault(p => p.Id == id);
        return Task.FromResult(produto);
    }

    private Task<bool> AtualizarProdutoNoBancoDeDados(Produto produtoAtualizado)
    {
        // Simulação de atualização no banco de dados
        // Retorna true se o produto existir e for atualizado
        bool produtoExiste = produtoAtualizado.Id >= 1 && produtoAtualizado.Id <= 3;
        return Task.FromResult(produtoExiste);
    }
}

// Modelo de Produto
public class Produto
{
    public int Id { get; set; }
    public string Nome { get; set; }
    public double Preco { get; set; }
}

Explicação do Código

  • Endpoint GetProduto(int id):
    • Cache Key Dinâmica:
      • A chave do cache é criada com base no ID do produto: produto_{id}.
      • Isso permite armazenar cada produto individualmente no cache.
    • Verificação do Cache:
      • Usa _cache.TryGetValue para verificar se o produto já está no cache.
      • Se não estiver, busca o produto no banco de dados.
    • Armazenamento no Cache:
      • Após obter o produto do banco de dados, ele é armazenado no cache com opções de expiração definidas.
  • Endpoint AtualizarProduto(int id, Produto produtoAtualizado):
    • Validação de ID:
      • Verifica se o ID fornecido na URL corresponde ao ID do objeto produtoAtualizado.
    • Atualização do Banco de Dados:
      • Chama o método AtualizarProdutoNoBancoDeDados para simular a atualização.
    • Invalidação do Cache:
      • Após a atualização, remove o item do cache usando _cache.Remove(cacheKey).
      • Isso garante que na próxima vez que o produto for solicitado, os dados atualizados sejam obtidos.

Fluxo de Funcionamento (__trocar por um diagrama__)

  1. Primeira Solicitação para Obter o Produto:
    • O cache é verificado e não contém o produto.
    • O produto é buscado no banco de dados.
    • O produto é armazenado no cache.
    • O produto é retornado ao cliente.
  2. Solicitações Subsequentes:
    • O cache é verificado e contém o produto.
    • O produto é retornado rapidamente do cache.
  3. Atualização do Produto:
    • O cliente envia uma solicitação para atualizar o produto.
    • O produto é atualizado no banco de dados.
    • O item correspondente é removido do cache.
  4. Solicitação Após Atualização:
    • O cache é verificado e não contém o produto (devido à invalidação).
    • O produto é buscado do banco de dados com as informações atualizadas.
    • O produto atualizado é armazenado no cache.
    • O produto é retornado ao cliente.

Considerações Adicionais

  • Controle de Conflitos:
    • Ao invalidar o cache após a atualização, garantimos que não haja conflitos entre dados antigos e novos.
  • Validade dos Dados:
    • As opções de expiração definidas no cache (AbsoluteExpirationRelativeToNow e SlidingExpiration) ajudam a manter os dados atualizados, mesmo se o produto não for atualizado manualmente.
  • Escalabilidade:
    • Em ambientes com múltiplas instâncias da aplicação, considere utilizar um cache distribuído para compartilhar o cache entre todos os servidores.

Por que a invalidação é importante? (reforçando)

  • Consistência dos Dados: Garante que os usuários sempre recebam informações atualizadas.
  • Confiança na Aplicação: Evita situações em que um usuário pode ver informações antigas, o que pode levar a insatisfação ou erros (por exemplo, comprando um produto com preço desatualizado).
  • Desempenho Otimizado: Mantém o equilíbrio entre performance (usando cache) e atualidade dos dados.

Estratégias para facilitar a invalidação

  • Chaves de Cache Bem Definidas:
    • Use chaves que reflitam a estrutura dos dados. Por exemplo, para produtos, use "produto_{id}".
    • Isso facilita a identificação e remoção do item específico do cache.
  • Agrupamento de Cache:
    • Se você tem vários itens relacionados, pode usar padrões nas chaves para invalidar grupos inteiros.
    • Por exemplo, "categoria_eletronicos_*" para todos os produtos eletrônicos.
  • Eventos de Domínio:
    • Utilize eventos dentro da aplicação que disparem automaticamente a invalidação do cache quando uma mudança relevante ocorrer.
    • Isso automatiza o processo e reduz a chance de esquecer de invalidar o cache.
  • Monitoramento e Logs:
    • Implemente logs para monitorar quando os itens são adicionados, servidos e removidos do cache.
    • Isso ajuda a identificar possíveis problemas na estratégia de invalidação.

Desafios comuns na invalidação de cache

  • Timing das Atualizações:
    • Garantir que o cache seja invalidado imediatamente após a atualização no banco de dados para evitar servir dados desatualizados.
  • Concorrência:
    • Em aplicações com alto volume de transações, múltiplas atualizações simultâneas podem complicar a estratégia de invalidação.
  • Cache Distribuído:
    • Em ambientes com múltiplos servidores, garantir que a invalidação ocorra em todos os nós pode ser desafiador.

Conclusão

Implementar técnicas de caching em suas aplicações .NET pode trazer melhorias significativas no desempenho, reduzindo a carga sobre o banco de dados e proporcionando tempos de resposta mais rápidos aos usuários. Ao armazenar em cache dados frequentemente acessados, você otimiza o uso de recursos e aumenta a escalabilidade da aplicação. Embora a invalidação do cache possa ser desafiadora, estratégias bem planejadas garantem que os usuários recebam informações atualizadas sem comprometer a performance.

E você, já utilizou alguma forma de caching em seus projetos? Quais foram os desafios que enfrentou e as soluções que encontrou? Compartilhe suas experiências nos comentários e vamos enriquecer essa discussão juntos!

Read more