Оптимізація 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.







