Николай Ланец
4 авг. 2013 г., 12:29

Большой интернет-магазин на MODX Revolution. 150 000+ товаров. Часть первая.

Внезапно, оказалось, что в магазине, в котором предполагалось 20 000+ товаров, на самом деле 150 000+ товаров… Ну чтож, давно хотел сделать магазин покрупнее :-)
Очень краткая предыстория: перенос существующего интернет-магазина с самописного движка на MODX Revolution.
Проблема №1: документы создаю через процессор resource/create, а обновляю через resource/update. Сама проблема заключается в малой скорости выполнения (из-за большого кеша карты ресурсов). Здесь помогает cacheOptimizer. Устанавливаем его и отключаем полностью кеширование карты кесурсов. Производительность на процессорах сразу поднимается в 3+ раза.
Вторая проблема тут же: в источнике очень много документов с повторяющимися названиями, а задача стоит такая, что ЧПУ-алиасы надо сгенерировать. И вот здесь почти каждую секунду цикл выполнения процессоров обрывается из-за дубляжа алиасов. Пошел на небольшую хитрость — создал расширяющий resource/create, resource/update процессор, и перегрузил проверку алиаса. Если если дубликат алиаса, я добавляю к алиасу случайное число, и вызываю родительскую функцию. Там опять выполняется проверка, и если дубляжа нет — едем дальше, а если есть, то тогда уже окончательно останавливаем выполнение ошибкой. Вот код такого update-процессора.
<?php require_once MODX_PROCESSORS_PATH . 'resource/update.class.php'; class modMgrResourceUpdateProcessor extends modResourceUpdateProcessor{ public static function getInstance(modX &$modx,$className,$properties = array()) { /** @var modResource $object */ $className = __CLASS__; /** @var modProcessor $processor */ $processor = new $className($modx,$properties); return $processor; } public function checkFriendlyAlias() { $this->isSiteStart = ($this->object->get('id') == $this->workingContext->getOption('site_start') || $this->object->get('id') == $this->modx->getOption('site_start')); $pageTitle = $this->getProperty('pagetitle',null); $alias = $this->getProperty('alias'); if ($this->workingContext->getOption('friendly_urls', false) && (!$this->getProperty('reloadOnly',false) || (!empty($pageTitle) || $this->isSiteStart))) { /* auto assign alias */ if (empty($alias) && ($this->isSiteStart || $this->workingContext->getOption('automatic_alias', false))) { if (empty($pageTitle)) { $alias = 'index'; } else { $alias = $this->object->cleanAlias($pageTitle); } } if (empty($alias)) { $this->addFieldError('alias', $this->modx->lexicon('field_required')); } /* check for duplicate alias */ $duplicateContext = $this->workingContext->getOption('global_duplicate_uri_check', false) ? '' : $this->getProperty('context_key'); $aliasPath = $this->object->getAliasPath($alias,$this->getProperties()); $duplicateId = $this->object->isDuplicateAlias($aliasPath, $duplicateContext); if (!empty($duplicateId)) { /* Вот здесь у наспроверка и вызов родительской функции. Если алиас совпадает, добавляем к нему случайное число. */ $this->setProperty('alias',$alias . '-'. rand(1,30)); /*print $this->getProperty('alias' ); exit;*/ return parent::checkFriendlyAlias(); } $this->setProperty('alias',$alias); } return $alias; } } return 'modMgrResourceUpdateProcessor';

Проблема №2: лимит на выполнение скриптов 30 секунд. Я веду работу на modxcloud.com, а там лимит на выполнение скриптов — 30 секунд. За это время MODX успевает прогрузить порядка 300-400 документов. А надо прогрузить более 150 000 документов (скрипт выполняю через Console). Так вот, здесь я использую маленькую хитрость: дело в том, что за 30 секунд — это nginx отбивает, но php-то живет своей жизнью. Так что я ставлю лимит записей гораздо больше (к примеру 5000), а в скрипте пишу это:
ini_set('max_execution_time', 0); ignore_user_abort(true);
Теперь даже если nginx отбивает через 30 секунд, сам php-скрипт продолжает выполняться. Каковы лимиты самого php я не знаю (на некоторых хостингах часто обрывают скрипты не только по времени, но и по «очкам нагрузки»), но во всяком случае 5000 записей за раз он прогружает. А выполняется скрипт или нет, я смотрю по остаткам не отработанных записей через phpMyAdmin. Конечно, особого выигрыша во времени в таком случае нет, но зато пока импортируются 5000 документов, можно чем-то параллельно позаниматься.
Проблема №3: большой кеш контекста. При частичном отключении карты ресурсов (фишка Ревы 2.2.7) кеш-файл весит почти 3,5 метра, а при заходе на сайт потребляется от 47Mb и больше. А если включить полное кеширование карты ресурсов, то объем кеш-файла почти 15 метров (и кеш создается секунд 8), а памяти во фронте кушается… А хз сколько кушается. На modxcloud.com все разваливается при заходе во фронт (просто белый экран без всяких сообщений об ошибках, как я ни старался их включить), а в админке дерево ресурсов вообще ответа от сервера нет. В общем, карту ресурсов отключаем полностью.
Проблема №4: долгие запросы к БД. У нас получается 150 000+ в таблице документов, 150 000+ в таблице товаров (потом еще будет наверняка не одна сотня тысяч записей в таблице TV-параметров). Так вот, индексы конечно настроены, и при простых джоинах запросы выполняются довольно быстро. Но как только начинается поиск по условиям, вот тут запросы начинают выполняться и пол-секунды, и больше. При чем одно дело, когда мы получаем какое-то ограниченное количество записей (эти запросы выполняются довольно быстро, так как СУБД получает запрашиваемое кол-во записей, и как только лимит получен, выборка прекращается). Но вот когда выполняется подсчет всех результатов, попадающих под условия запроса (а это нам надо знать для постраничности), вот тогда СУБД-шке надо подсчитать вообще все строки, то есть сделать полную выборку из указанных таблиц. Вот тогда время выполнения запроса запросто выходит за 1 секунду. И вот даже при использовании list-процессоров из shopModx выборка 10-ти документов (с подсчетом общего числа найденных записей) занимает 2 и более секунды.
Решение довольно не сложно в данном случае. Мы просто пишем общий запрос и делаем выборку всех записей из связанных таблиц без учета поиска. У нас получается одна такая большая таблица (content и т.п. нам не нужны, так как поиск мы по ним не делаем (если не делаем), нам эта таблица нужна только для поиска ID-шников тех документов, которые удовлетворяют условиям поиска, а по этим id-шникам мы уже получим конечные результаты из основных таблиц, так как чисто по id-шникам поиск выполняется достаточно быстро).
А вот list-процессор из shopModx-а пришлось серьезно оттюнинговать. Во-первых, основным объектом для запросов установил объект этой кеш-таблицы. Во-вторых, заменил стандарный $modx->getCount() на вот такую хитрую конструкцию (особенно обратите внимание на $this->modx->getValue()):
protected function countTotal($className, xPDOQuery & $query){ if (isset($query->query['columns'])) $query->query['columns'] = array(); $query->select(array("count(*)")); $query->prepare(); return $this->modx->getValue($query->stmt); }
Работает она гораздо быстрее стандартного $modx->getCount(), так как тот выполняет подсчет уникальных id-шников, а не просто count(*). Вот так там запрос строится:
$query->select(array ("COUNT(DISTINCT {$expr})"));
Разница в скорости подсчета в несколько раз.
UPD: в дальнейшем пришлось все-таки запрос переделать на count(distinct model_id), так как в кеш-таблице PK был category_id, model_id, product_id, и при выборке уникальных записей именно для model_id, простого подсчета общего числа строк было не достаточно.
В кеш-таблице удалил отдельные индексы для deleted, hidemenu, published и создал единый индекс для этих трех колонок. По наблюдениям при поиске по одной из этих трех колонок с отдельными индексами разницы в скорости не замечено, а вот как только хотя бы по двум колонкам ищешь, сразу скорость падает в раза в два.
В итоге, после всех этих манипуляций, я добился полного выполнения процессора на выборку 10-ти документов с полным подсчетом общего количества и т.п. менее чем за 0.09-0.2 сек. Итоговый процессор после тюнинга стал выглядеть так: gist.github.com/Fi1osof/d5a4dd427ee31b568e01/365a4ee20661b87c43b369e31683f9cad1c40635
Но все же этот результат не очень устраивал, так как конечная страница рендерилась 0,5-0,7 секунд, а это все-таки долго. В итоге я решил перевести MODX на кеш-провайдер APC, а результаты выборки из базы данных кешировать, чтобы не дергать каждый раз базу данных, тем более, что основную нагрузку создавал именно подсчет общего числа записей, удовлетворяющих условиям поиска, так что хорошего результата достаточно было просто кешировать этот результат, а конечную выборку со всеми сортировками и т.п. можно было бы уже и не кешировать, так как это только несколько записей. С формированием ключа проблем не возникло. На общее количество записей влияет только условие WHERE. Sort и т.п. вообще не учитываются. В итоге я написал вот так:
protected function countTotal($className, xPDOQuery & $query){ $total = 0; $key = 'catalog/category/'. md5(print_r($c->query['where'], true)); if(!$total = $this->modx->cacheManager->get($key)){ if (isset($query->query['columns'])) $query->query['columns'] = array(); $query->select(array("count(distinct model_id)")); $query->prepare(); if($total = $this->modx->getValue($query->stmt)){ $this->modx->cacheManager->set($key, $total); } } return $total; }
То есть я беру условие WHERE прям из самого объекта запроса (и таким образом избавляюсь от необходимости вклиниваться во все точки формирования условий), и формирую кеш-ключ. А далее если результат есть в кеше, то возвращаю его, а если нет, то получаю результат от базы данных и сохраняю его в кеш. С такой маленькой хитростью время на выполнение процессора сократилось до 0,02-0,05 сек, то есть сразу в несколько раз, а рендер страницы сократился до 0,3 — 0,5 сек (это кешируемая страница с некешируемым постраничным выводом каталога по 32 товара на страницу). Уже больше похоже на правду. Измененный процессор: gist.github.com/Fi1osof/d5a4dd427ee31b568e01/53da648ce470ea05db2f3bdffe0795b2e6a9bec0
Но я решил на этом не останавливаться, а еще включить кеширование уже конечного массива данных. В итоге, переопределил основную функцию process:
public function process() { if(!$result = $this->modx->cacheManager->get($this->key)){ $result = parent::process(); if(!empty($result['success'])){ $this->modx->cacheManager->set($this->key, $result); } } else{ $result = $this->outputArray($result['object'], $result['total']); } return $result; }
В этой функции я специально не стал сразу возвращать конечный результат, а прогоняю через $this->outputArray(), так как в той функции вызывается getPage. А если я этого не буду делать, то постраничность у нас просто пропадет (вывод [[+page.nav]]). Измененный процессор: gist.github.com/Fi1osof/d5a4dd427ee31b568e01/a0e4a39ddf9e90117204cfad591aed6ad7e98df7
И вот здесь еще один сюрприз меня ждал — getPage. Оказывается, он ппц какой прожорливый. Сравним скорость выполнения процессора без getPage — 0,036 сек., и с getPage — 0,15-0,17 сек. Сразу скорость выполнения падает почти в 5 раз. А ведь это основной процессор на выборку данных каталога. А сравним рендер страницы. Без getPage (страница из кеша, но процессор вызывается) — 0.1152-0.2 сек. А с getPage? 0.28-0.36 сек. В итоге один getPage выжирает в 2-3 раза больше, чем требуется всей странице! Ппц… Короче, getPage — следующий на замену. Надо будет тоже переписать.
P.S. Ссылка на магазин будет после того, как я закончу хотя бы основную часть. Продолжение следует…
P.S.S. если кому понадобится, ревизия изменения процессора: gist.github.com/Fi1osof/d5a4dd427ee31b568e01/revisions
Хм. По-моему когда 150 тысяч + товаров, тогда уже вопросы к покупке собственного датацентра могут отправляться…
А зачем? Практика показывает, что можно на небольшом выделенном сервере работать (сейчас я на modxcloud.com как обычно работаю, но уверен, боевую работу запросто обеспечит недорогой сервер с мемкешем или APC).
ну у меня отношение давно проще на этот счет. если можно замутить дата-центр, то так интересней, чем пытаться вжать супермаркет для мегаполиса в 96 мегабайт памяти и 1 жидкое ядро. =)
Ну вот смотри: на самом деле десятки и сотни тысяч товаров — это не супермного. Датацентр и т.п. — это все большие дальнейшие издержки. В год может капать тысячи долларов только на хостинг. А здесь сел, нормально все оптимизировал, и сидишь на modxcloud.com за $10 (если не выгонят :-) ).
Я это к тому, что никогда не лишнее оптимизировать. Считай, я за два дня выполнил оптимизацию, которая экономит тысячи долларов в год.
Как бы я не прикалывался, а оптимизация — вещь полезная, согласен. И будет полезной. И одно другому не мешает =)
Жаль только большинство заказчиков это вряд ли поймут, такие оптимизации тоже не дешевы, должны оценить достойно.
Да, не ценят. Те же хамстеры сколько сидели на своем неработающем магазине? Больше года?
да там жесть вообще, наверное где то так
Но ведь проблема не только в них, а еще и в том, что им просто не могли это сделать те программисты, которые занимались их магазином. Так что здесь вина не только на совести заказчика.
Я это просто к тому, что все это очень не однозначно. Нет четких критериев оценки крутости и стоимости разработки. Один школьник может запросить 100 000, просто потому что он будет 3 месяца этим заниматься (в то время, как профи сделает за пару дней). В свою очередь заказчик может не знать как это реально оценить, и не понимает, почему профи надо платить так много. Это вон в спорте все понятно — пробежал на пол-секунды стометровку быстрее других — самый крутой, на тебе золотую медаль!
хм, да здраво, хотя именно хамстер могли бы поучиться на собственных ошибках, оплатили тем программистам, они не справились, значит нужно нанять специалиста, а раз он все сделал и починил, то значит не зря он специалист. Надо и оплатить соответственно. Скорее всего должно быть так)
Здесь сложность в том, что без предоплаты никто не работает. А каждому оплачивать предоплату просто для того, чтобы убедиться, что он не справится — мягко говоря накладно получается.
1. Зачем случайное число и куча рекурсивных проходов вверх по дереву, если пары alias+id (который unique primary key)и проверки по одному полю в таблице с индексом в нужном месте вполне хватило бы закрыть эту проблему. 2. Кроме времени еще важна и память. На клауде можно не боятся, но на хостинге процесс могут затоптать за оверхед по памяти. 5000 тыс конечно не число, но я бы предусмотрел бы какое-нибудь порционное скармливание данных по чуть-чуть. Не знаю наверняка, но скорее всего так оно и есть. Печально, если нет :) Ну и запускать такое нужно не через веб-морду. В консоли ограничений на выполнение php точно нет. 3. Это скорее болезнь самого modx и его системы кеширования. Особо не придумать ничего. Разве то итемы каталога держать не в ресурсах 4. Стандартные решения удобны тем, что просты и понятны. Но на таких объемах я бы подумал о подключении какого-нибудь sphinx или провел бы серьезную денормализацию данных и возможно что-то положил бы в какой-нибудь redis или mongo или что там сейчас можно.
P.S. Ответы пишу не с пустой головы. Работаю сейчас с проектом, у которого под миллион уников в сутки и отдельные таблицы весят под 10 гб, не считая десятков миллионов записей в nosql-хранилищах. Это все добро конечно же не на modx, но подходы в крупных проектах все равно одни и те же.
1. Желательно цитировать или как-то указывать ту область статьи, на которую пишется возражение или вопрос. К примеру вот это мне вообще не понятно к какому месту относится:
1. Зачем случайное число и куча рекурсивных проходов вверх по дереву, если пары alias+id (который unique primary key)и проверки по одному полю в таблице с индексом в нужном месте вполне хватило бы закрыть эту проблему.
И где этот PK alias+id и где я зачем-то по дереву вверх поднимаюсь?
Печально, если нет :)
Если бы у меня здесь были проблемы, я бы использовал этот метод. А если проблемы не было, то и париться мне не за чем было.
В консоли ограничений на выполнение php точно нет.
Не верное утверждение. Где-то нет, а где-то и есть. на том же таймвебе есть и консольные ограничения, и за 5 секунд потребления процессора более чем на 60% приводит к обрыву процесса.
3. Это скорее болезнь самого modx и его системы кеширования. Особо не придумать ничего. Разве то итемы каталога держать не в ресурсах
Там полно вариантов что можно придумать, начиная от простой заплатки cacheOptimizer, и заканчивая этим решением.
4. Стандартные решения удобны тем, что просты и понятны. Но на таких объемах я бы подумал о подключении какого-нибудь sphinx или провел бы серьезную денормализацию данных и возможно что-то положил бы в какой-нибудь redis или mongo или что там сейчас можно
Всегда надо держать грань между производительностью и простотой (для простоты сопровождения). Если скорость устраивает, то зачем еще усложнять? Усложнение будет задирать планку на специалистов для сопровождения.
P.S. этот проект не на миллионы уников, так что сейчас мы не будем привлекать сюда все мощнейшие технологии. Мощнейшие проекты занимают не мало времени и как правило требуют участия группы программистов. А я это сделал за 2 дня. Так что здесь исходим из бюджетов и потребностей.
К экономии на хостинге прибавляем вашу стоимость поддержки. Ибо это уже не из коробки и просто так не обновить.

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