Оптимизация ORM-запросов D7 1С-Битрикс
D7 ORM — объектно-реляционный маппер нового ядра Битрикс. Он удобен для разработки, но умеет генерировать неэффективные запросы, если не понимать его поведение. Запрос, написанный за 5 минут, может делать SELECT * с тремя лишними JOIN — и на большом каталоге это 500 мс вместо 10 мс.
Как D7 ORM строит SQL
ORM читает описание таблицы из метода getMap() класса-сущности. Отношения (references) описываются там же. Когда вы пишете:
\Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'NAME', 'IBLOCK.NAME'],
]);
ORM автоматически добавляет LEFT JOIN b_iblock ON b_iblock.ID = b_iblock_element.IBLOCK_ID — потому что вы запросили поле через точечную нотацию. Это удобно, но если вы не знаете IBLOCK_ID заранее и он всегда одинаковый — JOIN бессмысленный.
Посмотреть итоговый SQL можно через:
$query = \Bitrix\Iblock\ElementTable::query();
$query->setSelect(['ID', 'NAME']);
$query->setFilter(['IBLOCK_ID' => 5]);
echo $query->getQuery(); // выводит сформированный SQL
Главные источники проблем
Лишние поля в select. Если не передать select, ORM выбирает все поля из getMap(). Для ElementTable это 20+ полей включая DETAIL_TEXT (тип Text — потенциально мегабайты). На 100 элементах это мегабайты данных, которые PHP получает из MySQL и тут же выбрасывает.
Правило: всегда явно указывать select:
$result = \Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'NAME', 'PREVIEW_PICTURE_ID', 'DETAIL_PAGE_URL'],
'filter' => ['=IBLOCK_ID' => 5, '=ACTIVE' => 'Y'],
'order' => ['SORT' => 'ASC'],
'limit' => 20,
]);
Автоматические JOIN через references. Когда в select присутствует поле через точечную нотацию (SECTION.NAME, IBLOCK.SORT) — ORM добавляет JOIN. Проверяйте через getQuery() — возможно, нужные данные уже есть в основной таблице или их можно получить отдельным запросом.
N+1 через fetchObject(). D7 поддерживает объектную модель (fetchObject()). При обращении к ленивым связям объект делает дополнительный запрос на каждое обращение:
// Это N+1 — каждый ->getSection() делает новый запрос
foreach ($elements->getIterator() as $element) {
echo $element->getSection()->getName(); // запрос на каждый элемент!
}
// Правильно — загрузить секции сразу через select
$result = \Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'NAME', 'IBLOCK_SECTION_ID', 'SECTION_' => 'IBLOCK_SECTION.NAME'],
'filter' => ['=IBLOCK_ID' => 5],
]);
Кеширование на уровне ORM-запроса
D7 поддерживает встроенный кеш через параметр cache:
$result = \Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'NAME', 'PREVIEW_PICTURE_ID'],
'filter' => ['=IBLOCK_ID' => 5, '=ACTIVE' => 'Y'],
'cache' => [
'ttl' => 3600, // время жизни в секундах
'cache_joins' => true, // кешировать с JOIN-ами
],
]);
Кеш хранится в файловой системе (или memcache/redis если настроен). Инвалидируется автоматически при изменении данных через ORM — если данные меняются прямым SQL, кеш не сбрасывается.
Важно: параметр cache_joins => true нужен если в select есть поля из связанных таблиц. Без него кеш работает некорректно при наличии JOIN.
Runtime-поля и подзапросы
D7 позволяет добавлять вычисляемые поля в запрос через runtime:
use Bitrix\Main\Entity;
$result = \Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'NAME', 'PRICE_VALUE'],
'runtime' => [
new Entity\ReferenceField(
'PRICE',
\Bitrix\Catalog\PriceTable::class,
['=this.ID' => 'ref.PRODUCT_ID', '=ref.CATALOG_GROUP_ID' => new Entity\ExpressionField('PTYPE', '1')],
['join_type' => 'LEFT']
),
new Entity\ExpressionField('PRICE_VALUE', '%s', ['PRICE.PRICE']),
],
'filter' => ['=IBLOCK_ID' => 5],
]);
Через ExpressionField можно вычислять значения на стороне MySQL без PHP-обработки: ROUND(%s * 1.2, 2) для расчёта цены с наценкой.
Работа с большими выборками
При обработке большого числа записей (импорт, экспорт, массовое обновление) не загружайте всё в память:
// Плохо: все 100 000 строк в памяти PHP
$result = SomeTable::getList(['select' => ['ID', 'NAME']]);
$all = $result->fetchAll();
// Хорошо: обработка пакетами по 500
$offset = 0;
$limit = 500;
do {
$result = SomeTable::getList([
'select' => ['ID', 'NAME'],
'limit' => $limit,
'offset' => $offset,
'order' => ['ID' => 'ASC'], // стабильная сортировка для пагинации
]);
$rows = $result->fetchAll();
foreach ($rows as $row) {
// обработка
}
$offset += $limit;
} while (count($rows) === $limit);
Альтернатива — курсорная пагинация по ID: filter => ['>ID' => $lastId], что эффективнее OFFSET при больших значениях.
Диагностика через SqlTracker
Для анализа ORM-запросов в реальном контексте:
$tracker = \Bitrix\Main\Diag\SqlTracker::getInstance();
$tracker->start();
// ... ваш код с ORM-запросами ...
$tracker->stop();
$queries = $tracker->getQueries();
usort($queries, fn($a, $b) => $b->getTime() <=> $a->getTime());
foreach (array_slice($queries, 0, 10) as $q) {
echo round($q->getTime() * 1000, 1) . ' ms: ' . substr($q->getSql(), 0, 200) . PHP_EOL;
}
Сроки работ
| Задача | Срок | Эффект |
|---|---|---|
| Аудит ORM-запросов, выявление лишних select/JOIN | 1–2 дня | Понимание картины |
| Оптимизация select, устранение N+1 | 2–4 дня | Снижение нагрузки на 30–60% |
| Добавление кеша к медленным запросам | 1–2 дня | Устранение повторных запросов |
| Рефакторинг сложных запросов (runtime, подзапросы) | 3–5 дней | Перенос вычислений на сторону БД |
| Комплексная оптимизация ORM-слоя | 1.5–2 недели | Стабильная работа под нагрузкой |
D7 ORM — инструмент баланса между удобством разработки и производительностью. Для читаемых запросов без критических требований к скорости — хорошо. Для горячих путей с тысячами вызовов в минуту — нужно контролировать каждый генерируемый SQL.







