ГлавнаяБлогИндексы MySQL под каталог 100k+ товаров в 1С-Битрикс

Индексы MySQL под каталог 100k+ товаров в 1С-Битрикс

Рамиль Юналиев
Рамиль Юналиев
E-Commerce Lead
30 июля 2026 г.
17 мин чтения

На каталоге в пару тысяч товаров почти любой запрос отрабатывает быстро сам по себе — таблицы маленькие, MySQL перебирает их целиком без индекса и не замечает разницы. На 100k+ товаров та же самая логика фильтра или сортировки начинает упираться в полное сканирование таблиц, и именно здесь решает не общая настройка сервера, а конкретный индекс под конкретный запрос. В посте про 15k+ заказов я уже писал, что первый инструмент под умный фильтр каталога — штатный фасетный индекс, а не ручные индексы. Здесь — про четыре места, где фасетный индекс либо не участвует, либо сам упирается в потолок, и что делать руками: с конкретными таблицами, EXPLAIN и CREATE INDEX.

Фильтр по нескольким свойствам одновременно — самое больное место

Как на самом деле хранятся свойства

Инфоблоки хранят свойства элементов не в колонках таблицы товара, а по модели, где каждое значение свойства — отдельная запись, привязанная к элементу и к свойству (такую модель называют EAV, «entity-attribute-value» — «сущность-атрибут-значение»). Гибко для админки: новое свойство добавляется без изменения структуры таблицы товаров. Но у фильтра по нескольким таким свойствам сразу есть цена: за каждое отфильтрованное множественное свойство запрос получает отдельное присоединение (JOIN) к таблице значений — если товар отбирается по трём множественным свойствам, это три присоединения. Насколько это точно устроено — дальше, для конкретной версии хранения.

Начиная с версии инфоблоков 2.0 (это текущий стандарт уже много лет, для новых инфоблоков включается по умолчанию) хранение разделено на две таблицы на каждый инфоблок:

  • b_iblock_element_prop_s{ID} — простые (не множественные) свойства: одна строка на элемент, одна колонка на каждое такое свойство, колонка называется PROPERTY_{ID свойства}. Это уже не EAV, а обычная широкая таблица — на все простые свойства нужен один общий JOIN к этой таблице, без отдельного условия на каждое свойство.
  • b_iblock_element_prop_m{ID} — множественные свойства (можно выбрать несколько значений сразу): здесь именно EAV, строка на каждое значение, колонки IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE, VALUE_NUM. Фильтр по такому свойству — это уже JOIN к этой таблице с условием на IBLOCK_PROPERTY_ID.

Общая таблица b_iblock_element_property (без суффикса ID инфоблока) в ядре тоже осталась, но это хранение версии 1.0 — для старых, не переведённых на 2.0 инфоблоков. Дальше речь про версию 2.0, она у подавляющего большинства каталогов.

Что происходит при фильтре по нескольким свойствам

Стандартный код фильтра каталога — привычный вызов CIBlockElement::GetList() с несколькими PROPERTY_* в фильтре:

$res = \CIBlockElement::GetList(
    ['SORT' => 'ASC'],
    [
        'IBLOCK_ID' => $iblockId,
        'ACTIVE' => 'Y',
        'PROPERTY_BREND' => $selectedBrandIds,   // множественное свойство
        'PROPERTY_COLOR' => $selectedColorIds,   // тоже множественное
        'PROPERTY_RAM' => $selectedRam,          // простое свойство
    ],
    false,
    false,
    ['ID', 'NAME']
);

PROPERTY_RAM — простое свойство, оно соответствует колонке PROPERTY_{ID} таблицы b_iblock_element_prop_s{ID} и попадает в один общий JOIN к этой таблице. А вот PROPERTY_BREND и PROPERTY_COLOR — множественные, и каждое из них добавляет свой отдельный JOIN к b_iblock_element_prop_m{ID} с условием на конкретный IBLOCK_PROPERTY_ID. По сути выполняется что-то вроде:

SELECT BE.ID, BE.NAME
FROM b_iblock_element BE
JOIN b_iblock_element_prop_m14 FP1
    ON FP1.IBLOCK_ELEMENT_ID = BE.ID AND FP1.IBLOCK_PROPERTY_ID = 181 AND FP1.VALUE IN (12, 15)
JOIN b_iblock_element_prop_m14 FP2
    ON FP2.IBLOCK_ELEMENT_ID = BE.ID AND FP2.IBLOCK_PROPERTY_ID = 184 AND FP2.VALUE IN (7)
JOIN b_iblock_element_prop_s14 PS
    ON PS.IBLOCK_ELEMENT_ID = BE.ID AND PS.PROPERTY_190 = 8
WHERE BE.IBLOCK_ID = 14 AND BE.ACTIVE = 'Y';

На таблице b_iblock_element_prop_m14 на 100k товаров и десятке множественных свойств может лежать несколько миллионов строк — по одной на каждое значение каждого свойства каждого товара. EXPLAIN на таком запросе без нужного индекса выглядит примерно так:

+----+-------------+-------+--------+--------------------+--------------------+---------+-----------------------+-------+--------------------------------+
| id | select_type | table | type   | possible_keys      | key                | key_len | ref                   | rows  | Extra                          |
+----+-------------+-------+--------+--------------------+--------------------+---------+-----------------------+-------+--------------------------------+
| 1  | SIMPLE      | FP1   | ref    | IBLOCK_PROPERTY_ID | IBLOCK_PROPERTY_ID | 4       | const                 | 48213 | Using where                    |
| 1  | SIMPLE      | BE    | eq_ref | PRIMARY            | PRIMARY            | 4       | FP1.IBLOCK_ELEMENT_ID | 1     | Using where                    |
| 1  | SIMPLE      | FP2   | ref    | IBLOCK_PROPERTY_ID | IBLOCK_PROPERTY_ID | 4       | const                 | 51876 | Using where; Using join buffer |
+----+-------------+-------+--------+--------------------+--------------------+---------+-----------------------+-------+--------------------------------+

Индекс по IBLOCK_PROPERTY_ID находит нужное свойство быстро, но дальше внутри этих 48-51 тысяч строк MySQL всё равно проверяет условие на VALUE построчно (Using where) — колонка не в индексе. С двумя множественными свойствами в фильтре это ещё терпимо, с четырьмя-пятью — уже секунды на запрос под нагрузкой.

Базовый D7-класс \Bitrix\Iblock\ElementTable::getList() фильтр по PROPERTY_* не принимает — падает с SystemException: Unknown field definition. Если у инфоблока задан API_CODE, ядро генерирует отдельный класс под конкретный инфоблок (\Bitrix\Iblock\Elements\Element{ApiCode}Table), и через него фильтр по свойству уже работает — '{PropertyCode}.ITEM.ID' => [...] для списочных свойств. На практике фильтр каталога всё равно почти везде остаётся на старом API — не потому что D7 не умеет, а потому что компоненты каталога и админки написаны под CIBlockElement::GetList() ещё до появления генерируемых классов, и их никто не переписывает без веской причины.

Индекс на VALUE — и почему для списочных свойств он не тот

Составной индекс, в который входит и IBLOCK_PROPERTY_ID, и VALUE (для числовых свойств — VALUE_NUM), и IBLOCK_ELEMENT_ID:

CREATE INDEX idx_prop_value_elem
    ON b_iblock_element_prop_m14 (IBLOCK_PROPERTY_ID, VALUE(255), IBLOCK_ELEMENT_ID);
 
-- для числовых множественных свойств отдельно, VALUE_NUM — отдельная колонка той же таблицы
CREATE INDEX idx_prop_valuenum_elem
    ON b_iblock_element_prop_m14 (IBLOCK_PROPERTY_ID, VALUE_NUM, IBLOCK_ELEMENT_ID);

VALUE в этой таблице — колонка типа TEXT, а MySQL не разрешает индексировать TEXT/BLOB без длины префикса, поэтому VALUE(255), а не просто VALUE — без этого CREATE INDEX упадёт с ошибкой 1170.

Перед добавлением стоит посмотреть SHOW INDEX FROM b_iblock_element_prop_m14, а не добавлять вслепую — индексы могли уже стоять, и дублирующий индекс просто зря расходует место и замедляет вставку.

Но у индекса на VALUE есть ограничение по типу свойства, которое легко упустить. VALUE — это колонка для свойств строкового типа (S). А для списочных свойств (L) — тип, который на практике чаще всего и выбирают под фильтруемые атрибуты вроде цвета или размера, чтобы в админке значение выбиралось из готового списка, а не вводилось текстом, — реальный фильтр идёт по другой колонке, VALUE_ENUM — в сгенерированном Битриксом SQL это FPV0.VALUE_ENUM IN (...), VALUE в условии вообще не участвует. Индекс на VALUE для списочного свойства просто мимо цели. Хорошая новость: под VALUE_ENUM у Битрикса уже есть штатный индекс (VALUE_ENUM, IBLOCK_PROPERTY_ID) прямо из коробки — свой добавлять не нужно, для списочных свойств проблема не «индекса нет», а тот же нюанс с планом запроса, что и ниже.

Важная оговорка, которую стоит проверить перед тем, как рассчитывать на индекс (что на VALUE, что на VALUE_ENUM):

  • сам факт существования индекса ничего не гарантирует — использует его MySQL или нет, решает оптимизатор по статистике таблицы, и это решение может меняться с объёмом данных даже для одного и того же запроса;
  • запрос, который реально генерирует CIBlockElement::GetList() с фильтром по свойствам, — индекс не использует никогда: значение проверяется отдельным условием Using where уже после того, как строка найдена по элементу, индекс остаётся в possible_keys, но не попадает в key;
  • ручной SQL — индекс может сработать, но не гарантированно: план запроса на большем объёме данных может измениться на тот же самый, что и у стандартного API;
  • единственный способ получить гарантированный, а не случайный результат — задать план явно через STRAIGHT_JOIN и FORCE INDEX, не полагаясь на выбор оптимизатора.

Цифры и разбор, что было на разных объёмах и для разных типов свойств, — в разделе про тестовый стенд ниже.

Где индекс уже не спасает

Индекс на одну пару (свойство, значение) не решает пересечение нескольких таких пар. При трёх-четырёх одновременно отфильтрованных множественных свойствах MySQL всё равно должен выполнить несколько присоединений подряд и пересечь их результаты, и на каждом шаге строит промежуточный набор строк, который дальше сужается следующим присоединением. Чем больше множественных свойств в фильтре — тем больше таких промежуточных пересечений, и оптимизатор запросов не всегда выбирает удачный порядок присоединений, особенно если статистика по таблице устарела. Это и есть тот случай, ради которого в Битрикс есть фасетный индекс (Контент → Инфоблоки → Фасетные индексы) — он заранее сводит значения всех свойств элемента в одну плоскую строку и фильтрует по ней без множественных JOIN. Для стандартного умного фильтра каталога это должно быть включено в первую очередь, ручные индексы на b_iblock_element_prop_m{ID} — для нестандартных запросов, которые фасетный индекс не покрывает (нестандартная выборка в компоненте, кастомный API-эндпойнт, экспорт).

А если и это не хватает — например, фильтр строится не по стандартному умному фильтру, а по кастомной сложной логике с десятком условий по свойствам сразу, — единственный работающий вариант: данные такой формы изначально держать не в EAV-свойствах, а в обычной таблице с настоящими колонками (такой подход называют денормализацией). Про то, как выбрать такой формат сразу, — в последнем разделе, про HL-блоки.

Цены и остатки на большом каталоге

Цены — b_catalog_price

Цены хранятся отдельно от карточки товара, в таблице b_catalog_price: ID, PRODUCT_ID, CATALOG_GROUP_ID (тип цены — розница, опт и так далее), PRICE, PRICE_SCALE (та же цена, пересчитанная в базовую валюту магазина), CURRENCY, QUANTITY_FROM, QUANTITY_TO. Дальше все примеры — на одной валюте, поэтому фильтр и индекс строятся на PRICE; если у каталога несколько валют, везде вместо PRICE нужен PRICE_SCALE — сравнивать цены в разных валютах напрямую по PRICE некорректно. Один товар может иметь несколько строк — по одной на каждый тип цены и диапазон количества, поэтому фильтр по диапазону цены без ограничения на конкретный QUANTITY_FROM может вернуть один и тот же товар несколько раз. D7-класс — \Bitrix\Catalog\PriceTable:

$prices = \Bitrix\Catalog\PriceTable::getList([
    'filter' => [
        'CATALOG_GROUP_ID' => 1,          // конкретный тип цены — розница
        'QUANTITY_FROM' => 0,             // без этого один товар может вернуться несколько раз
        '>=PRICE' => $priceFrom,
        '<=PRICE' => $priceTo,
    ],
    'select' => ['PRODUCT_ID', 'PRICE'],
    'order' => ['PRICE' => 'ASC'],
])->fetchAll();

По умолчанию у таблицы уже есть индексы под другие задачи: один — на связку «товар + тип цены» (для быстрого поиска цены конкретного товара), второй — просто на тип цены (CATALOG_GROUP_ID) без цены в нём. Второй индекс MySQL реально использует для фильтра по типу цены (type: ref), но PRICE в нём нет — диапазон проверяется построчно (Using where), а ORDER BY PRICE досортировывается отдельно (Using filesort). Нужен индекс, где после точного равенства (CATALOG_GROUP_ID) сразу идёт колонка для диапазона и сортировки (PRICE):

CREATE INDEX idx_group_price ON b_catalog_price (CATALOG_GROUP_ID, PRICE);

После него EXPLAIN показывает type: range, key: idx_group_price, без Using filesortORDER BY PRICE в пределах диапазона удовлетворяется порядком индекса. Штатный индекс на одном CATALOG_GROUP_ID после этого становится префиксом нового составного индекса и просто дублирует его — по тому же принципу «не плодить индексы вслепую» его стоит удалить (DROP INDEX), а не оставлять рядом с новым.

Остатки — b_catalog_store_product

Остатки по складам — отдельная таблица b_catalog_store_product: ID, STORE_ID, PRODUCT_ID, AMOUNT. Один товар — по одной строке на каждый склад, где он вообще заведён. D7-класс — \Bitrix\Catalog\StoreProductTable:

$inStock = \Bitrix\Catalog\StoreProductTable::getList([
    'filter' => ['PRODUCT_ID' => $productId, '>AMOUNT' => 0],
    'select' => ['STORE_ID', 'AMOUNT'],
])->fetchAll();

Штатная опция «скрывать отсутствующие товары» в умном фильтре каталога (catalog.smart.filter) фильтрует по полю AVAILABLE в b_catalog_product — это уже готовый признак «да/нет», без обращения к остаткам по складам. b_catalog_store_product и EXISTS по нему нужны для другого случая — когда включён складской учёт и наличие считается именно по остаткам на конкретных складах, а не по общему признаку. Такой фильтр «только товары в наличии» на списке из тысяч товаров обычно превращается в подзапрос вида «есть хотя бы одна строка остатков с положительным количеством»:

SELECT BE.ID
FROM b_iblock_element BE
WHERE BE.IBLOCK_ID = 14
  AND EXISTS (
      SELECT 1 FROM b_catalog_store_product SP
      WHERE SP.PRODUCT_ID = BE.ID AND SP.AMOUNT > 0
  );

У таблицы обычно уже есть индекс на связку PRODUCT_ID + STORE_ID — он уже даёт точечный поиск по товару (type: ref), а не полное сканирование. Но AMOUNT в этот индекс не входит, и условие AMOUNT > 0 проверяется отдельно по каждой найденной строке (Using where). Индекс, который закрывает и поиск по товару, и условие на количество прямо внутри себя:

CREATE INDEX idx_product_amount ON b_catalog_store_product (PRODUCT_ID, AMOUNT);

С этим индексом MySQL может отсечь строки с нулевым остатком прямо по индексу, не читая саму строку таблицы (Using index) — выигрыш заметный, но не такой большой, как в случае с ценами: базовый индекс здесь и так неплохо справлялся, разница именно в том, что теперь не нужно читать сами строки.

Почему один индекс не закрывает связку цена + наличие

У фильтра «цена от 1000 до 5000, только в наличии, сортировка по цене» в запросе участвуют три таблицы (элементы, цены, остатки), и MySQL использует по одному индексу на каждую таблицу отдельно, а не один индекс сквозь все три. Если индекс на b_catalog_price есть, а на b_catalog_store_product нет — узкое место просто переезжает со сканирования цен на сканирование остатков, общее время запроса остаётся почти таким же. Проверять EXPLAIN нужно на всём запросе целиком, а не на одной таблице — иначе легко решить не ту проблему, которая на самом деле тормозит.

Сортировка и пагинация на большой выборке

Отдельная сортировка после фильтра — filesort

Список каталога почти всегда идёт с сортировкой — по цене, по названию, по дате поступления — и с пагинацией. Если сортировка не совпадает с тем индексом, по которому идёт фильтр, MySQL сначала отбирает строки по фильтру, а затем досортировывает их отдельно, во временной области (это и есть Using filesort в EXPLAIN, происходит не обязательно на диске — при небольшом объёме строк сортировка укладывается в буфер sort_buffer_size в памяти, но остаётся отдельным дополнительным проходом):

SELECT ID, NAME FROM b_iblock_element
WHERE IBLOCK_ID = 14 AND ACTIVE = 'Y'
ORDER BY SORT ASC, ID ASC
LIMIT 30;
+----+-------------+-------+------+---------------+-----------+---------+-------+-------+-----------------------------+
| id | select_type | table | type | possible_keys | key       | key_len | ref   | rows  | Extra                       |
+----+-------------+-------+------+---------------+-----------+---------+-------+-------+-----------------------------+
| 1  | SIMPLE      | BE    | ref  | IBLOCK_ID     | IBLOCK_ID | 4       | const | 98450 | Using where; Using filesort |
+----+-------------+-------+------+---------------+-----------+---------+-------+-------+-----------------------------+

Индекс по IBLOCK_ID нашёл нужный инфоблок, но дальше 98 тысяч строк сортируются отдельным проходом. Составной индекс, который закрывает и фильтр, и сортировку сразу, убирает этот отдельный проход:

CREATE INDEX idx_iblock_active_sort ON b_iblock_element (IBLOCK_ID, ACTIVE, SORT, ID);

После него в EXPLAIN пропадает Using filesort: MySQL читает строки из индекса уже в нужном порядке, ORDER BY отдельно выполнять не нужно.

Глубокая пагинация — почему LIMIT с большим OFFSET не ускоряется индексом

Даже с идеальным индексом остаётся вторая проблема — глубокая постраничная выборка (переход на дальние страницы списка через большое смещение, OFFSET):

SELECT ID, NAME FROM b_iblock_element
WHERE IBLOCK_ID = 14 AND ACTIVE = 'Y'
ORDER BY SORT ASC, ID ASC
LIMIT 30 OFFSET 99000;

Индекс здесь помогает найти строки по порядку быстро, но MySQL всё равно обязан пройти и отбросить первые 99000 строк из отсортированного набора, прежде чем отдать нужные 30, — OFFSET не «перескакивает» к нужному месту, а буквально пропускает построчно. На первой странице списка разница незаметна, на дальних страницах при активном использовании (боты, каталог с постраничной навигацией на десятки тысяч позиций) запрос с большим OFFSET заметно медленнее запроса с маленьким, при абсолютно одинаковом индексе и плане выполнения.

Что делать вместо OFFSET

Если по интерфейсу нужен именно переход «вперёд, на следующую страницу» (а не «прыгнуть сразу на страницу 3400»), OFFSET можно вообще не использовать — вместо смещения запоминать значения сортировки и ID последней строки предыдущей страницы и продолжать выборку от них. Условие стоит писать не в виде пары (SORT, ID) > (:lastSort, :lastId) (такую пару в скобках слева от сравнения называют row-конструктором) — MySQL при двух равенствах перед такой парой (IBLOCK_ID, ACTIVE) использует индекс только по ним, а саму пару всё равно проверяет построчно, — а в развёрнутом виде через OR, тогда индекс используется целиком:

SELECT ID, NAME FROM b_iblock_element
WHERE IBLOCK_ID = 14 AND ACTIVE = 'Y'
  AND (SORT > :lastSort OR (SORT = :lastSort AND ID > :lastId))
ORDER BY SORT ASC, ID ASC
LIMIT 30;

С тем же составным индексом (IBLOCK_ID, ACTIVE, SORT, ID) это условие в развёрнутом виде покрывается индексом целиком, и время запроса не зависит от того, какая по счёту это страница — 2-я или 3000-я. Плата за это — теряется прямой переход на произвольный номер страницы по ссылке, работает только последовательный переход «дальше»/«назад», поэтому для постраничной навигации с номерами страниц в интерфейсе такой способ подходит не всегда, а для бесконечной прокрутки или экспорта большими порциями — подходит хорошо.

Проверено на MySQL 8.0.40 — в MariaDB оптимизатор устроен иначе, поведение row-конструктора там стоит проверять отдельно, не считать таким же по умолчанию.

Отдельный источник тормоза на глубоких страницах, который эта замена не убирает: штатная постраничная навигация Битрикса (CDBResult::NavStart()) при подсчёте общего числа страниц выполняет ещё и отдельный SELECT COUNT(*) по всей отобранной выборке. Если это узкое место, лечится не переписыванием условия пагинации, а отключением подсчёта общего количества там, где точное число страниц не обязательно показывать.

HL-блоки вместо EAV-свойств

Когда сразу выбирать HL-блок, а не свойство инфоблока

Индекс на b_iblock_element_prop_m{ID} и фасетный индекс закрывают основную часть фильтров каталога — обычных атрибутов вида «один товар, одно значение свойства». Но часть данных по своей природе не атрибут карточки, а отдельная сущность со своими связями — например, таблица соответствия «модель — совместимые комплектующие» на десятки тысяч строк, которую нужно фильтровать и по одной, и по другой стороне связи, с произвольными условиями. Такие данные не стоит с самого начала пытаться впихнуть в множественное свойство инфоблока в расчёте на индексы и фасетный индекс — они всё равно рано или поздно упрутся в тот же предел: слишком много присоединений на одно значение, слишком специфичные фильтры, под которые фасетный индекс не подстроен. Для данных такой формы сразу лучше подходит обычная таблица — то есть HL-блок, а не переделка задним числом уже забуксовавшего свойства.

Как устроен HL-блок и его индекс

Highload-блок (HL-блок) — это обычная таблица MySQL с настоящими колонками, каждая колонка — поле с префиксом UF_, без деления на «простое» и «множественное» хранение и без EAV-присоединений. Индекс на такую таблицу — обычный CREATE INDEX или ALTER TABLE, без оглядки на структуру ядра инфоблоков:

ALTER TABLE b_hlbd_product_attrs
    ADD INDEX idx_brand_ram (UF_BRAND_ID, UF_RAM_GB);

Читается такая таблица через обычный D7 ORM, без прослойки старого API:

$hlblock = \Bitrix\Highloadblock\HighloadBlockTable::getById($hlblockId)->fetch();
$entity = \Bitrix\Highloadblock\HighloadBlockTable::compileEntity($hlblock);
$dataClass = $entity->getDataClass();
 
$rows = $dataClass::getList([
    'filter' => ['=UF_BRAND_ID' => 15, '>=UF_RAM_GB' => 8],
    'select' => ['ID', 'UF_PRODUCT_ID', 'UF_RAM_GB'],
    'order' => ['UF_RAM_GB' => 'ASC'],
])->fetchAll();

Фильтр по UF_BRAND_ID и UF_RAM_GB — это уже не два присоединения к таблице значений, а обычное условие WHERE по обычным колонкам обычной таблицы, с обычным составным индексом.

Что теряется, если выбрать HL-блок вместо свойства

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

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

Поэтому HL-блок имеет смысл заводить не под весь каталог целиком, а под конкретное свойство такой формы — которое по факту не укладывается в модель EAV даже с индексами и с фасетным индексом и не обязано участвовать в стандартном умном фильтре напрямую.

Все рекомендации из этого поста проверены на живом тесте — 102 тысячи элементов, MySQL 8.0.40, конкретные цифры и разбор, что подтвердилось, а что пришлось скорректировать: Индексы MySQL под каталог Битрикса — тест на 102 000 товаров.

Итог

МестоЧто тормозитЧто помогает
Фильтр по нескольким свойствамJOIN на каждое множественное свойство, сканирование внутри b_iblock_element_prop_m{ID}Фасетный индекс — для умного фильтра; для своего SQL — индекс на VALUE/VALUE_ENUM (смотря по типу свойства) и обязательно STRAIGHT_JOIN+FORCE INDEX, иначе не сработает
Цена и наличиеШтатные индексы на b_catalog_price/b_catalog_store_product не покрывают диапазон цены и условие на остаток — Using filesort и лишнее чтение строк(CATALOG_GROUP_ID, PRICE) на ценах, (PRODUCT_ID, AMOUNT) на остатках
Сортировка + пагинацияСортировка не покрыта индексом (Using filesort); большой OFFSET перебирает и отбрасывает строкиСоставной индекс (фильтр, сортировка, ID); для дальних страниц — продолжение от последнего значения вместо OFFSET
Предел EAVСлишком много присоединений на одно значение, нестандартный фильтр вне фасетного индексаHL-блок — обычная таблица со своими индексами, ценой в виде утраты штатной интеграции с каталогом и админкой

Ни один из этих индексов не заменяет остальные — они закрывают разные запросы, и на каталоге 100k+ обычно нужны все сразу, добавленные не заранее по аналогии, а по тому, что реально показал EXPLAIN на конкретном медленном запросе.