Кэширование в памяти (In-Memory Cache)

Кэширование — это относительно простая и, в то же время, эффективная концепция в программировании, идея которой состоит в том, чтобы повторно использовать данные, не прибегая к выполнению повторных дорогостоящих операций. В ASP.NET Core предусмотрены различные механизмы кэширования данных.

Идея кэширования

Идею использования кэширования в ASP.NET Core (и не только) можно продемонстрировать следующей схемой

Когда пользователь впервые запрашивает данные (верхний рисунок), то приложение делает запрос, например, к базе данных, извлекает необходимые данные, сохраняет результат в кэш и возвращает полученные данные пользователю. Когда пользователь выполняет второй и последующие запросы, то приложение уже не обращается к БД, а извлекает полученные ранее данные из кэша и возвращает их пользователю. Так как обращение к БД — это часто довольно трудоемкие операции, то использование кэша позволяет повысить производительность нашего приложения.

В каких случаях можно использовать кэширование? Кэширование рекомендуется использовать для данных, которые не меняются вообще (идеальный вариант) или изменяются редко. Например, не стоит использовать кэширование показаний таймера или результаты каких-либо вычислений, которые в любой момент могут измениться. При этом, если мы храним, например, аватары пользователей в БД, то имеет смысл загрузить их в кэш.

Типы кэшей

Можно выделить три типа кэшей, которые могут использовать приложения:

  1. Кэш в памяти (In-Memory Cache). Этот тип кэша используется в случае, если нам достаточно хранить данные в рамках одного процесса. Когда процесс завершается, то кэш удаляется из памяти вместе с ним.
  2. Постоянный локальный кэш (Persistent in-process Cache) — это вариант, при котором создается резервная копию кэша вне памяти процесса, например, в файле или БД. В этом случае, при завершении процесса кэш не «умирает» и может быть восстановлен при следующем запуске процесса.
  3. Распределенный кэш (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;
}

Проверить работу кэша довольно просто – достаточно дважды выполнить запрос на получение прогноза погоды:

Первая попытка получения

Вторая и последующие попытки получения данных

Вот так просто мы используем кэш в памяти. Однако, при разработке реальных приложений следует учитывать следующие моменты:

  1. Кэшированные данные могут обновляться с течением времени, поэтому стоит предусматривать также механизм проверки кэшированной версии ресурса и при необходимости обновлять её. Например, если в приложении предусмотрено обновление ресурса пользователем, то перед обновлением стоит проверить кэш и, при необходимости, удалить кэшированную версию ресурса.
  2. Тонким местом в нашем приложении сейчас является то, что, по сути, наш кэш ограничен только объемом доступной оперативной памяти сервера, что недопустимо. Необходимо настроить кэширование данных таким образом, чтобы, с одной стороны, оно помогало нам повысить производительность приложение, а, с другой стороны – не мешало работать другим приложениям.

Рассмотрим, как и какие настройки кэширования в памяти мы можем применить.

Настройка 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.

Подписаться
Уведомить о
guest
0 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии