Оптимизации и тонкости работы с БД для больших магазинов на MODX Revolution

Небольшое вступление

Данная статья не претендует на универсальное решение. Всегда пользуйтесь собственной головой. Всё описанное ниже актуально для MODX Revolution 2.8.3-pl, miniShop2 2.9.1-pl.



От 50k товаров

MODX сам выдаёт достаточно предупреждений при очистке кеша для отключения различных опций для контекстов. И примерно на этом количестве предлагает стандартные оптимизации, которые не стоит игнорировать.

А вот о чём он не предупреждает — это карта ресурсов контекста, resourceMap. Она представляет собой индексный массив для всех родителей и потомков, которые могут понадобиться MODX.

Служат они только одной реальной цели — быть пищей для метода getChildIds (этот метод ещё не раз всплывёт, поверьте). В оригинальной реализации его используют различные дополнения меню и хлебных крошек. Именно они, к сожалению, первый кандидат на замену.

Основной симптом данной проблемы — завышенный parse time MODX, не зависимо от используемого парсера или наличия кеша страницы, и высокое потребление RAM на запрос.

Костыль OnMODXInit ниже может послужить хорошим решением данной проблемы, и будет надёжным подспорьем довольно долгое время.

//  Resource map patch to reduce parse time
  if( ! empty( $modx->context->resourceMap ) ) {
      $options = [ xPDO::OPT_CACHE_KEY => 'context_settings/' . $modx->context->key ];
      if( $val = $modx->getCacheManager()->get( 'context', $options ) ) {
          $val[ 'resourceMap' ] = [];
          $modx->getCacheManager()->set( 'context', $val, 0, $options );
      }
  }
  //--//
Искушённый читатель предложит расширить стандартный класс менеджера кеширования и подменить его. На мой взгляд избавиться от плагина потом проще.

На момент написания статьи, о resourceMap в комментариях к коду сказано следующее.

@todo Further refactor the generation of aliasMap and resourceMap so it uses less memory/file size.
Вес карты контекста достаточно сильно может зависеть от вложенности категорий. Например, для 3k категорий и 3m товаров её размер достигал 100M, при этом RAM только для этих данных требовалось около 256M на любой запрос или запуск cli скрипта.

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

Буду краток — не используйте Apache, по возможности обзаведитесь каким-нибудь CDN с защитой от популярных атак (например CloudFlare на бесплатном тарифе — к нему есть готовое дополнение в стандартном репозитории для очистки кеша), а так же отключите anonymous_sessions в настройках каждого контекста, кроме mgr.

В паре с этой настройкой используйте плагин примерно следующего содержания. Скорее всего в будущем я всё-таки опубликую этот плагин в виде дополнения в официальный репозиторий MODX.

if( $this instanceof modPlugin )
    switch( $modx->event->name ) {
        case 'OnMODXInit' :
            if( ! $modx->getOption( 'anonymous_sessions', NULL, TRUE ) &&
                ! $modx->getSessionState() &&
                is_file( $filename = MODX_CORE_PATH . 'components/' . $this->name . '/vendor/autoload.php' ) &&
                ( require_once $filename ) &&
                ( $CrawlerDetect = new Jaybizzle\CrawlerDetect\CrawlerDetect ) &&
                ! $CrawlerDetect->isCrawler()
            )
                $modx->startSession();
        break;
        default :
            //
        break;
    }
Я использовал jaybizzle/crawler-detect но можно заменить его на любой другой детектор ботов.

Это решение позволит уменьшить количество сессий до минимума, оставив их только реальным пользователям, что сильно снизит нагрузку на БД и, в будущем, файловую систему.

От 100k до 250k товаров

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

Зато симптомов более чем достаточно — замедление сортировки, постраничной навигации, поиска, линейное и стабильное повышение расхода RAM, медленная загрузка админпанели, пустые или неполные заказы в магазине, постоянно недовольные этим менеджеры, которые не успевают справляться с ручным редактированием товаров или загрузкой прайсов.

Сей список можно продлить, но смысла в этом точно нет.

Первое, о чём стоит задуматься — это автоматизация импорта. Ни одно из существующих решений, которые я испробовал, меня не обрадовало. Поэтому, я остановил свой выбор на самодельной системе служб на основе дополнения CronManager. Он позволяет довольно эффективно управлять процессами импорта и экспорта товаров, сохраняя код в обычных сниппетах.

На данном этапе развития магазина люди физически не способны обработать такое количество товаров и начинают тратить рабочее время на монотонную ерунду.

Если отказаться от использования всех возможных процессоров, и перейти на чистую работу с объектами классов msCategory, msProduct и msProductData, то производительность MODX будет примерно 200k товаров в 1 час при использовании таблиц с типом MyISAM. И даже солидный сервер не потянет более одного процесса импорта без последствий из-за блокировки метаданных таблиц БД.

К сожалению, msCategoryMember и загрузку файлов галереи переделать с процессоров на такой метод сложнее.

Вариант, который выбрал я — хранить картинки отдельно от галереи. Структура каталогов простая как двери — /%производитель%/%артикул%/%изображение%. Это позволяет контролировать наличие дубликатов и использовать любые доступные данные служб импорта для контроля существования картинок.

От мультикатегорий лучше вообще отказываться на этом этапе всеми возможными правдами и неправдами. Если это всё-таки необходимо — не используйте msCategoryMember, и пишите свою реализацию под задачу. Хорошим подспорьем может послужить литература по хранению графов в БД или использование FULLTEXT индексов для поиска по тегам.

Для стабильности работы магазина во время массового импорта можно воспользоваться одной из опций SQL.

$modx->query( '
      SET `low_priority_updates` = `ON`
  ' );
  //  Тут ресурсоёмкий импорт
  $modx->query( '
      SET `low_priority_updates` = `OFF`
  ' );
Проблема с пустыми товарами в заказе или их частичной потерей решается просто. Не используйте modSessionHandler, установите системную настройку session_handler_class в пустое значние, оставив сессии на PHP.

Товары пропадают из-за рассинхронизации записи в таблицу сессий, а блокирующие сессии PHP прекрасно решают эту проблему.

Так же стоит отказаться от дополнений, по типу showErrorLog. Нет, не из-за того, что они «плохие» или не справляеются со своей задачей. При использовании синхронной записи в сессию стоит воздержаться от лишних запросов в браузере.

А для всех самодельных AJAX запросов стоит вызывать session_close если не планируется что-то записывать в сессию. Это позволит не задерживать очередь.

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

Вместо использования memcache, memcached или Redis для хранения кеша или сессий — лучше обзаведитесь нормальным хостингом/сервером с NVMe SSD и любимым дистрибутивом Linux. На момент написания статьи их производительность вполне сравнима с хранением часто используемых данных в RAM.

Не используйте mSearch2 или измените его под себя. Нет, это не камень в огород популярного платного компонента и его автора. Не нужно брать в руки факелы и вилы.

Если с 50к ресурсов он ещё справляется, то на текущем этапе он почти ничего не может сделать. Внутренний механизм кеширования фильтров у него не так и плох, но их первая загрузка ужасна.

Причина кроется в архитектуре MODX и miniShop2, а точнее в способе хранения TV и опций товаров. В MODX нет полноценной EAV-модели (её использует, например, популярный движок Magento, претендующий на универсальность), и значения аттрибутов хранятся вместе, не зависимо от их типа.

В результате этого, любое лишнее TV поле или опция порождают ещё один JOIN с одной и той же таблицей, которую БД сканирует полностью (или по индексу, если повезёт, и не используется CAST).

Уже при использовании 2 TV и 2 опций товаров проще уйти искать товары в другой магазин.

Выход — использовать расширение класса msProductData своими собственными проиндексированными полями и хранить опции, не требующие сортировки и поиска, в отдельной JSON-свалке. Или использовать сторонние дополнения, способные хранить дополнительные поля согласно их типам, по канонам EAV-модели.

Не используйте pdoPage. А вот сейчас готовьте вилы и факелы. Под капотом у pdoPage есть нечто ужасное. Первое, что он делает — по указанными критериями собирает все id товаров, считает их при необходимости, используя SQL_CALC_ROWS, а после кормит этим списком msProducts.

Очевидным способом ускорить этот процесс является манипулирование параметрами limit и offset для сниппета msProducts, и подсчёт товаров по указанным критериям отдельным запросом с COUNT. Если последний повесить на AJAX, то можно получать список товаров менее чем за секунду.

У msProducts есть тоже небольшой подвох, но он не так ужасен. По-умолчанию там добавлена группировка по `msProduct`.`id`, а почти все любимые критерии, которые используются при сортировка товаров («от дешёвых к дорогим», «по популярности»), лежат в соседнем классе msProductData.

В результате БД ничего не остаётся, кроме как сбросить весь этот JOIN на диск во временный кеш, а потом уже фильтровать по нему. Самое простое решение до поры, пока магазин не подрос до миллиона, убрать этот самый GROUP BY прямо в сниппете.

Не используйте pdoMenu или замените реализацию getChildIds в pdoTools и MODX на более производительную.

Например на эту, которая понравилась мне. Данный пример взять из самодельного класса и не стоит его слепо копировать. В нём же есть и пример реализации кеширования.

public function getChildIds( $class, $id, $depth = 10, array $options = [] ) {
      /*
      if( ! $ids = $this->modx->getCacheManager()->get( $id, $this->options ) ) {
          //
          $this->modx->getCacheManager()->set( $id, $ids, 0, $this->options );
      }
      //
      */
      $xPDOQuery = $this->modx->newQuery( $class );
      $xPDOQuery->select( $this->modx->getSelectColumns( $class, $class, '', [ 'id', 'parent' ] ) );
      if( isset( $options[ 'where' ] ) )
          $xPDOQuery->where( $options[ 'where' ] );
      $ids = [];
      if( $xPDOQuery->prepare() && $xPDOQuery->stmt->execute() ) {
          $flat = [];
          $tree = [];
          foreach( $xPDOQuery->stmt->fetchAll( PDO::FETCH_KEY_PAIR ) as $child => $parent ) {
              if( ! isset( $flat[ $child ] ) )
                  $flat[ $child ] = [];
              if( ! empty( $parent ) )
                  $flat[ $parent ][ $child ] = &$flat[ $child ];
              else
                  $tree[ $child ] = &$flat[ $child ];
          }
          $ids = implode( ',', array_merge( [ $id ], $this->array_keys_recursive( $this->search( $id, $tree ), $depth ) ) );
      }
      return $ids;
  }
  public function array_keys_recursive( array $array, $depth = 10 ) {
      if( $depth > 0 ) {
          $keys = array_keys( $array );
          if( $depth > 1 )
              foreach( $array as $value )
                  if( is_array( $value ) )
                      $keys = array_merge( $keys, $this->array_keys_recursive( $value, $depth - 1 ) );
          return $keys;
      } else
          return [];
  }
Замена getChildIds так же позволит ускорить и другие сниппеты из набора pdoTools.

Ещё одним решением может быть использование GROUP_CONCAT и подзапроса или recursive CTE, при его наличии у сервера БД.

Если вы не используете на сайте мультикатегории, то лучше делать выборку товаров основываясь не на поле parent а на uri.

//$where = [ [ 'uri:REGEXP' => '^' . $modx->resource->get( 'uri' ) ] ];
  $where = [ [ 'uri:LIKE' => $modx->resource->get( 'uri' ) . '%' ] ];
В среднем, LIKE справляется быстрее, а REGEXP предоставляет больше возможностей.

Да, операция `msProduct`.`parent` IN довольно быстрая, но когда количество id в операторе IN переваливает за определённый порог (он зависит от настроек БД и обычно составляет около 750-1000 элементов), то БД всё равно сбрасывает результаты во временную таблицу и использует filesort. Проверить этот момент можно, использовав EXPLAIN на запросе из сниппета msProducts.

Самый важный момент, на который почти все забивают болт — карта сайта для поисковых систем. Она не может содержать более 50k ссылок или иметь вес более 100M.

Почитайте спецификацию sitemapIndex и вызывайте pdoSitemap с параметрами offset и limit.

От 1m товаров

Симптомы проблем — обычные тормоза при выборке из БД.

Причина кроется в разделении данных msProduct и msProductData. И на данном этапе игнорировать эту проблему уже невозможно.

Все запросы включаются себя условия из двух таблиц. Вот стандартный набор, который попадается повсюду.

`msProduct`.`published` = 1
    AND
        `msProduct`.`deleted` = 0
    AND
        `msProduct`.`class_key` = 'msProduct'
    AND
        `msProductData`.`price` > 0
Каждый раз происходит сброс миллиона товаров на диск по одному из критериев. БД не глупая, и выбирает кратчайший путь. Обычно это все опубликованные товары.

А обычный фильтр магазина будет выглядеть как-то так:

SELECT
      `prefix_ms2_vendors`.`id`,
      `prefix_ms2_vendors`.`name`,
      COUNT( `prefix_site_content`.`id` ) AS `count`
  FROM
      `prefix_site_content`
  LEFT JOIN
      `prefix_ms2_products`
  ON
      `prefix_site_content`.`id` = `prefix_ms2_products`.`id`
  LEFT JOIN
      `prefix_ms2_vendors`
  ON
      `prefix_ms2_products`.`vendor` = `prefix_ms2_vendors`.`id`
  WHERE
      `prefix_site_content`.`published` = 1
  AND
      `prefix_site_content`.`class_key` = 'msProduct'
  AND
      `prefix_site_content`.`uri` LIKE 'catalog/%'
  GROUP BY
      `prefix_ms2_products`.`vendor`
  ORDER BY
      `prefix_ms2_vendors`.`name`
Время на его сборку около 15 секунд, если всё работает правильно. Очевидно, что пользователь успеет заварить чай к тому времени, пока увидит список производителей.

Удаление из этого запроса COUNT проблемы не решает, и экономит всего-навсего секунды 2. Напрашивается вывод — AJAX и кеширование, примерно на сутки.

А что, если бы этот запрос выглядел так.

SELECT
      `prefix_ms2_vendors`.`id`,
      `prefix_ms2_vendors`.`name`,
      COUNT( `prefix_site_content`.`id` ) AS `count`
  FROM
      `prefix_ms2_products`
  LEFT JOIN
      `prefix_ms2_vendors`
  ON
      `prefix_ms2_products`.`vendor` = `prefix_ms2_vendors`.`id`
  WHERE
      `prefix_ms2_products`.`published` = 1
  AND
      `prefix_ms2_products`.`class_key` = 'msProduct'
  AND
      `prefix_ms2_products`.`uri` LIKE 'catalog/%'
  GROUP BY
      `prefix_ms2_products`.`vendor`
  ORDER BY
      `prefix_ms2_vendors`.`name`
Забегая наперёд, такой запрос будет выполняться более чем вдвое быстрее.

Для этого нужно объединить msProduct и msProductData в единое целое.

Разумеется, это никак не повлияет на запросы, которые используют поиск по значениям из других таблиц (TV и опции), и подойдёт далеко не для всех магазинов.

Но и подводный камней тут будет не так и много.

Первый — MODX не способен собрать ссылку на ресурс, которого нет в таблице prefix_site_content. Нужно обходиться своими силами, используя ужасно сложную магию.

[[++site_url]][[+uri]]
Нет, это не шутка. Почти для всех сайтов этого решения будет более чем достаточно, при использовании ЧПУ.

Второй — редактирование и просмотр товаров в админке стандартными методами невозможен, без правки процессоров. Но это нам и не нужно.

Используйте импорт из любимых источников (будь то 1С, Google Sheets или что либо другой). При необходимости не трудо собрать примитивный редактор с парой полей на случай, если кому-то приспичит поменять цену на одну из тысяч шапочек или рулон бумаги.

Админка в данном случае бесплезна, ведь в каталоге уже есть готовые фильтры для удобного поиска товаров. И они намного лучше и эффективнее дерева ресурсов MODX.

Третий — в корзину нельзя добавить товар, если он не лежит в таблице prefix_site_content.

Исправляется вот так. Возможно PR пропустят, и в будущих версиях miniShop2 этого прикола не будет.

@Денис Усачев/components/minishop2/model/minishop2/mscarthandler.class.php

  //  Метод
  public function add($id, $count = 1, $options = array())
  //  Вместо
  if ($product = $this->modx->getObject('modResource', $filter)) {
      if (!($product instanceof msProduct)) {
          return $this->error('ms2_cart_add_err_product', $this->status());
      }
  //  Должно быть примерно следующее
  if ($product = $this->modx->getObject('msProduct', $filter)) {
Четвёртый — товары нельзя будет просто так открыть по ссылке, а miniShop2 не знает как себя правильно вести с объектами. Решается плагином.

if( isset( $this ) && $this instanceof modPlugin )
      switch( $modx->event->name ) {
          case 'OnMODXInit' :
              //  Merge msProduct with msProductData
              $modx->loadClass( 'msProduct' );
              $modx->loadClass( 'msProductData' );
              $modx->map[ 'msProduct' ][ 'table' ] = $modx->map[ 'msProductData' ][ 'table' ];
              //--//
          break;
          case 'OnPageNotFound' :
              if( class_exists( 'msProduct' ) && class_exists( 'msProductData' ) &&
                  isset( $modx->map[ 'msProduct' ] ) && isset( $modx->map[ 'msProductData' ] ) &&
                  isset( $modx->map[ 'msProduct' ][ 'table' ] ) && isset( $modx->map[ 'msProductData' ][ 'table' ] ) &&
                  $modx->map[ 'msProduct' ][ 'table' ] === $modx->map[ 'msProductData' ][ 'table' ]
              ) {
                  /*
                      $modx->resourceMethod === 'alias'
                      $modx->resourceIdentifier === 'test.html/'
                  */
                  $uri = rtrim( $modx->resourceIdentifier, trim( $modx->getOption( 'container_suffix', NULL, '' ) ) );
                  $context_key = $modx->context->get( 'key' );
                  $xPDOQuery = $modx->newQuery( 'msProduct', [
                      'context_key' => $context_key,
                      'uri' => $uri,
                      'deleted' => 0
                  ] );
                  $xPDOQuery->select( $modx->getSelectColumns( 'msProduct', '', '', [ 'id' ] ) );
                  if( $xPDOQuery->prepare() && $modx->resource = $modx->getObject( 'msProduct', $modx->getValue( $xPDOQuery->stmt ) ) ) {
                      /*
                      $modx->resource->_content = $modx->resource->_output = '';
                      $modx->resource->_isForward = TRUE;
                      */
                      $modx->elementCache = [];
                      $modx->resourceGenerated = TRUE;
                      $modx->resourceIdentifier = $modx->resource->get( 'id' );
                      $modx->resourceMethod = 'id';
                      $modx->request->prepareResponse();
                  }
              }
          break;
          default :
              //
          break;
      }
И последний, пятый — при сохранении товара через API MODX нужно делать примерно следующее.

//  Тут много разного кода для получения экземпляра msProduct
  if( isset( $this->modx->map[ 'msProduct' ][ 'table' ] ) &&
      isset( $this->modx->map[ 'msProductData' ][ 'table' ] ) &&
      $this->modx->map[ 'msProduct' ][ 'table' ] === $this->modx->map[ 'msProductData' ][ 'table' ]
  ) {
      $msProduct->Data->setDirty();
      $msProduct->Data->_new = FALSE;
  }
  $msProduct->save();

Если всё вышеописанное Вас не смущает, то сам процесс объединения делается так.

Делаем бекапы уже существующих таблиц.

RENAME TABLE `database`.`prefix_ms2_products`
        TO `database`.`prefix_ms2_products_bak`;
    RENAME TABLE `database`.`prefix_site_content`
        TO `database`.`prefix_site_content_bak`;

Создаём таблицу с одним единственным ключём. Обратите внимание на description — в стандартной поставке у него часто нет значения по-умолчанию. Его обязательно нужно добавить.

CREATE TABLE `prefix_ms2_products` ( 
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `type` varchar(20) NOT NULL DEFAULT 'document',
      `contentType` varchar(50) NOT NULL DEFAULT 'text/html',
      `pagetitle` varchar(191) NOT NULL DEFAULT '',
      `longtitle` varchar(191) NOT NULL DEFAULT '',
      `description` mediumtext NOT NULL DEFAULT '',
      `alias` varchar(191) DEFAULT '',
      `link_attributes` varchar(191) NOT NULL DEFAULT '',
      `published` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `pub_date` int(20) NOT NULL DEFAULT 0,
      `unpub_date` int(20) NOT NULL DEFAULT 0,
      `parent` int(10) NOT NULL DEFAULT 0,
      `isfolder` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `introtext` mediumtext DEFAULT NULL,
      `content` longtext DEFAULT NULL,
      `richtext` tinyint(1) unsigned NOT NULL DEFAULT 1,
      `template` int(10) NOT NULL DEFAULT 0,
      `menuindex` int(10) NOT NULL DEFAULT 0,
      `searchable` tinyint(1) unsigned NOT NULL DEFAULT 1,
      `cacheable` tinyint(1) unsigned NOT NULL DEFAULT 1,
      `createdby` int(10) NOT NULL DEFAULT 0,
      `createdon` int(20) NOT NULL DEFAULT 0,
      `editedby` int(10) NOT NULL DEFAULT 0,
      `editedon` int(20) NOT NULL DEFAULT 0,
      `deleted` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `deletedon` int(20) NOT NULL DEFAULT 0,
      `deletedby` int(10) NOT NULL DEFAULT 0,
      `publishedon` int(20) NOT NULL DEFAULT 0,
      `publishedby` int(10) NOT NULL DEFAULT 0,
      `menutitle` varchar(191) NOT NULL DEFAULT '',
      `donthit` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `privateweb` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `privatemgr` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `content_dispo` tinyint(1) NOT NULL DEFAULT 0,
      `hidemenu` tinyint(1) unsigned NOT NULL DEFAULT 0,
      `class_key` varchar(100) NOT NULL DEFAULT 'modDocument',
      `context_key` varchar(100) NOT NULL DEFAULT 'web',
      `content_type` int(11) unsigned NOT NULL DEFAULT 1,
      `uri` mediumtext DEFAULT NULL,
      `uri_override` tinyint(1) NOT NULL DEFAULT 0,
      `hide_children_in_tree` tinyint(1) NOT NULL DEFAULT 0,
      `show_in_tree` tinyint(1) NOT NULL DEFAULT 1,
      `properties` longtext DEFAULT NULL,
      `alias_visible` tinyint(1) unsigned NOT NULL DEFAULT 1,
      `article` varchar(50) DEFAULT NULL,
      `price` decimal(12,2) DEFAULT 0.00,
      `old_price` decimal(12,2) DEFAULT 0.00,
      `weight` decimal(13,3) DEFAULT 0.000,
      `image` varchar(191) DEFAULT NULL,
      `thumb` varchar(191) DEFAULT NULL,
      `vendor` int(10) unsigned DEFAULT 0,
      `made_in` varchar(100) DEFAULT '',
      `new` tinyint(1) unsigned DEFAULT 0,
      `popular` tinyint(1) unsigned DEFAULT 0,
      `favorite` tinyint(1) unsigned DEFAULT 0,
      `tags` mediumtext DEFAULT NULL,
      `color` mediumtext DEFAULT NULL,
      `size` mediumtext DEFAULT NULL,
      `source` int(10) unsigned DEFAULT 1,
      PRIMARY KEY (`id`)
  ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;

Копируем товары. Эта операция довольно быстрая, и обычно вполне успевает за 30-60 секунд.

INSERT INTO
  `prefix_ms2_products` (
      `prefix_ms2_products`.`id`,
      `prefix_ms2_products`.`type`,
      `prefix_ms2_products`.`contentType`,
      `prefix_ms2_products`.`pagetitle`,
      `prefix_ms2_products`.`longtitle`,
      `prefix_ms2_products`.`description`,
      `prefix_ms2_products`.`alias`,
      `prefix_ms2_products`.`link_attributes`,
      `prefix_ms2_products`.`published`,
      `prefix_ms2_products`.`pub_date`,
      `prefix_ms2_products`.`unpub_date`,
      `prefix_ms2_products`.`parent`,
      `prefix_ms2_products`.`isfolder`,
      `prefix_ms2_products`.`introtext`,
      `prefix_ms2_products`.`content`,
      `prefix_ms2_products`.`richtext`,
      `prefix_ms2_products`.`template`,
      `prefix_ms2_products`.`menuindex`,
      `prefix_ms2_products`.`searchable`,
      `prefix_ms2_products`.`cacheable`,
      `prefix_ms2_products`.`createdby`,
      `prefix_ms2_products`.`createdon`,
      `prefix_ms2_products`.`editedby`,
      `prefix_ms2_products`.`editedon`,
      `prefix_ms2_products`.`deleted`,
      `prefix_ms2_products`.`deletedon`,
      `prefix_ms2_products`.`deletedby`,
      `prefix_ms2_products`.`publishedon`,
      `prefix_ms2_products`.`publishedby`,
      `prefix_ms2_products`.`menutitle`,
      `prefix_ms2_products`.`donthit`,
      `prefix_ms2_products`.`privateweb`,
      `prefix_ms2_products`.`privatemgr`,
      `prefix_ms2_products`.`content_dispo`,
      `prefix_ms2_products`.`hidemenu`,
      `prefix_ms2_products`.`class_key`,
      `prefix_ms2_products`.`context_key`,
      `prefix_ms2_products`.`content_type`,
      `prefix_ms2_products`.`uri`,
      `prefix_ms2_products`.`uri_override`,
      `prefix_ms2_products`.`hide_children_in_tree`,
      `prefix_ms2_products`.`show_in_tree`,
      `prefix_ms2_products`.`properties`,
      `prefix_ms2_products`.`alias_visible`,

      `prefix_ms2_products`.`article`,
      `prefix_ms2_products`.`price`,
      `prefix_ms2_products`.`old_price`,
      `prefix_ms2_products`.`weight`,
      `prefix_ms2_products`.`image`,
      `prefix_ms2_products`.`thumb`,
      `prefix_ms2_products`.`vendor`,
      `prefix_ms2_products`.`made_in`,
      `prefix_ms2_products`.`new`,
      `prefix_ms2_products`.`popular`,
      `prefix_ms2_products`.`favorite`,
      `prefix_ms2_products`.`tags`,
      `prefix_ms2_products`.`color`,
      `prefix_ms2_products`.`size`,
      `prefix_ms2_products`.`source`
  )
SELECT
  `prefix_site_content`.`id`,
  `prefix_site_content`.`type`,
  `prefix_site_content`.`contentType`,
  `prefix_site_content`.`pagetitle`,
  `prefix_site_content`.`longtitle`,
  `prefix_site_content`.`description`,
  `prefix_site_content`.`alias`,
  `prefix_site_content`.`link_attributes`,
  `prefix_site_content`.`published`,
  `prefix_site_content`.`pub_date`,
  `prefix_site_content`.`unpub_date`,
  `prefix_site_content`.`parent`,
  `prefix_site_content`.`isfolder`,
  `prefix_site_content`.`introtext`,
  `prefix_site_content`.`content`,
  `prefix_site_content`.`richtext`,
  `prefix_site_content`.`template`,
  `prefix_site_content`.`menuindex`,
  `prefix_site_content`.`searchable`,
  `prefix_site_content`.`cacheable`,
  `prefix_site_content`.`createdby`,
  `prefix_site_content`.`createdon`,
  `prefix_site_content`.`editedby`,
  `prefix_site_content`.`editedon`,
  `prefix_site_content`.`deleted`,
  `prefix_site_content`.`deletedon`,
  `prefix_site_content`.`deletedby`,
  `prefix_site_content`.`publishedon`,
  `prefix_site_content`.`publishedby`,
  `prefix_site_content`.`menutitle`,
  `prefix_site_content`.`donthit`,
  `prefix_site_content`.`privateweb`,
  `prefix_site_content`.`privatemgr`,
  `prefix_site_content`.`content_dispo`,
  `prefix_site_content`.`hidemenu`,
  `prefix_site_content`.`class_key`,
  `prefix_site_content`.`context_key`,
  `prefix_site_content`.`content_type`,
  `prefix_site_content`.`uri`,
  `prefix_site_content`.`uri_override`,
  `prefix_site_content`.`hide_children_in_tree`,
  `prefix_site_content`.`show_in_tree`,
  `prefix_site_content`.`properties`,
  `prefix_site_content`.`alias_visible`,

  `prefix_ms2_products`.`article`,
  `prefix_ms2_products`.`price`,
  `prefix_ms2_products`.`old_price`,
  `prefix_ms2_products`.`weight`,
  `prefix_ms2_products`.`image`,
  `prefix_ms2_products`.`thumb`,
  `prefix_ms2_products`.`vendor`,
  `prefix_ms2_products`.`made_in`,
  `prefix_ms2_products`.`new`,
  `prefix_ms2_products`.`popular`,
  `prefix_ms2_products`.`favorite`,
  `prefix_ms2_products`.`tags`,
  `prefix_ms2_products`.`color`,
  `prefix_ms2_products`.`size`,
  `prefix_ms2_products`.`source`
FROM
  `prefix_site_content_bak`
LEFT JOIN
  `prefix_ms2_products_bak`
ON
  `prefix_site_content_bak`.`id` = `prefix_ms2_products_bak`.`id`
WHERE
  `prefix_site_content_bak`.`class_key` = 'msProduct'
ON DUPLICATE KEY UPDATE
  `prefix_ms2_products`.`id` = `prefix_site_content_bak`.`id`,
  `prefix_ms2_products`.`type` = `prefix_site_content_bak`.`type`,
  `prefix_ms2_products`.`contentType` = `prefix_site_content_bak`.`contentType`,
  `prefix_ms2_products`.`pagetitle` = `prefix_site_content_bak`.`pagetitle`,
  `prefix_ms2_products`.`longtitle` = `prefix_site_content_bak`.`longtitle`,
  `prefix_ms2_products`.`description` = `prefix_site_content_bak`.`description`,
  `prefix_ms2_products`.`alias` = `prefix_site_content_bak`.`alias`,
  `prefix_ms2_products`.`link_attributes` = `prefix_site_content_bak`.`link_attributes`,
  `prefix_ms2_products`.`published` = `prefix_site_content_bak`.`published`,
  `prefix_ms2_products`.`pub_date` = `prefix_site_content_bak`.`pub_date`,
  `prefix_ms2_products`.`unpub_date` = `prefix_site_content_bak`.`unpub_date`,
  `prefix_ms2_products`.`parent` = `prefix_site_content_bak`.`parent`,
  `prefix_ms2_products`.`isfolder` = `prefix_site_content_bak`.`isfolder`,
  `prefix_ms2_products`.`introtext` = `prefix_site_content_bak`.`introtext`,
  `prefix_ms2_products`.`content` = `prefix_site_content_bak`.`content`,
  `prefix_ms2_products`.`richtext` = `prefix_site_content_bak`.`richtext`,
  `prefix_ms2_products`.`template` = `prefix_site_content_bak`.`template`,
  `prefix_ms2_products`.`menuindex` = `prefix_site_content_bak`.`menuindex`,
  `prefix_ms2_products`.`searchable` = `prefix_site_content_bak`.`searchable`,
  `prefix_ms2_products`.`cacheable` = `prefix_site_content_bak`.`cacheable`,
  `prefix_ms2_products`.`createdby` = `prefix_site_content_bak`.`createdby`,
  `prefix_ms2_products`.`createdon` = `prefix_site_content_bak`.`createdon`,
  `prefix_ms2_products`.`editedby` = `prefix_site_content_bak`.`editedby`,
  `prefix_ms2_products`.`editedon` = `prefix_site_content_bak`.`editedon`,
  `prefix_ms2_products`.`deleted` = `prefix_site_content_bak`.`deleted`,
  `prefix_ms2_products`.`deletedon` = `prefix_site_content_bak`.`deletedon`,
  `prefix_ms2_products`.`deletedby` = `prefix_site_content_bak`.`deletedby`,
  `prefix_ms2_products`.`publishedon` = `prefix_site_content_bak`.`publishedon`,
  `prefix_ms2_products`.`publishedby` = `prefix_site_content_bak`.`publishedby`,
  `prefix_ms2_products`.`menutitle` = `prefix_site_content_bak`.`menutitle`,
  `prefix_ms2_products`.`donthit` = `prefix_site_content_bak`.`donthit`,
  `prefix_ms2_products`.`privateweb` = `prefix_site_content_bak`.`privateweb`,
  `prefix_ms2_products`.`privatemgr` = `prefix_site_content_bak`.`privatemgr`,
  `prefix_ms2_products`.`content_dispo` = `prefix_site_content_bak`.`content_dispo`,
  `prefix_ms2_products`.`hidemenu` = `prefix_site_content_bak`.`hidemenu`,
  `prefix_ms2_products`.`class_key` = `prefix_site_content_bak`.`class_key`,
  `prefix_ms2_products`.`context_key` = `prefix_site_content_bak`.`context_key`,
  `prefix_ms2_products`.`content_type` = `prefix_site_content_bak`.`content_type`,
  `prefix_ms2_products`.`uri` = `prefix_site_content_bak`.`uri`,
  `prefix_ms2_products`.`uri_override` = `prefix_site_content_bak`.`uri_override`,
  `prefix_ms2_products`.`hide_children_in_tree` = `prefix_site_content_bak`.`hide_children_in_tree`,
  `prefix_ms2_products`.`show_in_tree` = `prefix_site_content_bak`.`show_in_tree`,
  `prefix_ms2_products`.`properties` = `prefix_site_content_bak`.`properties`,
  `prefix_ms2_products`.`alias_visible` = `prefix_site_content_bak`.`alias_visible`,

  `prefix_ms2_products`.`article` = `prefix_ms2_products_bak`.`article`,
  `prefix_ms2_products`.`price` = `prefix_ms2_products_bak`.`price`,
  `prefix_ms2_products`.`old_price` = `prefix_ms2_products_bak`.`old_price`,
  `prefix_ms2_products`.`weight` = `prefix_ms2_products_bak`.`weight`,
  `prefix_ms2_products`.`image` = `prefix_ms2_products_bak`.`image`,
  `prefix_ms2_products`.`thumb` = `prefix_ms2_products_bak`.`thumb`,
  `prefix_ms2_products`.`vendor` = `prefix_ms2_products_bak`.`vendor`,
  `prefix_ms2_products`.`made_in` = `prefix_ms2_products_bak`.`made_in`,
  `prefix_ms2_products`.`new` = `prefix_ms2_products_bak`.`new`,
  `prefix_ms2_products`.`popular` = `prefix_ms2_products_bak`.`popular`,
  `prefix_ms2_products`.`favorite` = `prefix_ms2_products_bak`.`favorite`,
  `prefix_ms2_products`.`tags` = `prefix_ms2_products_bak`.`tags`,
  `prefix_ms2_products`.`color` = `prefix_ms2_products_bak`.`color`,
  `prefix_ms2_products`.`size` = `prefix_ms2_products_bak`.`size`,
  `prefix_ms2_products`.`source` = `prefix_ms2_products_bak`.`source`;

Создаём желаемые индексы. Их вполне можно править под свои нужды, в зависимости от дополнительных полей и популярных критериев поиска и сортировки.

Ведь это и есть основная цель данного пункта. Если убрать лишние — это значительно ускорит импорт. Например, если вы сортируете по одному полю, а группируете по другому, то стоит добавить покрывающий индекс на оба поля сразу.

Так же здесь можно предотвратить появление дублей товаров, задав условия уникальности для сочетания нескольких полей (например производитель, артикул).

Сама операция может затянуться, так что приготовьте не только чай, но и десерт.

ALTER TABLE `prefix_ms2_products`
        ADD INDEX `alias` (`alias`),
        ADD INDEX `published` (`published`),
        ADD INDEX `pub_date` (`pub_date`),
        ADD INDEX `unpub_date` (`unpub_date`),
        ADD INDEX `parent` (`parent`),
        ADD INDEX `isfolder` (`isfolder`),
        ADD INDEX `template` (`template`),
        ADD INDEX `menuindex` (`menuindex`),
        ADD INDEX `searchable` (`searchable`),
        ADD INDEX `cacheable` (`cacheable`),
        ADD INDEX `hidemenu` (`hidemenu`),
        ADD INDEX `class_key` (`class_key`),
        ADD INDEX `context_key` (`context_key`),
        ADD INDEX `uri` (`uri`(250)),
        ADD INDEX `uri_override` (`uri_override`),
        ADD INDEX `hide_children_in_tree` (`hide_children_in_tree`),
        ADD INDEX `show_in_tree` (`show_in_tree`),
        ADD INDEX `cache_refresh_idx` (`parent`,`menuindex`,`id`),
        ADD FULLTEXT `content_ft_idx` (`pagetitle`,`longtitle`,`description`,`introtext`,`content`),
        ADD INDEX `article` (`article`),
        ADD INDEX `price` (`price`),
        ADD INDEX `old_price` (`old_price`),
        ADD INDEX `vendor` (`vendor`),
        ADD INDEX `new` (`new`),
        ADD INDEX `favorite` (`favorite`),
        ADD INDEX `popular` (`popular`),
        ADD INDEX `made_in` (`made_in`);

Последний этап — удаление товаров из оригинальной таблицы с контентом. Удалять будет долго.

DELETE FROM
    `prefix_site_content`
WHERE
    `prefix_site_content`.`class_key` = 'msProduct';

Ах да. Если всё прошло как задумано, то тот самый костыль для resourceMap больше не нужен.

В заключение

Это далеко не полный список того, что можно сделать для ускорения разных аспектов работы магазина. Проявите фантазию.

Спасибо за внимание.
wfoojjaec
16 сентября 2021, 19:09
modx.pro
30
2 556
+26
Поблагодарить автора Отправить деньги

Комментарии: 4

Роман
17 сентября 2021, 09:43
0
Спасибо, за проделанную работу.
При кэшировании resourceMap, перестает работать pdoCrumbs. Пробовал убрать у него кэширование, но все равно не выводит родителей.
А так, есть прирост скорости.
    wfoojjaec
    17 сентября 2021, 17:09
    0
    Помню, заменял его на BreadCrumb. Разницы в скорости там серьёзной нет.
    Дмитрий
    18 сентября 2021, 16:43
    0
    Ого-го статья, спасибо!
      Павел Голубев
      27 сентября 2021, 16:29
      0
      Если речь о 1m товаров, то зачем вообще их хранить в таблице с ресурсами и потом допилить это напильником?
        Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
        4