Печально новый год начинать с такого нехорошего топика, но ничего не поделаешь…
Конечно много уже кто бросал камни в 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, так как это только увеличит нагрузку на систему, и никак вообще нам не поможет.
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
Советую его всем скачать и изучить. Он совсем не большой, но это отличный прототип для патчей, так как не просто что-то устанавливает, а делает резервное копирование исходных файлов, и в дальнейшем при деинсталяции восстанавливает их.