Fi1osof 12 июля 2013 4 0
Печально новый год начинать с такого нехорошего топика, но ничего не поделаешь…

Конечно много уже кто бросал камни в MODX из-за проблем с кешированием, но сегодня сделаю это и я. Я конечно же очень люблю MODX, но некоторые вещи меня прямо-таки вымораживают! Сразу оговорюсь, что описываемые здесь проблемы касаются только тех случаев, когда предполагается большое количество документов в одном контексте (более 10 000).

Сегодня мы рассмотрим процесс генерации кеша контекстов и на что и как мы можем влиять.

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

А в чем проблема? А проблема в том, что это как минимум накладывает очень серьезные ограничения на максимальное кол-во документов в контексте. Почти два года назад я уже писал о своих исследованиях по этому поводу еще на версии Revo 2.0.8, так вот — с тех пор практически ничего не поменялось…

Сразу определим основную проблему: при обновлении кеша контекста, MODX перебирает все документы этого контекста (читай: делает много-много запросов к базе данных и получает и обрабатывает очень большой объем информации) и формирует карты ресурсов и алиасов. При этом он хранит эти карты не в отдельном кеш-файле, а именно в кеше настроек контекста.

Есть проблема — сразу же можно предположить парочку вариантов ее решения:
1. Запретить MODX-у делать выборку всех документов контекста. (Это был бы идеальный вариант — частично закешировать только важные документы, участвующие в формировании менюшек Wayfinder-ом и т.п., а те документы, которые мы получаем динамически нашими собственными специфическими скриптами, пропустить).
2. Вообще отключить кеширование контекста. (Почему это оказывается очень плохой вариант, мы рассмотрим и поймем позже).

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

Выполняется это все в одном методе modCacheManager::generateContext(). Давайте посмотрим на исходник:
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
Советую его всем скачать и изучить. Он совсем не большой, но это отличный прототип для патчей, так как не просто что-то устанавливает, а делает резервное копирование исходных файлов, и в дальнейшем при деинсталяции восстанавливает их.
0 комментариев
Авторизуйтесь или зарегистрируйтесь (можно через соцсети ), чтобы оставлять комментарии.