Кеширование в 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-кеширования. Тогда можно будет выдерживать большие нагрузки на довольно скромном железе.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 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
                          Думаю будет полезно дополнить статью примером с SqlDependency, поллингом таблиц и VaryByCustom=«user»
                          • 0
                            Думаю другую надо написать…
                          • 0
                            Я вот не нашел там ничего про настройку поллинга.
                            • 0
                              Снизу страницы
                              • 0
                                Конкретную цитату можете дать? Которая бы привела к тому, что на SQL 2008R2 использовались бы не Query Notifications, а поллинг таблиц.
                  • 0
                    Потому что кеш на сервере общий, надо прикручивать VaryByCustom=«user» и переопределять GetVaryByCustomString в HttpApplication.

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

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