Оптимизация запросов к базе данных в Битрикс D7: N+1, индексы и кэширование

Медленный Битрикс на проде почти всегда упирается не в «магию ядра», а в лишние SQL: N+1 в циклах, SELECT *, отсутствие индексов и кеш без инвалидации. D7 ORM удобен, но сам по себе не спасает — он лишь делает дорогие ошибки аккуратнее. Ниже — практический минимум: как увидеть запросы, убрать N+1, сузить select, поставить индекс и закешировать выборку с тегами.

Базовый контракт ORM (DataManager, getList, filter, Reference) разобран в статье Bitrix D7 ORM: DataManager, Entity, getList. Кеш — в Cache и TaggedCache. Здесь фокус именно на скорости запросов.

Включите профилировщик SQL

На dev (никогда на проде для всех пользователей) можно включить статистику запросов:

// local/php_interface/init.php — только на dev!
define('SHOW_SQL_STAT', true);

Внизу страницы появится список SQL с временем. Ищите повторяющиеся однотипные SELECT в цикле и запросы без WHERE по индексу. Альтернатива — медленный лог MySQL / EXPLAIN на конкретный запрос.

N+1 — главная проблема ORM

Классика: выбрали список сущностей, а внутри цикла снова ходите в другую таблицу.

use Bitrix\Main\Loader;
use Bitrix\Main\ORM\Fields\Relations\Reference;
use Bitrix\Main\ORM\Query\Join;

// ПЛОХО: N+1
$items = ElementTable::getList(['select' => ['ID']])->fetchAll();
foreach ($items as $item) {
    $props = PropertyTable::getList([
        'filter' => ['=ELEMENT_ID' => $item['ID']],
    ])->fetchAll();
}

// ХОРОШО: один запрос с JOIN (runtime Reference)
$items = ElementTable::getList([
    'select' => ['ID', 'NAME', 'PROP_VALUE' => 'PROPERTY.VALUE'],
    'runtime' => [
        new Reference(
            'PROPERTY',
            PropertyTable::class,
            Join::on('this.ID', 'ref.ELEMENT_ID')
        ),
    ],
])->fetchAll();

// ХОРОШО (альтернатива): два запроса + map в PHP
$items = ElementTable::getList(['select' => ['ID', 'NAME']])->fetchAll();
$ids = array_column($items, 'ID');
$propsByElement = [];
if ($ids) {
    $propRes = PropertyTable::getList([
        'filter' => ['@ELEMENT_ID' => $ids],
        'select' => ['ELEMENT_ID', 'VALUE'],
    ]);
    while ($p = $propRes->fetch()) {
        $propsByElement[(int)$p['ELEMENT_ID']][] = $p['VALUE'];
    }
}

Если связь постоянная — опишите Reference в getMap() сущности и берите поля через USER.LOGIN в select. Подробности карты полей и filter — в гайде по ORM.

Индексы: быстрая проверка

EXPLAIN SELECT * FROM b_iblock_element
WHERE IBLOCK_ID = 5 AND ACTIVE = 'Y';

Если в EXPLAIN видите type: ALL на большой таблице — подходящего индекса нет. Добавляйте составной индекс под реальный filter (порядок колонок важен):

ALTER TABLE b_iblock_element
  ADD INDEX idx_iblock_active (IBLOCK_ID, ACTIVE);

Не плодите индексы «на всякий случай»: каждый тормозит INSERT/UPDATE. Смотрите на фильтры, которые реально горят в SHOW_SQL_STAT.

Узкий select и пагинация

select => ['*'] тянет TEXT/BLOB и лишние колонки. Для списков оставляйте только нужное + limit/offset:

$page = max(1, (int)$page);
$limit = 20;

$result = ProductTable::getList([
    'select' => ['ID', 'NAME', 'PRICE'],
    'filter' => ['=ACTIVE' => 'Y'],
    'order'  => ['ID' => 'DESC'],
    'limit'  => $limit,
    'offset' => ($page - 1) * $limit,
    'count_total' => true,
]);

$total = $result->getCount();
$rows = $result->fetchAll();

Кэширование результатов getList

Файловый/управляемый кеш без тегов быстро отдаёт устаревшие списки. Минимум — TaggedCache и сброс тега на add/update/delete. Развёрнуто — в статье про Cache и TaggedCache.

use Bitrix\Main\Application;
use Bitrix\Main\Data\Cache;

$cache = Cache::createInstance();
$cacheId = 'orders_list_' . md5(serialize($filter));
$cacheDir = '/vendor/orders/';
$tagged = Application::getInstance()->getTaggedCache();

if ($cache->initCache(3600, $cacheId, $cacheDir)) {
    $data = $cache->getVars();
} elseif ($cache->startDataCache()) {
    $tagged->startTagCache($cacheDir);
    $tagged->registerTag('vendor_order');

    $data = OrderTable::getList([
        'filter' => $filter,
        'select' => ['ID', 'CODE', 'STATUS'],
        'limit' => 50,
    ])->fetchAll();

    $tagged->endTagCache();
    $cache->endDataCache($data);
}

// в add/update/delete заказа:
Application::getInstance()->getTaggedCache()->clearByTag('vendor_order');

Чеклист перед продом

  • Нет getList/getById внутри foreach по большому списку.
  • В select только нужные поля; есть limit.
  • Filter совпадает с индексом; EXPLAIN без полного скана на горячих запросах.
  • Даты в filter — объекты Type\DateTime, не «почти правильные» строки.
  • Кеш списка — с тегом и сбросом при записи.

Оптимизация SQL в Битрикс — это дисциплина запросов, а не отдельный «турбо-модуль». ORM даёт удобный API; скорость появляется, когда вы явно управляете JOIN, select, индексами и кешем. Если ещё нет каркаса таблицы — начните с DataManager и getList, затем вернитесь к этому чеклисту.