Помогаю зарабатывать (или не тратить) с помощью ИТ
–0,1
рейтинг
15 октября 2014 в 09:48

Разработка → Применение инфраструктуры кеширования в ASP.NET, продолжение

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

Для примера все так же использую проект Mvc Music Store.
Если вы не читали предыдущие посты, то самое время посмотреть как была ускорена домашняя страница.

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

Нагрузочный тест


Для проверки сделал нагрузочный тест в Visual Studio на 25 «виртуальных пользователей» с таким сценарием:
1) Запрос главной страницы
2) Запрос страницы жанра (каталога)
3) Запрос страницы альбома (товара)
Для верности сделал, чтобы были заходы не на одну и ту же страницу каталога\товара, а рандомизировано на разные страницы.
А также увеличил процент новых пользователей до 80%, что соответствует действительности.

Результат — 42 сценария в секунду.

Добавляем кеширование — простой подход


В ASP.NET можно задать атрибутами, кеширование с зависимостями от БД.
Для этого нужно выполнить несколько простых шагов:
1. Внести параметры кеширования в web.config

  <system.web>
    <caching>
      <sqlCacheDependency enabled="true" pollTime="1000">
        <databases>
          <add name="MusicStore" connectionStringName="MusicStoreEntities" />
        </databases>
      </sqlCacheDependency>
    <outputCacheSettings>
      <outputCacheProfiles>
        <add name="Catalog" 
             sqlDependency="MusicStore:Genres;MusicStore:Albums" 
             duration="86400" 
             location="ServerAndClient" 
             varyByParam="Genre"
             enabled="true" />
      </outputCacheProfiles>
    </outputCacheSettings>
    </caching>
  </system.web>

Элемент sqlCacheDependency определяет параметры зависимости кеша от БД. Зависимость от БД будет проверять изменения с интервалом pollTime, в данном случае 1000 миллисекунд (1 секунда).
Элемент outputCacheProfiles задает профили, чтобы не повторять одни и те же настройки для разных действий. Кроме того позволяет управлять кешированием, без пересборки проекта.

2. Внести изменения в схему БД, чтобы работали зависимости

Для этого надо в при старте приложения вызвать следующие строки кода
String connStr = System.Configuration.ConfigurationManager.ConnectionStrings["MusicStoreEntities"].ConnectionString;
System.Web.Caching.SqlCacheDependencyAdmin.EnableNotifications(connStr);
System.Web.Caching.SqlCacheDependencyAdmin.EnableTableForNotifications(connStr, "Genres");
System.Web.Caching.SqlCacheDependencyAdmin.EnableTableForNotifications(connStr, "Albums");


3. Добавить атрибуты

[OutputCache(CacheProfile = "Catalog")]
public ActionResult Browse(string genre)
{
 //...
}

[OutputCache(CacheProfile = "Catalog")]
public ActionResult Details(int id)
{
 //...
}


Снова запускаем тест — 60 сценариев в секунду. То есть удалось увеличить быстродействие в этом случае почти на 50%.

Установка зависимостей из кода


Если вы используете WebAPI, то атрибуты кеширования вы использовать не сможете. Но в этом случае вам поможет класс SqlCacheDependency. Использовать его очень просто — в конструкторе указываете имя БД из web.config и имя таблицы. Экземпляр SqlCacheDependency можно использовать для указания зависимостей элементов локального кеша.

Так можно сделать кеширование навигации, если кешировать все страницы окажется невыгодно.

[ChildActionOnly]
public ActionResult GenreMenu()
{
    var cacheKey = "Nav";
    var genres = this.HttpContext.Cache.Get(cacheKey);

    if (genres == null)
    {
        genres = storeDB.Genres.ToList();
        this.HttpContext.Cache.Insert(cacheKey, genres, new SqlCacheDependency("MusicStore","Genres"));
    }

    return PartialView(genres);
}


Есть еще один конструктор SqlCacheDependency, который принимает SqlCommand. Это совершенно другой механизм отслеживания изменений в БД, построенный на оповещениях от SQL Server Service Broker. Я пробовал использовать эти оповещения, но они работают далеко не для всех запросов. Причем если запрос «неправильный», то никаких ошибок не происходит и оповещение прилетает сразу же после создания. Кроме того оповещения очень медленные. По моим замерам в 8 раз замедляют запись в таблицы.

Обратная сторона медали


Зависимости от БД вовсе не бесплатные.Для их работы создаются триггеры, которые срабатывают на создание, изменение и удаление записей. Эти триггеры обновляют информацию в служебной таблице о том, какие таблицы и когда были изменены. А поток на стороне приложения периодически читает таблицу и оповещает зависимости.

Если объем изменения происходят нечасто, то накладные расходы на триггеры невелики. А если изменения происходят часто, то эффективность кеша падает. В примере с Mvc Music Store любое изменение любого альбома будет приводит к сбросу всего кеша для всего каталога.

Что же делать?


Если оставаться в рамках одного сервера, то подойдет такой же подход, как используется для кеширования корзины в Mvc Music Store — сохранять отельные элементы или выборки данных в кеше, а при записи — сбрасывать кеш (подробнее в раннем посте). При правильном подборе гранулярности кеширования и сброса можно добиться высокой эффективности кеша.

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

Нам поможет распределенный кеш

Если вы уже поставили более одного веб-сервера для обслуживания запросов, то стоит задуматься о распределенном кеше.
Один из лучших вариантов на сегодня — Redis. Он доступен как on premises, так и в облаке Microsoft Azure.

Чтобы добавить Redis в ASP.NET проект надо открыть Package Manager Console и выполнить пару команд
Install-Package Redis-64
Install-Package StackExchange.Redis


Redis поддерживает отличную возможность — так называемые Keyspace Notifications (http://redis.io/topics/notifications). Это позволяет отслеживать когда элемент был изменен, даже если изменения происходят на другом сервере.

Чтобы интегрировать эту возможность в ASP.NET я написал маленький класс:
class RedisCacheDependency: CacheDependency
{
    public RedisCacheDependency(string key):base()
    {
       Redis.Client.GetSubscriber().Subscribe("__keyspace@0__:" + key, (c, v) =>
       {
           this.NotifyDependencyChanged(new object(), EventArgs.Empty );                                        
       });
   }
}

Этот класс реализует CacheDependency в Redis.

А теперь сам клиент:
public static class Redis
{
    public static readonly ConnectionMultiplexer Client = ConnectionMultiplexer.Connect("localhost");

    public static CacheDependency CreateDependency(string key)
    {
        return new RedisCacheDependency(key);
    }

    public static T GetCached<T>(string key, Func<T> getter) where T:class 
    {
        var localCache = HttpRuntime.Cache;
        var result = (T) localCache.Get(key);
        if (result != null) return result;

        var redisDb = Client.GetDatabase();

        var value = redisDb.StringGet(key);
        if (!value.IsNullOrEmpty)
        {
            result = Json.Decode<T>(value);
            localCache.Insert(key, result, CreateDependency(key));
            return result;
        }

        result = getter();

        redisDb.StringSet(key, Json.Encode(result));
        localCache.Insert(key, result, CreateDependency(key));
        return result;
    }

    public static void DeleteKey(string key)
    {
        HttpRuntime.Cache.Remove(key);
        var redisDb = Client.GetDatabase();
        redisDb.KeyDelete(key);
    }
}

Метод GetCached сохраняет результат в локальном кеше ASP.NET. Локальный кеш очень быстр, проверка элемента в кеше занимает наносекунды. Это гораздо быстрее, чем удаленный запрос к Redis+сериализация-десериализация.

Теперь я могу привязать элемент в кеше Redis к кешу страницы:
public ActionResult Browse(string genre)
{
    var cacheKey = "catalog-" + genre;
    var genreModel = Redis.GetCached(cacheKey, () =>
        (from g in storeDB.Genres
            where g.Name == genre
            select new GenreBrowse
            {
                Name =  g.Name,
                Albums = from a in g.Albums
                        select new AlbumSummary
                        {
                            Title =  a.Title,
                            AlbumId =  a.AlbumId,
                            AlbumArtUrl = a.AlbumArtUrl
                        }
            }
                ).Single()
        );

    this.Response.AddCacheItemDependency(cacheKey);
    this.Response.Cache.SetLastModifiedFromFileDependencies();
    this.Response.Cache.AppendCacheExtension("max-age=0");
    this.Response.Cache.VaryByParams["genre"] = true;
    this.Response.Cache.SetCacheability(HttpCacheability.ServerAndPrivate);

    return View(genreModel);
}

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

Для сброса кеша нужно вызывать Redis.DeleteKey в методах, изменяющих данные.

Повторный нагрузочный тест выдал 52 сценария в секунду. Это меньше, чем без Redis, но производительность не будет заметно падать при увеличении количества записей в таблицу.

Что еще можно сделать с Redis?

Кроме ручного размещения данных в кеше можно воспользоваться NuGet пакетами Microsoft.Web.RedisSessionStateProvider и Microsoft.Web.RedisOutputCacheProvider для размещения состояния сеанса и кеша страниц в Redis. К сожалению кастомный OutputCacheProvider ограничивает использование CacheDependency для сброса кеша вывода.

Заключение


В ASP.NET очень много возможностей кеширования, кроме рассмтренных в этой серии постов есть еще коллбеки валидации кеша, привязка к файлам и каталогам. Но есть и подводные камни, о которых я еще не рассказывал. Если вам интересно все, что касается оптимизации веб-приложений на ASP.NET, то приходите на мой семинар — gandjustas.timepad.ru/event/150915

Все посты серии


Исходный код вместе с тестами доступен на GitHub — github.com/gandjustas/McvMusicStoreCache
Стас Выщепан @gandjustas
карма
20,5
рейтинг –0,1
Помогаю зарабатывать (или не тратить) с помощью ИТ
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (3)

  • 0
    Не упомянут MVCDonutChaching. Кеширование по заголовкам, по параметрам. Кеширование всей страницы, кеширование части страницы и т.п.
    • 0
      Кеширование всей страницы делается с помощью output cache — атрибутами или кодом. Кеширование экшенов намерено не упоминал, потому что нет механизма сброса кеша для частичного кеша, из-за этого часто возникает неконсистентность страниц. Лучше кешировать данные частей, чтобы несколько раз не бегать в базу.

      Идеальный сценарий для кеша:
      1) 90% запросов отдаются из кеша браузера (без запроса вообще или через ответ сервера с кодом 304)
      2) Из оставшихся 90% отдаются из кеша сервера, который кеширует полные ответы и управление даже не доходит до приложения
      3) Из оставшихся 90% отдается из кеша приложения, которое кеширует готовые к рендерингу данные
  • 0
    Недавно столкнулся с проблемой, когда кеш экспайрится, то IIS во сколько потоков у него есть, во столько и долбит контроллер пока кеш по новой не заполнится. Если метод контроллера тяжелый, то времени в итоге может не хватить и все упадет.
    Пришлось добавлять еще один уровень кеширования внутрипрограмный, который отдавал старую версию кеша, пока новый не заполнится в один поток.

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