Проблема N+1 запросов в 1С-Битрикс: причины и решения
Проблема N+1 возникает, когда в цикле для каждой выбранной записи выполняется дополнительный запрос к БД. Вместо 1 запроса получается 1 + N запросов. Чем больше N, тем сильнее деградация производительности.
Рассмотрим четыре реалистичных сценария — «плохой» и «хороший» для каждого.
Пример №1. Классический API: CIBlockElement — получение пользователя, создавшего элемент
❌ ПЛОХОЙ код (N+1):
// 1 запрос — получить 100 элементов
$rsItems = CIBlockElement::GetList(
[],
['IBLOCK_ID' => 5, 'ACTIVE' => 'Y'],
false,
false,
['ID', 'NAME', 'CREATED_BY']
);
while ($arItem = $rsItems->Fetch()) {
// +1 запрос в цикле на каждой итерации!
$rsUser = CUser::GetByID($arItem['CREATED_BY']);
$arUser = $rsUser->Fetch();
echo $arItem['NAME'] . ' — автор: ' . $arUser['NAME'] . '<br>';
}
// Итого: 1 (элементы) + 100 (пользователи) = 101 SQL-запрос
✅ ХОРОШИЙ код (1 запрос с GROUP / пакетная загрузка):
// 1 запрос — получаем все ID пользователей
$rsItems = CIBlockElement::GetList(
[],
['IBLOCK_ID' => 5, 'ACTIVE' => 'Y'],
false,
false,
['ID', 'NAME', 'CREATED_BY']
);
$userIds = [];
$items = [];
while ($arItem = $rsItems->Fetch()) {
$items[] = $arItem;
$userIds[] = (int)$arItem['CREATED_BY'];
}
$userIds = array_unique($userIds);
// 1 запрос — получаем ВСЕХ нужных пользователей разом
$users = [];
if (!empty($userIds)) {
$rsUsers = CUser::GetList(
'ID', 'ASC',
['ID' => implode('|', $userIds)]
);
while ($arUser = $rsUsers->Fetch()) {
$users[$arUser['ID']] = $arUser;
}
}
// Обработка — без единого запроса в цикле
foreach ($items as $arItem) {
$authorName = $users[$arItem['CREATED_BY']]['NAME'] ?? 'Неизвестно';
echo $arItem['NAME'] . ' — автор: ' . $authorName . '<br>';
}
// Итого: 2 SQL-запроса (элементы + пользователи)
Пример №2. ORM D7: связанные сущности через Reference
❌ ПЛОХОЙ код (N+1 через ручной fetch):
use Bitrix\Main\UserTable;
// 1 запрос
$rsUsers = UserTable::getList([
'select' => ['ID', 'NAME', 'LAST_NAME'],
'limit' => 50,
]);
$users = [];
while ($user = $rsUsers->Fetch()) {
// +1 запрос в цикле к каждой итерации!
$rsGroups = \Bitrix\Main\UserGroupTable::getList([
'filter' => ['=USER_ID' => $user['ID']],
'select' => ['GROUP_ID'],
]);
$groups = [];
while ($g = $rsGroups->Fetch()) {
$groups[] = $g['GROUP_ID'];
}
$user['GROUPS'] = $groups;
$users[] = $user;
}
// Итого: 1 + 50 = 51 SQL-запрос
✅ ХОРОШИЙ код (жадная загрузка через select + Reference):
use Bitrix\Main\UserTable;
use Bitrix\Main\ORM\Query\QueryHelper;
// Строим 1 запрос с JOIN через Reference
$query = UserTable::query()
->setSelect([
'ID',
'NAME',
'LAST_NAME',
// Жадная загрузка связанной сущности (LEFT JOIN)
'GROUPS_' => 'GROUPS.GROUP_ID',
])
->setLimit(50);
// Используем QueryHelper::decompose для корректной работы с лимитом и связями
$collection = QueryHelper::decompose($query, true, true);
foreach ($collection as $user) {
$groups = [];
foreach ($user->get('GROUPS_') as $group) {
$groups[] = $group['GROUP_ID'];
}
echo $user['NAME'] . ' — группы: ' . implode(', ', $groups) . '<br>';
}
// Итого: 2 SQL-запроса (основная выборка + связи), независимо от N
Пример №3. Классический API: получение свойств элементов инфоблока
❌ ПЛОХОЙ код (N+1 через GetProperty):
$rsItems = CIBlockElement::GetList(
[],
['IBLOCK_ID' => 10, 'ACTIVE' => 'Y'],
false,
['nTopCount' => 30],
['ID', 'NAME']
);
while ($arItem = $rsItems->Fetch()) {
// +1 запрос на каждый элемент!
$dbProps = CIBlockElement::GetProperty(
$iblockId, $arItem['ID']
);
echo $arItem['NAME'] . ': ';
while ($arProp = $dbProps->Fetch()) {
if ($arProp['CODE'] === 'ARTNUMBER') {
echo $arProp['VALUE'];
}
}
echo '<br>';
}
// Итого: 1 + 30 = 31 SQL-запрос
✅ ХОРОШИЙ код (1 запрос через GetList с PROPERTY_* в select):
$rsItems = CIBlockElement::GetList(
[],
['IBLOCK_ID' => 10, 'ACTIVE' => 'Y'],
false,
['nTopCount' => 30],
[
'ID',
'NAME',
'PROPERTY_ARTNUMBER', // свойство подтягивается одним JOIN
]
);
while ($arItem = $rsItems->Fetch()) {
echo $arItem['NAME'] . ': ' . $arItem['PROPERTY_ARTNUMBER_VALUE'] . '<br>';
}
// Итого: 1 SQL-запрос (все данные за один раз)
Пример №4. ORM: вложенные связи ManyToMany / OneToMany
❌ ПЛОХОЙ код (N+1 при обходе связанных записей):
use Bitrix\Iblock\ElementTable;
$elements = ElementTable::getList([
'select' => ['ID', 'NAME', 'IBLOCK_ID'],
'filter' => ['=IBLOCK_ID' => 3],
'limit' => 20,
])->fetchAll();
foreach ($elements as $element) {
// +1 запрос к свойству на каждый элемент!
$propValue = \CIBlockElement::GetProperty(
3, $element['ID'], [], ['CODE' => 'COLOR']
)->Fetch();
echo $element['NAME'] . ' — цвет: ' . $propValue['VALUE'] . '<br>';
}
// Итого: 1 + 20 = 21 SQL-запрос
✅ ХОРОШИЙ код (QueryHelper::decompose + runtime reference для свойств):
use Bitrix\Iblock\ElementTable;
use Bitrix\Main\ORM\Query\QueryHelper;
$query = ElementTable::query()
->setSelect([
'ID',
'NAME',
'IBLOCK_ID',
])
->setFilter(['=IBLOCK_ID' => 3])
->setLimit(20);
// QueryHelper разобьёт запрос на основной + связи, избегая N+1
$collection = QueryHelper::decompose($query, true, true);
foreach ($collection as $element) {
echo $element['NAME'] . ' (ID: ' . $element['ID'] . ')<br>';
}
// Итого: 1-2 SQL-запроса, независимо от количества записей
Ключевые принципы избегания N+1
- Убирайте запросы из циклов. Сначала соберите все ID в массив, затем выполните один запрос с
filter = ['ID' => $ids]. - Используйте PROPERTY_* и точечный select в классическом API. Указывайте только нужные поля и свойства — это даёт JOIN, а не N отдельных запросов.
- Используйте Reference / связи ORM. В D7 можно указать связанные поля в
select, и ORM сам построит корректный JOIN. - Применяйте QueryHelper::decompose(). Если у вас связи типа OneToMany / ManyToMany с LIMIT, этот метод разбивает запрос на основной + отдельные запросы для связей, но гарантированно без N+1 и без декартова произведения.
- Используйте кеш. Для редко меняющихся данных включите
'cache' => ['ttl' => 3600]в getList, чтобы не ходить в БД при каждом хите.