Помогаю зарабатывать (или не тратить) с помощью ИТ
0,0
рейтинг
10 февраля 2013 в 00:26

Разработка → Кеширование в ASP.NET MVC

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

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



Для примера я взял приложение MVC Music Store, которое используется в разделе обучение на сайте asp.net. Приложение представляет из себя интернет-магазин, с корзиной, каталогом товаров и небольшой админкой.

Исследуем проблему


Сразу создал нагрузочный тест на одну минуту, который открывает главную страницу. Получилось 60 страниц в секунду (все тесты запускал в дебаге). Это очень мало, полез разбираться в чем проблема.

Код контроллера главной страницы:
public ActionResult Index()
{
    // Get most popular albums
    var albums = GetTopSellingAlbums(5);
    return View(albums);
}

private List<Album> GetTopSellingAlbums(int count)
{
    // Group the order details by album and return
    // the albums with the highest count

    return storeDB.Albums
        .OrderByDescending(a => a.OrderDetails.Count())
        .Take(count)
        .ToList();
}


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

При этом в каждой странице выводится персонализированная информация — количество элементов в корзине.
Код _layout.cshtml (Razor):
<div id="header">
    <h1><a href="/">ASP.NET MVC MUSIC STORE</a></h1>
    <ul id="navlist">
        <li class="first"><a href="@Url.Content("~")" id="current">Home</a></li>
        <li><a href="@Url.Content("~/Store/")">Store</a></li>
        <li>@{Html.RenderAction("CartSummary", "ShoppingCart");}</li>
        <li><a href="@Url.Content("~/StoreManager/")">Admin</a></li>
    </ul>        
</div>


Такой «паттерн» часто встречается в веб-приложениях. На главной странице, которая открывается чаще всего, выводится в одном месте статистическая информация, которая требует больших затрат на вычисление и меняется нечасто, а в другом месте — персонализированная информация, которая часто меняется. Из-за этого главная страница работает медленно, и средствами HTTP её кешировать нельзя.

Делаем приложение пригодным для кеширования


Чтобы такой ситуации, как описано выше, не происходило надо разделить запросы и собирать части страницы на клиенте. В ASP.NET MVC это сделать довольно просто.
Код _layout.cshtml (Razor):
<div id="header">
    <h1><a href="/">ASP.NET MVC MUSIC STORE</a></h1>
    <ul id="navlist">
        <li class="first"><a href="@Url.Content("~")" id="current">Home</a></li>
        <li><a href="@Url.Content("~/Store/")">Store</a></li>
        <li><span id="shopping-cart"></span></li>
        <li><a href="@Url.Content("~/StoreManager/")">Admin</a></li>
    </ul>        
</div>

<!-- skipped -->

<script>        
    $('#shopping-cart').load('@Url.Action("CartSummary", "ShoppingCart")');
</script>


В коде контроллера:
//[ChildActionOnly] //Убрал
[HttpGet] //Добавил
public ActionResult CartSummary()
{
    var cart = ShoppingCart.GetCart(this.HttpContext);

    ViewData["CartCount"] = cart.GetCount();
    this.Response.Cache.SetCacheability(System.Web.HttpCacheability.NoCache); // Добавил
    return PartialView("CartSummary");
}


Установка режима кеширования NoCache необходима, так как браузеры могут по умолчанию кешировать Ajax запросы.

Само по себе такое преобразование делает приложение только медленнее. По результатам теста — 52 страницы в секунду, с учетом ajax запроса для получения состояния корзины.

Разгоняем приложение


Теперь можно прикрутить lazy кеширование. Саму главную страницу можно кешировать везде и довольно долго (статистика терпит погрешности).
Для этого можно просто навесить атрибут OutputCache на метод контроллера:
[OutputCache(Location=System.Web.UI.OutputCacheLocation.Any, Duration=60)]
public ActionResult Index()
{
    // skipped
}


Чтобы оно успешно работало при сжатии динамического контента необходимо в web.config добавить параметр:
<system.webServer>
  <urlCompression dynamicCompressionBeforeCache="false"/>
</system.webServer>

Это необходимо чтобы сервер не отдавал заголовок Vary:*, который фактически отключает кеширование.

Нагрузочное тестирование показало результат 197 страниц в секунду. Фактически страница home\index всегда отдавалась из кеша пользователя или сервера, то есть настолько быстро, насколько возможно и тест померил быстродействие ajax запроса, получающего количество элементов в корзине.

Чтобы ускорить работу корзины надо сделать немного больше работы. Для начала результат cart.GetCount() можно сохранить в кеше asp.net, и сбрасывать кеш при изменении количества элементов в корзине. Получится в некотором роде write-through кеш.

В MVC Music Store сделать такое кеширование очень просто, как так всего 3 экшена изменяют состояние корзины. Но в сложном случае, скорее всего, потребуется реализации publish\subscribe механизма в приложении, чтобы централизованно управлять сбросом кеша.

Метод получения количества элементов:
[HttpGet]
public ActionResult CartSummary()
{
    var cart = ShoppingCart.GetCart(this.HttpContext);
    var cacheKey = "shooting-cart-" + cart.ShoppingCartId;

    this.HttpContext.Cache[cacheKey] = this.HttpContext.Cache[cacheKey] ?? cart.GetCount();

    ViewData["CartCount"] = this.HttpContext.Cache[cacheKey];

    return PartialView("CartSummary");
}


В методы, изменяющие корзину, надо добавить две строчки:
var cacheKey = "shooting-cart-" + cart.ShoppingCartId;
this.HttpContext.Cache.Remove(cacheKey);


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

Используем HTTP кеширование


Последний аккорд — прикручивание HTTP кеширование к запросу количества элементов в корзине. Для этого нужно:
  1. Отдавать Last-Modified в заголовках ответа
  2. Обрабатывать If-Modified-Since в заголовках запроса (Conditional GET)
  3. Отдавать код 304 если значение не изменилось


Начнем с конца.
Код ActionResult для ответа Not Modified:
public class NotModifiedResult: ActionResult
{
    public override void ExecuteResult(ControllerContext context)
    {
        var response = context.HttpContext.Response;
        response.StatusCode = 304;
        response.StatusDescription = "Not Modified";
        response.SuppressContent = true;
    }
}


Добавляем обработку Conditional GET и установку Last-Modified:
[HttpGet]
public ActionResult CartSummary()
{
    //Кеширование только на клиенте, обновление при каждом запросе
    this.Response.Cache.SetCacheability(System.Web.HttpCacheability.Private);
    this.Response.Cache.SetMaxAge(TimeSpan.Zero);

    var cart = ShoppingCart.GetCart(this.HttpContext);
    var cacheKey = "shooting-cart-" + cart.ShoppingCartId;
    var cachedPair = (Tuple<DateTime, int>)this.HttpContext.Cache[cacheKey];

    if (cachedPair != null) //Если данные есть в кеше на сервере
    {
        //Устанавливаем Last-Modified
        this.Response.Cache.SetLastModified(cachedPair.Item1);

        var lastModified = DateTime.MinValue;

        //Обрабатываем Conditional Get
        if (DateTime.TryParse(this.Request.Headers["If-Modified-Since"], out lastModified)
                && lastModified >= cachedPair.Item1)
        {
            return new NotModifiedResult();
        }

        ViewData["CartCount"] = cachedPair.Item2;
    }
    else //Если данных нет в кеше на сервере
    {
        //Текущее время, округленное до секунды
        var now = DateTime.Now;
        now = new DateTime(now.Year, now.Month, now.Day,
                            now.Hour, now.Minute, now.Second);

        //Устанавливаем Last-Modified
        this.Response.Cache.SetLastModified(now);

        var count = cart.GetCount();
        this.HttpContext.Cache[cacheKey] = Tuple.Create(now, count);
        ViewData["CartCount"] = count;
    }

    return PartialView("CartSummary");
}


Конечно такой код в production писать нельзя, надо разбить на несколько функций и классов для удобства сопровождения и повторного использования.

Итоговый результат на минутном забеге — 321 страница в секунду, в 5,3 раза выше, чем в первоначальном варианте.

Залкючение


В реальном проекте надо с самого начала проектировать веб-приложение с учетом кеширования, особенно HTTP-кеширования. Тогда можно будет выдерживать большие нагрузки на довольно скромном железе.
Стас Выщепан @gandjustas
карма
28,5
рейтинг 0,0
Помогаю зарабатывать (или не тратить) с помощью ИТ
Реклама помогает поддерживать и развивать наши сервисы

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

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

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

  • 0
    Чтобы такой ситуации, как описано выше, не происходило надо разделить запросы и собирать части страницы на клиенте

    А что, способов это сделать на сервере нет? Partial views и RenderAction отменили?

    (я уже не говорю про многоуровневое кэширование данных)
    • 0
      Поправка: partial views не кэшируются, конечно, а вот child actions — легко.
  • 0
    Кеширование HTTP использует в качестве ключа url. Если серверу придется каждый раз отдавать страницу, даже собираемую из кеша на сервере, то это будет в разы медленнее, чем клиент просто будет брать страницу из локального кеша или из кеша прокси-сервера.
    • 0
      Такие вещи (пусть они и кажутся самоочевидными), надо мерять, а не говорить «надо делать вот так».
      • 0
        Если страница и её части 90% времени отдаются из кеша клиента\прокси сервера, то это будет все равно быстрее, чем сколь угодно заоптимизированная страница, отдаваемая серверным кодом. Потому что результат один и тот же (в смысле ответа сервера), а время на передачу данных от сервера клиенту гораздо меньше.
        • 0
          Если это одна страница, то вы, возможно, правы. А вот если страница + ajax-запросы — то уже не обязательно.

          Ну и да, еще нужно доказать про 90%.
    • +3
      Интересно, а если на странице будет много некешируемой информации (логин юзера, корзина, последние статьи/форумы/комментарии), и для каждого такого контрола делать ajax запрос на сервер, это будет быстрее, чем просто за раз все собрать на сервере (вместе с кешируемым контентом) и вернуть одной страницей? Думаю, тут надо в каждом случае делать тесты и выбирать один из вариантов.
      • 0
        В этом случае будет эффективно все данные пользователя отдавать одним запросом в вид JSON, а потом внедрять в страницу с помощью knockout или аналогичного инструмента. В идеале потом кешировать эти данные так, как в посте.

        Но в любом случае все что связано с быстродействием надо измерять.
  • 0
    Если уж говорить про ASP.NET MVC и write-through кеш, то есть вот такая штука из коробки:
    [OutputCache(Duration = 120, Location = OutputCacheLocation.Server, SqlDependency = "Site:Folders;Site:FoldersTree;")]
    • 0
      Вот скажите, у вас эти SqlDependency «из коробки» взлетели?
      • 0
        Да, сама SqlDependency взлетела. В Application_Start() надо сконфигурировать, плюс ещё в Web.config, и насколько помню — при деплое в БД будет создана таблица AspNet_SqlCacheTablesForChangeNotification и триггеры на таблицы.

        String connStr = System.Configuration.ConfigurationManager.ConnectionStrings["SQLConnectionMain"].ConnectionString;
        System.Web.Caching.SqlCacheDependencyAdmin.EnableNotifications (connStr);
        System.Web.Caching.SqlCacheDependencyAdmin.EnableTableForNotifications(connStr, "Folders");
        System.Web.Caching.SqlCacheDependencyAdmin.EnableTableForNotifications(connStr, "FoldersTree");
        System.Web.Caching.SqlCacheDependencyAdmin.EnableTableForNotifications(connStr, "FoldersPropertys");
        

        При обновлении таблиц — кеш обновлялся.
        Во время тестов всплыл баг: пользователь мог получить чужой кеш.
        • 0
          А, это, значит, уже новые SqlDependencies, потому что старые сидели на Query Notifications, и это был тихий ужас. Надо будет попробовать.
          • 0
            Это те же самые SqlDependencies, только они в namespace System.Web.Caching и изначально заточены под кеширование в ASP.NET. По сути они все вызывают классы SqlDependency в SqlClient.
            • 0
              Т.е., тоже работают через Query Notifications, а не через поллинг таблиц? Тогда не интересно.
              • 0
                • 0
                  Думаю будет полезно дополнить статью примером с SqlDependency, поллингом таблиц и VaryByCustom=«user»
                  • 0
                    Думаю другую надо написать…
                • 0
                  Я вот не нашел там ничего про настройку поллинга.
                  • 0
                    Снизу страницы
                    • 0
                      Конкретную цитату можете дать? Которая бы привела к тому, что на SQL 2008R2 использовались бы не Query Notifications, а поллинг таблиц.
                      • 0
                        82.157.70.109/mirrorbooks/asp.net/8877final/LiB0082.html тут вроде детально написано про polling.
                        • 0
                          Вы на дату книжки смотрели? У меня вот есть болезненное ощущение, что начиная с 2005 SQL автоматически включаются Query Notifications вместо триггеров.
                          • 0
                            Я сам это пробовал последний раз в 2006 году. Вот относительно свежая статья: www.dotnetfunda.com/articles/article1382-how-to-implement-sql-caching-in-aspnet-poll-based-sql-cache-dependency.aspx
                            Думаю обратная совместимость в этом месте есть.
                          • 0
                            Поковырялся ILSpy, есть обратная совместимость. Если не указан polling, то пытается прицепиться к оповещениям, если Polling указан, то запускает таймер.
                            • 0
                              Ок, спасибо. Будем смотреть.
        • 0
          Потому что кеш на сервере общий, надо прикручивать VaryByCustom=«user» и переопределять GetVaryByCustomString в HttpApplication.

          Подробнее тут: msdn.microsoft.com/ru-ru/library/5ecf4420(v=vs.90).aspx

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