CS-Cart 3 optimize

Всем привет! Прошлую ночь я провел за черной ssh-консолью в попытках заставить работать быстрее один сайт на CS-cart 3.0.6. Сам сайт крутится один одинешенек на выделенном сервере СPU 1 x Xeon E3-1230v3, Mem 2 x 8 192 MB, HDD 2 x 1000 GB SATA и умудряется там тормозить. Отчего становится совсем уж грустно. Ситуацию усугубляет что конкретно эта версия CMS требует для работы уже древний PHP 5.3 (вообще не заводится) и не приемлет подключение оп-кешеров (отваливается ajax).

Сперва я начал грешить на сервер от reg.ru и собственные кривые руки поэтому развернул копию сайта на другом сервере (EVO12-SSD от FastVPS). Да тут всего 2 ядра CPU и 12Gb RAM, но зато быстрый SSD-диск. Плюс конфигурацию я решил делать с упором на максимальную производительность. В итоге оказалось что дело не в сервере - сайт ощутимо тормозит и там.

Далее опишу что я делал. Сразу скажу что проблему полностью решить не удалось (пока), так что не обессудьте.

Включаем отладку

Итак, первым делом нужно иметь возможность удобного включения отладочного режима.
Для этого открываем /config.php и ищем закомментированную строчку define(‘DEBUG_MODE’…

Заменяем ее на:

1
2
if(isset($_REQUEST['mysuperpouper']))
define('DEBUG_MODE', true);

В итоге мы имеем возможность включить отладку простым добавлением GET-параметра mysuperpouper=Y к URL.

Собственно отладка показывает примерно вот такую картину:

Вывод отладки в конце страницы

Теперь мы видим за какое время выполнились SQL-запросы (Queries time) и отрендерились шаблоны (render location).
Это далеко не самая тяжелая страница на сервере, но на SQL-запросы ушло почти 3 секунды!

Всем запросам запрос

Всему виной оказался один запрос который занимается самым важным - получает список товаров для отображения на странице (генерируется огромной функцией fn_get_products() в огромном 330 кб файле /core/fn.catalog.php).
Думаю нетрудно догадаться что функция генерирует многоэтажный SQL-запрос с кучей IF,JOIN и GROUP BY с ORDER BY в конце. В итоге получается что запрос что 10 что 100 товаров выполняется практически одинаково медленно т.к. бедному серверу БД приходится перелопачивать огромные объемы данных чтобы все получить и отсортировать.

Сперва мне было непонятно почему при каждом запуске mysqltuner рекомендует мне увеличивать лимиты в my.cnf до безумных высот, а потом рекомендует «add more RAM». При этом существенного прироста в загрузке страниц не наблюдается. А потом я решил глянуть какие временные таблицы создаются во время загрузки страницы.

Я увидел несколько мелких таблиц и одну огромную в 2 гигабайта. Тут все вопросы отпали сами собой. Даже для того чтобы создать такую таблицу даже в RAM потребуется приличное время.

Поэтому я выставил разумные лимиты в конфиге, еще раз рестартнул MySQL-сервер и продолжил попытки увеличить производительность сайта.

Варианты ускорения

Вот строчка генерирующая злосчастный запрос.

1
$products = db_get_array("SELECT SQL_CALC_FOUND_ROWS " . implode(', ', $fields) . " FROM ?:products as products $join WHERE 1 $condition GROUP BY $group_by ORDER BY $sorting $limit");

Как мы видим это SQL в открытом виде и человеку хорошо разбирающемуся в готовке тяжелых запросов не составит труда разделить его на несколько более простых тем самым радикально улучшив производительность.
У меня же времени на подобные эксперименты не было и поэтому я выбрал вариант с костылем =)

Костыльное решение

Я просто поставил memcached на сервер и стал класть сериализованные результаты запросов в кэш.
В итоге получается что страдает только первый посетитель открывший страничку. А у остальных все грузится быстро.
Memcached в дефолтной конфигурации слушает локальный 11211 порт и 64 мегабайт выделенных ему по умолчанию хватит за глаза.

Кусок кода которым я заменил ту строчку:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*
Rail changes begin
*/
$unique_key = md5($join.$condition.$group_by.$sorting.$limit);

$memcache_obj = new Memcached();
$memcache_obj->addServer('127.0.0.1', 11211);
$var_key = @$memcache_obj->get($unique_key);
if(!empty($var_key))
{
$products = unserialize($var_key);
$my_count = $products['found_rows'];
unset($products['found_rows']);
}
else {
$products = db_get_array("SELECT SQL_CALC_FOUND_ROWS " . implode(', ', $fields) . " FROM ?:products as products $join WHERE 1 $condition GROUP BY $group_by ORDER BY $sorting $limit");
$products['found_rows'] = db_get_found_rows();
@$memcache_obj->set($unique_key,serialize($products));
$my_count = $products['found_rows'];
unset($products['found_rows']);
}

if (!empty($items_per_page)) {
$total = !empty($total)? $total : $my_count;
/*
Rail changes end
*/
/*
Standart begin
$products = db_get_array("SELECT SQL_CALC_FOUND_ROWS " . implode(', ', $fields) . " FROM ?:products as products $join WHERE 1 $condition GROUP BY $group_by ORDER BY $sorting $limit");

if (!empty($items_per_page)) {
$total = !empty($total)? $total : db_get_found_rows();*
Standart end
*/

Тут все просто - на основе параметров для SQL-запроса генерируем уникальный ключ по которому будем обращаться к данным в memcached и туда же их класть. Мутные строчки с $products[‘found_rows’] нужны для того чтобы куда то записывать полное количество товаров в списке (нужно для пагинации).

Итак, теперь проверяем скорость загрузки странички просто обратившись к ней повторно:

Вывод отладки в конце страницы

Согласитесь, 0.02 секунды это намного лучше чем 2.9 =)

Выводы

Да, конечно получилось не самое элегантное решение к тому же обладающее своими минусами (необходимо периодически рестартить memcached для сброса кеша), но зато рабочее. Возможно когда-нибудь я найду время на то чтобы переписать этот запрос и тем самым решить на корню.

А пока вот такие тезисы исходя из опыта возни с CS-cart:

  • В CS-cart 4 версии код этой злосчастной функции практически идентичен
  • Рендер tpl-кусков шаблона не ускорился от того что сайт стал крутиться на SSD-диске
  • Рендер tpl-кусков шаблона не ускорился от того что директория с шаблонами была перенесена в tmpfs
  • Рендер tpl-кусков шаблона на сервере с более мощным процем и SATA-диском пока выигрывает у более слабого проца + SSD и более слабого проца + tmpfs, а значит дело тут не в диске
  • Перенос файлового кэша в tmpfs также не дал никакого эффекта по сравнению с SSD хотя скорость записи у RAM выше в 3 раза (1.5Гбит/сек)
  • Подключение Nginx заметно ускорило сайт за счет раздачи им статики
  • Переключение на Nginx + PHP-FPM не дало никакого ощутимого ускорения по отношению к Nginx + Apache. Разве что памяти стало тратиться меньше, но времени на перенастройку ушло большое
  • Подключение APC с забиванием болта на нерабочий AJAX не ускорило сборку tpl-файлов и не оказало значимого эффекта на скорость загрузки сайта
  • На 2000 тысячах уников в сутки и на движке без самопальных модификаций кеша mysqladmin показывает Queries per second avg: 187.149

UPD 26.03.15

Так как никакого ответа я от представителей CS-cart не получил, то пришлось внедрять это решение на живую. Нагрузка на сайт значительно упала и Munin перестал ежедневно присылать мне грустные сообщения о высоком Write IO Wait time. Для посетителей также все стало открываться шустрее несмотря на 800-900(sic!) запросов к БД на одну страничку.

Как оказалось мое решение несет в себе небольшое неудобство - в админ-панели также стали кэшироваться некоторые вещи и с ней стало очень неудобно работать и грязный хак с мемкэшем пришлось временно отключить.

Решения я тут вижу 2:
1) Не включать кеш на запросах идущих к админке (тут легко ибо все запросы к админке идут через 1 скрипт)
2) Проверять пользователя на авторизацию и наличие у него администраторских прав. Грубо говоря не кэшировать для админов. В документацию к движку я не лез, но по уму должен быть доступ к какому либо объекту User который будет возвращать требуемые данные.

Вот такие дела.