ORM Битрикс: выборки и связи без дублей и лишних запросов
Список 50 заказов с именем покупателя и числом позиций — и вместо 50 строк приходит 180, limit перестаёт работать, а count_total врёт. Четыре способа вытащить связанные данные в Bitrix ORM и цена каждого.

Задача простая: вывести 50 последних заказов, у каждого показать имя покупателя и количество позиций. Пишете один getList, добавляете связи в select — и получаете 180 строк вместо 50, limit отрезает не те заказы, а count_total рапортует о 312 заказах там, где их пятьдесят. Ниже — четыре рабочих способа собрать связанные данные в Bitrix ORM, с ценой каждого. Примеры проверялись на модуле main 22+ и PHP 8.1; новый формат связей (Reference, OneToMany, Join::on) доступен начиная с main 18.x — версию модуля видно в админке, в списке обновлений.
Почему «добавить связь в select» ломается
ORM Битрикса не делает отдельных запросов под связи. Любая связь в select, filter или order превращается в JOIN внутри одного SQL-запроса. Для связи «многие к одному» это ровно то, что нужно: у заказа один покупатель, строк не прибавится. Для «одного к многим» СУБД честно размножит строку заказа по числу позиций — это дублирование выборки.2 А LIMIT 50 применяется уже к результату JOIN, то есть считает не заказы, а строки.
Обратная крайность — объектный API с ленивой подгрузкой: в цикле на каждый заказ уходит отдельный запрос за покупателем. Пятьдесят заказов — пятьдесят один запрос. Это N+1.1
Сначала — карта сущностей, на которой держатся все примеры.
use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
use Bitrix\Main\ORM\Fields\Relations\Reference;
use Bitrix\Main\ORM\Fields\Relations\OneToMany;
use Bitrix\Main\ORM\Query\Join;
class OrderTable extends DataManager
{
public static function getTableName(): string
{
return 'my_shop_order';
}
public static function getMap(): array
{
return [
(new IntegerField('ID'))
->configurePrimary(true)
->configureAutocomplete(true),
(new IntegerField('USER_ID'))->configureRequired(true),
(new StringField('STATUS'))->configureSize(10),
// многие к одному: покупатель заказа
(new Reference(
'USER',
\Bitrix\Main\UserTable::class,
Join::on('this.USER_ID', 'ref.ID')
))->configureJoinType('left'),
// один к многим: третий аргумент — имя Reference в дочерней сущности,
// который смотрит обратно на заказ
(new OneToMany('ITEMS', OrderItemTable::class, 'ORDER'))
->configureJoinType('left'),
];
}
}
Четыре способа вытащить связанные данные
1. JOIN через Reference в одном запросе
Берёте нужные поля связи прямо в select через точку и даёте им понятные псевдонимы.
$rows = OrderTable::getList([
'select' => [
'ID',
'STATUS',
'USER_NAME' => 'USER.NAME',
'USER_EMAIL' => 'USER.EMAIL',
],
'filter' => ['=STATUS' => 'N'],
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchAll();
Один запрос, плоский массив, предсказуемая постраничка. Платите тем, что способ работает только для «многие к одному». Как только в select попадает ITEMS.*, строки размножаются.
2. Два запроса и склейка по ID
Самый скучный и самый надёжный вариант для связей «один к многим»: сначала страница родителей, потом один запрос за всеми их детьми.
$orders = OrderTable::getList([
'select' => ['ID', 'STATUS', 'USER_NAME' => 'USER.NAME'],
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchAll();
$orderIds = array_column($orders, 'ID');
$itemsByOrder = [];
if ($orderIds) {
// оператор @ в фильтре — это IN
$itemRows = OrderItemTable::getList([
'select' => ['ID', 'ORDER_ID', 'PRODUCT_ID', 'QUANTITY'],
'filter' => ['@ORDER_ID' => $orderIds],
])->fetchAll();
foreach ($itemRows as $row) {
$itemsByOrder[$row['ORDER_ID']][] = $row;
}
}
Два запроса независимо от числа заказов, limit означает ровно то, что написано, дублей нет. Цена — руками собранная структура и лишние 10 строк кода на каждую связь.
3. Объектный API с предзагрузкой
Если данные едут дальше по коду как бизнес-объект, а не как массив из fetchAll, удобнее коллекции. Ключевое — перечислить связь в select, чтобы она пришла тем же запросом, а не догружалась в цикле.
$orders = OrderTable::getList([
'select' => ['ID', 'STATUS', 'USER'], // USER поедет одним JOIN
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchCollection();
foreach ($orders as $order) {
// обращения к покупателю уже не ходят в базу
echo $order->getStatus(), ' — ', $order->getUser()->getName(), PHP_EOL;
}
Уберите 'USER' из select — код продолжит работать и молча превратится в 51 запрос. Это главная ловушка объектного API: он не падает, он деградирует.
Для связи «один к многим» в объектном API есть пакетное дозаполнение коллекции (fill), но допустимые аргументы и маски различаются между версиями main — сверьте подпись в документации своей версии, прежде чем закладываться на неё в проде. Если сомневаетесь, способ 2 даёт тот же результат без сюрпризов.
4. Агрегат в runtime вместо подгрузки коллекции
Когда от связи нужны не сами записи, а число или сумма, не тащите коллекцию вообще.
use Bitrix\Main\ORM\Fields\ExpressionField;
$rows = OrderTable::getList([
'select' => ['ID', 'STATUS', 'ITEMS_CNT'],
'runtime' => [
// DISTINCT обязателен: без него второй JOIN раздует счётчик
new ExpressionField('ITEMS_CNT', 'COUNT(DISTINCT %s)', 'ITEMS.ID'),
],
'group' => ['ID'],
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchAll();
Один запрос, 50 строк, корректный limit. Платите тем, что GROUP BY по большой таблице с JOIN — это сортировка на стороне СУБД, и на сотнях тысяч заказов она становится заметной статьёй в плане запроса.
Сравнение
| Способ | Запросов к БД | Дубли строк | limit честный | Когда брать |
|---|---|---|---|---|
| JOIN в select | 1 | Нет (только many-to-one) | Да | 1–3 поля из связанной сущности |
| Два запроса + склейка | 2 на связь | Нет | Да | One-to-many, many-to-many, постраничка |
| Объектная коллекция | 1 с select, N+1 без него | Скрыты в объектах | Да для many-to-one | Объект едет дальше по коду |
| Runtime-агрегат | 1 | Нет | Да | Нужны счётчики и суммы, не записи |
Когда что выбирать
- Связь «многие к одному», нужны одно-два поля — JOIN в
select. Не усложняйте. - Связь «один к многим» вместе с постраничной навигацией — два запроса. Любая попытка уложить это в один
getListсломает илиlimit, или счётчик. - Данные уходят в сервисный слой и живут дольше одного шаблона — объектный API, но каждую связь явно перечисляйте в
select. - От связи нужно только число —
ExpressionFieldсCOUNT(DISTINCT …).
Выбор способа — это выбор, где делать соединение: в СУБД или в PHP. У первого цена — дубли и съехавшая постраничка, у второго — лишний round-trip и ручная сборка. Третьего не бывает.
Что ломается в проде
- Фильтр по полю связи превращает LEFT JOIN в INNER. Вы написали
configureJoinType('left'), но условие'filter' => ['=USER.ACTIVE' => 'Y']уходит вWHEREи отрезает все заказы, у которых покупателя нет. Если нужно именно «подтянуть активного, иначе пусто», условие переносят в саму связь:Join::on('this.USER_ID', 'ref.ID')->where('ref.ACTIVE', 'Y'). Конструкция живёт в новом формате связей, в старомReferenceFieldеё нет. - count_total врёт при one-to-many.
'count_total' => trueсчитает строки после соединения. Для честного числа родителей считайте отдельным запросом без связи либо черезCOUNT(DISTINCT ID). - select без перечисления полей.
'select' => ['*']тянет и текстовые поля, и сериализованные — на выборке в несколько тысяч строк это десятки мегабайт в памяти PHP на ровном месте. - Кеш не ускоряет запрос со связями. Параметр
'cache' => ['ttl' => 3600]без'cache_joins' => trueна выборках сJOINработать не будет — Битрикс намеренно не кеширует такие запросы по умолчанию, потому что инвалидация по связанным таблицам не отслеживается. - Нет индекса на поле соединения. ORM создаёт индекс по первичному ключу, но не по вашему
ORDER_IDв дочерней таблице. На 50 тысячах позиций это full scan при каждомJOIN. - Глубокие цепочки.
USER.GROUP.NAMEвыглядит элегантно и разворачивается в несколько соединений. На двух уровнях обычно терпимо, на трёх план запроса начинает жить своей жизнью — смотритеEXPLAIN, а не ощущения.
Свойства инфоблоков через ORM (Iblock::wakeUp($iblockId)->getEntityDataClass()) и пользовательские поля — отдельная история со своими правилами именования полей; здесь мы говорим только о собственных сущностях на DataManager.
Что делать дальше
Соберите SQL, который реально уходит в базу: объект запроса (OrderTable::query() с цепочкой setSelect/setFilter) умеет отдавать текст запроса до выполнения, а трекер соединения — список всех запросов страницы с их числом. Точные имена методов для вашей версии main возьмите в документации D7: они менялись, и подставлять их по памяти — плохая идея.
Дальше два измеримых показателя. Первый — число запросов на страницу: если оно растёт вместе с числом записей в списке, у вас N+1, и лечится он перечислением связей в select. Второй — EXPLAIN на самом тяжёлом списке: ищите type: ALL по дочерним таблицам и Using temporary; Using filesort на выборках с GROUP BY. Инвалидация ORM-кеша при записи — тема следующего разбора.
1 N+1 — антипаттерн, при котором один запрос за списком тянет за собой по одному дополнительному запросу на каждую запись списка. Опасен тем, что на тестовых десяти записях незаметен, а на тысяче кладёт страницу.
2 Дублирование выборки (data doubling) — размножение строк родительской сущности при соединении со связью «один к многим»: заказ с тремя позициями приходит в результате три раза.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: 1С-Битрикс: документация по API D7, Курс «Разработчик Bitrix Framework», раздел об ORM и связях между сущностями
Частые вопросы
У меня старая сборка, и Reference с Join::on не работает. Что делать?
Я перечислил связь в select, а запросов всё равно много. Почему?
Почему при выборке со связью один-к-многим приходит меньше 50 заказов, хотя limit равен 50?
Можно ли фильтровать по полю связанной сущности?
ORM медленнее, чем прямой SQL через Application::getConnection()?
Кеширование getList со связями действительно надо включать отдельно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.