ORM D7 в Битрикс: как не получить N+1 в списках и связях
Список из 50 заявок с логином автора превращается в 51 SQL-запрос, если читать связь в цикле. Разбираем четыре способа загрузить связанные данные в ORM D7 и цену каждого.

Страница личного кабинета выводит 50 заявок, рядом с каждой логин автора. Код выглядит аккуратно: getList, fetchCollection, а в цикле getUser()->getLogin(). В базе при этом уходит до 51 запроса вместо одного. Ниже разбор на схематичном проекте: заявки дилеров и позиции к ним. Цифр по времени здесь нет, они зависят от вашей базы. Есть число запросов и способ посчитать его самим.
Схема и место, где она ломается
Две таблицы: local_request (ID, TITLE, STATUS, USER_ID, CREATED_AT) и local_request_item (ID, REQUEST_ID, PRODUCT_ID, QTY). Заявка связана с пользователем через Reference на UserTable, с позициями через OneToMany. Примеры рассчитаны на модуль main в актуальных редакциях «1С-Битрикс: Управление сайтом» и PHP 8.x. Объектная модель (fetchObject, fetchCollection) есть не во всех старых установках, поэтому версию main сверьте до начала.
// Фрагмент RequestTable::getMap(): только связи
use Bitrix\Main\ORM\Fields\Relations\OneToMany;
use Bitrix\Main\ORM\Fields\Relations\Reference;
use Bitrix\Main\ORM\Query\Join;
use Bitrix\Main\UserTable;
(new Reference('USER', UserTable::class, Join::on('this.USER_ID', 'ref.ID')))
->configureJoinType('inner'),
// 'REQUEST' - имя Reference-поля в RequestItemTable, обратная сторона связи
new OneToMany('ITEMS', RequestItemTable::class, 'REQUEST'),
Очевидный код для списка:
$requests = RequestTable::getList([
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchCollection();
foreach ($requests as $request) {
// связь подгружается при первом обращении: отдельный запрос на каждую строку
echo $request->getUser()->getLogin();
}
ORM лениво подгружает связанный объект. Для одной заявки это удобно. Для списка это запрос на каждую строку, то есть N+1 (пояснение в конце).
Четыре способа загрузить связи
| Подход | Запросов на 50 заявок | Что выигрываете | Чем платите |
|---|---|---|---|
| Ленивая загрузка в цикле | до 51 | минимум кода | число запросов растёт вместе со страницей |
Связь в select (USER.LOGIN) | 1 | один запрос, честный limit | inner join отсеет заявки без пользователя |
Позиции в select (ITEMS.QTY) | 1 | всё одним запросом | дубли строк, риск с limit |
| Два запроса с IN | 2 | пагинация по заявкам, без дублей | ручная сборка массивов |
Многие к одному: связь в select
Если нужны одно-два поля связанной сущности, называйте их прямо в select. Ядро добавит join и заполнит связанный объект сразу.
$requests = RequestTable::getList([
'select' => ['ID', 'TITLE', 'STATUS', 'USER.LOGIN'],
'order' => ['ID' => 'DESC'],
'limit' => 50,
])->fetchCollection();
foreach ($requests as $request) {
// данные уже загружены, дополнительного запроса нет
echo $request->getUser()->getLogin();
}
Если объекты не нужны, задайте алиас: 'USER_LOGIN' => 'USER.LOGIN', и читайте плоский массив через fetchAll().
Один ко многим: два запроса вместо join
Если добавить в select ITEMS.PRODUCT_ID и ITEMS.QTY, SQL вернёт строку на каждую позицию. Заявка с тремя позициями займёт три строки, а limit в SQL считает строки. Как ядро справляется с этим на вашей версии, проверьте сами: посмотрите итоговый SQL через RequestTable::query() и метод getQuery(). Надёжнее разнести запросы.
// $requests получен выше, например с limit 50
$ids = [];
foreach ($requests as $request) {
$ids[] = $request->getId();
}
$itemsByRequest = [];
if ($ids !== []) { // пустой IN лучше не отправлять
$rows = RequestItemTable::getList([
'select' => ['REQUEST_ID', 'PRODUCT_ID', 'QTY'],
'filter' => ['@REQUEST_ID' => $ids],
]);
while ($row = $rows->fetch()) {
$itemsByRequest[$row['REQUEST_ID']][] = $row;
}
}
Кеш поверх getList
RequestTable::getList([
'select' => ['ID', 'TITLE', 'USER.LOGIN'],
'filter' => ['=STATUS' => 'new'],
'cache' => ['ttl' => 600, 'cache_joins' => true],
]);
По умолчанию запросы с join в кеш не попадают, флаг cache_joins это включает. Кеш таблицы сбрасывается при add, update и delete через DataManager. Прямой SQL и импорт мимо ORM его не сбросят. Инвалидация кеша на уровне страниц и тегов — отдельная тема, здесь не разворачиваем.
Проверяйте не результат getList, а SQL, который за ним стоит: ошибка в связях видна там сразу.
Когда что выбирать
- Связь «многие к одному», нужны несколько полей: связь в
select, всегда. - Связь «один ко многим» и список с пагинацией: два запроса с IN.
- Карточка одной заявки с позициями, пагинации нет: join в select допустим, строк мало.
- Кеш: страницу читают заметно чаще, чем меняют данные, и устаревание на время ttl безвредно. Для личных данных с деньгами кеш не ставьте.
Что ломается
add()не бросает исключение при ошибке валидации. Он возвращает результат: проверяйтеisSuccess()и читайтеgetErrorMessages().- Без
selectберутся все скалярные поля таблицы, включая тяжёлые текстовые. - В фильтре по датам передавайте
Bitrix\Main\Type\DateTime, а не строку в формате, зависящем от настроек сайта. - Прямой SQL мимо DataManager не вызывает события и не чистит кеш.
- Заявка и позиции пишутся в разных таблицах. Без транзакции сбой на третьей позиции оставит заявку наполовину.
use Bitrix\Main\Application;
$connection = Application::getConnection();
$connection->startTransaction();
try {
$result = RequestTable::add(['TITLE' => $title, 'USER_ID' => $userId]);
if (!$result->isSuccess()) {
throw new \RuntimeException(implode('; ', $result->getErrorMessages()));
}
foreach ($positions as $position) {
$itemResult = RequestItemTable::add([
'REQUEST_ID' => $result->getId(),
'PRODUCT_ID' => $position['product_id'],
'QTY' => $position['qty'],
]);
if (!$itemResult->isSuccess()) {
throw new \RuntimeException(implode('; ', $itemResult->getErrorMessages()));
}
}
$connection->commitTransaction();
} catch (\Throwable $e) {
$connection->rollbackTransaction();
throw $e;
}
Транзакции работают, только если таблица в InnoDB. На MyISAM откат ничего не вернёт.
Что делать дальше
- Возьмите самую тяжёлую страницу и посчитайте запросы трекером:
$connection = \Bitrix\Main\Application::getConnection(); $connection->startTracker(); // ... код страницы с вызовами getList $connection->stopTracker(); echo count($connection->getTracker()->getQueries()); - Найдите повторяющиеся запросы, отличающиеся только ID. Это N+1: переведите их на связь в select или на IN.
- Прогоните запросы с IN через
EXPLAINи проверьте индекс поREQUEST_ID. - Следите за двумя показателями: число запросов на страницу и время ответа на 95-м перцентиле. После правок сравните их с исходными.
N+1. Один запрос за списком из N строк порождает ещё N запросов за связанными данными, по одному на строку. Отсюда 1 + N.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Документация для разработчиков 1С-Битрикс (раздел про ORM и D7)
Частые вопросы
Что делать, если у меня старая версия и нет fetchCollection?
Select с 'USER.LOGIN' падает с ошибкой о неизвестном поле. Что проверить?
Почему add() не упал, а записи в базе нет?
Нужно ли сразу кешировать getList?
Что проверить перед выкладкой в прод?
Можно ли так же работать с элементами инфоблока?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.