Кэширование в .NET
В этой статье приведены сведения о различных механизмах кэширования. Кэширование — это хранение данных на промежуточном уровне, позволяющее ускорить повторные операции получения данных. По сути, кэширование является стратегией оптимизации производительности и аспектом проектирования. Кэширование может значительно повысить производительность приложения за счет улучшения доступности данных, которые изменяются нечасто (или для получения которых требуются значительные ресурсы). В этой статье представлены два основных типа кэширования, а также приведен пример исходного кода для обоих типов:
Внимание
В .NET есть два класса MemoryCache
: один в пространстве имен System.Runtime.Caching
, а другой — в пространстве имен Microsoft.Extensions.Caching
.
Хотя эта статья посвящена кэшированию, в ней не описывается пакет NuGet System.Runtime.Caching
. Все ссылки на MemoryCache
находятся в пределах пространства имен Microsoft.Extensions.Caching
.
Все пакеты Microsoft.Extensions.*
поставляются готовыми к внедрению зависимостей, а в качестве служб можно использовать интерфейсы IMemoryCache и IDistributedCache.
Кэширование в памяти
В этом разделе описан пакет Microsoft.Extensions.Caching.Memory . Текущая реализация класса IMemoryCache — это программа-оболочка для ConcurrentDictionary<TKey,TValue>, предоставляющая API с богатыми возможностями. Записи в кэше представлены интерфейсом ICacheEntry и могут быть любым объектом (object
). Решение кэша в памяти отлично подходит для приложений, работающих на одном сервере, где все кэшированные данные арендуют память в процессе приложения.
Совет
Для сценариев кэширования с несколькими серверами рассмотрите подход распределенного кэширования в качестве альтернативы кэшированию в памяти.
API кэширования в памяти
Потребитель кэша имеет контроль над скользящими и абсолютными сроками действия:
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
Установка срока действия приведет к вытеснению записей в кэше, если к ним не выполнялся доступ в течение заданного срока действия. У потребителей есть дополнительные возможности для управления записями кэша с помощью MemoryCacheEntryOptions. Каждый интерфейс ICacheEntry связан с классом MemoryCacheEntryOptions, который предоставляет возможность вытеснения при истечении срока действия с использованием IChangeToken, предоставляет параметры приоритетов с использованием CacheItemPriority и управляет ICacheEntry.Size. Рассмотрим следующие методы расширения:
- MemoryCacheEntryExtensions.AddExpirationToken
- MemoryCacheEntryExtensions.RegisterPostEvictionCallback
- MemoryCacheEntryExtensions.SetSize
- MemoryCacheEntryExtensions.SetPriority
Пример кэша в памяти
Чтобы использовать реализацию IMemoryCache по умолчанию, вызовите метод расширения AddMemoryCache, чтобы зарегистрировать все необходимые службы с помощью внедрения зависимостей. В следующем примере кода универсальный узел используется для предоставления функциональных возможностей 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();
В зависимости от рабочей нагрузки .NET вы можете получить доступ к IMemoryCache
другой версии, например внедрению конструктора. В этом примере применяется экземпляр IServiceProvider
к host
и вызывается универсальный метод расширения GetRequiredService<T>(IServiceProvider):
IMemoryCache cache =
host.Services.GetRequiredService<IMemoryCache>();
После регистрации служб кэширования в памяти и разрешения с помощью di вы можете начать кэширование. В этом примере выполняется перебор букв английского алфавита от A до Z. Тип record AlphabetLetter
содержит ссылку на букву и создает сообщение.
file record AlphabetLetter(char Letter)
{
internal string Message =>
$"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}
Совет
Модификатор file
доступа используется для AlphabetLetter
типа, так как он определен внутри и доступен только из файла Program.cs . Дополнительные сведения см. в файле (справочник по C#). Чтобы просмотреть полный исходный код, см . раздел Program.cs .
Пример включает вспомогательную функцию, которая выполняет итерацию по буквам алфавита:
static async ValueTask IterateAlphabetAsync(
Func<char, Task> asyncFunc)
{
for (char letter = 'A'; letter <= 'Z'; ++letter)
{
await asyncFunc(letter);
}
Console.WriteLine();
}
В приведенном выше коде C#:
- В каждом цикле ожидается функция
Func<char, Task> asyncFunc
, передающая текущую букву (letter
). - После обработки всех букв в консоль записывается пустая строка.
Чтобы добавить элементы в кэш, необходимо вызвать один из API Create
или 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;
В приведенном выше коде C#:
- Переменная
addLettersToCacheTask
делегируетIterateAlphabetAsync
и затем ожидает вызова. Func<char, Task> asyncFunc
выражается лямбдой.- Создается экземпляр
MemoryCacheEntryOptions
с абсолютным сроком действия по отношению к текущему моменту. - Регистрируется обратный вызов после вытеснения.
- Создается экземпляр объекта
AlphabetLetter
, который передается в Set вместе сletter
иoptions
. - Буква записывается в консоль при помещении в кэш.
- Наконец, возвращается Task.Delay.
Для каждой буквы в алфавите запись кэша записывается со сроком действия и обратным вызовом после вытеснения.
Обратный вызов после вытеснения записывает в консоль сведения о вытесненном значении:
static void OnPostEviction(
object key, object? letter, EvictionReason reason, object? state)
{
if (letter is AlphabetLetter alphabetLetter)
{
Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
}
};
Теперь, когда кэш заполнен, ожидается еще один вызов метода IterateAlphabetAsync
, но на этот раз вызывается 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;
Если cache
содержит ключ letter
, а value
является экземпляром AlphabetLetter
, в консоль добавляется запись. Если ключ letter
отсутствует в кэше, он был вытеснен с выполнением обратного вызова после вытеснения.
Дополнительные методы расширения
В IMemoryCache
входит множество удобных методов расширения, включая асинхронный GetOrCreateAsync
:
- CacheExtensions.Get
- CacheExtensions.GetOrCreate
- CacheExtensions.GetOrCreateAsync
- CacheExtensions.Set
- CacheExtensions.TryGetValue
Сборка
Весь исходный код примера приложения — это программа верхнего уровня, которая требует наличия двух пакетов 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.";
}
Вы можете настроить значения MillisecondsDelayAfterAdd
и MillisecondsAbsoluteExpiration
, чтобы наблюдать за изменениями в поведении до времени истечения срока действия и вытеснения кэшированных записей. Ниже приведен пример выходных данных для выполнения этого кода. Из-за недетерминированного характера событий .NET выходные данные могут отличаться.
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.
Так как задан абсолютный срок действия (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow), все элементы кэша в конечном итоге будут вытеснены.
Кэширование службы рабочей роли
Одной из распространенных стратегий кэширования данных является обновление кэша независимо от использования служб данных. Шаблон службы рабочей роли — это отличный пример, так как BackgroundService работает независимо от другого кода приложения (или в фоновом режиме). Если запускается приложение, в котором размещается реализация IHostedService, соответствующая реализация (в этом случае BackgroundService
или рабочая роль) запускается в том же процессе. Эти размещенные службы регистрируются с помощью внедрения зависимостей в виде объектов singleton с помощью метода расширения AddHostedService<THostedService>(IServiceCollection). Другие службы можно зарегистрировать с помощью внедрения зависимостей с любым временем существования службы.
Внимание
Очень важно понимать время существования службы. При вызове AddMemoryCache для регистрации всех служб кэширования в памяти службы регистрируются как объекты singleton.
Сценарий службы хранения фотографий
Представим, что вы разрабатываете службу хранения фотографий, использующую сторонние API, доступные через HTTP. Эти данные фотографий меняются нечасто, но их много. Каждая фотография представлена простой записью (record
):
namespace CachingExamples.Memory;
public readonly record struct Photo(
int AlbumId,
int Id,
string Title,
string Url,
string ThumbnailUrl);
В следующем примере вы увидите несколько служб, регистрируемых с помощью внедрения зависимостей. Каждая из служб отвечает за одну функциональность.
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();
В приведенном выше коде C#:
- Универсальный узел создается с использованием значений по умолчанию.
- Службы кэширования в памяти регистрируются с помощью AddMemoryCache.
- Экземпляр
HttpClient
регистрируется для классаCacheWorker
с помощью AddHttpClient<TClient>(IServiceCollection). - Класс
CacheWorker
регистрируется с помощью AddHostedService<THostedService>(IServiceCollection). - Класс
PhotoService
регистрируется с помощью AddScoped<TService>(IServiceCollection). - Класс
CacheSignal<T>
регистрируется с помощью AddSingleton. - Экземпляр
host
создается из построителя и запускается асинхронно.
Ответственность PhotoService
за получение фотографий, соответствующих заданным критериям (или 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();
}
}
}
В приведенном выше коде C#:
- Конструктор требует
IMemoryCache
,CacheSignal<Photo>
иILogger
. - Метод:
GetPhotosAsync
- Определяет параметр
Func<Photo, bool> filter
и возвращаетIAsyncEnumerable<Photo>
. - Вызывает метод
_cacheSignal.WaitAsync()
и ожидает его выполнения, чтобы обеспечить заполнение кэша перед обращением к нему. - Вызывает
_cache.GetOrCreateAsync()
, получая асинхронно все фотографии в кэше. - Аргумент
factory
записывает в журнал предупреждение и возвращает пустой массив фотографий — это не должно происходить. - Каждая фотография в кэше итерируется, фильтруется и материализуется с
yield return
помощью . - Наконец, сигнал кэша сбрасывается.
- Определяет параметр
Потребители этой службы могут вызывать метод GetPhotosAsync
и обрабатывать фотографии соответствующим образом. В HttpClient
нет необходимости, так как в кэше содержатся фотографии.
Асинхронный сигнал основан на инкапсулированном экземпляре SemaphoreSlim в объекте singleton универсального типа. CacheSignal<T>
зависит от экземпляра 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();
}
В предыдущем коде C# шаблон декоратора используется для создания программы-оболочки для экземпляра SemaphoreSlim
. CacheSignal<T>
Так как он зарегистрирован как одиночный, его можно использовать во всех сроках существования службы с любым универсальным типом в данном случаеPhoto
. Он отвечает за сигнализацию о заполнении кэша.
CacheWorker
является подклассом 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;
}
}
}
}
В приведенном выше коде C#:
- Конструктор требует
ILogger
,HttpClient
иIMemoryCache
. - Определяется
_updateInterval
в течение трех часов. - Метод:
ExecuteAsync
- Циклически выполняется во время выполнения приложения.
- Выполняет HTTP-запрос к
"https://jsonplaceholder.typicode.com/photos"
и сопоставляет ответ как массив объектовPhoto
. - Массив фотографий помещается в
IMemoryCache
под ключом"Photos"
. - Вызывается метод
_cacheSignal.Release()
, который освобождает всех потребителей, ожидающих сигнал. - Ожидается вызов метода Task.Delay с учетом интервала обновления.
- После трехчасовой задержки кэш снова обновляется.
Потребители в том же процессе могут попросить IMemoryCache
фотографии, но CacheWorker
отвечает за обновление кэша.
Распределенное кэширование
В некоторых сценариях требуется распределенный кэш. Это относится к нескольким серверам приложений. Распределенный кэш лучше поддерживает горизонтальное увеличение масштаба, чем метод кэширования в памяти. Использование распределенного кэша выгружает память кэша во внешний процесс, но требует дополнительных сетевых операций ввода-вывода и создает более длительную задержку (пусть даже с номинальной разницей).
Абстракции распределенного кэширования являются частью пакета NuGet Microsoft.Extensions.Caching.Memory
, и даже существует метод расширения AddDistributedMemoryCache
.
Внимание
AddDistributedMemoryCache следует использовать только в сценариях разработки и (или) тестирования; это не жизнеспособная производственная реализация.
Рассмотрите любую из доступных реализаций IDistributedCache
из следующих пакетов:
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
API распределенного кэширования
API распределенного кэширования немного проще, чем их аналоги API кэширования в памяти. Пары "ключ — значение" тоже проще. Ключи кэширования в памяти основаны на object
, а ключи распределенного кэширования являются типом string
. При использовании кэширования в памяти значением может быть любой строго типизированный универсальный тип, тогда как значения в распределенном кэшировании сохраняются в виде byte[]
. Это не означает, что различные реализации не предоставляют строго типизированные универсальные значения, но это будет особенностью реализации.
Создание значений
Чтобы создать значения в распределенном кэше, вызовите один из API установки:
Используя запись AlphabetLetter
из примера кэша в памяти, можно сериализовать объект в JSON, а затем закодировать string
как 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);
Как и кэширование в памяти, записи кэша могут иметь возможности для точной настройки их существования в кэше ( в данном случае — значение DistributedCacheEntryOptions).
Создание методов расширения
Есть несколько удобных методов расширения для создания значений, которые помогают избежать кодирования представлений string
объектов в byte[]
:
Чтение значений
Чтобы считать значения из распределенного кэша, вызовите один из API получения:
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);
}
После считывания записи из кэша, можно получить представление в виде string
в кодировке UTF8 из byte[]
.
Методы расширения для чтения
Есть несколько удобных методов расширения для чтения значений, которые помогают избежать декодирования представлений byte[]
в представления объектов в виде string
:
Обновление значений
Вместо этого нельзя обновить значения в распределенном кэше с одним вызовом API, а значения могут сбросить срок действия с одним из API обновления:
Если необходимо обновить фактическое значение, понадобится удалить это значение, а затем снова добавить его.
Удаление значений
Чтобы удалить значения в распределенном кэше, вызовите один из API удаления:
Совет
Хотя существуют синхронные версии упомянутых выше API, помните, что реализация распределенных кэшей зависит от сетевых операций ввода-вывода. По этой причине чаще целесообразней использовать асинхронные API.