Реализации кэша в C# .NET

Автор оригинала: michaelscodingspot.com
  • Перевод
Привет, Хабр! В преддверии старта курса «C# ASP.NET Core разработчик», подготовили перевод интересного материала о реализации кэша в C#. Приятного прочтения.




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

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

Кэширование отлично подходит для данных, которые изменяются нечасто. Или, в идеале, не меняются никогда. Данные, которые изменяются постоянно, например, текущее время, не должны кэшироваться, иначе вы рискуете получить неправильные результаты.

Локальный кэш, постоянный локальный кэш и распределенный кэш


Существует 3 типа кэшей:

  • Кэш в памяти (In-Memory Cache) используется для случаев, когда вам достаточно реализовать кэш в рамках одного процесса. Когда процесс умирает, кэш умирает вместе с ним. Если вы выполняете один и тот же процесс на нескольких серверах, у вас будет отдельный кэш для каждого сервера.
  • Постоянный локальный кэш (Persistent in-process Cache) — это когда вы создаете резервную копию кэша вне памяти процесса. Он может располагаться в файле или в базе данных. Он сложнее кэша в памяти, но если ваш процесс перезапускается, кэш не сбрасывается. Лучше всего подходит для случаев, когда получение кэшируемого элемента затратно, а ваш процесс имеет обыкновение часто перезапускаться.
  • Распределенный кэш (Distributed Cache) — это когда вам нужен общий кэш для нескольких машин. Обычно это несколько серверов. Распределенный кэш хранится во внешней службе. Это означает, что если один сервер сохранил элемент кэша, другие серверы также могут его использовать. Такие сервисы, как Redis, отлично для этого подходят.

Мы будем говорить только о локальном кэше.

Примитивная реализация


Начнем с создания очень простой реализации кэша в C#:

public class NaiveCache<TItem>
{
    Dictionary<object, TItem> _cache = new Dictionary<object, TItem>();
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        if (!_cache.ContainsKey(key))
        {
            _cache[key] = createItem();
        }
        return _cache[key];
    }
}

Использование:

var _avatarCache = new NaiveCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

Этот простой код решает важную проблему. Чтобы получить аватар пользователя, только первый запрос будет фактическим запросом из базы данных. Данные аватара (byte []) по результату запроса сохраняются в памяти процесса. Все последующие запросы аватара будут извлекать его из памяти, экономя время и ресурсы.

Но, как и большинство вещей в программировании, все не так просто. Приведенная выше реализация не является хорошим решением по ряду причин. С одной стороны, эта реализация не является потокобезопасной. При использовании из нескольких потоков могут возникать исключения. Кроме того, кэшированные элементы останутся в памяти навсегда, что на самом деле очень плохо.

Вот почему мы должны удалять элементы из кеша:

  1. Кэш может начать занимать много памяти, что в конечном итоге приведет к исключениям из-за ее нехватки и крашам.
  2. Высокое потребление памяти может привести к давлению на память (также известному как GC Pressure). В этом состоянии сборщик мусора работает намного больше, чем должен, что снижает производительность.
  3. Кэш может нуждаться в обновлении при изменении данных. Наша инфраструктура кэширования должна поддерживать эту возможность.

Для решения этих проблем в фреймворках существуют политики вытеснения (также известные как политики удаления — Eviction/Removal policies). Это правила удаления элементов из кэша в соответствии с заданной логикой. Среди распространенных политик удаления можно выделить следующие:

  • Политика абсолютного истечения срока (Absolute Expiration), которая удаляет элемент из кэша через фиксированный промежуток времени, несмотря ни на что.
  • Политика скользящего истечения срока (Sliding Expiration), которая удаляет элемент из кэша, если к нему не был осуществлен доступ в течение определенного периода времени. То есть, если я установлю срок истечения на 1 минуту, элемент будет оставаться в кэше, пока я использую его каждые 30 секунд. Если я не использую его дольше минуты, элемент будет удален.
  • Политика ограничения размера (Size Limit), которая будет ограничивать размер кэш-памяти.

Теперь, когда мы разобрались со всем необходимым, давайте перейдем к решениям получше.

Решения получше


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

Я покажу вам решение Microsoft, как эффективно его использовать, а затем как его улучшить для некоторых сценариев.

System.Runtime.Caching/MemoryCache против Microsoft.Extensions.Caching.Memory


У Microsoft есть 2 решения, 2 разных NuGet пакета для кэширования. Оба великолепны. Согласно рекомендациям Microsoft, предпочтительнее использовать Microsoft.Extensions.Caching.Memory, потому что он лучше интегрируется с Asp. NET Core. Его можно легко внедрить в механизм внедрения зависимостей Asp .NET Core.

Вот простой пример с Microsoft.Extensions.Caching.Memory:

public class SimpleMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry)) // Ищем ключ в кэше.
        {
            // Ключ отсутствует в кэше, поэтому получаем данные.
            cacheEntry = createItem();
            
            // Сохраняем данные в кэше. 
            _cache.Set(key, cacheEntry);
        }
        return cacheEntry;
    }
}

Использование:

var _avatarCache = new SimpleMemoryCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

Это очень напоминает мой собственный NaiveCache, так что же изменилось? Ну, во-первых, это потокобезопасная реализация. Вы можете безопасно вызывать ее из нескольких потоков одновременно.

Во-вторых, MemoryCache учитывает все политики вытеснения, о которых мы говорили ранее. Вот пример:

IMemoryCache с политиками вытеснения:

public class MemoryCacheWithPolicy<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()
    {
        SizeLimit = 1024
    });
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry))// Ищем ключ в кэше.
        {
            // Ключ отсутствует в кэше, поэтому получаем данные. 
            cacheEntry = createItem();
 
            var cacheEntryOptions = new MemoryCacheEntryOptions()
         	.SetSize(1)//Значение размера
         	// Приоритет на удаление при достижении предельного размера (давления на память)
                .SetPriority(CacheItemPriority.High)
                // Храним в кэше в течении этого времени, сбрасываем время при обращении.
                 .SetSlidingExpiration(TimeSpan.FromSeconds(2))
                // Удаляем из кэша по истечении этого времени, независимо от скользящего таймера.
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
 
            // Сохраняем данные в кэше.
            _cache.Set(key, cacheEntry, cacheEntryOptions);
        }
        return cacheEntry;
    }
}

Давайте проанализируем новые элементы:

  1. В MemoryCacheOptions был добавлен SizeLimit. Это добавляет политику ограничения размера к нашему кэш-контейнеру. Кэш не имеет механизма для измерения размера записей. Поэтому нам нужно устанавливать размер каждой записи в кэше. В данном случае мы каждый раз устанавливаем размер в 1 с помощью SetSize(1). Это означает, что наш кэш будет иметь ограничение в 1024 элемента.
  2. Какой элемент кэша должен быть удален, когда мы достигнем предельного размера кэша? Вы можете устанавливать приоритет с помощью .SetPriority (CacheItemPriority.High). Уровни приоритета следующие: Low (Низкий), Normal (Нормальный), High (Высокий) и NeverRemove (Не подлежит удалению).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) устанавливает скользящий срок жизни элемента равным 2 секундам. Это означает, что если к элементу не было доступа более 2 секунд, он будет удален.
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) устанавливает абсолютный срок жизни элемента равным 10 секундам. Это означает, что предмет будет удален в течение 10 секунд, если он еще не был удален.

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

Это довольно широкий набор функций, но, тем не менее, он мы должны задуматься, есть ли что-нибудь еще, чтобы можно было добавить. На самом деле есть пара вещей.

Проблемы и отсутствующий функцинал


В этой реализации отсутствует несколько важных частей.

  1. В то время как вы можете установить ограничение размера, кэширование фактически не контролирует давление на память. Если бы мы проводили мониторинг, мы могли бы ужесточить политику при высоком давлении и ослабить политику при низком.
  2. При запросе одного и того же элемента несколькими потоками одновременно, запросы не ожидают завершения первого запроса. Элемент будет создан несколько раз. Например, предположим, что мы кэшируем аватар, а получение аватара из базы данных занимает 10 секунд. Если мы запросим аватар через 2 секунды после первого запроса, он проверит, кэширован ли этот аватар (пока нет), и инициирует другой запрос в базу данных.

Что касается первой проблемы давления на gc: можно контролировать давление на gc несколькими методами и эвристиками. Этот пост не об этом, но вы можете прочитать мою статью «Поиск, исправление и предотвращение утечек памяти в C# .NET: 8 лучших практик», чтобы узнать о некоторых полезных методах.

Вторую проблему решить легче. Собственно, вот реализация MemoryCache, которая полностью ее решает:

public class WaitToFinishMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
    private ConcurrentDictionary<object, SemaphoreSlim> _locks = new ConcurrentDictionary<object, SemaphoreSlim>();
 
    public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem)
    {
        TItem cacheEntry;
 
        if (!_cache.TryGetValue(key, out cacheEntry))// Ищем ключ в кэше.
        {
            SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
 
            await mylock.WaitAsync();
            try
            {
                if (!_cache.TryGetValue(key, out cacheEntry))
                {
                    // Ключ отсутствует в кэше, поэтому получаем данные.
                    cacheEntry = await createItem();
                    _cache.Set(key, cacheEntry);
                }
            }
            finally
            {
                mylock.Release();
            }
        }
        return cacheEntry;
    }
}

Использование:

var _avatarCache = new WaitToFinishMemoryCache<byte[]>();
// ...
var myAvatar =
await _avatarCache.GetOrCreate(userId, async () => await _database.GetAvatar(userId));

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

Разбор кода


Эта реализация блокирует создание элемента. Блокировка происходит по ключу. Например, если мы ждем получения аватара Алексея, мы все равно сможем получить кэшированные значения Жени или Варвары в другом потоке.

В словаре _locks хранятся все блокировки. Обычные блокировки не работают с async/await, поэтому нам нужно использовать SemaphoreSlim.

Есть 2 проверки, чтобы проверить, кэшировано ли уже значение, если (!_Cache.TryGetValue(key, out cacheEntry)). Та, что в блокировке, — это та, которая обеспечивает единственное создание элемента. Та, что за пределами блокировки, для оптимизации.

Когда использовать WaitToFinishMemoryCache


Эта реализация, очевидно, имеет некоторые накладные расходы. Давайте рассмотрим, когда она актуальна.

Используйте WaitToFinishMemoryCache, когда:

  • Когда время создания элемента имеет какую-либо стоимость, и вы хотите минимизировать количество созданий, насколько это возможно.
  • Когда время создания предмета очень велико.
  • Когда создание элемента должно быть выполнено один раз для каждого ключа.

Не используйте WaitToFinishMemoryCache, когда:

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

Резюме


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

Я надеюсь, вам понравилась эта статья. Если вы интересуетесь управлением памятью, моя следующая статья будет посвящена опасностям давления на GC и методам его предотвращения, поэтому подписывайтесь. Приятного вам кодинга.



Узнать подробнее о курсе.


OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 2

    0
    Мой совет: не использовать WaitToFinishMemoryCache. Потому что _locks — это течь памяти.
      0

      Ну да. Сам же автор пишет, что


      Кроме того, кэшированные элементы останутся в памяти навсегда, что на самом деле очень плохо.

      И после этого для блокировок использует тот же вечный кеш. Нужно тогда уж задавать eviction delegate каждый раз и в нем чистить за собой блокировку.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое