Кеширование настроек контекстов в MODX Revolution. Я негодую
Печально новый год начинать с такого нехорошего топика, но ничего не поделаешь…
Конечно много уже кто бросал камни в MODX из-за проблем с кешированием, но сегодня сделаю это и я. Я конечно же очень люблю MODX, но некоторые вещи меня прямо-таки вымораживают! Сразу оговорюсь, что описываемые здесь проблемы касаются только тех случаев, когда предполагается большое количество документов в одном контексте (более 10 000).
Сегодня мы рассмотрим процесс генерации кеша контекстов и на что и как мы можем влиять.
Для начала немного теории: каждый раз, когда мы обновляем кеш сайта, MODX полностью перегенерирует и сохраняет настройки всех контекстов. То же самое он делает и с каждым контекстом в отдельности, когда, к примеру, сохраняется какой-либо документ контекста.
А в чем проблема? А проблема в том, что это как минимум накладывает очень серьезные ограничения на максимальное кол-во документов в контексте. Почти два года назад я уже писал о своих исследованиях по этому поводу еще на версии Revo 2.0.8, так вот — с тех пор практически ничего не поменялось…
Сразу определим основную проблему: при обновлении кеша контекста, MODX перебирает все документы этого контекста (читай: делает много-много запросов к базе данных и получает и обрабатывает очень большой объем информации) и формирует карты ресурсов и алиасов. При этом он хранит эти карты не в отдельном кеш-файле, а именно в кеше настроек контекста.
Есть проблема — сразу же можно предположить парочку вариантов ее решения:
1. Запретить MODX-у делать выборку всех документов контекста. (Это был бы идеальный вариант — частично закешировать только важные документы, участвующие в формировании менюшек Wayfinder-ом и т.п., а те документы, которые мы получаем динамически нашими собственными специфическими скриптами, пропустить).
2. Вообще отключить кеширование контекста. (Почему это оказывается очень плохой вариант, мы рассмотрим и поймем позже).
Для начала немного теории: каждый раз при генерации настроек контекста, MODX собирает не только его настройки как таковые, но и собирает все его документы и набивает в карты ресурсов, алиасов и т.п. Плюс к этому, если используются ЧПУ, он еще и проверяет их на уникальность.
Выполняется это все в одном методе modCacheManager::generateContext(). Давайте посмотрим на исходник:
<?phppublicfunctiongenerateContext($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)&&$objinstanceofmodContext&&$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($settingsas$setting){$k=$setting->get('key');$v=$setting->get('value');$matches=array();if(preg_match_all('~\{(.*?)\}~',$v,$matches,PREG_SET_ORDER)){foreach($matchesas$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($eventMapas$pluginKeys){foreach($pluginKeysas$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($pluginsas$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 — это не объект контекста, а сам modCacheManager, то есть выборка настроек происходит не из настроек контекста, а из переменной $options, переданной в метод generateContext. Посмотрим, какой параметр передается сюда при генерации кеша контекстов:
Ответ — никакой. То есть все настройки при генерации кеша берутся из самой системы. На что это влияет? А влияет это на то, что когда мы работаем в бэкенде, то при обновлении кеша мы не можем указать какие-то индивидуальные параметры кеширования для отдельных контекстов. К примеру есть системная настройка cache_context_settings, которая указывает кешировать настройки контекста или нет. По сути должно быть так: какому-то контексту мы установили эту настройку в false, и для этого контекста настройки не должны были бы кешироваться. Ан нет. Здесь или общая системная настройка установлена в true, и все контексты кешируются, или false, и тогда ни один контекст не кешируется, независимо от их настроек. Но следует отметить, что параметр $options передается самим контекстом в методе modContext::prepare();
То есть можно отключить кеширование контекстов в принципе, а для отдельных контекстов кеширование указать. Тогда при заходе на сайт, когда MODX выполнит $this->context->prepare(), тогда если контекст кешируемый, то кеш для этого контекста запишется. Но если при этом для контекста mgr кеширование будет установлено, то опять-таки по описанной выше причине, будут кешироваться все контексты в момент очистки кеша всего сайта.
Кстати, $options можно передать как второй параметр в метод $modx->initialize(). К примеру так:
Но эта фишка вообще бесполезная, так как могла бы иметь смысл только для динамической подмены каких-либо кешируемых настроек, так как само собой выполнение было бы быстрее, чем на уровне плагина, но переданные таким образом настройки тоже кешируются, так что единственный уместный момент — это только ручная очистка кеша контекста и опять-таки ручная инициализация его. Но это полный изврат, к тому же вообще не оправданный. Хотя нет, один момент есть: через интерфейс в настройки нельзя сохранять массивы. А так можно было бы передавать массивы, чтобы они сохранялись в кеш настроек.
Кстати, если глобально кеширование настроек контекстов отключено, а локально для конкретного контекста включено, то первичная инициализация контекста будет выполнена дважды, так как хотя для контекста кеширование указано, мы знаем, что оно не берется в расчет, и при первой инициализации настройки не будут сохранены. И вот при такой инициализации контекста с переданными в параметре настройками, эти настройки не будут сохранены в кеше контекста, и при последующих обращениях к страницам контекста этих настроек уже не будет, так что с этими параметрами следует сразу передавать и настройку cache_context_settings => 1.
Ладно, это было лирическое отступление, вернемся к нашей функции.генерации кеша контекста. Опять обратим внимание на эту строчку практически в самом начале функции: if (!$this->getOption('transient_context', $options, false)) {
То есть если для контекста указана эта настройка в true, то весь блок, в котором происходит выборка документов и настроек, пропускается. НО: как было написано выше, нельзя указать этот параметр отдельно для выбранных контекстов так, чтобы в админке для них эта настройка имела смысл, а для других нет. То есть если и устанавливать, то для всего сайта. А что происходит, если установить эту настройку для всего сайта? Забавная неприятность — 404 для всей админки после обновления кеша :-)
К слову, все контексты тоже окажутся неработающими, так как в их настройках не будет карты ресурсов, и даже если указать site_start, MODX все-равно не будет искать стартовый документ, не указанный в карте ресурсов. В итоге еще одна по сути не работающая фишка.
И все-таки, хоть на что-то мы можем воздействовать или нет? Можем. На ЧПУ. Единственное, что проверяется для каждого конкретного контекста в отдельности, это использование ЧПУ, и если не используется, то просто карта алиасов не будет набиваться. Все.
Вывод: cache_context_settings никогда не стоит устанавливать в false, так как это только увеличит нагрузку на систему, и никак вообще нам не поможет.
Еще одна забавная вещь: системная настройки cache_disabled. В официальной документации написано
Iftrue, 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.
А в чем фишка? А в том, что эта настройка вообще нигде не используется. Вообще. Только в конфиге прописано
То есть можете сколько угодно переключать ее в true, это вообще ни на что не влияет.
Честно сказать, вообще грустно от такого бардака в системе кеширования. Получается хочешь ты этого, или нет, но если у тебя в контексте много документов, то проблем тебе не избежать…
Но отчаиваться не будем, а постараемся все-таки найти хоть какое-то решение. И для себя я такое решение нашел. В общем так как узкое место во всем этом деле — это выборка документов для генерации карты ресурсов, я решил это дело и прикрыть.
Выборка ресурсов для генерации карты ресурсов выполняется в методе modContext_mysql::getResourceCacheMapStmt(). Исходник:
publicstaticfunctiongetResourceCacheMapStmt(&$context){$stmt=false;if($contextinstanceofmodContext){$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=newxPDOCriteria($context->xpdo,$sql,$bindings,false);if($criteria&&$criteria->stmt&&$criteria->stmt->execute()){$stmt=&$criteria->stmt;}}return$stmt;}
Как видно, выборка ресурсов происходит практически без разбору, и это следует исправить. Модифицированный код выглядит вот так:
publicstaticfunctiongetResourceCacheMapStmt(&$context){$stmt=false;if($contextinstanceofmodContext){// Get context setting$settings=array();if($result=$context->getMany('ContextSettings')){foreach($resultas$r){$settings[$r->get('key')]=$r->get('value');}}// If resource map disabled, skip itif($context->xpdo->getOption('cacheoptimizer.resource_map_disabled',$settings,false)){returnfalse;}$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=newxPDOCriteria($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
Советую его всем скачать и изучить. Он совсем не большой, но это отличный прототип для патчей, так как не просто что-то устанавливает, а делает резервное копирование исходных файлов, и в дальнейшем при деинсталяции восстанавливает их.