Pull to refresh

Расширяем и улучшаем Cache в ASP.NET

.NET *
Про ASP.NET-объект Cache наверняка знает каждый web-разработчик на платформе .NET. Совсем не странно, ведь это единственное решение для кэширования данных web-приложения в ASP.NET, доступное прямо из коробки.
Достаточно функциональный и легкий, снабженный механизмами приоритета, вытеснения, зависимостей и обратных вызовов, Cache хорошо подходит для небольших приложений, работая внутри AppDomain. Кажется, Microsoft предусмотрела все, что необходимо… Но я, тем не менее, хочу сделать его еще немного лучше. Чем же именно?

Синхронизация обновлений



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

List<Product> products;
products = (List<Product>)Cache["Products"];
if (products == null)
{
  products = db.Products.ToList();
  Cache.Insert("Products", products);
}


* This source code was highlighted with Source Code Highlighter.


Все выглядит правильно, но ровно до тех пор, пока мы не осознаем, что код может выполняться одновременно в нескольких потоках. И в этих нескольких строчках мы только что организовали классическое состояние гонки (race condition). Ничего страшного, конечно, не произойдет, просто элемент кэша будет обновлен несколько раз, и каждый раз для этого мы обратимся к базе данных. Но это лишняя работа, и ее можно избежать, применив обычную double-check блокировку. Вот так:

private static object _lock = new object();

...

object value;
if ((value = Cache["Products"]) == null)
{
  lock (_lock)
  {
   if ((value = Cache["Products"]) == null)
   {
      value = db.Products.ToList();
      Cache.Insert("Products", value);
   }
  }
}
var products = (List<Product>)value;


* This source code was highlighted with Source Code Highlighter.


Таким образом, мы гарантируем, что только один поток отправится в базу данных за списком товаров, а остальные подождут его возвращения. Можно писать такой код всякий раз, когда мы работаем с кэшем, но лучше реализовать расширение объекта Cache при помощи extension-метода.

Итак,



public static T Get<T>(this Cache cache, string key, object @lock, Func<T> selector,
  DateTime absoluteExpiration)
{
   object value;
   if ((value = cache.Get(key)) == null)
   {
     lock (@lock)
     {
      if ((value = cache.Get(key)) == null)
      {
        value = selector();
        cache.Insert(key, value, null,
         absoluteExpiration, Cache.NoSlidingExpiration,
         CacheItemPriority.Normal, null);
      }
   }
  }
  return (T)value;

}
* This source code was highlighted with Source Code Highlighter.


Если в кэше нашелся элемент с заданным ключом, метод просто возвратит его, а в противном случае установит блокировку и выполнит загрузку. Конструкции value = Cache.Get(key) нужны для того, чтобы не получить такую же гонку при удалении элемента кэша в другом потоке. Теперь для получения нашего списка товаров мы можем написать только одну строку, а все остальное наше расширение возьмет на себя. Перегрузки можно добавить по вкусу :)

private static object myLock = new object();
...
var products = Cache.Get<List<Product>>("Products", myLock,
  () => db.Products.ToList(), DateTime.Now.AddMinutes(10));


* This source code was highlighted with Source Code Highlighter.


Итак, с одной задачей мы расправились, но есть еще кое-что интересное. К примеру, ситуация, когда необходимо объявить невалидными сразу несколько связанных элементов кэша. ASP.NET Cache предоставляет нам возможность создания зависимости от одного или нескольких элементов. Примерно так:

string[] dependencies = { "parent" };
Cache.Insert("child", someData,
  new CacheDependency(null, dependencies));

* This source code was highlighted with Source Code Highlighter.


И при обновлении элемента parent элемент child будет удален. Пока ничего не напоминает? Что ж, еще немного кода, и у нас появится полноценная…

Поддержка тегов и групповой инвалидации



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

public static CacheDependency CreateTagDependency(
  this Cache cache, params string[] tags)
{
  if (tags == null || tags.Length < 1)
   return null;

  long version = DateTime.UtcNow.Ticks;
  for (int i = 0; i < tags.Length; ++i)
  {
   cache.Add("_tag:" + tags[i], version, null,
     DateTime.MaxValue, Cache.NoSlidingExpiration,
     CacheItemPriority.NotRemovable, null);
  }
  return new CacheDependency(null, tags.Select(s =>
   "_tag:" + s).ToArray());
}


* This source code was highlighted with Source Code Highlighter.


Здесь в качестве значения версии тега я использую текущее время, как рекомендовалось в статье о Memcached, но в нашем случае сравнивать ничего не придется, этим займется ASP.NET. Теперь при добавлении элемента в кэш мы можем легко и просто создать зависимость от указанных нами тегов.

Сache.Insert("key", value, Сache.CreateTagDependency("tag1", "tag2"));

* This source code was highlighted with Source Code Highlighter.


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

public static void Invalidate(this Cache cache, params string[] tags)
{
  long version = DateTime.UtcNow.Ticks;
  for (int i = 0; i < tags.Length; ++i)
  {
   cache.Insert("_tag:" + tags[i], version, null,
     DateTime.MaxValue, Cache.NoSlidingExpiration,
     CacheItemPriority.NotRemovable, null);
  }
}


* This source code was highlighted with Source Code Highlighter.


Обратите внимание на то, что в методе, создающем зависимость от тегов, использовался метод cache.Add, а здесь — cache.Insert. Существенная разница между этими в остальном очень похожими методами в том, что первый записывает информацию в кэш только в том случае, если указанный ключ не был создан ранее, а второй — записывает в любом случае, затирая старые данные. Это различие очень важно в нашем случае, потому что при простом добавлении элемента в кэш нам не нужно обновлять уже существующие теги.

На этом, кажется, и все…

Я требую продолжения банкета!



А продолжать тут еще есть куда. Например, можно доработать приведенный здесь метод Get так, чтобы вместо немедленного удаления данные кэша временно «переезжали» в другую ячейку, и вместо блокировки возвращать запрошенную информацию из нее, пока новые данные загружаются в кэш.

Можно вместо extension-методов сделать некую абстракцию провайдера кэширования и работать с любым хранилищем без изменения кода приложения или полностью отключать кэш при отладке, использовать IoC… да мало ли что!

И я надеюсь, что подходы, описанные в моей статье, окажутся полезными для вас ;)

UPDATE: Посмотрев незамыленным глазом на собственный код, я узрел в нем одну нехорошую вещь — блокировка при синхронизации выставлялась на весь кэш целиком. Поэтому я изменил extension-метод Get с тем, чтобы он принимал пользовательский объект для блокировки.

Tags:
Hubs:
Total votes 28: ↑21 and ↓7 +14
Views 8.3K
Comments Comments 21