Partilhar via


Armazenamento em cache no .NET

Neste artigo, você aprenderá sobre vários mecanismos de cache. O cache é o ato de armazenar dados em uma camada intermediária, tornando as recuperações de dados subsequentes mais rápidas. Conceitualmente, o cache é uma estratégia de otimização de desempenho e uma consideração de design. O armazenamento em cache pode melhorar significativamente o desempenho do aplicativo, tornando os dados que mudam com pouca frequência (ou são caros para recuperar) mais prontamente disponíveis. Este artigo apresenta os dois tipos principais de cache e fornece código-fonte de exemplo para ambos:

Importante

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

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

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

Colocar em cache dentro da memória

Nesta seção, você aprenderá sobre o pacote Microsoft.Extensions.Caching.Memory . A implementação atual do IMemoryCache é um wrapper em torno do 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 alugam memória no processo do aplicativo.

Gorjeta

Para cenários de cache multisservidor, 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 as expirações deslizantes e absolutas:

Definir uma expiração fará com que as entradas no cache sejam removidas se não forem acessadas dentro da alocação de tempo de expiração. Os consumidores têm opções adicionais para controlar entradas de cache, através do MemoryCacheEntryOptions. Cada ICacheEntry um é 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 padrão IMemoryCache , chame o AddMemoryCache método de extensão 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 de forma diferente, como a injeção do IMemoryCache construtor. Neste exemplo, você usa a IServiceProvider instância no host método de extensão genérica GetRequiredService<T>(IServiceProvider) e chama:

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 começar a armazenar em cache. Esta amostra itera através das letras do alfabeto inglês 'A' a 'Z'. O record AlphabetLetter tipo 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.";
}

Gorjeta

O file modificador de acesso é usado no AlphabetLetter tipo, como é definido e acessado apenas a partir do arquivo Program.cs . Para obter mais informações, consulte o 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 através 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 C# anterior:

  • O Func<char, Task> asyncFunc é aguardado em cada iteração, passando o atual letter.
  • Depois de todas as letras terem sido processadas, uma linha em branco é gravada no console.

Para adicionar itens ao cache, chame uma das CreateAPIs 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 C# anterior:

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

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

O retorno de chamada pós-despejo escreve os detalhes do valor que foi removido para o 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 para 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 o cache contém a letter chave, e o value é uma instância de um AlphabetLetter é gravado no console. Quando a letter chave não está no cache, ela foi removida e seu retorno de chamada pós-remoção foi invocado.

Métodos de extensão adicionais

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

Juntar tudo

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.";
}

Sinta-se à vontade para ajustar os MillisecondsDelayAfterAdd valores e MillisecondsAbsoluteExpiration para observar as mudanças de comportamento para a expiração e remoção de entradas em cache. A seguir está a saída de exemplo da execução deste código. Devido à natureza não determinística dos eventos .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.

Uma vez que a expiração absoluta (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) é definida, todos os itens armazenados em cache serão eventualmente removidos.

Cache do Serviço de Trabalho

Uma estratégia comum para armazenar dados em cache é atualizar o cache independentemente dos serviços de dados que consomem. 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 começa a ser executado que hospeda uma implementação do IHostedService, a implementação correspondente (neste caso, o BackgroundService ou "trabalhador") começa a ser executado no mesmo processo. Esses serviços hospedados são registrados com DI como singletons, através do método de AddHostedService<THostedService>(IServiceCollection) extensão. Outros serviços podem ser registrados na DI com qualquer vida útil.

Importante

A vida útil do serviço é muito importante de entender. Quando você liga 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 fotografia

Imagine que você está desenvolvendo um serviço de fotos que depende de API de terceiros acessível via HTTP. Esses dados fotográficos não mudam com muita frequência, mas há muito. 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 no 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 C# anterior:

O 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 C# anterior:

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

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

O sinal assíncrono é baseado em uma instância encapsulada SemaphoreSlim , dentro de um singleton restrito de tipo genérico. O CacheSignal<T> baseia-se em 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 decorador é usado para envolver uma ocorrência do SemaphoreSlim. Como o CacheSignal<T> é registrado como singleton, ele pode ser usado em todas as vidas de serviço com qualquer tipo genérico — neste caso, o Photo. Ele é responsável por sinalizar a semeadura do cache.

O 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 C# anterior:

  • O construtor requer um ILogger, HttpCliente IMemoryCache.
  • O _updateInterval é definido por três horas.
  • O ExecuteAsync método:
    • Loops enquanto o aplicativo está em execução.
    • Faz uma solicitação HTTP para "https://jsonplaceholder.typicode.com/photos"e mapeia a resposta como uma matriz de Photo objetos.
    • A matriz de fotos é colocada sob a IMemoryCache"Photos" chave.
    • O _cacheSignal.Release() é chamado, liberando todos os consumidores que estavam esperando pelo sinal.
    • A chamada para 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 poderiam pedir as IMemoryCache fotos, mas o CacheWorker é responsável por atualizar o cache.

Cache distribuído

Em alguns cenários, um cache distribuído é necessário, como é o caso de vários servidores de aplicativos. Um cache distribuído oferece suporte a uma expansão maior do que a abordagem de cache na memória. O uso de um cache distribuído descarrega a memória cache para um processo externo, mas requer 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 Microsoft.Extensions.Caching.Memory pacote NuGet e há até mesmo um AddDistributedMemoryCache método de extensão.

Atenção

O 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 dos IDistributedCache seguintes pacotes:

API de cache distribuído

As APIs de cache distribuído são um pouco mais primitivas do que suas contrapartes 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 são baseadas em um object, enquanto as chaves distribuídas são um stringarquivo . Com o cache na memória, o valor pode ser qualquer genérico fortemente tipado, enquanto os valores no cache distribuído são persistidos como byte[]. Isso não quer dizer que várias implementações não exponham valores genéricos fortemente tipados, mas isso seria um detalhe de implementação.

Criar valores

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

Usando o AlphabetLetter registro 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 — neste caso, o DistributedCacheEntryOptions.

Criar métodos de extensão

Existem vários métodos de extensão baseados em conveniência para criar valores, que ajudam a evitar a codificação string de representações 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 é lida fora do cache, você pode obter a representação codificada string UTF8 do byte[]

Ler métodos de extensão

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

Atualizar valores

Não há como atualizar os valores no cache distribuído com uma única chamada de 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ê terá que excluir o valor e, em seguida, adicioná-lo novamente.

Excluir valores

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

Gorjeta

Embora existam versões síncronas das APIs acima mencionadas, considere o fato de que as implementações de caches distribuídos dependem de E/S de rede. Por esse motivo, é preferível, na maioria das vezes, usar as APIs assíncronas.

Consulte também