В этом посте я описываю, как использовать MemoryCache и отслеживать время существования сущностей в приложениях .NET.

вступление

Кэширование — один из известных способов повысить производительность приложения и снизить нагрузку на поставщика данных. В .NET есть пакеты Microsoft.Extensions.Caching для использования разных хранилищ: Redis, SqlServer, Cosmos, Memory. Последний — самый простой способ. Он просто сохраняет данные в ОЗУ. Вы можете использовать его, просто установив Microsoft.Extensions.Caching.Memory из NuGet.

Использование кэша памяти

Для хранения данных используется внутренний ConcurrentDictionary. Таким образом, операции set/get являются потокобезопасными. Взаимодействие с MemoryCache описано в IMemoryCache. Этот интерфейс имеет несколько методов для установки, удаления и получения значения. Обычный алгоритм работы с кешем:
1. Попробуйте получить значение из кеша.
2. Если вы не получили значение из кеша, запросите источник данных и сохраните полученное значение в кеше.

Простой пример:

Мы должны быть осторожны в использовании памяти и избегать неконтролируемого распределения. Поэтому в приведенном выше коде мы ограничиваем размер кеша, время жизни сущностей на MemoryCacheEntryOptions.

Отслеживание объектов

Одной из важных частей кэширования является обновление данных в хранилище кэша. В приведенном выше коде мы проверяем существование объекта во время получения значения. Если его нет, мы получаем значение от поставщика данных, а затем сохраняем его в кеше. Также для этого расширение IMemoryCache имеет метод GetOrCreate<TItem>(Object key, Func<ICacheEntry,TItem> factory).
Следует упомянуть механизм истечения срока действия в MemoryCache. Когда мы пытаемся получить значение из кеша. Последний получает значение из внутреннего словаря и проверяет, не истек ли срок его действия. Если да, сущность помечается как просроченная, а затем удаляется. Если нет, то кеш просто возвращает значение. В обоих случаях кеш запускает процесс аннулирования всех значений во внутреннем словаре в отдельном потоке. Период этой проверки вы можете настроить в ExpirationScanFrequency поле MemoryCacheOptions (по умолчанию 1 минута). Также в MemoryCache есть логика сжатия. Объекты удаляются в следующем порядке:
1. Все элементы с истекшим сроком действия.

2. Предметы по приоритету. Элементы с самым низким приоритетом удаляются первыми.

3. Объекты, которые использовались реже всего.

4. Предметы с самым ранним абсолютным сроком действия.

5. Предметы с самым ранним скользящим сроком действия.

Закрепленные элементы с приоритетом NeverRemove никогда не удаляются. По этим причинам нам приходится отслеживать значения в кеше и обновлять его. Самый простой способ — проверка значения методом TryGetValue при получении его из кеша.

Проблема: обновление значения в MemoryCache

Мы видим в приведенном выше коде, что можно вызывать GetOrUpdate из нескольких потоков, и значение кеша будет обновляться несколько раз в этих потоках — состояние гонки. Есть разные способы избежать этого:

  1. Использование примитивов синхронизации. Например, вы можете использовать SemaphoreSlim:

Также мы можем использовать Interlocked методов типа здесь. Имеется замок под ключ. В любом случае, эта логика получения или обновления нарушает принцип единой ответственности. Вы получаете значение и иногда устанавливаете новое значение одним методом.

2. Использование Lazy<T> подхода. Поместите экземпляр Lazy<T> вместо T в кеш. Этот способ описан в StackOverflow. Также есть реализация LazyCache. Ленивый подход лучше предыдущего. Это позволяет сохранить принцип единой ответственности и использовать экземпляр из кеша, когда это действительно необходимо.

3. Использование обратных вызовов. В официальной документации упоминаются обратные вызовы выселения. Давайте посмотрим, что это такое. Каждая сущность inMemoryCache имеет коллекцию обратных вызовов типа PostEvictionCallbackRegistration, которые вызываются после вытеснения значения из кеша в отдельном потоке. Вы можете добавить своего делегата методом RegisterPostEvictionCallback в аргумент MemoryCacheEntryOptions метода Set:

Причина выселения устанавливается в EvictionReason.Removed после удаления и EvictionReason.Expiredпосле проверки срока службы. Когда объект кеша был обновлен, причиной удаления будет EvictionReason.Replaced В случае удаления из-за переполнения емкости, это EvictionReason.Capacity
Простой пример:

Он позволяет запускать фоновое обновление удаленных значений и разделять логику получения/установки — принцип единой ответственности. Но нужно помнить о состоянии гонки. CacheEntity позволяет отслеживать изменения с помощью IChangeToken. Я опускаю эту тему, так как она имеет аналогичный подход, и я не претендую на исчерпывающий учебник (определенно, это будет долгое чтение). Вы можете прочитать здесь об использовании токена изменения.

Краткое содержание

В этом посте я коснулся некоторых важных вопросов использования кеша памяти:
- Вы должны ограничить размер и время жизни сущностей. Это позволяет сократить использование памяти.
- Инвалидация кеша срабатывает при попытке получить значение. Он использует фоновый поток для проверки времени жизни и очистки объектов с истекшим сроком действия в кеше.
— MemoryCache является потокобезопасным, но не предотвращает состояние гонки для метода Set. Поэтому я описал несколько подходов к решению этой проблемы. У каждого есть плюсы и минусы, выбирайте лучший для своего случая. Также я увидел интересный асинхронный подход на основе TaskCompletionsSource. Дополнительную информацию вы можете получить из списка ниже.
P.S. Прочтите исходный код. Благодаря политике открытого исходного кода это хороший способ узнать больше, чем из документации.

  1. https://learn.microsoft.com/en-us/aspnet/core/performance/caching/memory?view=aspnetcore-6.0#use-setsize-size-and-sizelimit-to-limit-cache-size
  2. https://github.com/dotnet/runtime/tree/5c61ad424a7c0f50690f73235d695bad2c525634/src/libraries/Microsoft.Extensions.Caching.Memory
  3. https://blog.novanet.no/asp-net-core-memory-cache-is-get-or-create-thread-safe/
  4. https://cpratt.co/thread-safe-strong-typed-memory-caching-c-sharp/
  5. https://github.com/dotnet/runtime/issues/36500#issuecomment-491540894