Almacenamiento en caché en .NET

En este artículo, aprenderá sobre varios mecanismos de almacenamiento en caché. El almacenamiento en caché es el acto de almacenar datos en una capa intermedia, lo que acelera las posteriores recuperaciones de datos. Conceptualmente, el almacenamiento en caché es una estrategia de optimización del rendimiento y una consideración de diseño. El almacenamiento en caché puede mejorar significativamente el rendimiento de las aplicaciones al hacer que los datos que cambian con poca frecuencia (o que son costosos de recuperar) estén disponibles de manera más inmediata. En este artículo se presentan los dos tipos principales de almacenamiento en caché, y se proporciona código fuente de ejemplo para ambos:

Importante

Hay dos clases MemoryCache en .NET, una en el espacio de nombres System.Runtime.Caching, y otra en el espacio de nombres Microsoft.Extensions.Caching:

Aunque este artículo se centra en el almacenamiento en caché, no incluye el paquete NuGet System.Runtime.Caching. Todas las referencias a MemoryCache se encuentran dentro del espacio de nombres Microsoft.Extensions.Caching.

Todos los paquetes de Microsoft.Extensions.* vienen listos para la inserción de dependencias (ID), y tanto la interfaz IMemoryCache como IDistributedCache se pueden usar como servicios.

Almacenamiento en caché en memoria

En esta sección obtendrá información sobre el paquete Microsoft.Extensions.Caching.Memory. La implementación actual de IMemoryCache es un contenedor alrededor de ConcurrentDictionary<TKey,TValue>, que expone una API con muchas características. Las entradas dentro de la memoria caché se representan mediante ICacheEntry, y pueden ser cualquier object. La solución de caché en memoria es excelente para las aplicaciones que se ejecutan en un solo servidor, donde todos los datos almacenados en caché alquilan memoria en el proceso de la aplicación.

Sugerencia

Para escenarios de almacenamiento en caché de varios servidores, considere el enfoque de almacenamiento en caché distribuido como alternativa al almacenamiento en caché en memoria.

API de almacenamiento caché en memoria

El consumidor de la memoria caché tiene control sobre los vencimientos variables y absolutos:

Si se establece un vencimiento, las entradas de la memoria caché se expulsarán si no se tiene acceso a ellas dentro de la asignación de tiempo de vencimiento. Los consumidores tienen opciones adicionales para controlar las entradas de caché, a través de MemoryCacheEntryOptions. Cada ICacheEntry se empareja con MemoryCacheEntryOptions, lo que expone la funcionalidad de expulsión de vencimiento con IChangeToken, la configuración de prioridad con CacheItemPriority y el control de ICacheEntry.Size. Tenga en cuenta los siguientes métodos de extensión:

Ejemplo de caché en memoria

Para usar la implementación de IMemoryCache predeterminada, llame al método de extensión AddMemoryCache para registrar todos los servicios necesarios con la inserción de dependencias. En el ejemplo de código siguiente, se usa el host genérico para exponer la funcionalidad 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();

Según la carga de trabajo de .NET, puede acceder a IMemoryCache de manera diferente, como con la inserción de constructores. En este ejemplo, se usa la instancia de IServiceProvider en host y se llama al método de extensión GetRequiredService<T>(IServiceProvider) genérico:

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

Con los servicios de almacenamiento en caché en memoria registrados y resueltos a través de la inserción de dependencias, está listo para comenzar el almacenamiento en caché. Este ejemplo recorre en iteración las letras del alfabeto inglés, de la "A" a la "Z". El tipo record AlphabetLetter contiene la referencia a la letra y genera un mensaje.

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

Sugerencia

El modificador de acceso file se usa en el tipo AlphabetLetter, ya que se define dentro de y solo se accede desde el archivo Program.cs. Para obtener más información, consulte archivo (Referencia de C#). Para ver el código fuente completo, consulte la sección Program.cs.

El ejemplo incluye una función auxiliar que recorre en iteración las letras del alfabeto:

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

    Console.WriteLine();
}

En el código de C# anterior:

  • Se espera Func<char, Task> asyncFunc en cada interacción y pasa el elemento letter actual.
  • Una vez procesadas todas las letras, se escribe una línea en blanco en la consola.

Para agregar elementos a la memoria caché, llame a una de las API, Create o 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;

En el código de C# anterior:

  • La variable addLettersToCacheTask delega en IterateAlphabetAsync y se espera.
  • Se agrega un argumento lambda a Func<char, Task> asyncFunc.
  • Se crea una instancia de MemoryCacheEntryOptions con un vencimiento absoluto con respecto al ahora.
  • Se registra una devolución de llamada posterior a la expulsión.
  • Se crea una instancia de un objeto AlphabetLetter, que se pasa a Set junto con letter y options.
  • La letra se escribe en la consola como almacenada en caché.
  • Por último, se devuelve Task.Delay.

Para cada letra del alfabeto, se escribe una entrada de caché con un vencimiento y una devolución de llamada posterior a la expulsión.

La devolución de llamada posterior a la expulsión escribe en la consola los detalles del valor que se expulsó:

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

Ahora que se ha rellenado la memoria caché, se espera otra llamada a IterateAlphabetAsync, pero esta vez llamará a 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;

Si cache contiene la clave letter, y value es una instancia de AlphabetLetter, se escribe en la consola. Cuando la clave letter no se encuentra en la caché, se expulsó y se invocó su devolución de llamada posterior a la expulsión.

Métodos de extensión adicionales

IMemoryCache incluye muchos métodos de extensión basados en la conveniencia, incluido un GetOrCreateAsync asincrónico:

Colocación de todo junto

Todo el código fuente de la aplicación de ejemplo es un programa general y requiere dos paquetes 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.";
}

No dude en ajustar los valores MillisecondsDelayAfterAdd y MillisecondsAbsoluteExpiration para observar los cambios de comportamiento en el vencimiento y expulsión de las entradas almacenadas en caché. A continuación, se muestra la salida de ejemplo de la ejecución de este código. Debido a la naturaleza no determinista de los eventos de .NET, la salida podría 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.

Puesto que se ha establecido el vencimiento absoluto (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow), finalmente se expulsarán todos los elementos almacenados en caché.

Almacenamiento en caché del servicio de trabajo

Una estrategia común para almacenar datos en caché es actualizar la memoria caché independientemente de los servicios de datos que los consumen. La plantilla de servicio de trabajo es un buen ejemplo, ya que BackgroundService se ejecuta de manera independiente (o en segundo plano) del resto del código de aplicación. Cuando comienza a ejecutarse una aplicación que hospeda una implementación de IHostedService, la implementación correspondiente (en este caso, BackgroundService o el "trabajo") comienza a ejecutarse en el mismo proceso. Estos servicios hospedados se registran en la inserción de dependencias como singletons, mediante el método de extensión AddHostedService<THostedService>(IServiceCollection). Otros servicios se pueden registrar en la inserción de dependencias con cualquier duración del servicio.

Importante

Es muy importante entender la duración del servicio. Al llamar a AddMemoryCache para registrar todos los servicios de almacenamiento en caché en memoria, los servicios se registran como singletons.

Escenario de servicio de fotos

Imagine que está desarrollando un servicio de fotos que se basa en una API de terceros a la que se accede a través de HTTP. Estos datos de fotos no cambian muy a menudo, pero hay muchos. Cada foto se representa mediante un simple record:

namespace CachingExamples.Memory;

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

En el ejemplo siguiente, verá que varios servicios se registran en la inserción de dependencias. Cada servicio tiene una única responsabilidad.

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();

En el código de C# anterior:

PhotoService es responsable de obtener fotos que coincidan con un criterio (o filter) dado:

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();
        }
    }
}

En el código de C# anterior:

  • Un constructor requiere IMemoryCache, CacheSignal<Photo> y ILogger.
  • El método GetPhotosAsync realiza las acciones siguientes:
    • Define un parámetro Func<Photo, bool> filter y devuelve IAsyncEnumerable<Photo>.
    • Llama y espera a que _cacheSignal.WaitAsync() se libere, lo que garantiza que la memoria caché se rellene antes del acceso a la memoria caché.
    • Llama a _cache.GetOrCreateAsync(), con lo que obtiene de forma asincrónica todas las fotos de la memoria caché.
    • El argumento factory registra una advertencia y devuelve una matriz de fotos vacía; esto no debería suceder nunca.
    • Cada foto de la memoria caché se itera, filtra y materializa con yield return.
    • Por último, se restablece la señal de la caché.

Los consumidores de este servicio pueden llamar al método GetPhotosAsync y controlar las fotos en consecuencia. No es necesario ningún HttpClient, ya que la memoria caché contiene las fotos.

La señal asincrónica se basa en una instancia de SemaphoreSlim encapsulada, dentro de un singleton restringido de tipo genérico. CacheSignal<T> se basa en una instancia 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();
}

En el código de C# anterior, el patrón decorador se usa para encapsular una instancia de SemaphoreSlim. Puesto que CacheSignal<T> se registra como singleton, se puede usar en todas las duraciones del servicio con cualquier tipo genérico, en este caso, Photo. Es responsable de señalar la inicialización de la memoria caché.

CacheWorker es una subclase 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;
            }
        }
    }
}

En el código de C# anterior:

  • Un constructor requiere ILogger, HttpClient y IMemoryCache.
  • _updateInterval se define durante tres horas.
  • El método ExecuteAsync realiza las acciones siguientes:
    • Se recorre en bucle mientras la aplicación está en ejecución.
    • Hace una solicitud HTTP a "https://jsonplaceholder.typicode.com/photos" y asigna la respuesta como matriz de objetos Photo.
    • La matriz de fotos se coloca en IMemoryCache debajo de la clave "Photos".
    • Se llama a _cacheSignal.Release(), lo que libera a todos los consumidores que estaban esperando la señal.
    • Se espera la llamada a Task.Delay, dado el intervalo de actualización.
    • Después del retraso de tres horas, la memoria caché se vuelve a actualizar.

Los consumidores del mismo proceso podrían solicitar a IMemoryCache las fotos, pero CacheWorker es responsable de actualizar la memoria caché.

Almacenamiento en caché distribuido

En algunos escenarios, se requiere una caché distribuida, como sucede con servidores de varias aplicaciones. Una caché distribuida admite un escalado horizontal mayor que el enfoque de almacenamiento en caché en memoria. El uso de una caché distribuida descarga la memoria caché en un proceso externo, pero requiere E/S de red adicional e introduce un poco más de latencia (incluso si es nominal).

Las abstracciones de almacenamiento en caché distribuido forman parte del paquete NuGet Microsoft.Extensions.Caching.Memory e, incluso, hay un método de extensión AddDistributedMemoryCache.

Precaución

AddDistributedMemoryCache solo se debe usar en escenarios de desarrollo o pruebas, y no es una implementación viable para producción.

Considere cualquiera de las implementaciones disponibles de IDistributedCache de los siguientes paquetes:

API de almacenamiento en caché distribuido

Las API de almacenamiento en caché distribuido son un poco más primitivas que las API de almacenamiento en caché en memoria equivalentes. Los pares clave-valor son un poco más básicos. Las claves de almacenamiento en caché en memoria se basan en object, mientras que las claves distribuidas son string. Con el almacenamiento en caché en memoria, el valor puede ser cualquier genérico fuertemente tipado, mientras que los valores en el almacenamiento en caché distribuido se conservan como byte[]. Esto no significa que varias implementaciones no expongan valores genéricos fuertemente tipados, pero ese sería un detalle de implementación.

Creación de valores

Para crear valores en la caché distribuida, llame a una de las API set:

Con el registro AlphabetLetter del ejemplo de caché en memoria, puede serializar el objeto en JSON y, a continuación, codificar string como 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);

De manera muy similar al almacenamiento en caché en memoria, las entradas de caché pueden tener opciones para ayudar a ajustar su existencia en la memoria caché, en este caso, DistributedCacheEntryOptions.

Creación de métodos de extensión

Hay varios métodos de extensión basados en la conveniencia para crear valores, que ayudan a evitar la codificación de representaciones de string de objetos en byte[]:

Lectura de valores

Para leer valores de la caché distribuida, llame a una de las API 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);
}

Una vez que una entrada de caché se lee de la memoria caché, puede obtener la representación de string con codificación UTF8 de byte[].

Lectura de métodos de extensión

Hay varios métodos de extensión basados en la conveniencia para leer valores, que ayudan a evitar la descodificación de byte[] en representaciones de string de objetos:

Actualización de valores

No hay ninguna manera de actualizar los valores de la caché distribuida con una sola llamada API. En cambio, se puede restablecer el vencimiento variable de los valores con una de las API de actualización:

Si es necesario actualizar el valor real, tendría que eliminar el valor y luego volver a agregarlo.

Eliminación de valores

Para eliminar valores en la caché distribuida, llame a una de las API remove:

Sugerencia

Si bien hay versiones sincrónicas de las API mencionadas anteriormente, tenga en cuenta el hecho de que las implementaciones de memorias caché distribuidas dependen de la E/S de red. Por este motivo, en general se prefiere usar las API asincrónicas.

Vea también