Como criar um indexer customizado no Magento 2 (com Mview)
O Magento usa indexers para transformar dados normalizados em tabelas flat, rápidas de ler. Preços, estoque, URL rewrites, produtos por categoria e o índice de busca funcionam assim. Quando o seu próprio módulo precisa de dados pré-calculados, por exemplo um score por produto montado a partir de várias tabelas, ou uma tabela desnormalizada para uma exportação, um indexer customizado dá a mesma estrutura: um comando de reindex completo, atualizações parciais por triggers do banco e status visível no admin.
Este guia monta um para Magento Open Source ou Adobe Commerce 2.4.8.
Como a indexação funciona
Um indexer tem dois modos, definidos por indexer:
- Update on Save (realtime). O código que altera dados chama o indexer diretamente, e o índice é atualizado na mesma request.
- Update by Schedule. O Magento cria triggers MySQL nas tabelas em que você se inscreve. Cada insert, update ou delete grava o ID da entidade em uma tabela de changelog. Um job de cron lê o changelog e chama o seu indexer com a lista de IDs alterados.
O modo schedule é o que você quer em qualquer loja com tráfego real ou imports regulares. Ele tira a indexação da request de save e agrupa as mudanças. Os prós e contras dos dois modos estão em o artigo sobre modos de indexer.
A parte responsável por triggers e changelogs se chama Mview. Um indexer customizado normalmente tem uma declaração de indexer e uma view Mview.
O exemplo
O indexer vai manter a acme_product_score, uma tabela com uma linha por produto e um score calculado a partir de preço e estoque. Ele se inscreve na tabela de entidade de produto, na tabela do atributo de preço e na tabela de stock item, então uma mudança em qualquer uma delas dispara um reindex parcial.
Declarando o indexer
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="acme_product_score"
view_id="acme_product_score"
class="Acme\ProductScore\Model\Indexer\ProductScore">
<title translate="true">Acme Product Score</title>
<description translate="true">Calculates a sort score per product</description>
</indexer>
</config>
etc/mview.xml:
<?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="acme_product_score"
class="Acme\ProductScore\Model\Indexer\ProductScore"
group="indexer">
<subscriptions>
<table name="catalog_product_entity" entity_column="entity_id"/>
<table name="catalog_product_entity_decimal" entity_column="entity_id"/>
<table name="cataloginventory_stock_item" entity_column="product_id"/>
</subscriptions>
</view>
</config>
O view_id no indexer.xml precisa bater com o id no mview.xml. A entity_column é a coluna cujo valor vai para o changelog, e precisa ser o ID do produto em todas as tabelas inscritas.
No Adobe Commerce, as tabelas de entidade do catálogo usam row_id como campo de ligação por causa do content staging, enquanto o entity_id continua na catalog_product_entity. As tabelas de valores de atributo lá têm row_id, não entity_id. Se o módulo precisa funcionar nas duas edições, veja como os arquivos mview.xml do core tratam isso na sua versão antes de se inscrever em tabelas de atributo.
A classe do indexer
A classe implementa duas interfaces. A Magento\Framework\Indexer\ActionInterface é usada pela CLI e pelo modo realtime. A Magento\Framework\Mview\ActionInterface é usada pelo cron do modo schedule.
<?php
declare(strict_types=1);
namespace Acme\ProductScore\Model\Indexer;
use Acme\ProductScore\Model\ScoreBuilder;
use Magento\Framework\Indexer\ActionInterface as IndexerActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
class ProductScore implements IndexerActionInterface, MviewActionInterface
{
private const BATCH_SIZE = 1000;
public function __construct(
private readonly ScoreBuilder $scoreBuilder
) {
}
public function executeFull(): void
{
$this->scoreBuilder->rebuildAll(self::BATCH_SIZE);
}
public function executeList(array $ids): void
{
$this->reindexIds($ids);
}
public function executeRow($id): void
{
$this->reindexIds([$id]);
}
public function execute($ids): void
{
$this->reindexIds($ids);
}
private function reindexIds(array $ids): void
{
$ids = array_values(array_unique(array_map('intval', $ids)));
foreach (array_chunk($ids, self::BATCH_SIZE) as $chunk) {
$this->scoreBuilder->rebuildForProducts($chunk);
}
}
}
Mantenha a classe do indexer pequena e coloque o SQL em um service separado. Isso deixa a lógica testável e os pontos de entrada fáceis de ler.
Gravando os dados do índice
Indexers devem trabalhar com SQL, não com models e repositories. Carregar 100.000 produtos como objetos para calcular um número é a abordagem mais lenta possível. Uma reconstrução parcial apaga as linhas dos IDs informados e insere linhas novas a partir de um select:
<?php
declare(strict_types=1);
namespace Acme\ProductScore\Model;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Select;
use Magento\Framework\DB\Sql\Expression;
class ScoreBuilder
{
public function __construct(
private readonly ResourceConnection $resource
) {
}
public function rebuildForProducts(array $productIds): void
{
if ($productIds === []) {
return;
}
$connection = $this->resource->getConnection();
$table = $this->resource->getTableName('acme_product_score');
$connection->beginTransaction();
try {
$connection->delete($table, ['product_id IN (?)' => $productIds]);
$select = $this->buildSelect()->where('e.entity_id IN (?)', $productIds);
$connection->query($connection->insertFromSelect($select, $table, ['product_id', 'score']));
$connection->commit();
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
}
}
public function rebuildAll(int $batchSize): void
{
$connection = $this->resource->getConnection();
$table = $this->resource->getTableName('acme_product_score');
$replica = $this->resource->getTableName('acme_product_score_replica');
$entityTable = $this->resource->getTableName('catalog_product_entity');
$connection->truncateTable($replica);
$maxId = (int) $connection->fetchOne(
$connection->select()->from($entityTable, [new Expression('MAX(entity_id)')])
);
for ($from = 1; $from <= $maxId; $from += $batchSize) {
$select = $this->buildSelect()
->where('e.entity_id >= ?', $from)
->where('e.entity_id < ?', $from + $batchSize);
$connection->query($connection->insertFromSelect($select, $replica, ['product_id', 'score']));
}
$connection->renameTablesBatch([
['oldName' => $table, 'newName' => $table . '_outdated'],
['oldName' => $replica, 'newName' => $table],
['oldName' => $table . '_outdated', 'newName' => $replica],
]);
}
private function buildSelect(): Select
{
$connection = $this->resource->getConnection();
return $connection->select()
->from(['e' => $this->resource->getTableName('catalog_product_entity')], ['product_id' => 'entity_id'])
->joinLeft(
['si' => $this->resource->getTableName('cataloginventory_stock_item')],
'si.product_id = e.entity_id AND si.stock_id = 1',
['score' => new Expression('IF(si.is_in_stock = 1, 100, 0)')]
);
}
}
A fórmula do score aqui é só um placeholder. O que importa é o padrão: a reconstrução parcial apaga e insere em uma transação, em batches de IDs, inteiramente em SQL. A reconstrução parcial e a completa compartilham o mesmo select, então os dois caminhos nunca calculam o score de forma diferente. Parece óbvio, mas indexers em que a lógica completa e a parcial se afastaram uma da outra são uma fonte comum de dados que estão corretos depois de um reindex completo e errados um dia depois.
Reindex completo sem tabelas vazias
Se o executeFull() faz truncate da tabela ativa e a preenche de novo, toda query durante a reconstrução enxerga dados faltando. Por isso o rebuildAll() acima nunca toca a tabela ativa até o final. Ele preenche a acme_product_score_replica, uma tabela com a mesma estrutura declarada no db_schema.xml, em batches por faixa de ID. Depois o renameTablesBatch() troca as duas tabelas em um único comando RENAME TABLE.
O MySQL executa um rename de várias tabelas de forma atômica, então quem lê enxerga ou os dados antigos ou os novos, nunca uma tabela pela metade. Depois da troca, os dados antigos ficam na tabela replica até a próxima reconstrução completa fazer truncate dela. Os indexers do core usam a mesma ideia. Iterar por faixa de ID em vez de offset também mantém cada query de batch barata, porque usa a primary key e não precisa pular linhas.
Rodando e gerenciando
Depois do bin/magento setup:upgrade, o indexer aparece na lista junto com os outros:
bin/magento indexer:info
bin/magento indexer:set-mode schedule acme_product_score
bin/magento indexer:reindex acme_product_score
bin/magento indexer:status acme_product_score
Mudar para o modo schedule cria os triggers e a tabela de changelog acme_product_score_cl. A partir daí, o job de cron indexer_update_all_views processa o changelog. Ele precisa de um cron funcionando, como descrito em o guia de cron jobs.
Se um indexer está marcado como invalid, o indexer_reindex_all_invalid roda um reindex completo pelo cron. Você pode invalidar o seu pelo código quando uma mudança de configuração afeta todas as linhas, carregando-o pelo Magento\Framework\Indexer\IndexerRegistry e chamando invalidate().
Modo realtime e chamando o indexer pelo código
No modo Update on Save, nada chama o seu indexer automaticamente. As entidades do core chamam seus próprios indexers nos save handlers. Para o seu, adicione um plugin ou observer no save relevante e reindexe só quando o indexer não estiver em schedule:
$indexer = $this->indexerRegistry->get('acme_product_score');
if (!$indexer->isScheduled()) {
$indexer->reindexRow($productId);
}
Essa checagem importa. Chamar o reindexRow() em modo schedule faz o trabalho duas vezes, uma agora e outra pelo changelog.
O que observar
- Os triggers disparam a cada mudança nas tabelas inscritas, inclusive em imports. Inscreva-se só em tabelas que realmente afetam o seu índice.
- As tabelas de changelog crescem até o cron processá-las. Se o cron parar, elas continuam crescendo. Monitore a versão de cada view com
bin/magento indexer:status. - Indexers precisam ser idempotentes. O mesmo ID pode chegar duas vezes, e um reindex parcial pode rodar depois de um completo.
- Catálogos grandes precisam de batches. Nunca carregue todos os IDs de produto no PHP de uma vez em uma reconstrução completa. Itere por faixa de ID.
- Para páginas de categoria lentas causadas por indexação, o checklist de páginas de categoria lentas é um bom ponto de partida.
FAQ
Por que meu indexer não atualiza em modo schedule?
Confira se o cron roda, se os triggers existem nas tabelas inscritas e se a entity_column está correta. Reinicie o modo com indexer:set-mode realtime e depois schedule de novo para recriar os triggers.
Preciso do mview.xml para um indexer customizado?
Só se você quiser o modo schedule. Sem ele o indexer continua funcionando para reindexes completos e para chamadas realtime a partir do seu próprio código.
Qual a diferença entre executeList e execute?
O executeList vem da interface de action do indexer e é usado por código que reindexa IDs específicos diretamente. O execute vem da interface do Mview e é chamado pelo cron com os IDs do changelog. Os dois normalmente chamam o mesmo método interno.
Posso usar repositories dentro de um indexer?
Pode, mas é lento em catálogos grandes. Indexers devem ler e gravar com SQL em batches.
Atendo como freelancer em Magento e Adobe Commerce. Se a indexação está deixando sua loja ou seus imports lentos, fale comigo.
Artigos relacionados
Indexers do Magento 2: Update on Save vs Update by Schedule
Como funcionam os modos de indexer do Magento 2, por que lojas em produção usam Update by Schedule, como mview e changelogs funcionam e como manter o cron saudável.
Magento 2: tipos de cache, clean, flush e cache type próprio
O que cada cache type do Magento 2 guarda, a diferença real entre cache:clean e cache:flush, caches invalidados e como criar seu próprio cache type.
Core Web Vitals no Magento 2: como corrigir LCP, INP e CLS
Onde lojas Magento 2 com Luma perdem nos Core Web Vitals e como corrigir LCP, INP e CLS: resposta do servidor, CSS, fontes, imagens, galeria e scripts de terceiros.
Ajuste do PHP-FPM no Magento 2: workers, OPcache, php.ini
Dimensione os pools do PHP-FPM para o Magento 2, escolha o process manager certo, configure o OPcache para um codebase grande e evite os erros de php.ini que deixam a loja lenta.