Compartir a través de


Almacenamiento en caché en .NET

En este artículo, obtendrá información sobre varios mecanismos de almacenamiento en caché. El almacenamiento en caché es el acto de almacenar datos en una capa intermedia, lo que hace que las recuperaciones de datos posteriores sean más rápidas. 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 la aplicación al hacer que los datos que raramente cambian (o son difíciles de recuperar) estén más fácilmente disponibles. 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 System.Runtime.Caching paquete NuGet. Todas las referencias a MemoryCache están dentro del Microsoft.Extensions.Caching espacio de nombres.

Todos los Microsoft.Extensions.* paquetes vienen listos para la inserción de dependencias (DI), tanto las interfaces IMemoryCache como IDistributedCache pueden usarse 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 rica en funciones. Las entradas de la memoria caché se representan mediante ICacheEntryy 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 caché tiene control sobre las expiraciones deslizantes y absolutas:

Establecer una expiración hará que las entradas en la memoria caché se expulsen si no se accede a ellas dentro del periodo de expiración. 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 predeterminada IMemoryCache, llame al método de extensión AddMemoryCache para registrar todos los servicios necesarios con DI. 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, utiliza la instancia IServiceProvider en el host y llama al método de extensión genérico GetRequiredService<T>(IServiceProvider).

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 "A" a "Z". El record AlphabetLetter tipo 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 utiliza en el tipo AlphabetLetter, ya que está definido dentro del archivo Program.cs y solo se puede acceder a él desde allí. Para obtener más información, vea 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 instancia un objeto AlphabetLetter y 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 rellena 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 orientados a la comodidad, incluido un método asincrónico GetOrCreateAsync:

Ponlo todo junto

Todo el código fuente de la aplicación de ejemplo es un programa de nivel superior 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 en el comportamiento de la expiración 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.

Dado que se establece la expiración absoluta (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow), todos los elementos almacenados en caché finalmente se expulsarán.

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 consumen. La plantilla Servicio de trabajador es un buen ejemplo, ya que se BackgroundService ejecuta de forma independiente (o en segundo plano) del resto del código de la aplicación. Cuando una aplicación comienza a ejecutarse y hospeda una implementación de IHostedService, la implementación correspondiente (en este caso, el BackgroundService o "trabajador") 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 DI con cualquier tipo de duración del servicio.

Importante

La duración del servicio es muy importante para comprender. 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 la API de terceros accesible a través de HTTP. Estos datos de fotos no cambian muy a menudo, pero hay muchos. Cada foto se representa mediante un sencillo record:

namespace CachingExamples.Memory;

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

En el ejemplo siguiente, verás varios servicios registrarse en DI. Cada servicio tiene una sola 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:

El PhotoService es responsable de obtener fotos que sean similares a criterios dados (o 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();
        }
    }
}

En el código de C# anterior:

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

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

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

  • El constructor requiere un ILogger, HttpClienty IMemoryCache.
  • El _updateInterval está definido por tres horas.
  • El método ExecuteAsync realiza las acciones siguientes:
    • Se recorre en bucle mientras la aplicación está en ejecución.
    • Realiza una solicitud HTTP a "https://jsonplaceholder.typicode.com/photos"y asigna la respuesta como una matriz de Photo objetos.
    • La matriz de fotos se coloca en el IMemoryCache bajo 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 de retrasar durante tres horas, la memoria caché se actualiza de nuevo.

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 varios servidores de 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é distribuidas forman parte del Microsoft.Extensions.Caching.Memory paquete NuGet y incluso hay un AddDistributedMemoryCache método de extensión.

Precaución

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

Tenga en cuenta cualquiera de las implementaciones disponibles de IDistributedCache de los siguientes paquetes.

API de almacenamiento en caché distribuida

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

Creación de valores

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

Con el AlphabetLetter registro 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[]:

Leer 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 conveniencia para leer valores, que ayudan a evitar la descodificación de byte[] en representaciones de objetos string.

Actualizar valores

No hay ninguna manera de actualizar los valores de la caché distribuida con una sola llamada API, sino que los valores pueden tener sus expiraciones deslizantes restablecidas con una de las API de actualización:

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

Eliminar valores

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

Sugerencia

Aunque hay versiones sincrónicas de las API mencionadas anteriormente, tenga en cuenta el hecho de que las implementaciones de caché distribuidas dependen de la E/S de red. Por este motivo, a menudo se prefiere utilizar las API asincrónicas.

Consulte también