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:
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
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:
- MemoryCacheEntryExtensions.AddExpirationToken
- MemoryCacheEntryExtensions.RegisterPostEvictionCallback
- MemoryCacheEntryExtensions.SetSize
- MemoryCacheEntryExtensions.SetPriority
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 elementoletter
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 enIterateAlphabetAsync
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 conletter
yoptions
. - 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:
- CacheExtensions.Get
- CacheExtensions.GetOrCreate
- CacheExtensions.GetOrCreateAsync
- CacheExtensions.Set
- CacheExtensions.TryGetValue
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:
- El host genérico se crea con los valores predeterminados.
- Los servicios de almacenamiento en caché en memoria se registran en AddMemoryCache.
- Una instancia de
HttpClient
se registra en la claseCacheWorker
con AddHttpClient<TClient>(IServiceCollection): - La clase
CacheWorker
se registra en AddHostedService<THostedService>(IServiceCollection). - La clase
PhotoService
se registra en AddScoped<TService>(IServiceCollection). - La clase
CacheSignal<T>
se registra en AddSingleton. - Se crea una instancia de
host
a partir del generador y se inicia de forma asincrónica.
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>
yILogger
. - El método
GetPhotosAsync
realiza las acciones siguientes:- Define un parámetro
Func<Photo, bool> filter
y devuelveIAsyncEnumerable<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é.
- Define un parámetro
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
yIMemoryCache
. _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 objetosPhoto
. - 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:
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
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.