На самом деле хотел написать заметку про составление оптимизированных запросов при получении больших массивов информации из БД.
Но решил не делать пример в вакууме, поэтому взял наиболее частую задачу- работу с заказами.
В любом случае, в примере ниже вы получите то, за чем пришли :)- всю информацию о заказах.
Почему именно через getList-ы? Потому что это низкоуровневый инструмент с полным контролем над
запросом: можно явно указать нужные поля через select, задать условия через filter,
отсортировать через order.
Именно эта гибкость позволяет писать эффективные запросы и избежать распространённой ловушки.
У заметки есть аналог:
Заказы через ORM
Проблема N+1 в запросах
Есть соблазн получать данные порциями: в цикле по заказам дополнительно подгружать свойства, доставки, оплаты и т.д., дозаполняя массив с заказами прямо на месте.
При таком подходе можно не столкнуться с проблемой в процессе разработки на небольшом количестве заказов, но на продакшене, когда потребуется обработать сотни или тысячи заказов, это обернётся серьёзной потерей производительности.
Посмотрим на конкретный пример. Допустим, в базе 100 заказов. Код ниже выполнит:
- 1 запрос = список заказов (
Order::getList) - 100 запросов = название статуса для каждого заказа (
CSaleStatus::GetByID) - 100 запросов = товары корзины для каждого заказа (
Basket::getList) - 100 запросов = свойства для каждого заказа (
OrderPropsValueTable::getList) - 100 запросов = доставки для каждого заказа (
ShipmentTable::getList) - 100 запросов = оплаты для каждого заказа (
PaymentTable::getList)
Итого: 1 + 100 × 5 = 501 запрос вместо 6. Это и есть проблема N+1: вместо одного запроса на каждый тип данных выполняется по N запросов - по одному на каждую запись из первого запроса.
use Bitrix\Main\Loader,
Bitrix\Sale\Order,
Bitrix\Sale\Internals\OrderPropsValueTable,
Bitrix\Sale\Internals\ShipmentTable,
Bitrix\Sale\Internals\PaymentTable,
Bitrix\Sale\Basket;
Loader::includeModule('sale');
$dbRes = Order::getList([
'select' => [
"*"
],
'order' => [
'ID' => 'DESC'
]
]);
while ($order = $dbRes->fetch()) {
$arResult['ORDERS'][$order['ID']] = $order;
// Название статуса
$arResult['ORDERS'][$order['ID']]['STATUS_NAME'] = CSaleStatus::GetByID($order['STATUS_ID']);
// Получаем товары корзины заказа
$arResult['ORDERS'][$order['ID']]['BASKET_ITEMS'] = Basket::getList(
[
'select' => ['*'],
'filter' => [
'=ORDER_ID' => $order['ID']
]
]
)->FetchAll();
// Получаем свойства заказа
$arResult['ORDERS'][$order['ID']]['ORDER_PROPS'] = OrderPropsValueTable::getList(
[
'select' => ['*'],
'filter' => [
'=ORDER_ID' => $order['ID']
]
]
)->fetchAll();
// Получаем информацию о доставках
$arResult['ORDERS'][$order['ID']]['DELIVERY'] = ShipmentTable::getList([
'select' => ['*'],
'filter' => [
'=ORDER_ID' => $order['ID'],
'=SYSTEM' => 'N' // исключаем системную «виртуальную» отгрузку
]
])->fetchAll();
// Получаем информацию об оплатах
$arResult['ORDERS'][$order['ID']]['PAYMENTS'] = PaymentTable::getList([
'select' => ['*'],
'filter' => [
'=ORDER_ID' => $order['ID']
]
])->fetchAll();
}
// Все о заказе
var_dump($arResult['ORDERS']);
Чем больше заказов- тем хуже ситуация. При 1000 заказах это уже 5001 запрос вместо 6. Каждый запрос- это отдельное обращение к базе данных: установка соединения, передача запроса, ожидание ответа. Всё это многократно увеличивает время ответа страницы и нагрузку на сервер БД.
Правильное решение- выполнить по одному запросу на каждый тип данных, собрав сразу все нужные ID заказов, а затем "склеить" результаты в PHP.
Оптимизированный пример
Идея проста: сначала одним запросом получаем все заказы и собираем массив их ID, а затем делаем по одному запросу на каждый тип связанных данных, передавая сразу весь массив ID в фильтр. Результаты разбрасываем по нужным заказам уже в PHP- без единого лишнего обращения к базе.
use Bitrix\Main\Loader,
Bitrix\Sale\Order,
Bitrix\Sale\Internals\OrderPropsValueTable,
Bitrix\Sale\Internals\ShipmentTable,
Bitrix\Sale\Internals\PaymentTable,
Bitrix\Sale\Basket;
Loader::includeModule('sale');
$dbRes = \Bitrix\Sale\Order::getList([
'select' => [
"*"
],
'order' => [
'ID' => 'DESC'
]
]);
// Шаг 1: собираем заказы и их ID
$orderIds = [];
while ($order = $dbRes->fetch()) {
$arResult['ORDERS'][$order['ID']] = $order;
$arResult['ORDERS'][$order['ID']]['STATUS_NAME'] = CSaleStatus::GetByID($order['STATUS_ID']);
$arResult['ORDERS'][$order['ID']]['BASKET_ITEMS'] = [];
$arResult['ORDERS'][$order['ID']]['ORDER_PROPS'] = [];
$arResult['ORDERS'][$order['ID']]['DELIVERY'] = [];
$orderIds[] = $order['ID'];
}
if (!empty($orderIds)) {
// Шаг 2: корзины - 1 запрос
$dbBasket = Basket::getList([
'select' => ['*'],
'filter' => ['=ORDER_ID' => $orderIds]
]);
while ($item = $dbBasket->fetch()) {
$arResult['ORDERS'][$item['ORDER_ID']]['BASKET_ITEMS'][] = $item;
}
// Шаг 3: свойства - 1 запрос
$dbProps = \Bitrix\Sale\Internals\OrderPropsValueTable::getList([
'select' => ['*'],
'filter' => ['=ORDER_ID' => $orderIds]
]);
while ($prop = $dbProps->fetch()) {
$arResult['ORDERS'][$prop['ORDER_ID']]['ORDER_PROPS'][] = $prop;
}
// Шаг 4: доставки - 1 запрос
$dbShipments = \Bitrix\Sale\Internals\ShipmentTable::getList([
'select' => ['*'],
'filter' => [
'=ORDER_ID' => $orderIds,
'=SYSTEM' => 'N'
]
]);
while ($shipment = $dbShipments->fetch()) {
$arResult['ORDERS'][$shipment['ORDER_ID']]['DELIVERY'][] = $shipment;
}
// Шаг 5: оплаты - 1 запрос
$dbPayments = \Bitrix\Sale\Internals\PaymentTable::getList([
'select' => ['*'],
'filter' => ['=ORDER_ID' => $orderIds]
]);
while ($payment = $dbPayments->fetch()) {
$arResult['ORDERS'][$payment['ORDER_ID']]['PAYMENTS'][] = $payment;
}
}
// Все о заказе
var_dump($arResult['ORDERS']);
Теперь независимо от количества заказов выполняется ровно 6 запросов: по одному на каждый тип данных. При 100 заказах — 6 запросов, при 1000 — всё те же 6.
- 1 запрос = список заказов
- 1 запрос = корзины всех заказов
- 1 запрос = свойства всех заказов
- 1 запрос = доставки всех заказов
- 1 запрос = оплаты всех заказов
- N запросов = названия статусов (их тоже можно вынести отдельно, получив все нужные статусы
одним запросом через
CSaleStatus::GetList)
Ключевое отличие от плохого варианта - фильтр принимает массив ID ('=ORDER_ID' =>
$orderIds),
что транслируется в один SQL-запрос с конструкцией IN (...).
Разбивка полученных данных по заказам происходит в PHP через простой while с присвоением по ключу.