X

Magento 2: Как работает индексация

Для решения одной из проблем, пришлось разобраться с индексацией в Magento 2. В этой статье расскажу о том, что мне удалось узнать.

Индекс и индексатор

В Magento 2 довольно сложная система хранения продуктов, категорий и связанных с ними данных, таких как цены товаров, цены групп товаров, скидки и т.д. Для того, чтобы ускорить выборку этих данных, используются индексные таблицы. Без индексации, пришлось бы все это рассчитывать "на лету", что значительно отразилось бы на производительности. Индексные таблицы представляют собой набор данных, оптимизированный для быстрой выборки. Такой подход позволяет заранее произвести все нужные расчеты, например учесть скидки и в дальнейшем использовать уже обработанные данные вместо того, чтобы делать это для каждого запроса.

За создание индексов отвечают индексаторы. По сути, это классы отвечающие за то, чтобы обработать данные и сохранить их в нужном формате.

  • Индекс = это таблица с оптимизированным для выборки набором данных.
  • Индексатор = класс который оптимизирует данные и записывает их в таблицу индекса

Способы обновления данных

Предусмотрено 2а способа обновления данных индекса

  • Полный реиндекс - используется когда необходимо пересчитать все данные индекса.
  • Частичный реиндекс - используется при обновлении конкретного значения индекса.

Теперь поговорим про каждый из способов подробнее.

Полный реиндекс

Для запуска полного реиндекса используется крон задача indexer_reindex_all_invalid, описанная в файле

/vendor/magento/module-indexer/etc/crontab.xml

<!-- vendor/magento/module-indexer/etc/crontab.xml -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="index">
        <job name="indexer_reindex_all_invalid" instance="Magento\Indexer\Cron\ReindexAllInvalid" method="execute">
            <schedule>* * * * *</schedule>
        </job>
        ...
    </group>
</config>

Работает это так. Некоторые запускаемые процессы могут пометить весь индекс как "invalid". Делается это установкой статуса invalid в таблице indexer_state. Например, некоторый процесс поставит статус invalid для индексера catalog_product_flat

Magento 2: Статус invalid для индексера

В админке это будет выглядеть так

Magento 2: индексер atalog_product_flat

В случае, если magento крон установлен, то такой индекс будет обнаружен задачей indexer_reindex_all_invalid и отправлен на полную переиндексацию.

Возможности выполнить задачу indexer_reindex_all_invalid вручную средствами magento нет. Однако на помощь придет утилита N98-Magerun: https://github.com/netz98/n98-magerun2 . С ее помощью можно запустить эту задачу вот так:

php n98-magerun2.phar sys:cron:run indexer_reindex_all_invalid

а вот так посмотреть список всех доступных задач

php n98-magerun2.phar sys:cron:list

Другой способ запустить переиндексацию, это выполнить консольную команду указав конкретный индексатор

php bin/magento indexer:reindex catalog_product_flat

Так же из консоли можно перестроить все индексы сразу, для этого нужно просто опустить название индексатора

php bin/magento indexer:reindex

В результате вы получите сообщение, что все индексы были перестроены

Design Config Grid index has been rebuilt successfully in <time>
Customer Grid index has been rebuilt successfully in <time>
Category Products index has been rebuilt successfully in <time>
Product Categories index has been rebuilt successfully in <time>
Catalog Rule Product index has been rebuilt successfully in <time>
Product EAV index has been rebuilt successfully in <time>
Inventory index has been rebuilt successfully in <time>
Catalog Product Rule index has been rebuilt successfully in <time>
Stock index has been rebuilt successfully in <time>
Product Price index has been rebuilt successfully in <time>
Catalog Search index has been rebuilt successfully in <time>

Просмотреть статус индексов из консоли можно так

bin/magento indexer:status

результат

+----------------------+------------------+-----------+---------------------+---------------------+
| Title                | Status           | Update On | Schedule Status     | Schedule Updated    |
+----------------------+------------------+-----------+---------------------+---------------------+
| Catalog Product Rule | Reindex required | Save      |                     |                     |
| Catalog Rule Product | Reindex required | Save      |                     |                     |
| Catalog Search       | Ready            | Save      |                     |                     |
| Category Products    | Reindex required | Schedule  | idle (0 in backlog) | 2018-06-28 09:45:53 |
| Customer Grid        | Ready            | Schedule  | idle (0 in backlog) | 2018-06-28 09:45:52 |
| Design Config Grid   | Ready            | Schedule  | idle (0 in backlog) | 2018-06-28 09:45:52 |
| Inventory            | Ready            | Save      |                     |
| Product Categories   | Reindex required | Schedule  | idle (0 in backlog) | 2018-06-28 09:45:53 |
| Product EAV          | Reindex required | Save      |                     |                     |
| Product Price        | Reindex required | Save      |                     |                     |
| Stock                | Reindex required | Save      |                     |                     |
+----------------------+------------------+-----------+---------------------+---------------------+

Тут

  • Title = название индекса
  • Status = статус индекса указывающий на то, требуется обновление или нет
  • Update On = тип частичного реиндекса (см. следующий раздел)
  • Schedule Status = Статус индексатора, указывающий на то, был ли он запущен или нет.
  • Schedule Updated = Время обновления Schedule Status

Просмотреть id индексаторов из консоли можно так

bin/magento indexer:info

выведет

design_config_grid                       Design Config Grid
customer_grid                            Customer Grid
catalog_category_product                 Category Products
catalog_product_category                 Product Categories
catalogrule_rule                         Catalog Rule Product
catalog_product_attribute                Product EAV
inventory                                Inventory
catalogrule_product                      Catalog Product Rule
cataloginventory_stock                   Stock
catalog_product_price                    Product Price
catalogsearch_fulltext                   Catalog Search

Подведем итоги по командам: php bin/magento <команда>

  • indexer:status = покажет какие  индексы требуют обновления
  • indexer:info = покажет id всех индексаторов
  • indexer:reindex = переиндексировать все индексы (все индексаторы будут запущены по очереди)
  • indexer:reindex <id-индексатора> = запустить конкретный индексатор

Частичный реиндекс

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

В Magento 2 существует два типа частичного реиндекса:

  • Update on Save = данные обновляются в момент сохранения изменений
  • Update by Schedule = измененные данные обновляются по расписанию

Для каждого из индексов, можно задать свой тип обновления. Например, индекс цен обновлять "по расписанию", а индекc категорий обновлять "в момент сохранения". Изменить эти настройки, можно в админке: Magento Admin > System > Tools > Index Management, затем отметить чекбоксами нужные индексы и выбрать нужный пункт в выпадающем списке "Actions"

Magento 2. Тип индексации

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

Update on Save - используется в случае, если при сохранении не будет необходимости обновлять большое кол-во элементов. Например, у вас 3 категории и 50 товаров, тогда смело можно использовать этот способ. Кроме того, данный способ позволяет практически моментально синхронизировать изменения с витриной.

Update on Schedule -  используется в случае, когда у вас огромное кол-во товаров или требуется перестроить индекс для большого кол-ва записей. Работает это так: id измененных записей накапливаются в отдельной таблице с постфиксом _cl, по расписанию запускается процесс индексации, который поочередно обновляет эти записи.

Конфигурация расписания Update on Schedule

Как вы уже знаете, индексы для которых указан тип индексации "Update on Schedule" будут запущены по расписанию.

Индексаторы запускаются в отдельной крон-группе: index. Настройки для данной группы задаются в админке, в разделе:

Magento Admin > Store > Configuration > Advanced > System > Cron (Scheduled Tasks) > Cron configuration options for group: index

Magento 2: Настройки крона для группы indexer

Расписание для индексаторов задается в файлe /vendor/magento/module-indexer/etc/crontab.xml

<!-- vendor/magento/module-indexer/etc/crontab.xml -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="index">
        ... 
        <job name="indexer_update_all_views" instance="Magento\Indexer\Cron\UpdateMview" method="execute">
            <schedule>* * * * *</schedule>
        </job>
        <job name="indexer_clean_all_changelogs" instance="Magento\Indexer\Cron\ClearChangelog" method="execute">
            <schedule>0 * * * *</schedule>
        </job>
    </group>
</config>

Подробнее про настройки и то как работает планировщик можно прочитать тут: Magento 2: Планировщик задач

Тут мы видим 2 крона, связанных с частичным индексированием:

  • ndexer_update_all_views - индексирует записи из таблиц имя-таблицы_cl
  • indexer_clean_all_changelogs - очищает старые записи из таблиц имя-таблицы_cl

Прежде чем продолжать нам нужно разобраться с тем что такое MView и как это используется в Magento

MView

Mview это сокращение от Materialized Views или на русском Материализованное представление.

Материализо́ванное представле́ние — физический объект базы данных, содержащий результат выполнения запроса.
Материализованные представления позволяют многократно ускорить выполнение запросов, обращающихся к большому количеству (сотням тысяч или миллионам) записей, позволяя за секунды (и даже доли секунд) выполнять запросы к терабайтам данных. Это достигается за счет прозрачного использования заранее вычисленных итоговых данных и результатов соединений таблиц. Предварительно вычисленные итоговые данные обычно имеют очень небольшой объем по сравнению с исходными данными. Целостность данных в материализованных представлениях поддерживается за счёт периодических синхронизаций или с использованием триггеров.
Впервые появились в СУБД Oracle.

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

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

MySQL не имеет поддержки Materialized View, поэтому вместо виртуальных таблиц используются реальные. В Magento это индексные и flat таблицы.

Как работает обновление данных

Прежде всего, нужно пойти в админки и выбрать для определенного индекса режим работы "Update by Schedule"

Magento 2 индекс Update by schedule

В момент изменения режима работы, в базе данных будет создана таблица с постфиксом _cl (у нас это catalog_product_flat_cl), а так же будут добавлены тригеры, отвечающие за то, чтобы сохранять change log-и, или проще говоря идентификатор того, что определенное значение в таблице было обновлено. Тригеры навешиваются на CRUD операции в базе, поэтому добавляется сразу несколько тригеров. В случае, если сущность является EAV, то тригеры будет сгенерированы для каждой из участвующих таблиц. Т.е. в нашем примере, будет добавлено 27 тригеров, для 3х операций (INSERT, UPDATE, DELETE) в 9 таблицах:

catalog_product_entity
catalog_product_entity_datetime
catalog_product_entity_decimal
catalog_product_entity_gallery
catalog_product_entity_int
catalog_product_entity_media_gallery_value
catalog_product_entity_text
catalog_product_entity_tier_price
catalog_product_entity_varchar

Просмотреть тригеры можно такой командой

SHOW TRIGGERS FROM <db-name>;

пример вывода

Magento 2 тригеры

Если просмотреть запросы, мы заметим что все что делают эти тригеры, это добавление id сущности в таблицу с постфиксом _cl. Т.е. каждый раз при обновлении записи, в таблицу с постфиксом _cl будет добавлено новое значение. Если посмотреть структуру таких таблиц, то увидим что там сохраняется всего два значения version_id и entity_id. Поле version_id - является primary key с автоинкрементом, entity_id - id сущности которая поменялась.

Magento 2: Структура cl таблицы

Подведем промежуточный итог: при включении режима обновления индексов "Update by schedule" создается таблица с постфиксом _cl, а так же добавляются тригеры на изменения данных сущности, которые наполняют эту таблицу. Как только мы обновляем какой-то товар в админке, в таблицу catalog_product_flat_cl добавляется новое значение.

Далее, нам нужно обратить внимание на таблицу mview_state, которая содержит информацию о том какие индексы работают в режиме "Update by schedule", их статус и главное, последнее обработанное значение из _cl таблицы в поле version_id.

Magento 2: Таблица mview_state

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

  • Каждую минуту запускается magento планировщик (про планировщик написано тут)
  • Планировщик запускает индексатор, который называется indexer_update_all_views
  • Этот индексатор, перебирает поочередно все записи имеющие mode=enabled из таблицы mview_state
  • Далее он берет из этой таблицы mview_state.version_id и ищет в таблице catalog_product_flat_cl все version_id которые больше этого значения.
  • Таким образом индексатор узнает какие записи обновились с момента последней обработки, а соответственно их нужно обновить в индексе
  • Производит обновление индексов для этих записей
  • Обновляет значение mview_state.version_id , на последнее обработанное
  • Завершает работу

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

Если присмотреться к списку шагов, вы не найдете шага с удалением обработанных записей из cl таблицы. Действительно, индексатор indexer_update_all_views не удаляет записи, которые он обработал, только меняет version_id на последний в таблице mview_state. За очистку старых данных в cl таблице отвечает другая задача, а именно indexer_clean_all_changelogs.

Объявления обоих индексаторов и связанных с ними классов находятся в файле: /vendor/magento/module-indexer/etc/crontab.xml

Под капотом indexer_update_all_views

Рассмотрим стек вызова индексаторов при запуске задачи indexer_update_all_views.

Я пока не определился с нормальным переводом view, view processor, indexer в используемом контексте, поэтому все три сущности буду называть индексатор, чтобы не путать терминами типа "вид", "процессор вида" и т.д.

Сперва запускается стандартный cron:run который согласно расписания определенного тут

/vendor/magento/module-indexer/etc/crontab.xml

<!-- vendor/magento/module-indexer/etc/crontab.xml -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="index">
        ... 
        <job name="indexer_update_all_views" instance="Magento\Indexer\Cron\UpdateMview" method="execute">
            <schedule>* * * * *</schedule>
        </job>
       ... 
    </group>
</config>

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

Magento\Indexer\Cron\UpdateMview::execute

vendor/magento/module-indexer/Cron/UpdateMview.php 
<?php
namespace Magento\Indexer\Cron;

class UpdateMview
{
    /**
     * @var \Magento\Indexer\Model\Processor
     */    protected $processor;

    /**
     * @param \Magento\Indexer\Model\Processor $processor
     */    public function __construct(
        \Magento\Indexer\Model\Processor $processor
    ) {
        $this->processor = $processor;
    }

    /**
     * Regenerate indexes for all invalid indexers
     *
     * @return void
     */    public function execute()
    {
        $this->processor->updateMview();
    }
}

как видим тут ничего практически не происходит, поэтому обратимся к вызову находящемуся внутри метода execute

\Magento\Indexer\Model\Processor::updateMview 
vendor/magento/module-indexer/Model/Processor.php
<?php
namespace Magento\Indexer\Model;

class Processor
{
...
    /**
     * Update indexer views
     *
     * @return void
     */    public function updateMview()
    {
        $this->mviewProcessor->update('indexer');
    }
..
}

как видим и тут не очень много полезного, опускаемся еще ниже

\Magento\Framework\Mview\Processor::update
vendor/magento/framework/Mview/Processor.php
<?php
namespace Magento\Framework\Mview;

class Processor implements ProcessorInterface
{
    /**
     * @var View\CollectionFactory
     */    protected $viewsFactory;

    /**
     * @param View\CollectionFactory $viewsFactory
     */    public function __construct(View\CollectionFactory $viewsFactory)
    {
        $this->viewsFactory = $viewsFactory;
    }

    /**
     * Return list of views by group
     *
     * @param string $group
     * @return ViewInterface[]
     */    protected function getViewsByGroup($group = '')
    {
        $collection = $this->viewsFactory->create();
        return $group ? $collection->getItemsByColumnValue('group', $group) : $collection->getItems();
    }

    /**
     * Materialize all views by group (all views if empty)
     *
     * @param string $group
     * @return void
     */    public function update($group = '')
    {
        foreach ($this->getViewsByGroup($group) as $view) {
            $view->update();
        }
    }

...
}

тут все становится немного интереснее, а именно мы видим что выбираются индексаторы соответствующей группы (если она указана) и далее уже у индексаторов поочередно запускается метод update. Это означает, что если в одном из индексаторов произойдет ошибка которая прервет выполнение работы, то остальные индексаторы запущены не будут. Очередность можно получить с помощью вот такого простого скрипта, который надо положить в корневую диекторию проекта и запустить из консоли

<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
ini_set('memory_limit', '5G');
error_reporting(E_ALL);

require 'app/bootstrap.php';

$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);

$objectManager = $bootstrap->getObjectManager();

/** @var  $state \Magento\Framework\App\State */
$state = $objectManager->get(\Magento\Framework\App\State::class);
$state->setAreaCode(\Magento\Framework\App\Area::AREA_GLOBAL);

/** @var  $state \Magento\Framework\Mview\Processor */$mview = $objectManager->create(\Magento\Framework\Mview\Processor::class);

$r = new ReflectionMethod($mview, 'getViewsByGroup');
$r->setAccessible(true);
$collection = $r->invoke($mview, 'indexer');

foreach($collection as $item) {
    echo $item->getViewId().PHP_EOL;
}

Выполнятся следующие индексаторы сверху вниз

design_config_dummy
customer_dummy
catalog_category_product
catalog_product_category
catalogrule_rule
catalog_product_attribute
cataloginventory_stock
inventory
catalogrule_product
catalog_product_price
targetrule_product_rule
targetrule_rule_product
salesrule_rule
catalogsearch_fulltext
catalogpermissions_category
catalogpermissions_product

по-умолчанию, индексаторы имеют тип

Magento\Framework\Mview\View
vendor/magento/framework/Mview/View.php
class View extends \Magento\Framework\DataObject implements ViewInterface
{
...
    public function update()
    {
        if ($this->getState()->getStatus() == View\StateInterface::STATUS_IDLE) {
            try {
                $currentVersionId = $this->getChangelog()->getVersion();
            } catch (ChangelogTableNotExistsException $e) {
                return;
            }
            $lastVersionId = (int) $this->getState()->getVersionId();
            $action = $this->actionFactory->get($this->getActionClass());

            try {
                $this->getState()->setStatus(View\StateInterface::STATUS_WORKING)->save();

                $versionBatchSize = self::$maxVersionQueryBatch;
                $batchSize = isset($this->changelogBatchSize[$this->getChangelog()->getViewId()])
                    ? $this->changelogBatchSize[$this->getChangelog()->getViewId()]
                    : self::DEFAULT_BATCH_SIZE;

                for ($versionFrom = $lastVersionId; $versionFrom < $currentVersionId; $versionFrom += $versionBatchSize) {
                    // Don't go past the current version for atomicy.
                    $versionTo = min($currentVersionId, $versionFrom + $versionBatchSize);
                    $ids = $this->getChangelog()->getList($versionFrom, $versionTo);

                    // We run the actual indexer in batches.  Chunked AFTER loading to avoid duplicates in separate chunks.
                    $chunks = array_chunk($ids, $batchSize);
                    foreach ($chunks as $ids) {
                        $action->execute($ids);
                    }
                }

                $this->getState()->loadByView($this->getId());
                $statusToRestore = $this->getState()->getStatus() == View\StateInterface::STATUS_SUSPENDED
                    ? View\StateInterface::STATUS_SUSPENDED
                    : View\StateInterface::STATUS_IDLE;
                $this->getState()->setVersionId($currentVersionId)->setStatus($statusToRestore)->save();
            } catch (\Exception $exception) {
                $this->getState()->loadByView($this->getId());
                $statusToRestore = $this->getState()->getStatus() == View\StateInterface::STATUS_SUSPENDED
                    ? View\StateInterface::STATUS_SUSPENDED
                    : View\StateInterface::STATUS_IDLE;
                $this->getState()->setStatus($statusToRestore)->save();
                throw $exception;
            }
        }
    }
...
}

если убрать разбиение на группы и переключение статусов, то мы увидим что тут из view берется action_class и у него вызывается метод execute с передачей внутрь id-шек которые нужно проиндексировать:

$action = $this->actionFactory->get($this->getActionClass());
...
foreach ($chunks as $ids) { 
    $action->execute($ids); 
}

эти $action'ы - это классы определенные в файлах etc/indexer.xml , пример

vendor/magento/module-catalog-rule/etc/indexer.xml

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
    <indexer id="catalogrule_rule" view_id="catalogrule_rule" class="Magento\CatalogRule\Model\Indexer\Rule\RuleProductIndexer" shared_index="catalogrule">
        <title translate="true">Catalog Rule Product</title>
        <description translate="true">Indexed rule/product association</description>
    </indexer>
    <indexer id="catalogrule_product" view_id="catalogrule_product" class="Magento\CatalogRule\Model\Indexer\Product\ProductRuleIndexer" shared_index="catalogrule">
        <title translate="true">Catalog Product Rule</title>
        <description translate="true">Indexed product/rule association</description>
    </indexer>
    <indexer id="catalog_product_price">
        <dependencies>
            <indexer id="catalogrule_rule" />
        </dependencies>
    </indexer>
</config>

вот полный список классов по-умолчанию для упомянутых индексаторов

design_config_dummy         => Magento\Framework\Indexer\Action\Dummy
customer_dummy              => Magento\Framework\Indexer\Action\Dummy
catalog_category_product    => Magento\Catalog\Model\Indexer\Category\Product
catalog_product_category    => Magento\Catalog\Model\Indexer\Product\Category
catalogrule_rule            => Magento\CatalogRule\Model\Indexer\Rule\RuleProductIndexer
catalog_product_attribute   => Magento\Catalog\Model\Indexer\Product\Eav
cataloginventory_stock      => Magento\CatalogInventory\Model\Indexer\Stock
inventory                   => Magento\InventoryIndexer\Indexer\Mview\Action
catalogrule_product         => Magento\CatalogRule\Model\Indexer\Product\ProductRuleIndexer
catalog_product_price       => Magento\Catalog\Model\Indexer\Product\Price
targetrule_product_rule     => Magento\TargetRule\Model\Indexer\TargetRule\Product\Rule
targetrule_rule_product     => Magento\TargetRule\Model\Indexer\TargetRule\Rule\Product
salesrule_rule              => Magento\AdvancedSalesRule\Model\Indexer\SalesRule
catalogsearch_fulltext      => Magento\CatalogSearch\Model\Indexer\Mview\Action
catalogpermissions_category => Magento\CatalogPermissions\Model\Indexer\Category
catalogpermissions_product  => Magento\CatalogPermissions\Model\Indexer\Product

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

Заключение

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

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

Полезнае ссылки

Категории: Magento