Николай Ланец
11 июля 2013 г., 23:27

Кеширование настроек контекстов в MODX Revolution. Я негодую

Печально новый год начинать с такого нехорошего топика, но ничего не поделаешь…
Конечно много уже кто бросал камни в MODX из-за проблем с кешированием, но сегодня сделаю это и я. Я конечно же очень люблю MODX, но некоторые вещи меня прямо-таки вымораживают! Сразу оговорюсь, что описываемые здесь проблемы касаются только тех случаев, когда предполагается большое количество документов в одном контексте (более 10 000).
Сегодня мы рассмотрим процесс генерации кеша контекстов и на что и как мы можем влиять.
Для начала немного теории: каждый раз, когда мы обновляем кеш сайта, MODX полностью перегенерирует и сохраняет настройки всех контекстов. То же самое он делает и с каждым контекстом в отдельности, когда, к примеру, сохраняется какой-либо документ контекста.
А в чем проблема? А проблема в том, что это как минимум накладывает очень серьезные ограничения на максимальное кол-во документов в контексте. Почти два года назад я уже писал о своих исследованиях по этому поводу еще на версии Revo 2.0.8, так вот — с тех пор практически ничего не поменялось…
Сразу определим основную проблему: при обновлении кеша контекста, MODX перебирает все документы этого контекста (читай: делает много-много запросов к базе данных и получает и обрабатывает очень большой объем информации) и формирует карты ресурсов и алиасов. При этом он хранит эти карты не в отдельном кеш-файле, а именно в кеше настроек контекста.
Есть проблема — сразу же можно предположить парочку вариантов ее решения: 1. Запретить MODX-у делать выборку всех документов контекста. (Это был бы идеальный вариант — частично закешировать только важные документы, участвующие в формировании менюшек Wayfinder-ом и т.п., а те документы, которые мы получаем динамически нашими собственными специфическими скриптами, пропустить). 2. Вообще отключить кеширование контекста. (Почему это оказывается очень плохой вариант, мы рассмотрим и поймем позже).
Для начала немного теории: каждый раз при генерации настроек контекста, MODX собирает не только его настройки как таковые, но и собирает все его документы и набивает в карты ресурсов, алиасов и т.п. Плюс к этому, если используются ЧПУ, он еще и проверяет их на уникальность.
Выполняется это все в одном методе modCacheManager::generateContext(). Давайте посмотрим на исходник:

<?php public function generateContext($key, array $options = array()) { $results = array(); if (!$this->getOption('transient_context', $options, false)) { /** @var modContext $obj */ $obj = $this ->modx ->getObject('modContext', $key, true); if (is_object($obj) && $obj instanceof modContext && $obj->get('key')) { $cacheKey = $obj->getCacheKey(); $contextKey = is_object($this ->modx ->context) ? $this ->modx ->context ->get('key') : $key; $contextConfig = array_merge($this ->modx->_systemConfig, $options); /* generate the ContextSettings */ $results['config'] = array(); if ($settings = $obj->getMany('ContextSettings')) { /** @var modContextSetting $setting */ foreach ($settings as $setting) { $k = $setting->get('key'); $v = $setting->get('value'); $matches = array(); if (preg_match_all('~\{(.*?)\}~', $v, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { if (array_key_exists("{$match[1]}", $contextConfig)) { $matchValue = $contextConfig["{$match[1]}"]; } else { $matchValue = ''; } $v = str_replace($match[0], $matchValue, $v); } } $results['config'][$k] = $v; $contextConfig[$k] = $v; } } $results['config'] = array_merge($results['config'], $options); /* generate the aliasMap and resourceMap */ $collResources = $obj->getResourceCacheMap(); $results['resourceMap'] = array(); $results['aliasMap'] = array(); if ($collResources) { /** @var Object $r */ while ($r = $collResources->fetch(PDO::FETCH_OBJ)) { $results['resourceMap'][(string)$r->parent][] = (string)$r->id; if ($this ->modx ->getOption('friendly_urls', $contextConfig, false)) { if (array_key_exists($r->uri, $results['aliasMap'])) { $this ->modx ->log(xPDO::LOG_LEVEL_ERROR, "Resource URI {$r->uri} already exists for resource id = {$results['aliasMap'][$r ->uri]}; skipping duplicate resource URI for resource id = {$r->id}"); continue; } $results['aliasMap'][$r ->uri] = $r->id; } } } /* generate the webLinkMap */ $collWebLinks = $obj->getWebLinkCacheMap(); $results['webLinkMap'] = array(); if ($collWebLinks) { while ($wl = $collWebLinks->fetch(PDO::FETCH_OBJ)) { $results['webLinkMap'][$wl ->id] = $wl->content; } } $this ->modx ->log(modX::LOG_LEVEL_ERROR, $key); /* generate the eventMap and pluginCache */ $results['eventMap'] = array(); $results['pluginCache'] = array(); $eventMap = $this ->modx ->getEventMap($obj->get('key')); if (is_array($eventMap) && !empty($eventMap)) { $results['eventMap'] = $eventMap; $pluginIds = array(); $plugins = array(); $this ->modx ->loadClass('modScript'); foreach ($eventMap as $pluginKeys) { foreach ($pluginKeys as $pluginKey) { if (isset($pluginIds[$pluginKey])) { continue; } $pluginIds[$pluginKey] = $pluginKey; } } if (!empty($pluginIds)) { $pluginQuery = $this ->modx ->newQuery('modPlugin', array( 'id:IN' => array_keys($pluginIds) ) , true); $pluginQuery->select($this ->modx ->getSelectColumns('modPlugin', 'modPlugin')); if ($pluginQuery->prepare() && $pluginQuery ->stmt ->execute()) { $plugins = $pluginQuery ->stmt ->fetchAll(PDO::FETCH_ASSOC); } } if (!empty($plugins)) { foreach ($plugins as $plugin) { $results['pluginCache'][(string)$plugin['id']] = $plugin; } } } /* cache the Context ACL policies */ $results['policies'] = $obj->findPolicy($contextKey); } } else { $results = $this->getOption("{$key}_results", $options, array()); $cacheKey = "{$key}/context"; $options['cache_context_settings'] = array_key_exists('cache_context_settings', $results) ? (boolean)$results : false; } if ($this->getOption('cache_context_settings', $options, true) && is_array($results) && !empty($results)) { $options[xPDO::OPT_CACHE_KEY] = $this->getOption('cache_context_settings_key', $options, 'context_settings'); $options[xPDO::OPT_CACHE_HANDLER] = $this->getOption('cache_context_settings_handler', $options, $this->getOption(xPDO::OPT_CACHE_HANDLER, $options)); $options[xPDO::OPT_CACHE_FORMAT] = (integer)$this->getOption('cache_context_settings_format', $options, $this->getOption(xPDO::OPT_CACHE_FORMAT, $options, xPDOCacheManager::CACHE_PHP)); $options[xPDO::OPT_CACHE_ATTEMPTS] = (integer)$this->getOption('cache_context_settings_attempts', $options, $this->getOption(xPDO::OPT_CACHE_ATTEMPTS, $options, 10)); $options[xPDO::OPT_CACHE_ATTEMPT_DELAY] = (integer)$this->getOption('cache_context_settings_attempt_delay', $options, $this->getOption(xPDO::OPT_CACHE_ATTEMPT_DELAY, $options, 1000)); $lifetime = (integer)$this->getOption('cache_context_settings_expires', $options, $this->getOption(xPDO::OPT_CACHE_EXPIRES, $options, 0)); if (!$this->set($cacheKey, $results, $lifetime, $options)) { $this ->modx ->log(modX::LOG_LEVEL_ERROR, 'Could not cache context settings for ' . $key . '.'); } } return $results; }
Первое, на что сразу следует обратить внимание — откуда происходит выборка настроек, к примеру вот здесь:
$this->getOption('transient_context', $options, false)
$this — это не объект контекста, а сам modCacheManager, то есть выборка настроек происходит не из настроек контекста, а из переменной $options, переданной в метод generateContext. Посмотрим, какой параметр передается сюда при генерации кеша контекстов:
$contextResults[$context] = ($this->generateContext($context) ? true : false);
Ответ — никакой. То есть все настройки при генерации кеша берутся из самой системы. На что это влияет? А влияет это на то, что когда мы работаем в бэкенде, то при обновлении кеша мы не можем указать какие-то индивидуальные параметры кеширования для отдельных контекстов. К примеру есть системная настройка cache_context_settings, которая указывает кешировать настройки контекста или нет. По сути должно быть так: какому-то контексту мы установили эту настройку в false, и для этого контекста настройки не должны были бы кешироваться. Ан нет. Здесь или общая системная настройка установлена в true, и все контексты кешируются, или false, и тогда ни один контекст не кешируется, независимо от их настроек. Но следует отметить, что параметр $options передается самим контекстом в методе modContext::prepare();
$context = $this->xpdo->cacheManager->generateContext($this->get('key'), $options);
То есть можно отключить кеширование контекстов в принципе, а для отдельных контекстов кеширование указать. Тогда при заходе на сайт, когда MODX выполнит $this->context->prepare(), тогда если контекст кешируемый, то кеш для этого контекста запишется. Но если при этом для контекста mgr кеширование будет установлено, то опять-таки по описанной выше причине, будут кешироваться все контексты в момент очистки кеша всего сайта.
Кстати, $options можно передать как второй параметр в метод $modx->initialize(). К примеру так:
$options = array( 'site_start' => 3, 'site_name' => 'New sitename' ); $modx->initialize('web', $options)

Но эта фишка вообще бесполезная, так как могла бы иметь смысл только для динамической подмены каких-либо кешируемых настроек, так как само собой выполнение было бы быстрее, чем на уровне плагина, но переданные таким образом настройки тоже кешируются, так что единственный уместный момент — это только ручная очистка кеша контекста и опять-таки ручная инициализация его. Но это полный изврат, к тому же вообще не оправданный. Хотя нет, один момент есть: через интерфейс в настройки нельзя сохранять массивы. А так можно было бы передавать массивы, чтобы они сохранялись в кеш настроек. Кстати, если глобально кеширование настроек контекстов отключено, а локально для конкретного контекста включено, то первичная инициализация контекста будет выполнена дважды, так как хотя для контекста кеширование указано, мы знаем, что оно не берется в расчет, и при первой инициализации настройки не будут сохранены. И вот при такой инициализации контекста с переданными в параметре настройками, эти настройки не будут сохранены в кеше контекста, и при последующих обращениях к страницам контекста этих настроек уже не будет, так что с этими параметрами следует сразу передавать и настройку cache_context_settings => 1.
Ладно, это было лирическое отступление, вернемся к нашей функции.генерации кеша контекста. Опять обратим внимание на эту строчку практически в самом начале функции: if (!$this->getOption('transient_context', $options, false)) { То есть если для контекста указана эта настройка в true, то весь блок, в котором происходит выборка документов и настроек, пропускается. НО: как было написано выше, нельзя указать этот параметр отдельно для выбранных контекстов так, чтобы в админке для них эта настройка имела смысл, а для других нет. То есть если и устанавливать, то для всего сайта. А что происходит, если установить эту настройку для всего сайта? Забавная неприятность — 404 для всей админки после обновления кеша :-) К слову, все контексты тоже окажутся неработающими, так как в их настройках не будет карты ресурсов, и даже если указать site_start, MODX все-равно не будет искать стартовый документ, не указанный в карте ресурсов. В итоге еще одна по сути не работающая фишка.
И все-таки, хоть на что-то мы можем воздействовать или нет? Можем. На ЧПУ. Единственное, что проверяется для каждого конкретного контекста в отдельности, это использование ЧПУ, и если не используется, то просто карта алиасов не будет набиваться. Все.
Вывод: cache_context_settings никогда не стоит устанавливать в false, так как это только увеличит нагрузку на систему, и никак вообще нам не поможет.
Еще одна забавная вещь: системная настройки cache_disabled. В официальной документации написано
If true, disables all MODx caching features.
И жирное предупреждение:
// This feature is experimental. MODx recommends not turning off caching site-wide, as it can significantly slow down your site.
А в чем фишка? А в том, что эта настройка вообще нигде не используется. Вообще. Только в конфиге прописано
if (!defined('MODX_CACHE_DISABLED')) { $modx_cache_disabled= false; define('MODX_CACHE_DISABLED', $modx_cache_disabled); }
То есть можете сколько угодно переключать ее в true, это вообще ни на что не влияет. Честно сказать, вообще грустно от такого бардака в системе кеширования. Получается хочешь ты этого, или нет, но если у тебя в контексте много документов, то проблем тебе не избежать…
Но отчаиваться не будем, а постараемся все-таки найти хоть какое-то решение. И для себя я такое решение нашел. В общем так как узкое место во всем этом деле — это выборка документов для генерации карты ресурсов, я решил это дело и прикрыть.
Выборка ресурсов для генерации карты ресурсов выполняется в методе modContext_mysql::getResourceCacheMapStmt(). Исходник:
public static function getResourceCacheMapStmt(&$context) { $stmt = false; if ($context instanceof modContext) { $tblResource= $context->xpdo->getTableName('modResource'); $tblContextResource= $context->xpdo->getTableName('modContextResource'); $resourceFields= array('id','parent','uri'); $resourceCols= $context->xpdo->getSelectColumns('modResource', 'r', '', $resourceFields); $bindings = array($context->get('key'), $context->get('key')); $sql = "SELECT {$resourceCols} FROM {$tblResource} `r` FORCE INDEX (`cache_refresh_idx`) LEFT JOIN {$tblContextResource} `cr` ON `cr`.`context_key` = ? AND `r`.`id` = `cr`.`resource` WHERE `r`.`id` != `r`.`parent` AND (`r`.`context_key` = ? OR `cr`.`context_key` IS NOT NULL) AND `r`.`deleted` = 0 GROUP BY `r`.`parent`, `r`.`menuindex`, `r`.`id`"; $criteria = new xPDOCriteria($context->xpdo, $sql, $bindings, false); if ($criteria && $criteria->stmt && $criteria->stmt->execute()) { $stmt =& $criteria->stmt; } } return $stmt; }

Как видно, выборка ресурсов происходит практически без разбору, и это следует исправить. Модифицированный код выглядит вот так:
public static function getResourceCacheMapStmt(&$context) { $stmt = false; if ($context instanceof modContext) { // Get context setting $settings = array(); if($result = $context->getMany('ContextSettings')){ foreach($result as $r){ $settings[$r->get('key')] = $r->get('value'); } } // If resource map disabled, skip it if($context->xpdo->getOption('cacheoptimizer.resource_map_disabled', $settings , false)){ return false; } $tblResource= $context->xpdo->getTableName('modResource'); $tblContextResource= $context->xpdo->getTableName('modContextResource'); $resourceFields= array('id','parent','uri'); $resourceCols= $context->xpdo->getSelectColumns('modResource', 'r', '', $resourceFields); $bindings = array($context->get('key'), $context->get('key')); $sql = "SELECT {$resourceCols} FROM {$tblResource} `r` FORCE INDEX (`cache_refresh_idx`) LEFT JOIN {$tblContextResource} `cr` ON `cr`.`context_key` = ? AND `r`.`id` = `cr`.`resource` WHERE `r`.`id` != `r`.`parent` AND (`r`.`context_key` = ? OR `cr`.`context_key` IS NOT NULL) AND `r`.`deleted` = 0 GROUP BY `r`.`parent`, `r`.`menuindex`, `r`.`id`"; $criteria = new xPDOCriteria($context->xpdo, $sql, $bindings, false); if ($criteria && $criteria->stmt && $criteria->stmt->execute()) { $stmt =& $criteria->stmt; } } return $stmt; }
То есть если глобальная настройка cacheoptimizer.resource_map_disabled или настройка конкретно для этого контекста установлена в true, то для контекста выборка документов не выполняется.
Вообще можно было бы еще более хитро поступить (к примеру добавить условие пропускать ресурсы с замороженным URI, и все ресурсы, которые не следует в карту подбирать, пропускать, а остальные брать, но это мне кажется уже лишнее). А так получается, что если у нас предполагается большое кол-во ресурсов на сайте, то мы создаем один контекст основной (рабочий), для которого все будет кешироваться, где будут все положенные проверки доступов и т.п., и создаем один (или несколько) контекстов, которые не будут кешироваться, и из которых мы будем делать выборку документов своими скриптами. Кстати, второй контекст (catalog) часто используется в shopKeeper. Надо поэкспериментировать, наверняка эта фишка там будет работать.
И да, если кто обратил внимание на то, что настройка имеет префикс cacheoptimizer: да, это оформленно в готовый пакетик :-) Этакий патч. И доступен он в моем репозитории rest.modxstore.ru/extras/
А исходник лежит на гитхабе: github.com/Fi1osof/cacheOptimizer Советую его всем скачать и изучить. Он совсем не большой, но это отличный прототип для патчей, так как не просто что-то устанавливает, а делает резервное копирование исходных файлов, и в дальнейшем при деинсталяции восстанавливает их.

Добавить комментарий