Борьба за миллисекунды: как оптимизировать тяжелую фильтрацию в catalog.section
Rus
Eng
Борьба за миллисекунды: как оптимизировать тяжелую фильтрацию в catalog.section
Решаю сложные технические задачи для Bitrix и 1С: от аудита чужого кода до разработки сайтов и настройки сложных интеграций с гарантией результата
Рекомендую

Хостинг для сайта

Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.

Перейти Партнёрская ссылка. Для вас цена не меняется.

Почему фильтр в catalog.section съедает сервер

Каталог на 500 000 товаров, 40 свойств, 15 активных фильтров одновременно. Страница генерируется 8 секунд. Сервер в 100% загрузке. MySQL перебирает миллионы строк в таблицах свойств, потому что каждое свойство в фильтре — это отдельный JOIN, и без индексов база вычитывает таблицу целиком. Стандартный arrFilter, который прекрасно работал на 10 000 позиций, на миллионном каталоге превращается в генератор slow query.

Это не значит, что catalog.section плохой. Это значит, что его дефолтные настройки рассчитаны на средний каталог, а не на промышленный. И то же самое касается catalog.smart.filter: он строит свою фасетную выборку поверх тех же EAV-таблиц и часто оказывается вторым, а то и главным источником тормозов. Разберём семь уровней оптимизации: сначала находим, что именно тормозит, потом переписываем выборку через ORM, оптимизируем сам умный фильтр, настраиваем кеш, подпираем индексами и — если всё вместе не помогает — выносим фильтрацию во внешний поисковый движок.

Уровень 1. Найти узкое место, а не гадать

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

Монитор производительности Bitrix

Встроенный монитор производительности — самый быстрый способ увидеть, какие именно запросы выполняются на странице каталога. Включается в настройках модуля main, раздел «Монитор производительности». После включения внизу страницы появляется панель с деревом запросов.

Что смотреть в первую очередь:

  • Самые долгие запросы. Обычно это выборка элементов по arrFilter с десятком JOIN на таблицы свойств.
  • Количество запросов. Если на странице каталога выполняется 200+ запросов, проблема не в одном медленном, а в том, что каждый элемент списка тянет свойства отдельно. Лечится урезанием select и переходом на ORM.
  • Дублирующиеся запросы. Одна и та же выборка может выполняться по 10–15 раз за страницу. Это сигнал, что кеш не работает или работает неправильно.

Монитор показывает SQL-запрос целиком. Копируем самый долгий и идём в EXPLAIN ANALYZE.

Умный фильтр catalog.smart.filter

Про catalog.section говорят много, а про catalog.smart.filter — почти ничего, хотя именно он часто съедает больше всего. Компонент делает две тяжёлые вещи одновременно: для каждого значения каждого свойства считает количество подходящих товаров и собирает список доступных значений с учётом уже применённых фильтров. То есть на каждое движение пользователя по фильтру он прогоняет агрегацию по всей таблице свойств.

Внутри catalog.smart.filter есть два режима. Первый — работа через фасетный индекс: компонент читает денормализованную таблицу b_iblock_N_index и достаёт готовые счётчики. Второй — прямой перебор по EAV-таблицам: если фасетный индекс невалиден, старый или не собирался, компонент падает на GROUP BY по миллионам строк.

Что смотреть в мониторе производительности по этому компоненту:

  • Запросы к b_iblock_N_index. Если их нет, а есть тяжёлые GROUP BY по b_iblock_element_prop_* — фасетный индекс не работает, и весь фильтр считается на живую.
  • Количество запросов к таблицам свойств. Умный фильтр может выполнять десятки отдельных выборок — по одной на свойство, ещё по одной на счётчик. На 20 свойствах это легко превращается в сотню запросов на страницу.
  • Общее время компонента. В мониторе есть разбивка по компонентам. Если catalog.smart.filter съедает 60–70% времени генерации страницы, оптимизировать надо в первую очередь его, а не catalog.section.

Отсюда следует простой практический вывод: пока не собраны факты, нельзя сказать, кто виноват. Может оказаться, что catalog.section работает за 300 миллисекунд, а умный фильтр — за 7 секунд. Или наоборот.

EXPLAIN ANALYZE вместо старого EXPLAIN

С MySQL 8.0.18 EXPLAIN ANALYZE даёт не план, а реальные замеры: сколько строк реально прошло через каждую стадию, сколько времени заняла каждая. Для оптимизации сложных запросов это точнее старого EXPLAIN, который показывает только оценки оптимизатора, и они часто врут на порядок.

EXPLAIN ANALYZE
SELECT BE.ID
FROM b_iblock_element BE
WHERE BE.IBLOCK_ID = 5
  AND EXISTS (
      SELECT 1 FROM b_iblock_element_prop_m5 P
      WHERE P.IBLOCK_ELEMENT_ID = BE.ID
        AND P.IBLOCK_PROPERTY_ID = 1075
        AND P.VALUE_ENUM IN (101, 102, 103)
  )
LIMIT 20;

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

Старый EXPLAIN тоже полезен: если в колонке type стоит ALL — это полный перебор таблицы, и это видно сразу без замеров. Но для понимания, где именно тормозит конкретный запрос, EXPLAIN ANALYZE вне конкуренции.

pt-query-digest: как читать отчёт

slow query log включается в my.cnf: slow_query_log = 1, long_query_time = 2. За сутки набирается статистика реальных медленных запросов. Дальше pt-query-digest из Percona Toolkit группирует их по частоте и суммарному времени.

pt-query-digest /var/log/mysql/slow.log

В отчёте важен верхний блок — «Profile» по суммарному времени, а не по отдельным запросам. Пример вывода:

# Profile
# Rank Query ID           Response time Calls  R/Call  V/M   Item
# ==== ================== ============= ====== ======= ===== ======
#    1 0x8C4F2B7E1A9D3C05 312.4001 68.2%  84213  0.0037  0.02 SELECT b_iblock_element_prop_m5
#    2 0x2A1D5C8F9B7E4A03  78.9002 17.2%  42106  0.0019  0.01 SELECT b_iblock_element
#    3 0x1F3E8B2C4A5D9E07  45.2001  9.9%   9821  0.0046  0.02 SELECT b_iblock_section

Первая строка говорит: 84213 вызовов одного и того же запроса съели 68% всего времени. Оптимизировать надо не десять разных запросов, а один. Это и есть основной практический вывод отчёта: найти запрос с наибольшим процентом и наименьшим R/Call — там и лежит узкое место.

Замеры: что и как считать

Любое утверждение «стало быстрее» без цифр — это самообман. Снимать нужно минимум три метрики до и после: время генерации страницы (TTFB), общее количество запросов и суммарное время выполнения всех SQL на странице. Монитор производительности Bitrix отдаёт все три — в отчёте по странице есть и время, и число запросов, и разбивка по конкретным вызовам.

Что касается разницы между старым API и ORM, она сильно зависит от:

  • количества активных фильтров в arrFilter (каждый фильтр — это JOIN);
  • размера таблиц свойств (чем больше каталог, тем сильнее разрыв);
  • наличия индексов (без них ORM тоже будет тормозить, только менее заметно);
  • версии MySQL и настроек innodb_buffer_pool_size.

На типичном каталоге в 100 000 товаров с 6–8 активными фильтрами, где свойства хранятся в EAV, разрыв в пользу ORM с подзапросом обычно составляет от 5 до 30 раз — но не потому что ORM магический, а потому что архитектура запроса меняется с «пересечь всё со всем» на «сначала сузить до нужного». На каталоге в 10 000 товаров без множественных свойств разница может быть всего 1.5–2 раза и не окупать сложность поддержки.

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

Уровень 2. Фильтрация по свойствам через D7 ORM

Классический CIBlockElement::GetList с arrFilter работает так: вы передаёте массив условий, и Bitrix строит SQL с десятком JOIN на таблицы свойств. На маленьком каталоге это терпимо. На большом — каждый JOIN добавляет строки, и запрос раздувается до сотен тысяч строк в промежуточном результате.

D7 ORM даёт инструменты, которых нет в старом GetList: ExpressionField, подзапросы, Runtime-поля. Они позволяют перенести вычисления на сторону SQL вместо того, чтобы вычитывать всё в PHP и фильтровать в цикле.

Главная сложность: раздельное хранение свойств

В Bitrix есть два режима хранения свойств инфоблока. При общей схеме все свойства лежат в таблице b_iblock_element_property, и к ней привязана сущность \Bitrix\Iblock\ElementPropertyTable. При раздельном хранении (инфоблоки 2.0, включено по умолчанию на современных проектах) простые свойства уходят в b_iblock_element_prop_s{ID}, множественные — в b_iblock_element_prop_m{ID}, и ElementPropertyTable их просто не видит.

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

Фильтр по свойству через EXISTS-подзапрос

Вместо ReferenceField регистрируем ExpressionField с коррелированным EXISTS-подзапросом. MySQL выполняет его по индексу (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM) — именно в таком порядке, потому что условие по элементу идёт первым в подзапросе. Важно: значение экранируется через SqlHelper::forSql(), иначе фильтр превращается в SQL-инъекцию.

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;

Loader::includeModule('iblock');

$iblockId = 5;
$propertyId = 1075;
$tableName = 'b_iblock_element_prop_m' . $iblockId;

// Экранирование значений — обязательно
$helper = Application::getConnection()->getSqlHelper();
$values = [101, 102, 103]; // уже целые, но для строковых свойств нужно forSql

$query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

$query->registerRuntimeField(
    'IN_BRAND',
    new ExpressionField(
        'IN_BRAND',
        'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = ' . (int)$propertyId
            . ' AND VALUE_ENUM IN (' . implode(',', array_map('intval', $values)) . '))',
        ['ID']
    )
);

$query->addSelect('ID');
$query->addSelect('NAME');

$query->setFilter([
    '=IBLOCK_ID' => $iblockId,
    '=ACTIVE' => 'Y',
    '=IN_BRAND' => 1,
]);

$result = $query->exec();

while ($row = $result->fetch()) {
    // $row['ID'], $row['NAME']
}

Почему EXISTS, а не CASE WHEN ID IN (...): обёртка в CASE мешает оптимизатору MySQL кешировать подзапрос, и на больших таблицах он может выполняться построчно. EXISTS с коррелированным условием по индексированной колонке отрабатывает за логарифм на каждую строку — итого O(n log n), что на миллионе товаров даёт предсказуемые миллисекунды.

Универсальный метод для любого типа свойства

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

/**
 * Строит EXISTS-подзапрос для фильтрации по свойству инфоблока.
 *
 * @param int    $iblockId     ID инфоблока
 * @param string $propertyCode Символьный код свойства
 * @param mixed  $value        Значение или массив значений
 * @return string SQL-фрагмент EXISTS (...)
 */
protected function buildPropertyExists(int $iblockId, string $propertyCode, $value): string
{
    $property = $this->getPropertyInfo($iblockId, $propertyCode);

    if (!$property) {
        throw new \Bitrix\Main\ArgumentException('Property not found: ' . $propertyCode);
    }

    $propertyId = (int)$property['ID'];
    $tableName = $property['MULTIPLE'] === 'Y'
        ? 'b_iblock_element_prop_m' . $iblockId
        : 'b_iblock_element_prop_s' . $iblockId;

    $helper = \Bitrix\Main\Application::getConnection()->getSqlHelper();

    if ($property['PROPERTY_TYPE'] === 'L') {
        $values = array_map('intval', (array)$value);
        $condition = 'VALUE_ENUM IN (' . implode(',', $values) . ')';
    } elseif ($property['PROPERTY_TYPE'] === 'N') {
        $range = array_map('floatval', (array)$value);
        $condition = 'VALUE_NUM BETWEEN ' . $range[0] . ' AND ' . $range[1];
    } else {
        $escaped = $helper->forSql((string)$value);
        $condition = "VALUE LIKE '" . $escaped . "'";
    }

    return 'EXISTS (SELECT 1 FROM ' . $tableName
        . ' WHERE IBLOCK_ELEMENT_ID = %s'
        . ' AND IBLOCK_PROPERTY_ID = ' . $propertyId
        . ' AND ' . $condition . ')';
}

Как применить ORM внутри catalog.section

Стандартный catalog.section жёстко завязан на CIBlockElement::GetList() и массив arrFilter. Просто передать ORM-объект в компонент нельзя. Ниже — два честных пути.

Подход 1. Свой компонент выборки

Структура файлов:

/local/components/custom/catalog.section.orm/
├── class.php
├── .description.php
└── templates/
    └── .default/
        └── template.php

Класс компонента. Обратите внимание на несколько ключевых моментов: фильтр берётся из $GLOBALS по стандарту Bitrix, разделы собираются подзапросом к b_iblock_section_element (а не по полю IBLOCK_SECTION_ID, которое хранит только основной раздел), URL детальной страницы строится через шаблон инфоблока с подстановкой #SECTION_CODE# и #ELEMENT_CODE#, а фильтр по разделу идёт через ExpressionField с SqlExpression — это корректнее, чем устаревший синтаксис с ['@ID' => new SqlExpression].

<?php

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;
use Bitrix\Main\UI\PageNavigation;
use Bitrix\Main\DB\SqlExpression;

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

Loader::includeModule('iblock');
Loader::includeModule('catalog');

class CustomCatalogSectionOrm extends CBitrixComponent
{
    /** @var array Кеш информации о свойствах */
    private static $propertyCache = [];

    /** @var array Кеш шаблона URL детальной страницы */
    private $detailUrlTemplate = '';

    /** @var array Кеш кодов разделов по ID */
    private static $sectionCodeCache = [];

    /**
     * Строит выборку элементов через D7 ORM.
     *
     * @param array $arFilter Фильтр в формате старого arrFilter
     * @param array $arSort   Параметры сортировки
     * @param PageNavigation $nav Объект навигации
     * @return array Массив элементов
     */
    protected function getElementList(array $arFilter, array $arSort, PageNavigation $nav): array
    {
        $query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

        $this->attachPropertyFilters($query, $arFilter);
        $this->attachSectionFilter($query, $arFilter);

        $query->setSelect([
            'ID',
            'NAME',
            'CODE',
            'PREVIEW_TEXT',
            'PREVIEW_PICTURE',
            'DETAIL_PICTURE',
            'IBLOCK_SECTION_ID',
        ]);

        $query->setFilter($this->buildFilter($arFilter));
        $query->setOrder($arSort ?: ['SORT' => 'ASC']);
        $query->setOffset($nav->getOffset());
        $query->setLimit($nav->getLimit());

        $result = $query->exec();

        $items = [];
        while ($row = $result->fetch()) {
            $row['DETAIL_PAGE_URL'] = $this->buildDetailUrl(
                (int)$row['ID'],
                (string)$row['CODE'],
                (int)$row['IBLOCK_SECTION_ID']
            );
            $items[$row['ID']] = $row;
        }

        if ($items) {
            $this->enrichItems($items);
        }

        return $items;
    }

    /**
     * Присоединяет фильтр по разделу через ExpressionField с подзапросом
     * к b_iblock_section_element. Работает как с одним разделом, так и
     * с деревом подразделов.
     *
     * @param Query $query    ORM-запрос
     * @param array $arFilter Фильтр в формате старого API
     * @return void
     */
    protected function attachSectionFilter(Query $query, array $arFilter): void
    {
        if (empty($arFilter['SECTION_ID'])) {
            return;
        }

        $sectionIds = $this->collectSectionIds((int)$arFilter['SECTION_ID']);
        if (!$sectionIds) {
            return;
        }

        $connection = Application::getConnection();
        $sqlHelper = $connection->getSqlHelper();

        $idsSql = implode(',', array_map('intval', $sectionIds));

        // Выражение через SqlExpression — не устаревший '@ID', а корректный
        // ExpressionField, который корректно работает со всеми версиями D7
        $query->registerRuntimeField(
            'IN_SECTION',
            new ExpressionField(
                'IN_SECTION',
                'EXISTS (SELECT 1 FROM b_iblock_section_element SE'
                    . ' WHERE SE.IBLOCK_ELEMENT_ID = %s'
                    . ' AND SE.IBLOCK_SECTION_ID IN (' . $idsSql . '))',
                ['ID']
            )
        );
    }

    /**
     * Присоединяет фильтры по свойствам через ExpressionField с EXISTS.
     *
     * @param Query $query    ORM-запрос
     * @param array $arFilter Фильтр в формате старого API
     * @return void
     */
    protected function attachPropertyFilters(Query $query, array $arFilter): void
    {
        if (empty($arFilter['PROPERTY_VALUES']) || !is_array($arFilter['PROPERTY_VALUES'])) {
            return;
        }

        foreach ($arFilter['PROPERTY_VALUES'] as $code => $value) {
            $fieldName = 'IN_PROP_' . strtoupper($code);

            $existsSql = $this->buildPropertyExists(
                (int)$this->arParams['IBLOCK_ID'],
                (string)$code,
                $value
            );

            $query->registerRuntimeField(
                $fieldName,
                new ExpressionField($fieldName, $existsSql, ['ID'])
            );
        }
    }

    /**
     * Строит EXISTS-подзапрос для фильтрации по свойству инфоблока.
     * Работает при любой схеме хранения (общая или раздельная).
     *
     * @param int    $iblockId     ID инфоблока
     * @param string $propertyCode Символьный код свойства
     * @param mixed  $value        Значение или массив значений
     * @return string SQL-фрагмент EXISTS (...)
     */
    protected function buildPropertyExists(int $iblockId, string $propertyCode, $value): string
    {
        $property = $this->getPropertyInfo($iblockId, $propertyCode);

        if (!$property) {
            throw new \Bitrix\Main\ArgumentException('Property not found: ' . $propertyCode);
        }

        $propertyId = (int)$property['ID'];
        $tableName = $property['MULTIPLE'] === 'Y'
            ? 'b_iblock_element_prop_m' . $iblockId
            : 'b_iblock_element_prop_s' . $iblockId;

        $helper = Application::getConnection()->getSqlHelper();

        if ($property['PROPERTY_TYPE'] === 'L') {
            $values = array_map('intval', (array)$value);
            $condition = 'VALUE_ENUM IN (' . implode(',', $values) . ')';
        } elseif ($property['PROPERTY_TYPE'] === 'N') {
            $range = array_map('floatval', (array)$value);
            $condition = 'VALUE_NUM BETWEEN ' . $range[0] . ' AND ' . $range[1];
        } else {
            $escaped = $helper->forSql((string)$value);
            $condition = "VALUE LIKE '" . $escaped . "'";
        }

        return 'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = ' . $propertyId
            . ' AND ' . $condition . ')';
    }

    /**
     * Возвращает информацию о свойстве с кешированием.
     *
     * @param int    $iblockId     ID инфоблока
     * @param string $propertyCode Символьный код
     * @return array|null
     */
    protected function getPropertyInfo(int $iblockId, string $propertyCode): ?array
    {
        $cacheKey = $iblockId . ':' . $propertyCode;

        if (isset(self::$propertyCache[$cacheKey])) {
            return self::$propertyCache[$cacheKey];
        }

        $row = \Bitrix\Iblock\PropertyTable::getList([
            'filter' => ['=IBLOCK_ID' => $iblockId, '=CODE' => $propertyCode],
            'select' => ['ID', 'PROPERTY_TYPE', 'MULTIPLE'],
            'limit' => 1,
        ])->fetch();

        self::$propertyCache[$cacheKey] = $row ?: null;

        return self::$propertyCache[$cacheKey];
    }

    /**
     * Строит URL детальной страницы через шаблон инфоблока
     * с подстановкой SEF-плейсхолдеров.
     *
     * @param int    $elementId   ID элемента
     * @param string $elementCode Символьный код элемента
     * @param int    $sectionId   ID основного раздела
     * @return string
     */
    protected function buildDetailUrl(int $elementId, string $elementCode, int $sectionId): string
    {
        if ($this->detailUrlTemplate === '') {
            $iblock = \CIBlock::GetArrayByID((int)$this->arParams['IBLOCK_ID']);
            $this->detailUrlTemplate = $iblock['DETAIL_PAGE_URL'] ?? '/catalog/#ELEMENT_CODE#/';
        }

        $sectionCode = $this->getSectionCode($sectionId);

        return str_replace(
            ['#IBLOCK_ID#', '#IBLOCK_CODE#', '#SECTION_ID#', '#SECTION_CODE#', '#ELEMENT_ID#', '#ELEMENT_CODE#', '#SITE_DIR#'],
            [(int)$this->arParams['IBLOCK_ID'], '', $sectionId, $sectionCode, $elementId, $elementCode, SITE_DIR],
            $this->detailUrlTemplate
        );
    }

    /**
     * Возвращает символьный код раздела с кешированием.
     *
     * @param int $sectionId ID раздела
     * @return string
     */
    protected function getSectionCode(int $sectionId): string
    {
        if ($sectionId <= 0) {
            return '';
        }

        if (isset(self::$sectionCodeCache[$sectionId])) {
            return self::$sectionCodeCache[$sectionId];
        }

        $row = \Bitrix\Iblock\SectionTable::getList([
            'filter' => ['=ID' => $sectionId, '=IBLOCK_ID' => $this->arParams['IBLOCK_ID']],
            'select' => ['CODE'],
            'limit' => 1,
        ])->fetch();

        self::$sectionCodeCache[$sectionId] = $row['CODE'] ?? '';

        return self::$sectionCodeCache[$sectionId];
    }

    /**
     * Преобразует arrFilter в условия D7 ORM.
     * Подзапрос по разделу уже зарегистрирован в attachSectionFilter(),
     * здесь только ссылаемся на него.
     *
     * @param array $arFilter Фильтр в формате старого API
     * @return array
     */
    protected function buildFilter(array $arFilter): array
    {
        $filter = [
            '=IBLOCK_ID' => (int)$this->arParams['IBLOCK_ID'],
            '=ACTIVE' => 'Y',
        ];

        if (!empty($arFilter['SECTION_ID'])) {
            $filter['=IN_SECTION'] = 1;
        }

        if (!empty($arFilter['PROPERTY_VALUES']) && is_array($arFilter['PROPERTY_VALUES'])) {
            foreach ($arFilter['PROPERTY_VALUES'] as $code => $value) {
                $filter['=IN_PROP_' . strtoupper($code)] = 1;
            }
        }

        return $filter;
    }

    /**
     * Собирает ID раздела и всех вложенных подразделов одним запросом
     * через Nested Sets (LEFT_MARGIN / RIGHT_MARGIN).
     *
     * @param int $sectionId ID корневого раздела
     * @return array
     */
    protected function collectSectionIds(int $sectionId): array
    {
        $rootSection = \CIBlockSection::GetList(
            [],
            ['IBLOCK_ID' => $this->arParams['IBLOCK_ID'], 'ID' => $sectionId],
            false,
            ['ID', 'LEFT_MARGIN', 'RIGHT_MARGIN']
        )->Fetch();

        if (!$rootSection) {
            return [$sectionId];
        }

        $leftMargin = (int)$rootSection['LEFT_MARGIN'];
        $rightMargin = (int)$rootSection['RIGHT_MARGIN'];

        $ids = [$sectionId];

        $rs = \CIBlockSection::GetList(
            ['LEFT_MARGIN' => 'ASC'],
            [
                'IBLOCK_ID' => $this->arParams['IBLOCK_ID'],
                '>LEFT_MARGIN' => $leftMargin,
                '<RIGHT_MARGIN' => $rightMargin,
            ],
            false,
            ['ID']
        );

        while ($row = $rs->Fetch()) {
            $ids[] = (int)$row['ID'];
        }

        return $ids;
    }

    /**
     * Подтягивает свойства и цены для уже выбранных элементов.
     *
     * @param array $items Элементы (по ссылке)
     * @return void
     */
    protected function enrichItems(array &$items): void
    {
        $ids = array_keys($items);

        $rsElements = \CIBlockElement::GetList(
            [],
            ['IBLOCK_ID' => $this->arParams['IBLOCK_ID'], 'ID' => $ids],
            false,
            false,
            ['ID', 'PROPERTY_*']
        );
        while ($ob = $rsElements->GetNextElement()) {
            $fields = $ob->GetFields();
            $items[$fields['ID']]['PROPERTIES'] = $ob->GetProperties();
        }

        $priceRes = \Bitrix\Catalog\PriceTable::getList([
            'filter' => ['@PRODUCT_ID' => $ids],
            'select' => ['PRODUCT_ID', 'PRICE', 'CURRENCY', 'CATALOG_GROUP_ID'],
        ]);
        while ($price = $priceRes->fetch()) {
            $items[$price['PRODUCT_ID']]['PRICES'][$price['CATALOG_GROUP_ID']] = $price;
        }
    }

    /**
     * Считает общее количество элементов под фильтром.
     * COUNT вместо COUNT(DISTINCT) — EXISTS-подзапросы не дублируют
     * строки во внешней выборке, поэтому DISTINCT здесь избыточен и
     * только замедляет запрос.
     *
     * @param array $arFilter Фильтр
     * @return int
     */
    protected function countElements(array $arFilter): int
    {
        $query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

        // Обязательно — иначе buildFilter вернёт условие на незарегистрированные поля
        $this->attachPropertyFilters($query, $arFilter);
        $this->attachSectionFilter($query, $arFilter);

        $query->registerRuntimeField('CNT', new ExpressionField('CNT', 'COUNT(%s)', 'ID'));
        $query->setSelect(['CNT']);
        $query->setFilter($this->buildFilter($arFilter));

        $row = $query->exec()->fetch();

        return (int)($row['CNT'] ?? 0);
    }

    public function executeComponent(): void
    {
        $pageSize = (int)($this->arParams['PAGE_ELEMENT_COUNT'] ?: 20);

        $nav = new PageNavigation('catalog-orm-nav');
        $nav->allowAllRecords(false)
            ->setPageSize($pageSize)
            ->initFromUri();

        // Фильтр берётся стандартным для Bitrix способом — через $GLOBALS.
        // Если имя не задано или совпадает с чужой переменной, компонент
        // не сломается: используется явное значение из параметров.
        $filterName = (string)($this->arParams['FILTER_NAME'] ?: '');
        $filter = [];

        if ($filterName !== '' && isset($GLOBALS[$filterName]) && is_array($GLOBALS[$filterName])) {
            $filter = $GLOBALS[$filterName];
        }

        $sort = $this->arParams['SORT'] ?? [];

        $total = $this->countElements($filter);
        $nav->setRecordCount($total);

        $this->arResult['ITEMS'] = $this->getElementList($filter, $sort, $nav);
        $this->arResult['NAV_OBJECT'] = $nav;
        $this->arResult['TOTAL_COUNT'] = $total;
        $this->arResult['FILTER_NAME'] = $filterName;

        $this->includeComponentTemplate();
    }
}

Шаблон компонента. Ключевое здесь — любое поле, пришедшее из базы, выводится через htmlspecialcharsbx(). Имя товара, URL детальной страницы, название раздела — всё это пользовательские данные, и без экранирования они превращаются в XSS.

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

/** @var array $arResult */
/** @var array $arParams */

$nav = $arResult['NAV_OBJECT'] ?? null;
$items = $arResult['ITEMS'] ?? [];

if (!$items) {
    return;
}
?>

<div class="catalog--list">
    <?php foreach ($items as $item): ?>
        <div class="catalog--item" data-id="<?= (int)$item['ID'] ?>">
            <a class="catalog--link" href="<?= htmlspecialcharsbx($item['DETAIL_PAGE_URL']) ?>">
                <span class="catalog--name"><?= htmlspecialcharsbx($item['NAME']) ?></span>
            </a>
            <?php if (!empty($item['PREVIEW_TEXT'])): ?>
                <div class="catalog--preview"><?= htmlspecialcharsbx($item['PREVIEW_TEXT']) ?></div>
            <?php endif; ?>
        </div>
    <?php endforeach; ?>
</div>

<?php
if ($nav && $nav->getPageCount() > 1) {
    $APPLICATION->IncludeComponent(
        'bitrix:main.pagenavigation',
        '',
        [
            'NAV_OBJECT' => $nav,
            'SEF_MODE' => 'N',
        ],
        false
    );
}

Ключевые моменты:

  • Фильтр по разделу идёт через ExpressionField с SqlExpression. Устаревший синтаксис ['@ID' => new SqlExpression(...)] работал, но в актуальных версиях D7 правильнее регистрировать поле через registerRuntimeField(), как в attachSectionFilter().
  • COUNT вместо COUNT(DISTINCT). EXISTS-подзапросы не размножают строки внешней выборки, поэтому DISTINCT только замедляет запрос. На миллионе строк это заметно.
  • FILTER_NAME извлекается явно и проверяется. Если имя пустое или переменная в $GLOBALS не массив — фильтр не применяется, компонент не падает.
  • attachPropertyFilters() вызывается и в getElementList(), и в countElements(). Без этого buildFilter() вернёт условие на незарегистрированное поле, и ORM упадёт.
  • buildPropertyExists() работает при любой схеме хранения. Имя таблицы вычисляется из ID инфоблока, тип колонки — из типа свойства. Строковые значения экранируются через forSql().
  • buildDetailUrl() использует шаблон инфоблока и подставляет #SECTION_CODE#, #ELEMENT_CODE# и остальные плейсхолдеры. Коды разделов кешируются.
  • Шаблон оборачивает NAME и DETAIL_PAGE_URL в htmlspecialcharsbx().

Как кастомный catalog.section стыкуется со стандартным умным фильтром

Ключ к стыковке — параметр FILTER_NAME. Стандартный catalog.smart.filter по этому имени складывает текущий фильтр в $GLOBALS, а кастомный catalog.section.orm по тому же имени его читает.

Структура, которую ожидает кастомный компонент:

$GLOBALS['arrFilter'] = [
    'SECTION_ID' => 42,
    'PROPERTY_VALUES' => [
        'BRAND' => [101, 102],
        'COLOR' => [15],
        'PRICE' => [1000, 50000],
    ],
];

Про FILTER_NAME и коллизии. Имя arrFilter — стандартное для Bitrix, и в шаблоне сайта оно может быть занято другим компонентом или пользовательским кодом. Перед интеграцией проверяем:

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

// Имя фильтра задаём один раз, чтобы не пересекаться с другими блоками
$filterName = 'catalogArrFilter';

// Если переменная уже занята — берём собственное имя
if (isset($GLOBALS[$filterName]) && !is_array($GLOBALS[$filterName])) {
    $filterName = 'catalogArrFilter_' . $APPLICATION->GetCurPage();
}

$iblockId = 5;

$APPLICATION->IncludeComponent(
    'bitrix:catalog.smart.filter',
    '.default',
    [
        'IBLOCK_ID' => $iblockId,
        'FILTER_NAME' => $filterName,
        'PRICE_CODE' => ['BASE'],
        'CACHE_TYPE' => 'A',
        'CACHE_TIME' => 3600,
        'CACHE_GROUPS' => 'N',
        'SAVE_IN_SESSION' => 'N',
    ],
    false
);
?>

<div class="catalog--layout">
    <aside class="catalog--sidebar">
        <?php // Сюда шаблон умного фильтра выводит панель ?>
    </aside>

    <div class="catalog--content">
        <?php
        $APPLICATION->IncludeComponent(
            'custom:catalog.section.orm',
            '.default',
            [
                'IBLOCK_ID' => $iblockId,
                'FILTER_NAME' => $filterName,
                'PAGE_ELEMENT_COUNT' => 20,
                'SORT' => ['SORT' => 'ASC'],
            ],
            false
        );
        ?>
    </div>
</div>

Поток данных целиком:

  1. Пользователь кликает чекбокс в панели умного фильтра. Штатный JS компонента обновляет $GLOBALS[$filterName] и подгружает новые данные через AJAX.
  2. При полной перезагрузке страницы catalog.smart.filter собирает фильтр из $_GET или из сессии и кладёт его в $GLOBALS[$filterName].
  3. Кастомный catalog.section.orm читает $GLOBALS[$filterName] в executeComponent().
  4. buildFilter() превращает SECTION_ID в ссылку на IN_SECTION, а PROPERTY_VALUES — в набор IN_PROP_*.
  5. attachPropertyFilters() и attachSectionFilter() регистрируют эти поля в Query до вызова setFilter().
  6. Счётчик countElements() и выборка getElementList() используют один и тот же фильтр — количество товаров в пагинации совпадает с реальным списком.

Подход 2. Предварительная выборка ID и передача в arrFilter

Без своего компонента. Строим ORM-запрос, получаем массив ID и передаём его в стандартный catalog.section.

<?php

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;
use Bitrix\Main\DB\SqlExpression;

Loader::includeModule('iblock');

$iblockId = 5;
$sectionId = 42;

// Собираем ID раздела и всех подразделов одним запросом
$rootSection = \CIBlockSection::GetList(
    [],
    ['IBLOCK_ID' => $iblockId, 'ID' => $sectionId],
    false,
    ['ID', 'LEFT_MARGIN', 'RIGHT_MARGIN']
)->Fetch();

$sectionIds = [$sectionId];
if ($rootSection) {
    $rsSections = \CIBlockSection::GetList(
        ['LEFT_MARGIN' => 'ASC'],
        [
            'IBLOCK_ID' => $iblockId,
            '>LEFT_MARGIN' => (int)$rootSection['LEFT_MARGIN'],
            '<RIGHT_MARGIN' => (int)$rootSection['RIGHT_MARGIN'],
        ],
        false,
        ['ID']
    );
    while ($row = $rsSections->Fetch()) {
        $sectionIds[] = (int)$row['ID'];
    }
}

$query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

// EXISTS-подзапрос по свойству BRAND (множественное, списочное)
$tableName = 'b_iblock_element_prop_m' . $iblockId;
$query->registerRuntimeField(
    'IN_BRAND',
    new ExpressionField(
        'IN_BRAND',
        'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = 1075'
            . ' AND VALUE_ENUM IN (101, 102))',
        ['ID']
    )
);

// Подзапрос по разделу — тоже через ExpressionField
$sectionIdsSql = implode(',', array_map('intval', $sectionIds));
$query->registerRuntimeField(
    'IN_SECTION',
    new ExpressionField(
        'IN_SECTION',
        'EXISTS (SELECT 1 FROM b_iblock_section_element SE'
            . ' WHERE SE.IBLOCK_ELEMENT_ID = %s'
            . ' AND SE.IBLOCK_SECTION_ID IN (' . $sectionIdsSql . '))',
        ['ID']
    )
);

$query->setSelect(['ID']);
$query->setFilter([
    '=IBLOCK_ID' => $iblockId,
    '=ACTIVE' => 'Y',
    '=IN_BRAND' => 1,
    '=IN_SECTION' => 1,
]);
$query->setOrder(['SORT' => 'ASC']);

$result = $query->exec();
$filteredIds = [];
while ($row = $result->fetch()) {
    $filteredIds[] = (int)$row['ID'];
}

$arFilter = [
    'IBLOCK_ID' => $iblockId,
    'ID' => $filteredIds ?: [0],
];

Что ломается. Пагинация: компонент получит весь массив ID и будет резать его стандартным nav. На каталоге со 100 000 товаров массив ID — это сотни килобайт памяти. Второй минус — сортировка: ORM-запрос вернёт ID в одном порядке, а компонент отсортирует их заново по своим правилам. Третий — вывод в шаблоне остаётся целиком на совести стандартного catalog.section и его шаблона.

Что выбрать

Если фильтрация — ключевой сценарий и каталог больше 100 000 товаров, имеет смысл писать свой компонент. Если каталог меньше или фильтров немного — хватит предварительной выборки ID.

Уровень 3. Умный фильтр: конкретные техники

Стандартный catalog.smart.filter поверх фасетного индекса работает приемлемо, пока каталог небольшой. На миллионном каталоге даже с включённым фасетным индексом он начинает тормозить, потому что на каждое движение пользователя пересчитывает счётчики заново. Ниже — четыре техники, которые дают заметный прирост без переписывания компонента целиком.

Техника 1. Кеширование счётчиков отдельно от товаров

Самая тяжёлая часть умного фильтра — не выборка товаров, а подсчёт счётчиков для каждого значения свойства. Если текущий фильтр по бренду — BRAND = [101, 102], то для свойства COLOR нужно посчитать, сколько товаров подходит под этот бренд и одновременно под каждое значение цвета. Это отдельный GROUP BY по фасетному индексу, и его результат меняется только при изменении состава товаров, а не при каждой перезагрузке.

<?php

namespace Local\Catalog\SmartFilter;

use Bitrix\Iblock\PropertyEnumerationTable;
use Bitrix\Iblock\PropertyTable;
use Bitrix\Main\Application;
use Bitrix\Main\Data\Cache;
use Bitrix\Main\Loader;

class FacetCounterCache
{
    private const CACHE_TTL = 900;
    private const CACHE_PATH = '/catalog/smart.filter.facet/';
    private const MAX_ENUM_VALUES = 200;

    private int $iblockId;

    /** @var array Кеш проверки существования таблиц */
    private static array $tableExistsCache = [];

    /** @var array Кеш счётчиков значений свойств */
    private static array $enumCountCache = [];

    /**
     * @param int $iblockId ID инфоблока каталога
     */
    public function __construct(int $iblockId)
    {
        $this->iblockId = $iblockId;
        Loader::includeModule('iblock');
    }

    /**
     * Возвращает счётчики по значениям свойств для текущего фильтра.
     * Результат кешируется на 15 минут.
     *
     * @param array $activeFilter Фильтр в формате arrFilter
     * @return array
     */
    public function getCounters(array $activeFilter): array
    {
        // ksort стабилизирует порядок ключей: без него md5(serialize())
        // даёт разные ключи кеша при одинаковом по смыслу фильтре
        $sortedFilter = $activeFilter;
        if (!empty($sortedFilter['PROPERTY_VALUES']) && is_array($sortedFilter['PROPERTY_VALUES'])) {
            ksort($sortedFilter['PROPERTY_VALUES']);
        }

        $cache = Cache::createInstance();
        $cacheId = 'facet_' . $this->iblockId . '_' . md5(serialize($sortedFilter));

        if ($cache->initCache(self::CACHE_TTL, $cacheId, self::CACHE_PATH)) {
            return $cache->getVars();
        }

        $result = $this->buildCounters($sortedFilter);

        if ($cache->startDataCache()) {
            $cache->endDataCache($result);
        }

        return $result;
    }

    /**
     * Строит счётчики по всем списочным свойствам инфоблока.
     *
     * @param array $activeFilter
     * @return array
     */
    private function buildCounters(array $activeFilter): array
    {
        $facetTable = 'b_iblock_' . $this->iblockId . '_index';

        if (!$this->tableExists($facetTable)) {
            return [];
        }

        $properties = $this->loadListProperties();
        $result = [];

        foreach ($properties as $property) {
            $counters = $this->loadCountersForProperty($facetTable, $property, $activeFilter);
            if ($counters) {
                $result[$property['CODE']] = $counters;
            }
        }

        return $result;
    }

    /**
     * Считает товары по каждому значению свойства с учётом уже
     * применённых фильтров, кроме самого этого свойства.
     *
     * @param string $facetTable
     * @param array  $property
     * @param array  $activeFilter
     * @return array
     */
    private function loadCountersForProperty(string $facetTable, array $property, array $activeFilter): array
    {
        $connection = Application::getConnection();
        $sqlHelper = $connection->getSqlHelper();

        $propertyId = (int)$property['ID'];

        $sql = 'SELECT I.VALUE AS VALUE_ID, COUNT(DISTINCT I.ELEMENT_ID) AS CNT '
            . 'FROM ' . $sqlHelper->quote($facetTable) . ' I '
            . 'INNER JOIN b_iblock_facet F ON F.ID = I.FACET_ID '
            . 'WHERE F.PROPERTY_ID = ' . $propertyId
            . $this->buildExcludeClause($facetTable, $activeFilter, (string)$property['CODE'])
            . ' GROUP BY I.VALUE';

        $rows = $connection->query($sql)->fetchAll();
        if (!$rows) {
            return [];
        }

        $enumIds = array_column($rows, 'VALUE_ID');
        $enumMap = $this->loadEnumNames($propertyId, $enumIds);
        $result = [];

        foreach ($rows as $row) {
            $enumId = (int)$row['VALUE_ID'];
            if (!isset($enumMap[$enumId])) {
                continue;
            }

            $result[] = [
                'ID' => $enumId,
                'VALUE' => $enumMap[$enumId],
                'COUNT' => (int)$row['CNT'],
            ];
        }

        return $result;
    }

    /**
     * Собирает ограничения по остальным активным фильтрам.
     * $excludeCode сравнивается со строковыми ключами из фильтра,
     * но в SQL подставляется только PROPERTY_ID, полученный из БД —
     * инъекция невозможна даже при подделке ключа в массиве.
     *
     * @param string $facetTable
     * @param array  $activeFilter
     * @param string $excludeCode
     * @return string
     */
    private function buildExcludeClause(string $facetTable, array $activeFilter, string $excludeCode): string
    {
        $connection = Application::getConnection();
        $sqlHelper = $connection->getSqlHelper();
        $parts = [];

        // Свойства, которые есть в фасетном индексе. Если фильтр ссылается
        // на свойство, которого там нет, условие по нему пропускаем, чтобы
        // не получить пустой результат.
        $facetPropertyIds = $this->loadFacetPropertyIds();

        foreach ($activeFilter['PROPERTY_VALUES'] ?? [] as $code => $values) {
            if ($code === $excludeCode) {
                continue;
            }

            $propertyId = $this->getPropertyIdByCode((string)$code);
            if ($propertyId === 0 || !in_array($propertyId, $facetPropertyIds, true)) {
                continue;
            }

            $enumIds = array_map('intval', (array)$values);
            if (!$enumIds) {
                continue;
            }

            // EXISTS обычно быстрее, чем IN с подзапросом по фасетной таблице,
            // потому что не материализует промежуточный набор ID
            $parts[] = 'EXISTS ('
                . 'SELECT 1 FROM ' . $sqlHelper->quote($facetTable) . ' X '
                . 'WHERE X.ELEMENT_ID = I.ELEMENT_ID '
                . 'AND X.FACET_ID IN (SELECT ID FROM b_iblock_facet WHERE PROPERTY_ID = ' . $propertyId . ') '
                . 'AND X.VALUE IN (' . implode(',', $enumIds) . '))';
        }

        return $parts ? ' AND ' . implode(' AND ', $parts) : '';
    }

    /**
     * Возвращает список PROPERTY_ID, участвующих в фасетном индексе.
     *
     * @return array
     */
    private function loadFacetPropertyIds(): array
    {
        $connection = Application::getConnection();

        $rows = $connection->query(
            'SELECT DISTINCT PROPERTY_ID FROM b_iblock_facet WHERE IBLOCK_ID = ' . $this->iblockId
        )->fetchAll();

        return array_map(static fn($row) => (int)$row['PROPERTY_ID'], $rows ?: []);
    }

    /**
     * Возвращает ID свойства по символьному коду.
     *
     * @param string $code
     * @return int
     */
    private function getPropertyIdByCode(string $code): int
    {
        $row = PropertyTable::getList([
            'filter' => ['=IBLOCK_ID' => $this->iblockId, '=CODE' => $code],
            'select' => ['ID'],
            'limit' => 1,
        ])->fetch();

        return $row ? (int)$row['ID'] : 0;
    }

    /**
     * Возвращает список списочных свойств, пригодных для фасета.
     * Свойства с числом значений больше MAX_ENUM_VALUES отсекаются.
     *
     * @return array
     */
    private function loadListProperties(): array
    {
        $rows = PropertyTable::getList([
            'filter' => [
                '=IBLOCK_ID' => $this->iblockId,
                '=ACTIVE' => 'Y',
                '=PROPERTY_TYPE' => 'L',
                '=FILTRABLE' => 'Y',
            ],
            'select' => ['ID', 'CODE'],
        ])->fetchAll();

        if (!$rows) {
            return [];
        }

        $result = [];
        foreach ($rows as $row) {
            $propertyId = (int)$row['ID'];

            if (!isset(self::$enumCountCache[$propertyId])) {
                self::$enumCountCache[$propertyId] = (int)PropertyEnumerationTable::getCount([
                    '=PROPERTY_ID' => $propertyId,
                ]);
            }

            $enumCount = self::$enumCountCache[$propertyId];
            if ($enumCount > 0 && $enumCount <= self::MAX_ENUM_VALUES) {
                $result[] = $row;
            }
        }

        return $result;
    }

    /**
     * Подгружает значения списочного свойства по ID.
     *
     * @param int   $propertyId
     * @param array $enumIds
     * @return array
     */
    private function loadEnumNames(int $propertyId, array $enumIds): array
    {
        if (!$enumIds) {
            return [];
        }

        $rows = PropertyEnumerationTable::getList([
            'filter' => [
                '=PROPERTY_ID' => $propertyId,
                '@ID' => array_map('intval', $enumIds),
            ],
            'select' => ['ID', 'VALUE'],
        ])->fetchAll();

        $map = [];
        foreach ($rows as $row) {
            $map[(int)$row['ID']] = (string)$row['VALUE'];
        }

        return $map;
    }

    /**
     * Проверяет, существует ли таблица. Результат кешируется в статике —
     * SHOW TABLES не использует индексы и на большой БД не бесплатен.
     *
     * @param string $table
     * @return bool
     */
    private function tableExists(string $table): bool
    {
        if (isset(self::$tableExistsCache[$table])) {
            return self::$tableExistsCache[$table];
        }

        $connection = Application::getConnection();

        // information_schema быстрее SHOW TABLES LIKE на больших БД
        $row = $connection->query(
            'SELECT 1 FROM information_schema.TABLES '
            . 'WHERE TABLE_SCHEMA = DATABASE() '
            . 'AND TABLE_NAME = \'' . $connection->getSqlHelper()->forSql($table) . '\' '
            . 'LIMIT 1'
        )->fetch();

        self::$tableExistsCache[$table] = !empty($row);

        return self::$tableExistsCache[$table];
    }

    /**
     * Ограничивает список значений свойства топ-N по популярности.
     * Экономит разметку на странице, но не уменьшает нагрузку на БД.
     *
     * @param array $counters
     * @param int   $limit
     * @return array
     */
    public function limitTopValues(array $counters, int $limit = 30): array
    {
        if (count($counters) <= $limit) {
            return $counters;
        }

        usort($counters, static function (array $a, array $b): int {
            return $b['COUNT'] <=> $a['COUNT'];
        });

        return array_slice($counters, 0, $limit);
    }
}

Дальше этот класс вызывается в result_modifier.php шаблона умного фильтра, подменяет штатные счётчики кешированными и применяет ограничение топ-N:

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

use Local\Catalog\SmartFilter\FacetCounterCache;

$filterName = (string)($arParams['FILTER_NAME'] ?? '');
$activeFilter = ($filterName !== '' && isset($GLOBALS[$filterName]) && is_array($GLOBALS[$filterName]))
    ? $GLOBALS[$filterName]
    : [];

$cache = new FacetCounterCache((int)$arParams['IBLOCK_ID']);
$counters = $cache->getCounters($activeFilter);

foreach ($arResult['ITEMS'] as &$item) {
    $code = (string)($item['CODE'] ?? '');
    if ($code === '' || empty($counters[$code])) {
        continue;
    }

    $counterMap = [];
    foreach ($counters[$code] as $row) {
        $counterMap[(int)$row['ID']] = (int)$row['COUNT'];
    }

    foreach ($item['VALUES'] as &$value) {
        $id = (int)($value['ID'] ?? 0);
        if ($id > 0 && isset($counterMap[$id])) {
            $value['COUNT'] = $counterMap[$id];
        }
    }
    unset($value);

    // Топ-30 по популярности — остальное через поиск внутри фильтра
    $item['VALUES'] = $cache->limitTopValues($item['VALUES'], 30);
}
unset($item);

Техника 2. Прямое чтение фасетного индекса вместо EAV

Если штатный умный фильтр по каким-то причинам не использует фасетный индекс, можно построить выборку напрямую из b_iblock_N_index. Запрос «найти все товары, у которых BRAND = 101 или 102 и COLOR = 15»:

SELECT DISTINCT I.ELEMENT_ID
FROM b_iblock_5_index I
INNER JOIN b_iblock_facet F ON F.ID = I.FACET_ID
WHERE F.IBLOCK_ID = 5
  AND (
    (F.PROPERTY_ID = 1075 AND I.VALUE IN (101, 102))
    OR (F.PROPERTY_ID = 1076 AND I.VALUE = 15)
  )
GROUP BY I.ELEMENT_ID
HAVING COUNT(DISTINCT F.PROPERTY_ID) = 2;

HAVING COUNT(DISTINCT F.PROPERTY_ID) = 2 гарантирует, что товар подходит по обоим свойствам, а не по одному из них.

Важно понимать ограничения. Проверка isFacetIndexReady() ниже отвечает только на вопрос «таблица существует и не пуста». Она не отвечает на вопрос «индекс актуален». На каталогах, где состав товаров меняется часто, индекс может отставать на часы. Единственный способ понять, что он устарел — сравнить время последней записи в индекс с временем последнего изменения товаров, либо настроить мониторинг агента пересборки.

/**
 * Проверяет, существует ли фасетный индекс и есть ли в нём строки.
 * Это не проверка актуальности: индекс может отставать на часы.
 *
 * @param int $iblockId
 * @return bool
 */
protected function isFacetIndexReady(int $iblockId): bool
{
    $connection = Application::getConnection();
    $table = 'b_iblock_' . $iblockId . '_index';

    // information_schema быстрее SHOW TABLES на больших БД
    $exists = $connection->query(
        'SELECT 1 FROM information_schema.TABLES '
        . 'WHERE TABLE_SCHEMA = DATABASE() '
        . 'AND TABLE_NAME = \'' . $connection->getSqlHelper()->forSql($table) . '\' '
        . 'LIMIT 1'
    )->fetch();

    if (!$exists) {
        return false;
    }

    $row = $connection->query(
        'SELECT 1 FROM ' . $connection->getSqlHelper()->quote($table) . ' LIMIT 1'
    )->fetch();

    return !empty($row);
}

Если метод вернул true — работаем через фасетный индекс. Если false — откатываемся на EXISTS по EAV. На проектах, где актуальность критична, стоит дополнительно хранить в опции дату последней пересборки индекса и сравнивать её с текущей датой. Если разрыв больше суток — считать индекс подозрительным и использовать fallback.

Техника 3. Отсечение тяжёлых свойств от фасета

Не каждое свойство стоит показывать в умном фильтре. Свойства с десятками тысяч уникальных значений (артикулы, штрих-коды) в фасет не годятся. Правило простое: если у свойства больше 200 уникальных значений — оно не фасетируется. Проверка уже встроена в loadListProperties() выше, и счётчик значений кешируется в статической переменной — на каталоге с 40 свойствами это экономит 40 запросов на каждый вызов.

Техника 4. Ограничение на длину выборки значений

Даже с осмысленными свойствами список значений может быть длинным. Метод limitTopValues() из класса выше сортирует значения по популярности и оставляет топ-30, а остальные схлопываются в пункт «Показать все», который обрабатывается на клиенте. Это не уменьшает нагрузку на базу, но заметно ускоряет рендеринг и снижает объём HTML.

Когда этих техник достаточно, а когда нет

Четыре техники вместе закрывают большую часть проблем умного фильтра на каталогах до 300–500 тысяч товаров. Дальше начинается потолок: даже фасетный индекс и кешированные счётчики дают секунды на самых сложных комбинациях. Признаки, что пора переходить на внешний поиск:

  • счётчики в умном фильтре кешируются, но первая генерация занимает больше 30 секунд;
  • комбинации из трёх-четырёх фильтров стабильно уходят за 2–3 секунды даже на пустом кеше;
  • пользователи перестали пользоваться фильтром, потому что каждый клик ждёт ответа.

Уровень 4. Кеширование: composite cache и не только

Кеш в catalog.section — тема, на которой сломано больше всего копий. По умолчанию компонент кеширует страницу целиком. Если включён фильтр, кеш либо отключается, либо создаётся отдельная версия для каждого набора параметров.

Composite cache

Композитный кеш — главный инструмент снятия нагрузки с MySQL в Bitrix, и в разговорах про каталоги его упоминают редко, потому что он не про скорость генерации, а про то, что генерации вообще не происходит. Настроенный композит отдаёт страницу из статического HTML-файла без обращения к базе.

Включается в настройках модуля main, раздел «Композитный сайт». Для каталога важно три момента. Первый — статические страницы должны быть помечены как композитные через $APPLICATION->SetPageProperty('COMPOSITE', 'Y') или через настройку в шаблоне сайта. Второй — динамические блоки (корзина, авторизация, счётчик товаров в шапке) выделяются в отдельные «динамические области» через $APPLICATION->setFrameMode(true). Третий — сброс композита должен происходить по событию изменения элементов каталога, а не по времени.

Если всё сделано правильно, страница каталога на 500 000 товаров отдаётся за 30–50 миллисекунд даже при полностью прогретой базе. Проблема начинается тогда, когда пользователь ставит фильтр: композитные страницы не кешируются с учётом $_GET, поэтому при любой комбинации фильтров кеш промахивается, и мы снова упираемся в MySQL.

Что делать: выделить саму страницу в композит, а результат фильтра подгружать отдельным AJAX-запросом. Тогда композит отдаёт каркас мгновенно, а тяжёлый блок со списком товаров и умным фильтром грузится динамически. Настроенный композит на каталоге экономит больше, чем все оптимизации запросов вместе взятые.

Параметр CACHE_FILTER

У catalog.section есть параметр CACHE_FILTER. Если он установлен в Y, компонент кеширует результат с учётом фильтра. На каталоге с 50 000 товаров и 10 фильтрами это может дать тысячи кеш-файлов, и генерация одного из них занимает секунды.

CACHE_FILTER = Y имеет смысл только если:

  • Фильтров мало (2–3 активных параметра).
  • Каталог обновляется редко.
  • Кеш-хранилище — не файлы, а memcached или Redis.

В остальных случаях лучше кешировать не весь результат, а его части: например, список ID товаров, который потом используется для выборки. Или кешировать агрегаты (количество товаров по фильтрам) отдельно от самих товаров.

Тегированный кеш

$cache = \Bitrix\Main\Data\Cache::createInstance();
$cacheId = 'catalog_filter_' . md5(serialize($arFilter));
$cachePath = '/catalog/filter/';
$cacheTime = 3600;

if ($cache->initCache($cacheTime, $cacheId, $cachePath)) {
    $arResult = $cache->getVars();
} elseif ($cache->startDataCache()) {
    $arResult = // тяжёлая выборка

    $cache->endDataCache($arResult);
}

Для сброса по тегам используется \Bitrix\Main\Data\Cache::clearByTag(). При ошибках внутри тяжёлой выборки стоит вызывать $cache->abortDataCache() — иначе в кеш запишется пустой результат.

Кеширование счётчиков отдельно

Самая тяжёлая часть фильтра — подсчёт количества товаров для каждого значения свойства. Реализация — в Уровне 3, техника 1.

Уровень 5. Индексы MySQL под специфику Bitrix

Bitrix хранит свойства инфоблока в таблицах b_iblock_element_prop_*. При раздельном хранении (инфоблоки 2.0) простые свойства уходят в b_iblock_element_prop_s{ID}, множественные — в b_iblock_element_prop_m{ID}. Структура обеих таблиц: IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE, VALUE_ENUM, VALUE_NUM.

Какой индекс нужен под EXISTS-подзапрос

В buildPropertyExists() подзапрос выглядит так:

EXISTS (
    SELECT 1 FROM b_iblock_element_prop_m5
    WHERE IBLOCK_ELEMENT_ID = <внешний ID>
      AND IBLOCK_PROPERTY_ID = 1075
      AND VALUE_ENUM IN (101, 102, 103)
)

Условие IBLOCK_ELEMENT_ID = %s — коррелированное, оно подставляется для каждого элемента внешней выборки. Оптимальный индекс под такой подзапрос — (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM). Колонки идут в порядке селективности: сначала элемент, потом свойство, потом значение. Такой индекс позволяет MySQL найти ровно нужные строки по конкретному элементу и не сканировать остальные.

Штатный индекс Bitrix — (VALUE_ENUM, IBLOCK_PROPERTY_ID). Он хорошо работает в другом сценарии: когда фильтр идёт от значений к элементам, без корреляции. Это классический сценарий arrFilter, где Bitrix строит JOIN и оптимизатор сам решает, с какой стороны подходить. Но для EXISTS-подзапроса этот индекс неоптимален, потому что не покрывает IBLOCK_ELEMENT_ID.

Это не значит, что штатный индекс нужно удалять — на нём держатся другие запросы (в том числе внутри catalog.smart.filter при работе с фасетным индексом). Это значит, что для EXISTS-подзапросов стоит добавить ещё один индекс, специфичный под коррелированное условие.

CREATE INDEX idx_prop_m14_elem_prop_enum
ON b_iblock_element_prop_m14 (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM);

Для строковых свойств — аналогично, с VALUE вместо VALUE_ENUM:

CREATE INDEX idx_prop_m14_elem_prop_value
ON b_iblock_element_prop_m14 (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE(32));

VALUE(32) — префиксный индекс по первым 32 символам. Полный индекс по длинной текстовой колонке бессмыслен: селективность не растёт, а размер растёт быстро. Для фильтров обычно хватает префикса.

Перед созданием индекса проверяем, что такого ещё нет: SHOW INDEX FROM b_iblock_element_prop_m14. Дублирующие индексы зря расходуют место и замедляют вставку.

Фасетный индекс

Штатный механизм Bitrix для ускорения фильтрации — фасетный индекс. Хранится в отдельной таблице b_iblock_N_index: каждая строка — комбинация значений свойств для одного товара. Фильтр строится не через JOIN по таблице свойств, а через выборку из этой таблицы.

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

Индексы для торговых предложений и остатков

Для таблиц торговых предложений и остатков индексы строятся отдельно. Например, для фильтра «только в наличии» условие AMOUNT > 0 не входит в штатный индекс (PRODUCT_ID, STORE_ID) — оно проверяется построчно. Составной индекс (PRODUCT_ID, AMOUNT) закрывает и поиск по товару, и условие на количество прямо внутри индекса.

Денормализация: когда EAV мешает

Архитектура EAV удобна для хранения, но плоха для фильтрации. Если каталог большой и фильтрация — ключевой сценарий, имеет смысл вынести часто фильтруемые свойства в отдельную плоскую таблицу или в highload-блок с собственными индексами.

Это не отменяет Bitrix: достаточно построить таблицу-сателлит, которая обновляется по событию OnIBlockElementUpdate, и использовать её в кастомном фильтре вместо arrFilter по свойствам. На каталоге в 50 000 товаров с 200 характеристиками вынос числовых свойств в highload-блок сократил таблицу b_iblock_element_property с 8 миллионов строк до 1.2 миллиона.

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

Уровень 6. Когда MySQL больше не тянет: Elasticsearch и Sphinx

Все предыдущие уровни — оптимизация внутри MySQL. Но есть порог, за которым любая оптимизация SQL даёт всё меньше, а сложность поддержки растёт. На каталогах от 500 000 товаров с десятком активных фильтров одновременно правильным архитектурным решением часто становится вынос фильтрации во внешний поисковый движок.

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

Elasticsearch: фасетная агрегация вместо JOIN

В Elasticsearch каждый товар — документ, свойства — поля документа. Фильтрация идёт по индексу, а не по JOIN. Подсчёт количества товаров для каждого значения фильтра (фасетная агрегация) выполняется одним запросом, без GROUP BY по миллионам строк.

Сравнивать MySQL и Elasticsearch на каталоге в 10 000 SKU смысла нет: с фасетным индексом MySQL отвечает за 30–80 миллисекунд, и Elasticsearch там ничего не улучшит. Разрыв начинается на каталогах от 100 000 товаров, где MySQL с фасетным индексом стабильно уходит за 300–500 миллисекунд, а на миллионном каталоге с десятком активных фильтров — за 2–5 секунд.

На этих объёмах Elasticsearch отдаёт результат за 80–150 миллисекунд при любой комбинации фильтров, включая агрегацию по фасетам. Разрыв в 5–30 раз стабильно воспроизводится на реальных проектах, но только начиная с сотен тысяч товаров.

Архитектура при этом не требует переписывать каталог целиком. Bitrix остаётся системой хранения и администрирования, а поисковый индекс обновляется по событиям изменения элементов. Задержка между изменением товара и его появлением на витрине — 2–3 минуты, что для большинства B2B-каталогов приемлемо.

Sphinx: когда нужна морфология и дельта-индексация

Sphinx — более старая технология, но она никуда не ушла и хорошо работает в связке с Bitrix. Основное её преимущество — высокая скорость индексации и поддержка морфологии. На каталоге автозапчастей в 350 000 позиций после внедрения Sphinx время поиска сократилось с 8 секунд до 50 миллисекунд, а фильтрация по 12 свойствам стала мгновенной.

Sphinx поддерживает до сотен атрибутов без потери производительности. Данные читаются из MySQL через sql_attr_multi для множественных свойств, и на выходе получается индекс, в котором фильтрация по атрибутам — операция над битовой картой, а не перебор строк.

Когда пора уходить с MySQL

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

  • До 50 000 товаров и 3–4 активных фильтра — хватает фасетного индекса и составных индексов MySQL.
  • 50 000–500 000 товаров — фасетный индекс плюс вынос самых тяжёлых свойств в highload-блоки плюс продуманное кеширование. В большинстве случаев этого достаточно.
  • 500 000+ товаров или 10+ активных фильтров одновременно — пора смотреть на Elasticsearch или Sphinx.

Внешний поисковый движок — не замена MySQL, а дополнение. Bitrix остаётся системой управления каталогом, заказами, ценами и остатками. Elasticsearch или Sphinx берут на себя только поиск и фильтрацию.

Чек-лист: что делать по шагам

  1. Включить монитор производительности и найти самые долгие запросы на странице каталога.
  2. Отдельно замерить время catalog.smart.filter и catalog.section.
  3. Прогнать самые долгие запросы через EXPLAIN ANALYZE. Если actual rows сильно отличается от оценки — обновить статистику или добавить индекс.
  4. Прогнать pt-query-digest по slow query log и найти запрос с наибольшим процентом суммарного времени.
  5. Проверить, какая схема хранения свойств используется в инфоблоке: общая таблица или раздельная.
  6. Переписать тяжёлую выборку с CIBlockElement::GetList на D7 ORM, добавив EXISTS-подзапросы для свойств и разделов.
  7. Убедиться, что кастомный компонент читает $GLOBALS[FILTER_NAME] и его структура совпадает с тем, что пишет умный фильтр. Проверить имя на коллизии.
  8. Ввести кеш счётчиков умного фильтра отдельно от товаров. TTL 10–15 минут.
  9. Отсечь от фасета свойства с числом значений больше 200.
  10. Проверить шаблон на XSS: NAME, DETAIL_PAGE_URL, PREVIEW_TEXT оборачивать в htmlspecialcharsbx().
  11. Настроить composite cache: статический каркас страницы плюс AJAX-подгрузка фильтра и списка.
  12. Проверить CACHE_FILTER: если фильтров много, отключить и перейти на тегированный кеш.
  13. Убедиться, что фасетный индекс актуален. Если каталог часто меняется — настроить пересоздание по агенту и мониторинг отставания.
  14. Добавить составные индексы под EXISTS-подзапросы: (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM) для списочных свойств, (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE(32)) для строковых.
  15. Если каталог выше 500 000 товаров и фильтров больше десяти — посчитать бюджет на Elasticsearch или Sphinx.

Оптимизация каталога — не одна волшебная настройка. Это последовательность: найти узкое место, переписать запрос, оптимизировать умный фильтр, включить композит, закрыть всё кешем, подпереть индексами. И если всё вместе не даёт результата — признать, что MySQL упирается в свой архитектурный потолок, и вынести фильтрацию туда, где она решается за миллисекунды.

Александр Тихий
Автор статьи

Александр Тихий

Fullstack-разработчик · 1С-Битрикс

Разрабатываю и развиваю интернет-магазины и корпоративные порталы на 1С-Битрикс. Интеграции с 1С, highload-нагрузки, безопасность по 152-ФЗ.


Комментарии

Комментариев еще нет, Вы можете стать первым кто его оставит

Оставьте комментарий

На сайте используется система премодерирования комментариев, поэтому ваше сообщение будет опубликовано лишь после одобрения модератором

Вы отвечаете на комментарий пользователя

Отправить

ОБРАТНАЯ СВЯЗЬ

Напишите мне

Напишите мне, чтобы обсудить задачу. Работаю удаленно, официально (самозанятый), стоимость часа — 1800 ₽, в день могу уделять проекту 3–4 часа. Отвечаю быстро, консультация бесплатная.

Во время отправки произошла ошибка, пожалуйста попробуйте еще раз через некоторое время
Сообщение отправлено успешно

Телефоны

+7(993) 007-18-96

Email

info@tichiy.ru

Адрес

Россия, г. Москва

Отправляя форму Вы автоматически подтверждаете, что ознакомились и принимаете Политику конфиденциальности сайта