System indeksowania Magento 2 to jeden z bardziej niedocenianych mechanizmów platformy. Gdy masz własne dane które muszą być szybko dostępne – ceny z zewnętrznego ERP, stany magazynowe z WMS, własne atrybuty wyszukiwania – napisanie własnego indeksera jest często lepszym rozwiązaniem niż zapisywanie wszystkiego do EAV przy każdym zapytaniu. Pokazuję jak zbudować kompletny indekser z obsługą pełnej i częściowej reindeksacji.
Kiedy własny indekser ma sens?
Magento 2 ma wbudowane indeksery dla cen, kategorii, stanów magazynowych i wyszukiwania. Własny indekser jest uzasadniony gdy:
- Masz dane z zewnętrznego systemu (ERP, WMS) które zmieniają się niezależnie od Magento
- Obliczenia są kosztowne (złożone reguły cen B2B, agregacje danych sprzedaży) i robisz je za często
- Potrzebujesz własnej flat tabeli zoptymalizowanej pod konkretne zapytania
- Chcesz pre-obliczać dane które są wyświetlane na każdej stronie produktu
Architektura indeksera
Vendor/Module/
Model/
Indexer/
ProductCustomData.php - główna klasa indeksera
Action/
Full.php - pełna reindeksacja
Row.php - reindeksacja jednego wiersza
Rows.php - reindeksacja wielu wierszy
ResourceModel/
Indexer/
ProductCustomData.php - operacje na flat table
etc/
indexer.xml - rejestracja indeksera
mview.xml - konfiguracja triggerów change tracking
Setup/
InstallSchema.php - tworzenie flat table
Rejestracja indeksera – indexer.xml
<!-- etc/indexer.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
<indexer id="vendor_module_product_custom"
view_id="vendor_module_product_custom"
class="Vendor\Module\Model\Indexer\ProductCustomData"
primary="catalog_product">
<title translate="true">Vendor Product Custom Data</title>
<description translate="true">Indexes custom product data from external sources</description>
<!-- Zależności - nasz indekser wymaga aktualnego indeksu produktów -->
<fieldset name="catalogrule_product">
<field name="entity_id" xsi:type="filterable" handler="Magento\CatalogRule\Model\Indexer\DeltaIndexer">
<depends>
<indexer id="catalog_product_flat"/>
</depends>
</field>
</fieldset>
</indexer>
</config>
Konfiguracja change tracking – mview.xml
<!-- etc/mview.xml - obserwuj zmiany w tabelach i triggeruj reindeksację -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Mview/etc/mview.xsd">
<view id="vendor_module_product_custom"
class="Vendor\Module\Model\Indexer\ProductCustomData"
group="indexer">
<subscriptions>
<!-- Obserwuj zmiany w tabeli produktów -->
<table name="catalog_product_entity" entity_column="entity_id"/>
<!-- Obserwuj zmiany w naszej własnej tabeli z danymi z ERP -->
<table name="vendor_module_erp_data" entity_column="product_id"/>
<!-- Obserwuj zmiany cen -->
<table name="catalog_product_entity_decimal"
entity_column="entity_id"
column="attribute_id"/>
</subscriptions>
</view>
</config>
Flat table – setup schema
<?php
declare(strict_types=1);
namespace Vendor\Module\Setup;
use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
use Magento\Framework\Setup\ModuleContextInterface;
class InstallSchema implements InstallSchemaInterface
{
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context): void
{
$setup->startSetup();
$table = $setup->getConnection()->newTable(
$setup->getTable('vendor_module_product_index')
)
->addColumn('entity_id', Table::TYPE_INTEGER, null, [
'unsigned' => true,
'nullable' => false,
'primary' => true,
], 'Product ID')
->addColumn('erp_stock_qty', Table::TYPE_DECIMAL, '10,4', [
'nullable' => false,
'default' => '0.0000',
], 'ERP Stock Quantity')
->addColumn('erp_price', Table::TYPE_DECIMAL, '12,4', [
'nullable' => true,
], 'ERP Price Override')
->addColumn('lead_time_days', Table::TYPE_SMALLINT, null, [
'unsigned' => true,
'nullable' => false,
'default' => 0,
], 'Lead Time in Days')
->addColumn('is_available_erp', Table::TYPE_SMALLINT, null, [
'nullable' => false,
'default' => 1,
], 'Available in ERP')
->addColumn('indexed_at', Table::TYPE_TIMESTAMP, null, [
'nullable' => false,
'default' => Table::TIMESTAMP_INIT_UPDATE,
], 'Last Indexed At')
->addIndex(
$setup->getIdxName('vendor_module_product_index', ['is_available_erp']),
['is_available_erp']
)
->addForeignKey(
$setup->getFkName('vendor_module_product_index', 'entity_id', 'catalog_product_entity', 'entity_id'),
'entity_id',
$setup->getTable('catalog_product_entity'),
'entity_id',
Table::ACTION_CASCADE
)
->setComment('Vendor Module Product Custom Index');
$setup->getConnection()->createTable($table);
$setup->endSetup();
}
}
Główna klasa indeksera
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Indexer;
use Magento\Framework\Indexer\ActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
class ProductCustomData implements ActionInterface, MviewActionInterface
{
public function __construct(
private Action\Full $fullAction,
private Action\Row $rowAction,
private Action\Rows $rowsAction
) {}
// Wywoływane przez bin/magento indexer:reindex vendor_module_product_custom
public function executeFull(): void
{
$this->fullAction->execute();
}
// Wywoływane dla jednego produktu (np. po zapisaniu w adminie)
public function executeRow($id): void
{
$this->rowsAction->execute([$id]);
}
// Wywoływane dla wielu produktów naraz
public function executeList(array $ids): void
{
$this->rowsAction->execute($ids);
}
// MviewActionInterface - wywoływane przez cron z listą zmienionych ID
public function execute($ids): void
{
$this->rowsAction->execute($ids);
}
}
Full reindeksacja
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Indexer\Action;
use Vendor\Module\Model\ResourceModel\Indexer\ProductCustomData as IndexerResource;
class Full
{
private const BATCH_SIZE = 1000;
public function __construct(
private IndexerResource $indexerResource,
private \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory,
private \Vendor\Module\Model\ErpDataProvider $erpDataProvider,
private \Psr\Log\LoggerInterface $logger
) {}
public function execute(): void
{
$this->logger->info('Full reindex started');
$start = microtime(true);
// Wyczyść starą flat table
$this->indexerResource->clearTable();
// Pobierz wszystkie ID produktów w batchach
$collection = $this->productCollectionFactory->create();
$collection->addFieldToSelect('entity_id');
$ids = $collection->getAllIds();
$batches = array_chunk($ids, self::BATCH_SIZE);
$total = count($ids);
$processed = 0;
foreach ($batches as $batchIds) {
$this->processBatch($batchIds);
$processed += count($batchIds);
if ($processed % 5000 === 0) {
$this->logger->info("Reindex progress: {$processed}/{$total}");
}
}
$elapsed = round(microtime(true) - $start, 2);
$this->logger->info("Full reindex completed: {$total} products in {$elapsed}s");
}
private function processBatch(array $productIds): void
{
// Pobierz dane z ERP dla całego batcha jednym zapytaniem
$erpData = $this->erpDataProvider->getDataForProducts($productIds);
$rows = [];
foreach ($productIds as $productId) {
$erp = $erpData[$productId] ?? [];
$rows[] = [
'entity_id' => $productId,
'erp_stock_qty' => $erp['qty'] ?? 0.0,
'erp_price' => $erp['price'] ?? null,
'lead_time_days' => $erp['lead_time'] ?? 0,
'is_available_erp' => !empty($erp) ? 1 : 0,
];
}
// Zbiorczy insert - znacznie szybszy niż pojedyncze row-by-row
$this->indexerResource->insertBatch($rows);
}
}
Resource Model indeksera
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\ResourceModel\Indexer;
use Magento\Framework\App\ResourceConnection;
class ProductCustomData
{
private const TABLE = 'vendor_module_product_index';
public function __construct(
private ResourceConnection $resourceConnection
) {}
public function clearTable(): void
{
$this->getConnection()->truncateTable($this->getTableName());
}
public function insertBatch(array $rows): void
{
if (empty($rows)) {
return;
}
$this->getConnection()->insertOnDuplicate(
$this->getTableName(),
$rows,
['erp_stock_qty', 'erp_price', 'lead_time_days', 'is_available_erp']
);
}
public function deleteByProductIds(array $ids): void
{
$this->getConnection()->delete(
$this->getTableName(),
['entity_id IN (?)' => $ids]
);
}
public function getDataByProductId(int $productId): ?array
{
$select = $this->getConnection()->select()
->from($this->getTableName())
->where('entity_id = ?', $productId);
$result = $this->getConnection()->fetchRow($select);
return $result ?: null;
}
private function getConnection(): \Magento\Framework\DB\Adapter\AdapterInterface
{
return $this->resourceConnection->getConnection();
}
private function getTableName(): string
{
return $this->resourceConnection->getTableName(self::TABLE);
}
}
Używanie danych z indeksu w modułach
<?php
declare(strict_types=1);
// Plugin który dodaje dane z flat index do produktu w GraphQL/API
namespace Vendor\Module\Plugin;
class AddIndexedDataToProductPlugin
{
public function __construct(
private \Vendor\Module\Model\ResourceModel\Indexer\ProductCustomData $indexResource
) {}
public function afterGetPrice(
\Magento\Catalog\Model\Product $subject,
float $result
): float {
// Użyj dane z flat index zamiast zapytania do EAV
$indexData = $this->indexResource->getDataByProductId((int) $subject->getId());
if ($indexData && $indexData['erp_price'] !== null) {
return (float) $indexData['erp_price'];
}
return $result;
}
}
# Uruchamianie indeksera bin/magento indexer:reindex vendor_module_product_custom # Status bin/magento indexer:status vendor_module_product_custom # Ustaw tryb reindeksacji bin/magento indexer:set-mode schedule vendor_module_product_custom # Ręczne uruchomienie mview dla trybu schedule bin/magento indexer:reindex vendor_module_product_custom --mode=changelog
Podsumowanie
Własny indekser w Magento 2 to inwestycja która zwraca się gdy masz dane które zmieniają się niezależnie od cyklu życia produktu w Magento lub gdy obliczenia w locie przy każdym requeście są zbyt kosztowne. Flat table z własną strukturą jest zawsze szybsza do odczytu niż EAV join przez 5 tabel. Konfiguracja mview.xml sprawia że reindeksacja odbywa się automatycznie w tle – bez manualnego uruchamiania przy każdej zmianie.
