Содержание
Кэширование — это относительно простая и, в то же время, эффективная концепция в программировании, идея которой состоит в том, чтобы повторно использовать данные, не прибегая к выполнению повторных дорогостоящих операций. В ASP.NET Core предусмотрены различные механизмы кэширования данных.
Идея кэширования
Идею использования кэширования в ASP.NET Core (и не только) можно продемонстрировать следующей схемой
Когда пользователь впервые запрашивает данные (верхний рисунок), то приложение делает запрос, например, к базе данных, извлекает необходимые данные, сохраняет результат в кэш и возвращает полученные данные пользователю. Когда пользователь выполняет второй и последующие запросы, то приложение уже не обращается к БД, а извлекает полученные ранее данные из кэша и возвращает их пользователю. Так как обращение к БД — это часто довольно трудоемкие операции, то использование кэша позволяет повысить производительность нашего приложения.
В каких случаях можно использовать кэширование? Кэширование рекомендуется использовать для данных, которые не меняются вообще (идеальный вариант) или изменяются редко. Например, не стоит использовать кэширование показаний таймера или результаты каких-либо вычислений, которые в любой момент могут измениться. При этом, если мы храним, например, аватары пользователей в БД, то имеет смысл загрузить их в кэш.
Типы кэшей
Можно выделить три типа кэшей, которые могут использовать приложения:
- Кэш в памяти (In-Memory Cache). Этот тип кэша используется в случае, если нам достаточно хранить данные в рамках одного процесса. Когда процесс завершается, то кэш удаляется из памяти вместе с ним.
- Постоянный локальный кэш (Persistent in-process Cache) — это вариант, при котором создается резервная копию кэша вне памяти процесса, например, в файле или БД. В этом случае, при завершении процесса кэш не «умирает» и может быть восстановлен при следующем запуске процесса.
- Распределенный кэш (Distributed Cache) — такой кэш используется в том случае, если нужен общий кэш для нескольких машин. Распределенный кэш хранится в какой-либо внешней службе и, если один сервер сохранил элемент кэша, то другие серверы могут его использовать. Например, в ASP.NET Core для распределенного кэша может использоваться такой сервис, как Redis.
В этой части мы рассмотрим первый тип кэша — кэширование в памяти.
Кэширование в памяти (In-Memory Cache)
Кэширование в памяти – это наиболее простой в использовании способ кэширования данных, который мы можем применить в своем приложении. Чтобы воспользоваться кэшированием в памяти мы должны подключить в приложении сервис кэширования и воспользоваться им в необходимом нам месте.
Чтобы продемонстрировать работу этиго типа кэша, создадим новое приложение ASP.NET Core Web API и изменим код файла Program.cs следующим образом:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddMemoryCache();//подключаем сервис кэширования var app = builder.Build(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Таким образом мы подключаем кэширование в памяти, используя настройки кэширования по умолчанию. Теперь мы можем воспользоваться сервисом кэширования в любом месте нашего приложения. Перейдем в файл WeatherForecastController.cs и внесем следующие изменения в наш контроллер
[ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase { private static readonly string[] Summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; private readonly ILogger<WeatherForecastController> _logger; private readonly IMemoryCache _memoryCache; public WeatherForecastController(ILogger<WeatherForecastController> logger, IMemoryCache memoryCache) { _logger = logger; _memoryCache = memoryCache; } [HttpGet] public IEnumerable<WeatherForecast> Get() { _memoryCache.TryGetValue("forecast", out WeatherForecast[]? forecast); if (forecast == null) //не смогли получить прогноз погоды из кэша { //формируем массив var forecastArray = Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }).ToArray(); _memoryCache.Set("forecast", forecastArray); _logger.LogInformation($"Прогноз погоды помещен в кэш"); return forecastArray; } else { _logger.LogWarning($"Прогноз погоды возвращен из кэша"); return forecast; } } }
Во-первых, в конструкторе контроллера мы запрашиваем сервис для кэширования данных в памяти – IMemoryCache
.
Во-вторых, были внесены изменения в действие Get()
, которые стоит рассмотреть немного подробнее. Теперь первое, что происходит в действии – это попытка получить данные о задаче из кэша:
_memoryCache.TryGetValue("forecast", out WeatherForecast[]? forecast);
Здесь в качестве ключа элемента кэша мы используем строку «forecast». Если в кэше не нашлось необходимого элемента, то мы формируем новый массив данных и записываем его в кэш, после чего возвращаем его же пользователю:
if (forecast == null) //не смогли получить прогноз погоды из кэша { //формируем массив var forecastArray = Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }).ToArray(); _memoryCache.Set("forecast", forecastArray); _logger.LogInformation($"Прогноз погоды помещен в кэш"); return forecastArray; }
Если же попытка получения задачи из кэша оказалась успешной, то мы возвращаем пользователю кэшированную версию:
else { _logger.LogWarning($"Прогноз погоды возвращен из кэша"); return forecast; }
Проверить работу кэша довольно просто – достаточно дважды выполнить запрос на получение прогноза погоды:
Первая попытка получения
Вторая и последующие попытки получения данных
Вот так просто мы используем кэш в памяти. Однако, при разработке реальных приложений следует учитывать следующие моменты:
- Кэшированные данные могут обновляться с течением времени, поэтому стоит предусматривать также механизм проверки кэшированной версии ресурса и при необходимости обновлять её. Например, если в приложении предусмотрено обновление ресурса пользователем, то перед обновлением стоит проверить кэш и, при необходимости, удалить кэшированную версию ресурса.
- Тонким местом в нашем приложении сейчас является то, что, по сути, наш кэш ограничен только объемом доступной оперативной памяти сервера, что недопустимо. Необходимо настроить кэширование данных таким образом, чтобы, с одной стороны, оно помогало нам повысить производительность приложение, а, с другой стороны – не мешало работать другим приложениям.
Рассмотрим, как и какие настройки кэширования в памяти мы можем применить.
Настройка In-Memory кэша
В ASP.NET Core предусмотрено использование следующих политик вытеснения (второе название — политик удаления) элементов кэша:
- политика абсолютного истечения срока (Absolute Expiration). В соответствии с этой политикой, элемент кэша удаляется через фиксированный промежуток времени, несмотря ни на что.
- политика скользящего истечения срока (Sliding Expiration). В соответствии с этой политикой, элемент кэша удаляется, если к нему не был осуществлен доступ в течение определенного периода времени.
- политика ограничения размера (Size Limit). Эта политика ограничивает размер кэш-памяти.
Мы можем применять как отдельные политики вытеснения, так и все сразу, используя настройки кэширования в целом для всего сервиса и каждого элемента кэша, в частности. Для управления настройками используются два класса: MemoryCacheOptions
— для настроек сервиса кэширования и MemoryCacheEntryOptions
— для настроек элемента кэша. Рассмотрим применение этих классов в нашем приложении.
Класс MemoryCacheOptions
Объекты класса MemoryCacheOptions
используются для настроек сервиса кэширования в памяти и содержит следующие полезные для нас свойства
Название | Тип | Описание |
CompactionPercentage |
double |
Задает величину, на которую необходимо сжать кэш при превышении допустимого размера. Например, значение 0,1 указывает на то, что кэш должен быть сжат на 10% |
ExpirationScanFrequency |
TimeSpan |
Частота, с которой проверяется истечение срока действия элементов кэша |
SizeLimit |
long |
Размер кэша в условных единицах |
TrackStatistics |
bool |
Указывает следует ли вести статистику использования кэша |
Среди всех представленных свойств особое внимание следует уделить свойству SizeLimit
. После того, как задается значение этого свойства, все записи кэша должны передавать в сервис свой размер. Однако кэш не имеет механизмов подсчета размера записей, поэтому
значение SizeLimit
— это не килобайты и байты, а, скорее, условный размер по которому разработчик приложения может отслеживать наполнение кэша.
Например, если наше приложение кэширует строки, то мы можем указать значение SizeLimit
, как количество байт текста, которое может содержать кэш. В этом случае каждая передаваемая в кэш строка будет сопровождаться своим истинным размером. Другой вариант – использовать значение SizeLimit
как общее количество элементов кэша, которое может содержаться в памяти. В этом случае каждый элемент кэша будет передавать в сервис свой условный размер, например единицу.
Чтобы настроить сервис кэширования, перейдем в файл Program.cs и используем для добавления сервиса кэширования в памяти вторую версию метода AddMemoryCache()
следующим образом
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddMemoryCache(options => { options.TrackStatistics = true; options.SizeLimit = 1024; options.CompactionPercentage = 0.1; options.ExpirationScanFrequency = TimeSpan.FromMinutes(1); }); var app = builder.Build(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Здесь в качестве параметра метода мы использовали делегат вида Action<MemoryCacheOptions>
. Теперь размер кэша составляет 1024
условных единицы, один раз в минуту сервис будет сканироваться на наличие просроченных записей, а при достижении максимального размера, кэш будет сжат на 10%.
Так как мы указали в настройках значение SizeLimit
, нам необходимо сделать так, чтобы каждый элемент кэша при записи передавал свой размер. Для этого разберемся со вторым классом – MemoryCacheEntryOptions
.
Класс MemoryCacheEntryOptions
Объекты класса MemoryCacheEntryOptions
используются для настройки каждого элемента кэша. Этот класс предоставляет нам следующие полезные свойства
Название | Тип | Описание |
AbsoluteExpiration |
DateTimeOffset? |
Абсолютная дата истечения срока действия для записи в кэше. |
AbsoluteExpirationRelativeToNow |
TimeSpan? |
Абсолютное время истечения срока действия относительно текущего момента |
ExpirationTokens |
IList<IChangeToken> |
Возвращает экземпляры IChangeToken записей срок действия которых истекает |
PostEvictionCallbacks |
IList<PostEvictionCallbackRegistration> |
Обратные вызовы, которые будут запущены после удаления записи из кэша |
Priority |
CacheItemPriority |
Приоритет для сохранения записи кэша во время очистки, вызванной нехваткой памяти. Значение по умолчанию — Normal |
Size |
long? |
Значение размера записи кэша |
SlidingExpiration |
TimeSpan? |
Определяет в течение какого времени запись в кэше может быть неактивной, прежде чем она будет удалена. Это значение не продлит время жизни записи сверх установленного значения в свойствах AbsoluteExpiration и AbsoluteExpirationRelativeToNow . |
Попробуем воспользоваться некоторыми из этих свойств для настройки кэширования элементов в нашем приложении. Для этого изменим действие Get()
в контроллере следующим образом:
[HttpGet] public IEnumerable<WeatherForecast> Get() { _memoryCache.TryGetValue("forecast", out WeatherForecast[]? forecast); if (forecast == null) //не смогли получить прогноз погоды из кэша { //формируем массив var forecastArray = Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }).ToArray(); var entryOptions = new MemoryCacheEntryOptions(); entryOptions.SetAbsoluteExpiration(TimeSpan.FromSeconds(30)); entryOptions.SetSlidingExpiration(TimeSpan.FromSeconds(10)); entryOptions.SetSize(1); entryOptions.SetPriority(CacheItemPriority.High); var callback = new PostEvictionCallbackRegistration(); callback.State = typeof(WeatherForecast[]); callback.EvictionCallback = (key, val, reason, state) => { _logger.LogInformation($"Элемент с ключом {key} удаляется из кэша. Причина {reason}"); }; entryOptions.PostEvictionCallbacks.Add(callback); _memoryCache.Set("forecast", forecastArray, entryOptions); _logger.LogInformation($"Прогноз погоды помещен в кэш"); return forecastArray; } else { _logger.LogWarning($"Прогноз погоды возвращен из кэша"); return forecast; } }
Здесь мы воспользовались одной из версий метода расширения Set()
сервиса кэширования для добавления в кэш нового элемента:
_memoryCache.Set("forecast", forecastArray, entryOptions);
В объекте entryOptions
мы и передаем настройки элемента:
var entryOptions = new MemoryCacheEntryOptions(); entryOptions.SetAbsoluteExpiration(TimeSpan.FromSeconds(30)); entryOptions.SetSlidingExpiration(TimeSpan.FromSeconds(10)); entryOptions.SetSize(1); entryOptions.SetPriority(CacheItemPriority.High); var callback = new PostEvictionCallbackRegistration(); callback.State = typeof(WeatherForecast[]); callback.EvictionCallback = (key, val, reason, state) => { _logger.LogInformation($"Элемент с ключом {key} удаляется из кэша. Причина {reason}"); }; entryOptions.PostEvictionCallbacks.Add(callback);
Если прочитать эти настройки дословно, то получим следующее поведение: элемент с ключом «forecast» удалиться из кэша через 10 секунд, если к нему не будет обращений:
entryOptions.SetSlidingExpiration(TimeSpan.FromSeconds(10));
Вне зависимости от того будет обращение или нет к элементу кэша, он будет удален из кэша через 30 секунд
entryOptions.SetAbsoluteExpiration(TimeSpan.FromSeconds(30));
Размер элемента составляет 1
entryOptions.SetSize(1);
Приоритет элемента – высокий:
entryOptions.SetPriority(CacheItemPriority.High);
После удаления элемента необходимо осуществить обратный вызов, который выведет в лог сообщение об удалении элемента кэша:
var callback = new PostEvictionCallbackRegistration(); callback.State = typeof(WeatherForecast[]); callback.EvictionCallback = (key, val, reason, state) => { _logger.LogInformation($"Элемент с ключом {key} удаляется из кэша. Причина {reason}"); }; entryOptions.PostEvictionCallbacks.Add(callback);
Отдельное внимание стоит уделить приоритету элемента и функции обратного вызова. Элементу кэша можно назначить следующие приоритеты, которые содержатся в перечислении CacheItemPriority
Представление | Числовое значение | Описание |
High |
2 |
Запись кэша должна удаляться только в том случае, если во время очистки, активировавшейся нехваткой памяти, отсутствуют другие записи кэша с низким или нормальным приоритетом |
Low |
0 |
Запись кэша должна быть удалена как можно скорее во время очистки, активировав нехватку памяти. |
NeverRemove |
3 |
Запись кэша никогда не должна удаляться во время очистки, вызываемой нехваткой памяти. |
Normal |
1 |
Запись кэша должна быть удалена, если во время очистки, активировавшейся нехваткой памяти, отсутствуют другие записи кэша с низким приоритетом. |
Что касается регистрации функции обратного вызова, то для её регистрации мы должны создать объекта класса PostEvictionCallbackRegistration
, который содержит два важных для нас свойства
Название | Тип | Описание |
State |
Object |
состояние, передаваемое делегату обратного вызова |
EvictionCallback |
PostEvictionDelegate |
делегат обратного вызова, который будет запущен после удаления записи из кэша |
В качестве состояния мы можем использовать любой объект, который мы хотим использовать в функции обратного вызова. Для примера, в это свойство мы записали тип объекта:
callback.State = typeof(WeatherForecast[]);
Что касается делегата обратного вызова, то его описание выглядит следующим образом:
public delegate void PostEvictionDelegate(object key, object? value, EvictionReason reason, object? state);
где
key
— ключ исключаемой записи,value
— значение исключаемой записи,reason
– причина удаления записиstate
— сведения, переданные при регистрации обратного вызова.
Параметр reason
может принимать следующие значения
Представление | Числовое значение | Описание |
Capacity |
5 |
Переполнение кэша |
Expired |
3 |
Истекло время ожидания |
None |
0 |
Элемент не был удален |
Removed |
1 |
Элемент удален вручную |
Replaced |
2 |
Элемент был перезаписан |
TokenExpired |
4 |
Сработало событие |
Запустим приложение и запросим прогноз погоды, чтобы он был добавлен в кэш, подождем 10 секунд и попробуем снова выполнить запрос:
Сообщение об удалении элемента кэша появляется только после того, как мы попытаемся произвести какое-либо действие. Если вас не устраивает такое поведение сервиса кэширования, то можно определить для элементов кэша токены отмены, например, так
//тут код с другими настройками элемента кэширования var expirationToken = new CancellationChangeToken(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); entryOptions.ExpirationTokens.Add(expirationToken); _memoryCache.Set("forecast", forecastArray, entryOptions);
Здесь мы передаем в список entryOptions.ExpirationTokens
токен, который отменяется через 10 секунд. Теперь, если запустить приложение и добавить в кэш какой-либо элемент, то через 10 секунд сработает токен и элемент будет удален из кэша, а мы увидим сообщение без каких-либо дополнительных действий:
Обратите внимание на причину удаления – TokenExpired
, которая говорит на именно о том, что элемент удален в результате события отмены токена. Таким образом, мы с вами настроили сервис кэширования данных в памяти, а также научились настраивать различные политики вытеснения элементов кэша.
Итого
Кэширование в памяти позволяет размещать любые объекты в оперативной памяти и повторно их использовать. Для того, чтобы ограничить объем оперативной памяти для хранения элементов кэша, а также сделать использование кэша более эффективным, мы можем использовать различные настройки политик кэширования используя классы MemoryCacheOptions
и MemoryCacheEntryOptions
.