Cache no .NET

Neste artigo, você aprenderá sobre mecanismos de cache diversos. O cache é o ato de armazenar dados em uma camada intermediária, agilizando as recuperações de dados subsequentes. Conceitualmente, o cache é uma estratégia de otimização de desempenho e consideração de design. O cache pode melhorar consideravelmente o desempenho do aplicativo, tornando os dados pouco alterados (ou caros para recuperar) mais facilmente disponíveis. Este artigo apresenta os dois tipos principais de cache e fornece código-fonte de exemplo para ambos:

Importante

Há duas classes MemoryCache no .NET, uma no namespace System.Runtime.Caching e outra no namespace Microsoft.Extensions.Caching:

Embora este artigo concentre-se em cache, ele não inclui o pacote NuGet System.Runtime.Caching. Todas as referências a MemoryCache estão contidas no namespace Microsoft.Extensions.Caching.

Todos os pacotes Microsoft.Extensions.* vêm prontos para injeção de dependência (DI), tanto as interfaces IMemoryCache quanto IDistributedCache podem ser usadas como serviços.

cache na memória

Nesta seção, você aprenderá sobre o pacote Microsoft.Extensions.Caching.Memory. A implementação atual do IMemoryCache é um wrapper em torno de ConcurrentDictionary<TKey,TValue>, expondo uma API rica em recursos. As entradas dentro do cache são representadas pelo ICacheEntry e podem ser qualquer object. A solução de cache na memória é ótima para aplicativos executados em um único servidor, onde todos os dados armazenados em cache locam memória no processo do aplicativo.

Dica

Para cenários de cache de vários servidores, considere a abordagem de Cache distribuído como uma alternativa ao cache na memória.

API de cache na memória

O consumidor do cache tem controle sobre expirações deslizantes e absolutas:

A configuração de uma expiração fará com que as entradas no cache sejam removidas se não forem acessadas dentro do tempo de expiração alocado. Os consumidores têm opções adicionais para controlar entradas de cache por meio do MemoryCacheEntryOptions. Cada ICacheEntry é emparelhado com MemoryCacheEntryOptions o qual expõe a funcionalidade de remoção de expiração com IChangeToken, configurações de prioridade com CacheItemPriority e controlando o ICacheEntry.Size. Considere os seguintes métodos de extensão:

Exemplo de cache na memória

Para usar a implementação IMemoryCache padrão, chame o método de extensão AddMemoryCache para registrar todos os serviços necessários com DI. No exemplo de código a seguir, o host genérico é usado para expor a funcionalidade DI:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

Dependendo da sua carga de trabalho do .NET, você pode acessar o IMemoryCache de forma diferente, como injeção de construtor. Neste exemplo, você usa a instância de IServiceProvider no host e chama o método de extensão GetRequiredService<T>(IServiceProvider) genérico:

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

Com os serviços de cache na memória registrados e resolvidos por meio de DI, você está pronto para iniciar o cache. Este exemplo itera através das letras no alfabeto da língua portuguesa "A" a "Z". O tipo record AlphabetLetter contém a referência à letra e gera uma mensagem.

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

Dica

O modificador de acesso file é usado no tipo AlphabetLetter, pois ele é definido dentro e acessado apenas do arquivo Program.cs. Para obter mais informações, confira arquivo (referência C#). Para ver o código-fonte completo, consulte a seção Program.cs.

O exemplo inclui uma função auxiliar que itera por meio das letras do alfabeto:

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

No código anterior do C#:

  • Func<char, Task> asyncFunc é aguardado em cada iteração, passando o atual letter.
  • Depois que todas as letras forem processadas, uma linha em branco será gravada no console.

Para adicionar itens ao cache, chame uma das APIs Create ou Set:

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

No código anterior do C#:

  • A variável addLettersToCacheTask delega para IterateAlphabetAsync e é aguardada.
  • Func<char, Task> asyncFunc é argumentado com um lambda.
  • MemoryCacheEntryOptions é instanciado com uma expiração absoluta em relação ao agora.
  • Um retorno de chamada pós-remoção é registrado.
  • Um objeto AlphabetLetter é instanciado e passado para Set junto com letter e options.
  • A letra é gravada no console como sendo armazenada em cache.
  • Por fim, um Task.Delay retorna.

Para cada letra no alfabeto, uma entrada de cache é gravada com uma expiração e chamada de remoção pós-remoção.

O retorno de chamada pós-remoção grava os detalhes do valor que foi removido no console:

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

Agora que o cache está preenchido, outra chamada a IterateAlphabetAsync é aguardada, mas, desta vez, você chamará IMemoryCache.TryGetValue:

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

Se cache contiver a chave letter e for uma instância value de uma AlphabetLetter, será gravada no console. Quando a chave letter não estiver no cache, ela terá sido removida e seu retorno de chamada pós-remoção terá sido invocado.

Métodos de extensão adicionais

IMemoryCache vem com muitos métodos de extensão baseados em conveniência, incluindo um GetOrCreateAsync assíncrono:

Colocar tudo isso junto

Todo o código-fonte do aplicativo de exemplo é um programa de nível superior e requer dois pacotes NuGet:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

const int MillisecondsDelayAfterAdd = 50;
const int MillisecondsAbsoluteExpiration = 750;

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

await host.RunAsync();

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

Fique à vontade para ajustar os valores MillisecondsDelayAfterAdd e MillisecondsAbsoluteExpiration a fim de observar as alterações no comportamento para a expiração e remoção de entradas armazenadas em cache. Veja a seguir a saída de exemplo da execução desse código. Devido à natureza não determinística dos eventos do .NET, sua saída pode ser diferente.

A was cached.
B was cached.
C was cached.
D was cached.
E was cached.
F was cached.
G was cached.
H was cached.
I was cached.
J was cached.
K was cached.
L was cached.
M was cached.
N was cached.
O was cached.
P was cached.
Q was cached.
R was cached.
S was cached.
T was cached.
U was cached.
V was cached.
W was cached.
X was cached.
Y was cached.
Z was cached.

A was evicted for Expired.
C was evicted for Expired.
B was evicted for Expired.
E was evicted for Expired.
D was evicted for Expired.
F was evicted for Expired.
H was evicted for Expired.
K was evicted for Expired.
L was evicted for Expired.
J was evicted for Expired.
G was evicted for Expired.
M was evicted for Expired.
N was evicted for Expired.
I was evicted for Expired.
P was evicted for Expired.
R was evicted for Expired.
O was evicted for Expired.
Q was evicted for Expired.
S is still in cache. The 'S' character is the 19 letter in the English alphabet.
T is still in cache. The 'T' character is the 20 letter in the English alphabet.
U is still in cache. The 'U' character is the 21 letter in the English alphabet.
V is still in cache. The 'V' character is the 22 letter in the English alphabet.
W is still in cache. The 'W' character is the 23 letter in the English alphabet.
X is still in cache. The 'X' character is the 24 letter in the English alphabet.
Y is still in cache. The 'Y' character is the 25 letter in the English alphabet.
Z is still in cache. The 'Z' character is the 26 letter in the English alphabet.

Como a expiração absoluta (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) está definida, todos os itens armazenados em cache serão removidos em algum momento.

Cache do Serviço de Trabalho

Uma estratégia comum para armazenar dados em cache é atualizar o cache independentemente do consumo dos serviços de dados. O modelo Serviço de Trabalho é um ótimo exemplo, pois BackgroundService é executado de forma independente (ou em segundo plano) do outro código do aplicativo. Quando um aplicativo que hospeda uma implementação de IHostedService começa a ser executado, a implementação correspondente (nesse caso, o BackgroundService ou "trabalho") começa a ser executada no mesmo processo. Esses serviços hospedados são registrados com DI como singletons, por meio do método de extensão AddHostedService<THostedService>(IServiceCollection). Outros serviços podem ser registrados com DI com qualquer tempo de vida do serviço.

Importante

É muito importante para entender o tempo de vida do serviço. Quando você chama AddMemoryCache para registrar todos os serviços de cache na memória, os serviços são registrados como singletons.

Cenário do serviço de fotos

Imagine que você esteja desenvolvendo um serviço de fotos que depende da API de terceiros, acessível por meio de HTTP. Esses dados de foto não mudam com muita frequência, mas há muitos deles. Cada foto é representada por um simples record:

namespace CachingExamples.Memory;

public readonly record struct Photo(
    int AlbumId,
    int Id,
    string Title,
    string Url,
    string ThumbnailUrl);

No exemplo a seguir, você verá vários serviços sendo registrados com DI. Cada serviço tem uma única responsabilidade.

using CachingExamples.Memory;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<CacheWorker>();
builder.Services.AddHostedService<CacheWorker>();
builder.Services.AddScoped<PhotoService>();
builder.Services.AddSingleton(typeof(CacheSignal<>));

using IHost host = builder.Build();

await host.StartAsync();

No código anterior do C#:

PhotoService é responsável por obter fotos que correspondam a determinados critérios (ou filter):

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class PhotoService(
        IMemoryCache cache,
        CacheSignal<Photo> cacheSignal,
        ILogger<PhotoService> logger)
{
    public async IAsyncEnumerable<Photo> GetPhotosAsync(Func<Photo, bool>? filter = default)
    {
        try
        {
            await cacheSignal.WaitAsync();

            Photo[] photos =
                (await cache.GetOrCreateAsync(
                    "Photos", _ =>
                    {
                        logger.LogWarning("This should never happen!");

                        return Task.FromResult(Array.Empty<Photo>());
                    }))!;

            // If no filter is provided, use a pass-thru.
            filter ??= _ => true;

            foreach (Photo photo in photos)
            {
                if (!default(Photo).Equals(photo) && filter(photo))
                {
                    yield return photo;
                }
            }
        }
        finally
        {
            cacheSignal.Release();
        }
    }
}

No código anterior do C#:

  • O construtor requer um IMemoryCache, CacheSignal<Photo> e ILogger.
  • O método GetPhotosAsync:
    • Define um parâmetro Func<Photo, bool> filter e retorna um IAsyncEnumerable<Photo>.
    • Chama e aguarda a liberação de _cacheSignal.WaitAsync(), isso garante que o cache seja preenchido antes de acessar o cache.
    • Chama _cache.GetOrCreateAsync(), obtendo de forma assíncrona todas as fotos no cache.
    • O argumento factory registra um aviso e retorna uma matriz de fotos vazia – isso nunca deve acontecer.
    • Cada foto no cache é iterada, filtrada e materializada com yield return.
    • Por fim, o sinal de cache é redefinido.

Os consumidores desse serviço são livres para chamar o método GetPhotosAsync e lidar com fotos adequadamente. Nenhum HttpClient é necessário, pois o cache contém as fotos.

O sinal assíncrono baseia-se em uma instância de SemaphoreSlim encapsulada, dentro de um singleton restrito de tipo genérico. CacheSignal<T> depende de uma instância de SemaphoreSlim:

namespace CachingExamples.Memory;

public sealed class CacheSignal<T>
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    /// <summary>
    /// Exposes a <see cref="Task"/> that represents the asynchronous wait operation.
    /// When signaled (consumer calls <see cref="Release"/>), the 
    /// <see cref="Task.Status"/> is set as <see cref="TaskStatus.RanToCompletion"/>.
    /// </summary>
    public Task WaitAsync() => _semaphore.WaitAsync();

    /// <summary>
    /// Exposes the ability to signal the release of the <see cref="WaitAsync"/>'s operation.
    /// Callers who were waiting, will be able to continue.
    /// </summary>
    public void Release() => _semaphore.Release();
}

No código C# anterior, o padrão de decorador é usado para encapsular uma instância do SemaphoreSlim. Como CacheSignal<T> é registrado como singleton, ele pode ser usado em todos os tempos de vida do serviço com qualquer tipo genérico, nesse caso, Photo. Ele é responsável por sinalizar a propagação do cache.

CacheWorker é uma subclasse de BackgroundService:

using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class CacheWorker(
    ILogger<CacheWorker> logger,
    HttpClient httpClient,
    CacheSignal<Photo> cacheSignal,
    IMemoryCache cache) : BackgroundService
{
    private readonly TimeSpan _updateInterval = TimeSpan.FromHours(3);

    private bool _isCacheInitialized = false;

    private const string Url = "https://jsonplaceholder.typicode.com/photos";

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await cacheSignal.WaitAsync();
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Updating cache.");

            try
            {
                Photo[]? photos =
                    await httpClient.GetFromJsonAsync<Photo[]>(
                        Url, stoppingToken);

                if (photos is { Length: > 0 })
                {
                    cache.Set("Photos", photos);
                    logger.LogInformation(
                        "Cache updated with {Count:#,#} photos.", photos.Length);
                }
                else
                {
                    logger.LogWarning(
                        "Unable to fetch photos to update cache.");
                }
            }
            finally
            {
                if (!_isCacheInitialized)
                {
                    cacheSignal.Release();
                    _isCacheInitialized = true;
                }
            }

            try
            {
                logger.LogInformation(
                    "Will attempt to update the cache in {Hours} hours from now.",
                    _updateInterval.Hours);

                await Task.Delay(_updateInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                logger.LogWarning("Cancellation acknowledged: shutting down.");
                break;
            }
        }
    }
}

No código anterior do C#:

  • O construtor requer um ILogger, HttpClient e IMemoryCache.
  • O _updateInterval é definido para três horas.
  • O método ExecuteAsync:
    • Faz um loop enquanto o aplicativo está em execução.
    • Faz uma solicitação HTTP a "https://jsonplaceholder.typicode.com/photos" e mapeia a resposta como uma matriz de objetos Photo.
    • A matriz de fotos é colocada no IMemoryCache sob a chave "Photos".
    • O _cacheSignal.Release() é chamado, liberando todos os consumidores que estavam esperando pelo sinal.
    • A chamada a Task.Delay é aguardada, dado o intervalo de atualização.
    • Depois de atrasar por três horas, o cache é atualizado novamente.

Os consumidores no mesmo processo podem solicitar as fotos a IMemoryCache, mas CacheWorker é responsável por atualizar o cache.

Cache distribuído

Em alguns cenários, um cache distribuído é necessário, por exemplo, com vários servidores de aplicativos. Um cache distribuído dá suporte a uma expansão superior em comparação à abordagem de cache na memória. O uso de um cache distribuído descarrega a memória de cache para um processo externo, mas exige E/S de rede extra e introduz um pouco mais de latência (mesmo que nominal).

As abstrações de cache distribuído fazem parte do pacote NuGet Microsoft.Extensions.Caching.Memory e há até mesmo um método de extensão AddDistributedMemoryCache.

Cuidado

AddDistributedMemoryCache deve ser usado apenas em cenários de desenvolvimento e/ou teste e não é uma implementação de produção viável.

Considere qualquer uma das implementações disponíveis de IDistributedCache nos seguintes pacotes:

API de cache distribuído

As APIs de cache distribuído são um pouco mais primitivas do que suas equivalentes de API de cache na memória. Os pares chave-valor são um pouco mais básicos. As chaves de cache na memória têm base em um object, enquanto as chaves distribuídas são um string. Com o cache na memória, o valor pode ser qualquer genérico fortemente tipado, enquanto os valores no cache distribuído são persistentes como byte[]. Isso não quer dizer que várias implementações não exponham valores genéricos fortemente tipado, mas isso seria um detalhe de implementação.

Criar valores

Para criar valores no cache distribuído, chame uma das APIs definidas:

Usando o registro AlphabetLetter do exemplo de cache na memória, você pode serializar o objeto para JSON e, em seguida, codificar o string como um byte[]:

DistributedCacheEntryOptions options = new()
{
    AbsoluteExpirationRelativeToNow =
        TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
};

AlphabetLetter alphabetLetter = new(letter);
string json = JsonSerializer.Serialize(alphabetLetter);
byte[] bytes = Encoding.UTF8.GetBytes(json);

await cache.SetAsync(letter.ToString(), bytes, options);

Assim como o cache na memória, as entradas de cache podem ter opções para ajudar a ajustar sua existência no cache, nesse caso, o DistributedCacheEntryOptions.

Criar métodos de extensão

Há vários métodos de extensão baseados em conveniência para criar valores que ajudam a evitar a codificação de representações de string de objetos em um byte[]:

Ler valores

Para ler valores do cache distribuído, chame uma das APIs get:

AlphabetLetter? alphabetLetter = null;
byte[]? bytes = await cache.GetAsync(letter.ToString());
if (bytes is { Length: > 0 })
{
    string json = Encoding.UTF8.GetString(bytes);
    alphabetLetter = JsonSerializer.Deserialize<AlphabetLetter>(json);
}

Depois que uma entrada de cache for lida fora do cache, você poderá obter a representação string codificada em UTF8 do byte[]

Localizar métodos de extensão

Há vários métodos de extensão baseados em conveniência para leitura de valores que ajudam a evitar a codificação de byte[] em representações string de objetos:

Atualizar valores

Não há nenhuma maneira de atualizar os valores no cache distribuído com uma única chamada à API. Em vez disso, os valores podem ter suas expirações deslizantes redefinidas com uma das APIs de atualização:

Se o valor real precisar ser atualizado, você precisará excluí-lo e adicioná-lo novamente.

Excluir valores

Para excluir valores no cache distribuído, chame uma das APIs de remoção:

Dica

Embora existam versões síncronas das APIs mencionadas acima, considere o fato de que as implementações de caches distribuídos dependem da E/S da rede. Por esse motivo, é preferível usá-las do que não usar as APIs assíncronas.

Confira também