Кеширование в Symfony. Идеология HTML-кеширования. Components & partials

    За 2.5 года использования symfony мне постоянно приходится сталкиваться с проблемой недопонимания программистами на symfony идеи html-кеширования. Цель этого поста — донести до светлых умов symfony-девелоперов осознание парадигмы использования partials & components.



    Итак, о какой проблеме идет речь. Любой человек, дошедший до понимания идеи фреймворка, в частности симфони, худо-бедно слышал про оптимизацию. Стремление к преждевременной оптимизации у разработчиков проектов убивает все идеи, на которых построена кеш-система symfony.

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

    Обновление информации идет очень часто: предложения имеют актуальность несколько дней, информация по ним может часто меняться. Получается такой общественный блог, который пишут специально обученные люди: )

    Перейдем к упрощенной реализации.

    Наш пример: есть турпутевки — таблица А, есть информация о туроператорах — табица В, есть информация по отелям — таблица С. Там еще много всего есть, но для нас это не имеет значения.

    В качестве ORM используем Propel.

    Задача: создать страницу листинга товаров. В листинге нужно выводить краткую информацию по отелю, в который поедет турист и по поставщику услуги — туроператору. То же самое при листинге холодильников или телефонов или чего еще можно продавать: информация о производителе, информация о категории товара, о поставщике, о доставке… Суть одна: надо выбрать множество сущностей из базы. Причем не абы-как, а желательно используя парадигмы ORM.

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

    Считаем, что в модели у нас следующие классы: A, B, C, APeer, BPeer, CPeer.

    Листинг делаем с некими фильтрами, тонкости которых нас совершенно не волнуют.

    1. <?php
    2. class indexActions extends sfActions {
    3.    
    4.     public function executeListing (sfWebRequest $r) {
    5.        
    6.         $f = new AFormFilter();
    7.         $f->bind($r->getParameter('filter'));
    8.         if ($f->isValid()) {
    9.            
    10.             $c = $f->buildCriteria();
    11.             $this->array_of_a = APeer::doSelect($c);
    12.  
    13.             $this->array_of_b = BPeer::retrieveBySelectedA($this->array_of_a);
    14.             $this->array_of_c = CPeer::retrieveBySelectedA($this->array_of_a);
    15.         } else {
    16.            
    17.             $this->getUser()->setFlash('error', 'Error while retrieving data.');
    18.         }
    19.     }
    20. }


    Что мы тут сделали: По фильтрам, полученным из запроса, вернули объект Criteria из объекта AFormFilter instanceof sfFormFilter. Далее выбрали необходимые нам записи из таблиц В и С, например по id всех выбранных сущностей из таблицы A и проиндексировали. И сделали все это в созданных нами методах retrieveBySelectedA в классах BPeer и CPeer. 3 целевых обращения к базе. Неплохо.

    Далее мы создаем /actions/index/templates/listingSuccess.php, в котором выводим листинг элементов.

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

    Включаем кеш. Первое, с чем мы сталкиваемся — что при наличии параметров (GET или POST запроса) Экшн не кешируется. Ок, что же делать: выносить запрос к базе в компонент, передавая ему лишь массив пришедших параметров.

    Ладно, а если у нас не страница листинга, а обычная рядовая индексная страница. Можно закешировать все с лейаутом, указав в /actions/index/config/cache.yml следующий параметр:

    1. list:
    2.   enabled:     on
    3.   with_layout: true


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

    Симфони замечателен тем, что кеширует любой элемент страницы, добавленный через PartialHelper: include_partial и include_component. При этом кеширует вообще все элементы, те если был очищен кеш родительского элемента — кеш дочерних элементов будет сохранен.

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

    1. <?php
    2.  
    3. /* actions.class.php */
    4. class indexActions extends sfActions {
    5.    
    6.     public function executeListing (sfWebRequest $r) {
    7.        
    8.        
    9.         $this->filterParams = $r->getParameter('filter');
    10.     }
    11. }
    12. /* end of actions.class.php */
    13.  
    14. /* components.class.php */
    15. class IndexComponents extends sfComponents {
    16.  
    17.     public function executeListingBlock() {
    18.        
    19.         $f = new AFormFilter();
    20.         $f->bind($this->filterParams);
    21.        
    22.         if ($f->isValid()) {
    23.            
    24.             $c = $f->buildCriteria();
    25.             $this->array_of_a = APeer::doSelect($c);
    26.  
    27.             $this->array_of_b = BPeer::retrieveBySelectedA($this->array_of_a);
    28.             $this->array_of_c = CPeer::retrieveBySelectedA($this->array_of_a);
    29.         } else {
    30.            
    31.             $this->getUser()->setFlash('error', 'Error while retrieving data.');
    32.         }
    33.     }
    34. }
    35. /* end of components.class.php */


    1. /* listingSuccess.php */
    2. <?php include_component('index', 'listingBlock', array('filterParams' => $filterParams)) ?>
    3. /* end of listingSuccess.php */


    Отлично, теперь у нас для одинаковых параметров фильтров будет подтягиваться закешированный шаблон, и таким образом количество целевых запросов — 0. Для новых параметров оно по-прежнему равняется 3.

    Все хорошо, пока не добавляется новый товар. Как правильные ребята, мы вешаем на экшн сохранения в админке следующий код:

    1. <?php
    2. $configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'prod', false);
    3. sfContext::createInstance($configuration, 'frontend');
    4. $cacheManager = sfContext::getInstance('frontend')->getViewCacheManager();
    5.  
    6. $cacheManager->remove('@sf_cache_partial?module=index&action=_listingBlock&sf_cache_key=*');


    Кеш очистили. Для всех запросов опять по 3 обращения к базе.

    Система благополучно работает. В действительности, конечно, все сложнее. Первая проблема, с которой мы столкнулись — ORM кушала много памяти. Попробовали оптимизировать, переведя на обычный sql-запрос с выборкой только необходимых значений. Возникли проблемы с недостаточностью абстракции — я все же за стандарты кода, накладываемые фреймворком, и не люблю изобретатения велосипедов. Все получилось точно как в учебниках: код, написанный одним человеком, с большими трудозатратами модифицировался другими разработчиками. Оооочень хотелось использовать адекватно абстрагированный ORM.

    Естетственно, дошли до кеширования каждого предложения, при этом вынесли дополнительные запросы в отдельный компонент для предложений:

    1. /* components.class.php */
    2. class IndexComponents extends sfComponents {
    3.  
    4.     public function executeListingBlock() {
    5.        
    6.         $f = new AFormFilter();
    7.         $f->bind($this->filterParams);
    8.        
    9.         if ($f->isValid()) {
    10.            
    11.             $c = $f->buildCriteria();
    12.             $this->array_of_a = APeer::doSelect($c);
    13.  
    14.         } else {
    15.            
    16.             $this->getUser()->setFlash('error', 'Error while retrieving data.');
    17.         }
    18.     }
    19.    
    20.     public function executeOfferItem() {
    21.        
    22.         $this->b = $this->a->getBRelatedByB();
    23.         $this->c = $this->a->getCRelatedByC();
    24.     }
    25. }
    26. /* end of components.class.php */


    1. /* _listingBlock.php */
    2. <? foreach ($array_of_a as $a): ?>
    3.     <? include_component('index', 'offerItem', array('a' => $a)) ?>
    4. <? endforeach ?>






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

    Давайте все супер упрощенно посчитаем:

    Пусть у меня 5 фильтров, в каждом из которых 3 варианта, средняя выборка возвращает 100 результатов. Обновление происходит каждые 5 минут (добавление / изменение 1 записи). В сутки пускай имеем 50к хостов на этой странице, что для ровного счета 2000 хостов в час.

    Первый вариант помним, да? — 3 запроса для каждого варианта. Имеем 45 ~= 50 запросов на полное покрытие всех вариантов. Тогда если у нас есть ~150 хостов в 5 минут, то считаем ~50 запросов к базе и 100 чтений из кеша (конечно, я сильно утрирую, считая что каждый 5минутный период покрываются все варианты). За сутки имеем: 50 * 12 * 24 ~=15к целевых чтений из базы.

    Второй вариант с кешированием отдельно взятого предложения:
    чтобы покрыть все варианты необходимо выполнить: 15вариантов * (1запрос + 100предложений * 2доп.запроса) = 3015 целевых чтений. Каждые 5 минут происходит изменение:
    3запроса * 12 * 24 ~= 900 запросов. Итого в сутки имеем примерно 4к целевых чтений из базы.

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

    О времени генерации страницы я рассуждать побоюсь, все зависит от настроек. В своем проекте мы использовали XCache для ViewCache:

    1. /* factories.yml */
    2. view_cache:
    3.   class: sfXCacheCache
    4.   param:
    5.     automaticCleaningFactor: 0
    6.     storeCacheInfo:          true


    Разумеется, время генерации страницы при использовании стандартного sfFileCache несопоставимо больше. Но вопрос конфигурации системы давайте оставим в стороне.

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

    При применении ORM в симфони по умолчанию не предусмотрено кеширование самих запросов. Это не потому, что разработчики глупые, а потому, что тут используется кеш другого уровня. Другая идея.

    К минусам такого подхода стоит отнести порой очень запутанную логику удаления элементов из кеша. Допустим, пользователь изменяет данные о загруженном им видео, при этом придется очистить кеш для всех элементов с этим видео на страницах листинга (для всех вариантов), для детальной страницы, для элементов из rss-feed если такой имеется и тд. Обо всем этом приходится задумываться и в нужный момент помнить, поэтому в качестве рекомендации могу посоветовать сразу проектировать систему с учетом кеширования.

    В нашем проекте админка физически находится в другом контейнере, что затрудняет управление кешем. Поэтому пришлось сделать простенький XMLRPC-интерфейс для этого.

    Вынесение затратных операций в отдельные компоненты и есть основная идея html-кеширования. Не нужно изобретать велосипедов, все уже работает, причем очень просто и функционально. Если все парни из моей команды это поймут, может престану с ними ругаться: )

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

      +3
      у себя использую следующую схему:
      1. на каждую модель propel навешивается таг с условным именем равным имени таблицы, при операциях с моделью (save, delete) в код внедряется очистка соответствующих тегов (билдеры пропела расточены).
      2. На вью участки помечаются от каких тегов они зависят a, b, c
      3. Дело техники дописать обработчик и механизм тегов кеширования
        +2
        Тегирование — отличный вариант, достойный реализации в виде плагина; )
          +1
          у меня не симфони а свой фв, поэтому увы :)
            0
            Как вам вот такая реализация?
            forum.symfony.org.ua/topic/664/svptagedcacheplugin-keshirovanie-s-tegami/
              +1
              Реализация как реализация. А если классов сто тыщ?

              Плагин оно както красивше.

              Автор сверху кстати про html-кеширование. А вы про кеширование объектов.
              +1
              кстати, еще одну штуку опишу — в тему, отложенные lazy запросы.
              Идея в том, что в Propel дописывается билдер кторые генерит Lazy мапперы модели — образно это объект (м моей реализации), содержащий Criteria объект и Peer class, реализует итератор и getObject() ($peer::doSelectOne) и магию — и получается черным ящиком который снаружи выглядит как список обычных Propel инстансов.

              В итоге, фактически запрос будет выполнен не в контроллере а переносится во вью «по требованию» — полный аналог Django.

              Так вот, используя такой подход и теги кеша на вью вам не нужны партиалсы и вообще не нужно парить моск на этапе разработки — все делаете как обычно, а вот теги проставляете во вью уже на этапе оптимизации с профайлером и т.п.
            +1
            При определенных условиях, с достаточно большим количеством компонентов на странице и простых запросах, я столкнулся с тем что кеш работает медленнее чем выборка из базы, правда это был обычный файловый кеш.
              +1
              Я специально не стал затрагивать тему временных затрат, покольку все очень сильно завязано на конфигурацию сервера. Разумеется операция чтения данных из ФС значительно дороже по времени чем операция тения данных из оперативной памяти. Мы используем файловый кеш для dev-окружения, таким образом можно физически проверять работу кеш-системы.

              Для продакшн-версии проекта была выбрана конфигурация Nginx + php-fastcgi + xcache. Время генерации закешированной страницы листинга описанного примера было около 0.1 сек на свободном сервере.
                0
                Ну мне, к сожалению, кеш применять особенно негде было, так… игрался…
                Все впереди.
                  0
                  Можно конфигурацию сервера, действительно интересно сравнить с нашими результатами
                +1
                Возможно вы хотели сказать:
                «При применении Propel в качестве ORM в симфони по умолчанию не предусмотрено кеширование самих запросов.»
                … потому как www.doctrine-project.org/documentation/manual/1_1/en/caching
                  0
                  Да, спасибо что поправили.
                    0
                    www.symfony-project.org/plugins/DbFinderPlugin тоже умеет кешировать запросы.
                      0
                      Кешеры запросов действительно существуют, и это хорошо если они вам больше подходят под ваши задачи. В нашем проекте самым выгодным оказалось html-кеширование.
                  0
                  спасибо за статью, очень интересный подход к кэшированию.

                  У меня возник вопрос про удаление данных из кэша,
                  Вы писали $cacheManager->remove('@sf_cache_partial?module=index&action=_listingBlock&sf_cache_key=*');
                  Можно ли мы как-то вычислить sf_cache_key, чтобы удалить конкретный элемент из кэша?
                    0
                    Кстати хороший вопрос для тех, задумывается о динамичном управлении кешем.

                    За создание html-кеша отвечает класс sfViewCacheManager, а именно его метод:

                    sfViewCacheManager::generateCacheKey($internalUri, $hostName = '', $vary = '', $contextualPrefix = '')

                    В этом методе происходит получение передаваемых в компонент параметров из контекста, и для них вызывается метод sfViewCacheManager::convertParametersToKey($params). Хеш ключа вычисляется в методе sfViewCacheManager::computeCacheKey, где делается так:

                    return md5(serialize($parameters));

                    Соответственно, чтобы воспроизвести ключ кеша для определенного компонента или партиала, необходимо подать все эти параметры в метод computeCacheKey.

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

                    Именно поэтому максимальное зло — передавать параметром компонента комбинированный массив из тучи параметров, выбранных из разных таблицы join'ами, а не обычный объект модели.
                      0
                      В каждый партиал или компонент можно передавать с параметрами sf_cache_key. В этом случае ключ не генерируется, генерируете его вы сами.

                      Или вы можете переопределить обработчик генерации ключей. В settings.yml сразу после .settings определить параметр cache_namespace_callable.

                      Например
                      cache_namespace_callable: [«CacheGenerator», «generateCacheKey»]
                      +2
                      > $cacheManager->remove('@sf_cache_partial?module=index&action=_listingBlock&sf_cache_key=*');

                      Лучше не использовать звездочки (*) при чистке кэша при условии использования memcache. Так как при удалении со звездочкой используется метод removePattern, который проходит по всем ключам из кэша и удаляет нужные по маске. Так вот хранятся эти все ключи там же в memcache в отдельной записи [prefix]_metadata c expire равной 0. Может случится ситуация, когда объем записи превысит лимит. ( у меня случилось к слову ) А дальше у сами додумайте ).

                      Отключить сохранение ключей в одну запись можно параметром:

                      storeCacheInfo: true

                      В версия symfony < 1.2.9 эти ключи добавлялись даже без удаления дубликатов:
                      protected function setCacheInfo($key)
                       {
                        $keys = $this->memcache->get($this->getOption('prefix').'_metadata');
                        if (!is_array($keys))
                        {
                         $keys = array();
                        }
                        $keys[] = $this->getOption('prefix').$key;
                        $this->memcache->set($this->getOption('prefix').'_metadata', $keys, 0);
                       }


                      * This source code was highlighted with Source Code Highlighter.


                      Сейчас ситуация лучше, ключи удаляются:
                      protected function setCacheInfo($key, $delete = false)
                       {
                        $keys = $this->memcache->get($this->getOption('prefix').'_metadata');
                        if (!is_array($keys))
                        {
                         $keys = array();
                        }

                        if ($delete)
                        {
                          if (($k = array_search($this->getOption('prefix').$key, $keys)) !== false)
                          {
                           unset($keys[$k]);
                          }
                        }
                        else
                        {
                         if (!in_array($this->getOption('prefix').$key, $keys))
                         {
                          $keys[] = $this->getOption('prefix').$key;
                         }
                        }

                        $this->memcache->set($this->getOption('prefix').'_metadata', $keys, 0);
                       }


                      * This source code was highlighted with Source Code Highlighter.
                        0
                        storeCacheInfo: false — отключает )
                          0
                          Очень полезный коммент кстати. Спасибо большое!
                          0
                          > При применении ORM в симфони по умолчанию не предусмотрено кеширование самих запросов. Это не потому, что разработчики глупые, а потому, что тут используется кеш другого уровня. Другая идея.

                          Это верно для Пропела, но не для Doctrine. Который объективно лучше на порядок. Один раз попробовал — больше с Пропелом связываться не хочу :)
                            0
                            Здравствуйте, Дмитрий; )

                            Я и не отмечал, что никакие дргугие орм не умеют кешить запросы. Видимо, неправильно построил предложение. Смысл в том, что заложенная изначально идеология кеширования — другая. Не кешируют они сами запросы, но кешируют куски хтмл. Это не плюс и не минус, это по-другому. HongKilDong комментом выше это тоже заметил кстати.
                              0
                              Привет, Олег :)

                              Ага, тот коммент я сразу не заметил. Кэширование ХТМЛ наверное побыстрей будет, поскольку до ОРМ-а в этом случае дело не доходит вообще.

                              А вообще, за статью — респект. У меня тоже сейчас актуален вопрос высоких нагрузок.

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

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