Производительность и частые ошибки

Заказ интернет-магазина состоит из основной записи и связанных коллекций: корзины, оплат и отгрузок.

Чтение списка и изменение заказа создают разную нагрузку. Для списка достаточно получить нужные поля. При изменении код через объектную модель согласует данные коллекций, при необходимости выполняет расчеты и затем сохраняет заказ.

Лишние данные, повторные запросы и ненужные пересчеты замедляют работу с заказами. Списки читайте через ORM, а заказ изменяйте через объектную модель. После оптимизации сравните время и число запросов. Суммы и состав заказа должны остаться прежними.

О выборе API для чтения и изменения читайте в статье Как выбрать API интернет-магазина. Об устройстве заказа и его коллекций читайте в статье Схема работы интернет-магазина и основные объекты.

Выбрать API для задачи

ORM связывает PHP-классы с таблицами базы данных. ORM-классы подходят для чтения строк и отдельных полей. Объектная модель управляет изменениями, расчетами и сохранением связанных объектов заказа.

Во время изменения и сохранения объектная модель вызывает события. Зарегистрированные обработчики могут проверить данные или выполнить дополнительное действие. Поэтому изменение объекта заказа требует больше работы, чем чтение нескольких полей через ORM.

Задача

API

Причина

Показать список заказов или построить отчет

Bitrix\Sale\Internals\OrderTable

Позволяет ограничить поля, фильтр и число строк без загрузки коллекций заказа

Получить позиции, оплаты или отгрузки нескольких заказов

Bitrix\Sale\Internals\BasketTable, Bitrix\Sale\Internals\PaymentTable, Bitrix\Sale\Internals\ShipmentTable

Позволяет выбрать связанные строки пакетно по массиву ORDER_ID

Изменить заказ, корзину, оплату или отгрузку

Bitrix\Sale\Order и коллекции заказа

Сохраняет согласованное состояние, выполняет проверки и вызывает события

Пересчитать скидки и налоги после значимого изменения

Bitrix\Sale\Order::doFinalAction(true)

Выполняет финальный расчет для уже подготовленного объекта заказа

Поддержать существующую интеграцию на CSale*

Классический API

Сохраняет совместимость существующего кода. Для новой задачи сначала проверьте объектную альтернативу

Для изменения загрузите объект Bitrix\Sale\Order. Измените поля заказа и его коллекции, затем один раз вызовите метод Bitrix\Sale\Order::save().

Для изменения корзины получите ее методом Bitrix\Sale\Order::getBasket(). Метод Bitrix\Sale\Order::getDiscount() нужен коду, который работает с расчетом скидок конкретного заказа. Для списка заказов корзина и объект скидок не нужны: ORM возвращает выбранные поля без загрузки коллекций заказа.

Не изменяйте заказ методами add(), update() и delete() ORM-таблиц. Прямая запись не выполняет полный жизненный цикл Bitrix\Sale\Order::save(). Поэтому корзина, оплаты, отгрузки, скидки и история могут потерять согласованность.

Найти причину медленной работы

Оптимизируйте код по измерениям, а не по предполагаемой причине. Проверьте:

  • время выполнения всей операции и отдельных этапов,

  • число SQL-запросов за одну операцию,

  • повторяющиеся запросы с разными идентификаторами,

  • время самых медленных запросов,

  • максимальный объем памяти,

  • число загруженных заказов и позиций корзины,

  • число вызовов расчета доставки, скидок и финального расчета заказа.

Откройте страницу Настройки > Производительность > SQL-запросы в административном разделе. Повторяющийся запрос к заказу, корзине, оплате, отгрузке, товару или цене внутри одной операции обычно указывает на проблему N+1. При N+1 один запрос получает список, а затем отдельный запрос получает связанные данные для каждой строки списка.

Если SQL-запросы быстрые, причину задержки нужно искать в другом месте. Проверьте обработчики событий, провайдер товара, службы доставки и внешние обращения. Оптимизация ORM-запроса не ускорит ожидание внешнего сервиса.

Провести диагностику по шагам

Используйте одинаковые входные данные до и после изменения. Иначе на результат повлияют размер корзины, набор скидок, состояние кеша или время ответа внешней службы.

  1. Зафиксируйте ожидаемый бизнес-результат: состав заказа, итоговую сумму, оплаты и отгрузки.

  2. Измерьте общее время, число SQL-запросов, память и количество позиций.

  3. Измерьте отдельно загрузку и обновление корзины, получение товарных данных через провайдера, расчет скидок, расчет доставки, финальный расчет, сохранение заказа и обработчики событий.

  4. Определите источник задержки: базу данных, PHP-код, обработчик события, провайдер товара или внешний сервис.

  5. Устраните одну причину и повторите ту же операцию.

  6. Сравните показатели и сохраненное состояние заказа. Ускорение не должно менять бизнес-результат.

Не задавайте универсальный предел допустимого времени или числа запросов. Базовый уровень зависит от проекта. Сравнивайте одинаковые операции и контролируйте рост показателей при увеличении числа заказов и позиций.

Определить причину по симптому

Симптом

Вероятная причина

Что сделать

Число запросов растет вместе с числом заказов или позиций

Чтение связанных данных в цикле

Сгруппировать идентификаторы и найти повторяющийся SQL-запрос

Запросов немного, но один выполняется долго

Неоптимальный фильтр, сортировка, вычисляемое поле или индекс

Открыть план запроса и использовать рекомендации из статьи Как оптимизировать запросы к базе данных

SQL-запросы быстрые, а Bitrix\Sale\Order::save() работает долго

Обработчики событий, провайдер товара, кассы или внешние обращения

Измерить этапы сохранения и пользовательские обработчики

Память растет на каждой порции

Полные объекты и связанные коллекции остаются в памяти

Сократить порцию, читать только нужные поля и освобождать обработанные объекты

После повтора появляются дубли уведомлений или внешних операций

Нет защиты от повторного выполнения

Проверить идентификатор операции, состояние заказа и обработчики событий

Цена после обработки отличается от ожидаемой

Смешаны сохраненная цена каталога и итоговый расчет либо пропущен нужный пересчет

Проверить обновление корзины, доставку и финальный расчет в правильном порядке

Массовая задача пропускает или повторяет заказы

Прогресс сохранен до успеха, фильтр меняется во время обработки или работают несколько процессов

Проверить последний успешно обработанный ID, границы набора и параллельную обработку одного заказа

Прочитать список заказов через ORM

Для списка, экспорта или отчета получите основные поля через Bitrix\Sale\Internals\OrderTable::getList(). Не загружайте объект Bitrix\Sale\Order для каждой строки.

Получите заказы с нужным статусом и ограничьте число строк текущей порции заказов. Перед вызовом подготовьте переменные:

  • $statusId — код статуса заказа,

  • $limit — максимальное число строк. Значение должно быть больше нуля,

  • $orders — массив результата. Ключ содержит идентификатор заказа, значение — выбранные поля.

if (!\Bitrix\Main\Loader::includeModule('sale'))
{
    throw new \RuntimeException('Модуль sale не подключен');
}

$statusId = 'N'; // Код статуса заказа
$limit = 100; // Максимальное число строк
$orders = []; // Заказы, сгруппированные по ID

$orderIterator = \Bitrix\Sale\Internals\OrderTable::getList([
    'select' => [
        'ID',
        'ACCOUNT_NUMBER',
        'DATE_INSERT',
        'STATUS_ID',
        'PRICE',
        'CURRENCY',
    ],
    'filter' => [
        '=STATUS_ID' => $statusId,
    ],
    'order' => [
        'ID' => 'ASC',
    ],
    'limit' => $limit,
]);

while ($order = $orderIterator->fetch())
{
    $orders[(int)$order['ID']] = $order;
}

Ограничьте select полями, которые нужны текущей задаче. Вычисляемые поля и связи могут усложнить SQL-запрос и увеличить объем результата. Поэтому не добавляйте их без необходимости.

Если требуется изменить найденный заказ, используйте ORM только для выбора идентификаторов. Затем загрузите ограниченную порцию объектов методом Bitrix\Sale\Order::load().

Класс Bitrix\Sale\Internals\OrderTable возвращает только активные заказы. После архивирования заказ исчезает из основной таблицы, поэтому отчет или интеграция за длительный период может получить неполный результат.

Если период пересекается с архивом, запросите архивные строки через Bitrix\Sale\Archive\Manager::getList(). Перед объединением приведите поля двух выборок к одной структуре и добавьте каждой строке признак источника active или archive.

О подготовке двух выборок и ограничениях архивных данных читайте в статье Архив заказов.

Проблема N+1 возникает, когда код сначала получает N заказов, а затем для каждого заказа отдельно читает корзину, оплаты, отгрузки или свойства. Количество запросов растет вместе с числом заказов.

Соберите идентификаторы заказов и выполните по одному запросу для каждого набора связанных данных. Код ниже получает корзины, оплаты и отгрузки для всей порции.

  • Переменная $orderIds содержит непустой массив идентификаторов заказов.

  • Массивы $basketItemsByOrder, $paymentsByOrder и $shipmentsByOrder группируют строки по ORDER_ID.

$basketItemsByOrder = [];
$paymentsByOrder = [];
$shipmentsByOrder = [];

$basketIterator = \Bitrix\Sale\Internals\BasketTable::getList([
    'select' => [
        'ID',
        'ORDER_ID',
        'MODULE',
        'PRODUCT_ID',
        'PRODUCT_PROVIDER_CLASS',
        'NAME',
        'QUANTITY',
        'PRICE',
        'CURRENCY',
    ],
    'filter' => [
        '=ORDER_ID' => $orderIds,
    ],
    'order' => [
        'ID' => 'ASC',
    ],
]);

while ($basketItem = $basketIterator->fetch())
{
    $orderId = (int)$basketItem['ORDER_ID'];
    $basketItemsByOrder[$orderId][] = $basketItem;
}

$paymentIterator = \Bitrix\Sale\Internals\PaymentTable::getList([
    'select' => [
        'ID',
        'ORDER_ID',
        'PAY_SYSTEM_ID',
        'PAID',
        'SUM',
        'CURRENCY',
    ],
    'filter' => [
        '=ORDER_ID' => $orderIds,
    ],
    'order' => [
        'ID' => 'ASC',
    ],
]);

while ($payment = $paymentIterator->fetch())
{
    $orderId = (int)$payment['ORDER_ID'];
    $paymentsByOrder[$orderId][] = $payment;
}

$shipmentIterator = \Bitrix\Sale\Internals\ShipmentTable::getList([
    'select' => [
        'ID',
        'ORDER_ID',
        'DELIVERY_ID',
        'SYSTEM',
        'DEDUCTED',
        'PRICE_DELIVERY',
        'CURRENCY',
    ],
    'filter' => [
        '=ORDER_ID' => $orderIds,
    ],
    'order' => [
        'ID' => 'ASC',
    ],
]);

while ($shipment = $shipmentIterator->fetch())
{
    $orderId = (int)$shipment['ORDER_ID'];
    $shipmentsByOrder[$orderId][] = $shipment;
}

Поле SYSTEM отличает системную отгрузку от пользовательской. Оставьте системную отгрузку, если отчету нужны нераспределенные позиции. Для отчета только по доставкам покупателю исключите системную отгрузку.

Пакетные ORM-выборки подходят только для чтения и отчетов. Изменение полученных массивов не сохраняет заказ. Для записи загрузите объект Bitrix\Sale\Order и работайте с его коллекциями.

Если нужны только позиции корзины, не запрашивайте оплаты и отгрузки. Каждый пакетный запрос должен отвечать конкретной задаче.

Получить свойства заказов пакетно

Проблема N+1 возникает и при отдельном запросе свойств для каждого заказа. Для отчета соберите идентификаторы заказов и передайте массив в фильтр Bitrix\Sale\Internals\OrderPropsValueTable::getList().

Поле ENTITY_TYPE отличает свойства заказа от свойств отгрузки в одной таблице. Значение ORDER оставляет в результате только свойства заказов.

$propertyValuesByOrder = [];

$propertyIterator = \Bitrix\Sale\Internals\OrderPropsValueTable::getList([
    'select' => [
        'ID',
        'ORDER_ID',
        'ORDER_PROPS_ID',
        'CODE',
        'VALUE',
    ],
    'filter' => [
        '=ORDER_ID' => $orderIds,
        '=ENTITY_TYPE' => 'ORDER',
    ],
    'order' => [
        'ID' => 'ASC',
    ],
]);

while ($propertyValue = $propertyIterator->fetch())
{
    $orderId = (int)$propertyValue['ORDER_ID'];
    $propertyValuesByOrder[$orderId][] = $propertyValue;
}

Пакетная выборка предназначена только для чтения. Для изменения свойства загрузите объект Bitrix\Sale\Order и работайте с Bitrix\Sale\PropertyValueCollection.

Свойства могут содержать персональные данные. Проверьте доступ к каждому заказу и не записывайте значения свойств в диагностический журнал.

О получении свойств без загрузки заказа читайте в статье Свойства заказа.

Выбрать уровень состояния

Не загружайте коллекции оплат и отгрузок, если нужен только общий признак заказа. Поле PAYED класса Bitrix\Sale\Internals\OrderTable относится ко всему заказу, а поле PAID класса Bitrix\Sale\Internals\PaymentTable — к отдельной оплате.

Поле DEDUCTED класса Bitrix\Sale\Internals\OrderTable показывает состояние заказа целиком. Одноименное поле класса Bitrix\Sale\Internals\ShipmentTable относится к конкретной отгрузке.

Профильные таблицы нужны, когда отчет должен различать несколько оплат или отгрузок. Для общего списка используйте поля заказа и не загружайте связанные строки без необходимости. О выборе источника данных читайте в разделе Выбрать источник данных.

Получить коллекции отдельными запросами

Оператор JOIN объединяет строки связанных таблиц в одном SQL-запросе. У одного заказа может быть несколько позиций корзины, оплат и отгрузок.

Если присоединить все коллекции одновременно, база данных сформирует комбинации их строк. Результат станет больше исходных наборов, а PHP-коду придется удалять повторы.

Для списка заказов используйте отдельную выборку Bitrix\Sale\Internals\OrderTable. Связанные строки получите пакетными запросами по ORDER_ID, как показано в сценарии Загрузить связанные данные без N+1.

JOIN допустим, если план запроса подтверждает, что связи не размножают строки.

Ограничить сложность запроса

Большой пакетный запрос не всегда быстрее нескольких ограниченных запросов. Разбивайте длинные массивы идентификаторов на порции и считывайте результат через fetch(), если полный массив не нужен одновременно.

Не добавляйте подсчет строк, вычисляемые поля и сортировку без необходимости. Эти операции увеличивают работу базы данных.

Пересчет статистики покупателя — служебная операция, а не часть чтения отчета. Метод Bitrix\Sale\BuyerStatistic::calculate() читает активные и архивные заказы одного покупателя и обновляет его статистику. Поэтому запускайте метод только в контролируемой фоновой задаче, при импорте или восстановлении данных.

О проверке результата пересчета читайте в статье Покупатели и внутренние счета.

Если запрос остается медленным, изучите его план. План показывает, как база данных находит и сортирует строки. После проверки плана можно решить, нужен ли индекс.

О разборе плана читайте в статье Как оптимизировать запросы к базе данных.

Обработать заказы порциями

Выбирайте идентификаторы заказов порциями и сохраняйте ID последнего успешно обработанного заказа. Не загружайте весь набор в память.

Для набора, который может изменяться во время обработки, переходите к следующей порции по последнему ID. Смещение offset заставляет базу данных пропускать все больше строк на каждой странице.

Код ниже показывает обработку заказов одной фоновой задачей. Модуль sale должен быть подключен до запуска. Прогресс хранится через Bitrix\Main\Config\Option. Переменная $progressModuleId содержит идентификатор модуля, в настройках которого хранится прогресс. Замените vendor.module на значение из проекта, а тело $processOrder — на обработку одного заказа. Обработчик должен выбросить исключение при ошибке.

Переменная $selectionCursorId хранит идентификатор последнего прочитанного заказа. Переменная $lastProcessedOrderId хранит идентификатор последнего успешно обработанного заказа. Во внешнем хранилище сохраняется только $lastProcessedOrderId.

$batchSize = 100;
$statusId = 'N'; // Замените кодом статуса из условия задачи
$progressModuleId = 'vendor.module'; // Идентификатор модуля проекта
$progressOptionName = 'sale_orders_last_processed_id'; // Уникальное имя задачи

$processOrder = static function (int $orderId): void
{
    // Выполните обработку заказа
    // При ошибке выбросьте исключение
};

$lastProcessedOrderId = (int)\Bitrix\Main\Config\Option::get(
    $progressModuleId,
    $progressOptionName,
    '0'
);
$selectionCursorId = $lastProcessedOrderId;

do
{
    $orderIds = [];

    $orderIterator = \Bitrix\Sale\Internals\OrderTable::getList([
        'select' => ['ID'],
        'filter' => [
            '>ID' => $selectionCursorId,
            '=STATUS_ID' => $statusId,
        ],
        'order' => [
            'ID' => 'ASC',
        ],
        'limit' => $batchSize,
    ]);

    while ($order = $orderIterator->fetch())
    {
        $orderId = (int)$order['ID'];
        $orderIds[] = $orderId;
        $selectionCursorId = $orderId;
    }

    if (!$orderIds)
    {
        break;
    }

    foreach ($orderIds as $orderId)
    {
        $processOrder($orderId);

        $lastProcessedOrderId = $orderId;
        \Bitrix\Main\Config\Option::set(
            $progressModuleId,
            $progressOptionName,
            (string)$lastProcessedOrderId
        );
    }
}
while (count($orderIds) === $batchSize);

Метод Bitrix\Main\Config\Option::set() вызывается после $processOrder. Если обработчик выбросит исключение, постоянное значение останется на предыдущем успешно обработанном заказе.

Размер порции подбирайте по измерениям. Учитывайте число позиций корзины, связанные коллекции, обработчики событий и лимит времени фоновой задачи.

Порция из 100 заказов с одной позицией и порция из 100 заказов с большой корзиной создают разную нагрузку.

Сохранить прогресс после успешной операции

Не сохраняйте идентификатор последнего прочитанного заказа $selectionCursorId. Он может указывать на прочитанный, но еще не обработанный заказ. Записывайте $lastProcessedOrderId после успешной обработки каждого заказа или всей порции.

Внешнее хранилище должно сохранять прогресс между запусками PHP-процесса. Для одной фоновой задачи подходит настройка модуля через Bitrix\Main\Config\Option. Для нескольких задач используйте отдельную запись для каждой задачи, чтобы процессы не перезаписывали общий идентификатор.

После сбоя загрузите сохраненный $lastProcessedOrderId. Следующий процесс повторит незавершенный заказ и не пропустит оставшуюся часть порции.

Для каждого заказа соблюдайте порядок:

  1. Загрузите актуальный объект и убедитесь, что заказ существует.

  2. Проверьте права, статус заказа и признак уже выполненного изменения.

  3. Внесите изменения и выполните только необходимые пересчеты.

  4. Сохраните заказ, обработайте ошибки и предупреждения.

  5. Проверьте сохраненные поля, которые нужны внешнему сервису.

  6. Зафиксируйте успешное завершение и последний обработанный ID.

Для длительного процесса дополнительно:

  • сохраните последний успешно обработанный ID,

  • зафиксируйте ошибку отдельно для каждого заказа,

  • пропустите успешно завершенную операцию после проверки ее состояния,

  • освободите ссылки на загруженные объекты после каждой порции,

  • перенесите обработку в фоновую задачу, если один HTTP-запрос не успевает завершиться,

  • защитите изменение от повторного выполнения и параллельной обработки одного заказа.

О вариантах фонового выполнения читайте в статье Фоновые задания.

Ограничьте транзакцию одним заказом или группой действий, которые должны завершиться вместе. После успеха запишите прогресс.

Не объединяйте всю массовую обработку в одну транзакцию. Долгая транзакция удерживает блокировки и мешает повторить работу после сбоя.

Чтение по ID не фиксирует состояние всей выборки. Заказы могут измениться во время обработки. Если нужен неизменяемый набор, заранее сохраните его идентификаторы или пометьте выбранные заказы отдельным полем проекта.

Если новые заказы не должны попадать в текущую обработку, заранее зафиксируйте максимальный ID. Изменение статуса или другого поля фильтра также меняет состав следующих выборок.

Защитить заказ от параллельного изменения

Перед записью заново загрузите заказ. Не продолжайте работу с объектом, который другой процесс мог изменить после первоначального чтения.

Для редактирования в коде доступны методы Bitrix\Sale\Order::lock(), Bitrix\Sale\Order::isLocked() и Bitrix\Sale\Order::unlock().

О правилах блокировки читайте в статье Изменение и чтение заказа.

Фоновая задача должна безопасно переносить повторное выполнение. Перед оплатой, уведомлением или запросом во внешнюю систему проверьте идентификатор операции. Повтор после сбоя не должен дублировать оплату, уведомление или запрос во внешнюю систему.

Изменить заказ без лишних пересчетов

Загружайте объект Bitrix\Sale\Order только для изменения заказа или его коллекций. Выполните связанные изменения в памяти. Затем один раз пересчитайте и сохраните заказ.

Когда обновлять, рассчитывать и сохранять заказ

Операции решают разные задачи. Не заменяйте одну другой и не выполняйте весь набор после любого изменения.

Операция

Результат

Когда использовать

Bitrix\Sale\Basket::refresh()

Получает у провайдера актуальные товарные данные позиции

Изменился товар, количество или нужно проверить цену и доступность

Bitrix\Sale\ShipmentCollection::calculateDelivery()

Обновляет расчет доставки

Изменились местоположение, служба или состав отгрузки

Bitrix\Sale\Order::doFinalAction(true)

Рассчитывает скидки, налоги и итоговые поля заказа

Изменились данные, которые влияют на стоимость

Bitrix\Sale\Order::save()

Сохраняет подготовленный заказ и связанные коллекции

Все изменения и необходимые расчеты завершены

В сигнатуре Bitrix\Sale\Order::doFinalAction(bool $hasMeaningfulField = false) параметр $hasMeaningfulField указывает, изменились ли данные, значимые для стоимости заказа. Значение true запускает финальный расчет скидок, налогов и рассчитанных полей.

Каждая операция возвращает объект Bitrix\Sale\Result. После ошибки не переходите к следующему этапу.

Об обновлении корзины читайте в статье Работа с корзиной. Об изменении сохраненного заказа — в статье Изменение заказа.

Метод Bitrix\Sale\Basket::refreshData() устарел, но продолжает передавать выполнение в Bitrix\Sale\Basket::refresh(). В новом коде используйте метод Bitrix\Sale\Basket::refresh().

Методы объектной модели не проверяют права пользователя в коде проекта. Поэтому перед изменением заказа проверьте доступ и разрешение на операцию.

О проверке доступа читайте в статье Права доступа в интернет-магазине.

Метод Bitrix\Sale\Order::doFinalAction(true) рассчитывает скидки и налоги. Он также обновляет итоговые поля заказа. Вызывайте метод после изменений, которые влияют на стоимость:

  • состава, количества или товарных данных корзины,

  • скидок и купонов,

  • местоположения, если оно влияет на доставку или налоги,

  • службы, состава или стоимости доставки,

  • других данных, которые участвуют в расчете налогов.

Изменение статуса, трек-номера, комментария или контактного значения обычно не требует финального пересчета. Сохраните заказ после успешного изменения.

Если изменились доставка или состав отгрузок, сначала вызовите Bitrix\Sale\ShipmentCollection::calculateDelivery(). Затем вызовите Bitrix\Sale\Order::doFinalAction(true).

Сначала подготовьте согласованное состояние заказа, затем выполните расчет один раз. Не выполняйте финальный расчет после каждого вызова Bitrix\Sale\Order::setField().

О порядке пересчета и сохранения читайте в статье Изменение и чтение заказа.

Проверить результат сохранения

Методы изменения и Bitrix\Sale\Order::save() возвращают Bitrix\Sale\Result. Проверяйте каждый результат. Иначе обработка продолжится после ошибки, а ее причина потеряется.

В примере $order — ранее загруженный объект Bitrix\Sale\Order.

$setResult = $order->setField(
    'USER_DESCRIPTION',
    'Обработано фоновой задачей'
);

if (!$setResult->isSuccess())
{
    throw new \RuntimeException(
        implode('; ', $setResult->getErrorMessages())
    );
}

$saveResult = $order->save();

if (!$saveResult->isSuccess())
{
    throw new \RuntimeException(
        implode('; ', $saveResult->getErrorMessages())
    );
}

foreach ($saveResult->getWarningMessages() as $warningMessage)
{
    error_log($warningMessage);
}

Успешный результат Bitrix\Sale\Result::isSuccess() может содержать предупреждения. Запишите сообщения из Bitrix\Sale\Result::getWarningMessages() в журнал. Если внешний процесс использует сумму, оплату или отгрузку, после предупреждения заново загрузите заказ и проверьте нужные данные.

Успешный результат не заменяет проверку входных данных. До изменения заказа проверьте, что идентификаторы и коды существуют и доступны для текущей операции. Например, Bitrix\Sale\Order::setField() и Bitrix\Sale\Order::save() могут не сообщить об ошибке для несуществующего кода статуса.

Не повторяйте Bitrix\Sale\Order::save() автоматически с тем же объектом после ошибки или предупреждения. Сначала запишите сообщения в журнал и заново загрузите заказ. Затем проверьте, можно ли безопасно повторить изменение.

Учесть стоимость сохранения

Метод Bitrix\Sale\Order::save() не ограничивается записью одной строки. Он сохраняет измененные коллекции, вызывает провайдер товара, обрабатывает события и обновляет историю.

Если метод Bitrix\Sale\Order::save() работает долго, измерьте пользовательские обработчики событий и внешние обращения.

Если внешнее действие можно отложить, добавьте его в очередь после успешного сохранения. Повторное выполнение задачи не должно создавать второе уведомление или обращение во внешний сервис.

О защите уведомлений от дублей читайте в статье Уведомления по заказам.

Найти медленный обработчик

Если SQL-запросы выполняются быстро, а сохранение остается медленным, измерьте пользовательские обработчики отдельно от метода Bitrix\Sale\Order::save().

Для каждого обработчика зафиксируйте:

  • имя события и обработчика,

  • число вызовов за одно сохранение,

  • длительность обработчика и его SQL-запросов,

  • длительность обращений к внешним сервисам.

Повторные вызовы одного обработчика могут указывать на рекурсивное сохранение или на несколько сохранений заказа в одной операции. Долгое внешнее обращение вынесите в очередь, если бизнес-операция не требует синхронного ответа.

Измерьте один и тот же сценарий на заказах одинакового состава.

Не записывайте в журнал свойства заказа, контактные данные и другие персональные значения.

О выборе события и защите обработчика от повторного действия читайте в разделе События заказа и связанных объектов.

Предотвратить рекурсивное сохранение заказа

Если обработчик должен изменить текущий заказ до записи, измените переданный объект в OnSaleOrderBeforeSaved. Для внешнего действия после сохранения добавьте защиту от повторной обработки. Проверьте идентификатор заказа и состояние операции.

Не вызывайте безусловно метод Bitrix\Sale\Order::save() из обработчиков OnSaleOrderBeforeSaved и OnSaleOrderSaved. Повторный вызов снова сохраняет заказ и может привести к рекурсии.

Обработать большие корзины

Чем больше корзина, тем дольше загружается и пересчитывается заказ. Время также зависит от правил скидок, провайдера товара и обработчиков проекта.

Для чтения списка позиций используйте Bitrix\Sale\Internals\BasketTable и ограниченный select. Для изменения используйте корзину из загруженного заказа. Не смешивайте эти подходы в одной операции записи.

Для большой корзины учитывайте не только число заказов, но и число позиций в порции. Обрабатывайте позиции в памяти, согласуйте отгрузки и сохраните заказ один раз.

При массовой обработке не храните в памяти корзины всех заказов одновременно.

О добавлении, изменении и удалении позиций читайте в статье Работа с корзиной.

Получить товары, цены и остатки пакетно

Позиция корзины содержит идентификатор покупаемого товара или торгового предложения. Не выполняйте запрос к модулю catalog внутри цикла по позициям.

Поле MODULE содержит код модуля позиции. Поле PRODUCT_PROVIDER_CLASS содержит класс провайдера товара. Позиция может использовать модуль catalog или собственный провайдер проекта.

Таблицы Bitrix\Catalog\ProductTable и Bitrix\Catalog\PriceTable содержат данные модуля catalog. Поэтому не передавайте в запрос идентификаторы позиций других модулей.

Соберите уникальные PRODUCT_ID позиций модуля catalog, затем выполните пакетные выборки.

В примере $basketItems — массив позиций одной порции. Каждый элемент — массив полей позиции и должен содержать MODULE и PRODUCT_ID. Переменная $priceTypeId — целое число с идентификатором типа цены для фильтра CATALOG_GROUP_ID. Если нужна базовая цена, получите идентификатор по сценарию Получить базовый тип цены. Если после фильтрации список пуст, запросы не выполняются.

if (!\Bitrix\Main\Loader::includeModule('catalog'))
{
    throw new \RuntimeException('Модуль catalog не подключен');
}

$productIds = [];

foreach ($basketItems as $basketItem)
{
    if (($basketItem['MODULE'] ?? '') !== 'catalog')
    {
        continue;
    }

    $productIds[] = (int)$basketItem['PRODUCT_ID'];
}

$productIds = array_values(array_unique(array_filter($productIds)));

$products = [];
$prices = [];
$parentProducts = [];

if ($productIds)
{
    $productIterator = \Bitrix\Catalog\ProductTable::getList([
        'select' => ['ID', 'QUANTITY', 'AVAILABLE', 'TYPE'],
        'filter' => ['=ID' => $productIds],
    ]);

    while ($product = $productIterator->fetch())
    {
        $products[(int)$product['ID']] = $product;
    }

    $priceIterator = \Bitrix\Catalog\PriceTable::getList([
        'select' => [
            'ID',
            'PRODUCT_ID',
            'PRICE',
            'CURRENCY',
            'QUANTITY_FROM',
            'QUANTITY_TO',
        ],
        'filter' => [
            '=PRODUCT_ID' => $productIds,
            '=CATALOG_GROUP_ID' => $priceTypeId,
        ],
    ]);

    while ($price = $priceIterator->fetch())
    {
        $prices[(int)$price['PRODUCT_ID']][] = $price;
    }

    $parentProducts = \CCatalogSKU::getProductList($productIds) ?: [];
}

Класс Bitrix\Catalog\PriceTable возвращает сохраненные записи выбранного типа цены, включая диапазоны количества. Эти записи не равны итоговой цене покупателя.

Итоговая цена также зависит от групп пользователя, скидок, количества и других условий.

Используйте пакетное чтение цен только тогда, когда нужны записи конкретного типа цены.

Если корзина содержит торговое предложение, сохраните в позиции идентификатор предложения, а не родительского товара. Метод \CCatalogSKU::getProductList() пакетно определяет родительские товары для списка предложений.

Общий остаток, остатки по складам, доступность к покупке и резерв имеют разное назначение. Например, положительный остаток еще не гарантирует доступность товара для покупки. Запросите показатель, который нужен текущей проверке.

О выборе цен, остатков и торговых предложений читайте в статье Производительность и частые ошибки Торгового каталога.

О резервах и складском списании читайте в статье Резервирование и списание.

Кешировать только стабильные результаты чтения

Кеширование сокращает повторное чтение отчетов, итоговых показателей и справочных данных. Для кеша заранее определите допустимый срок хранения и условие сброса.

Кеширование не исправляет N+1 и медленный одиночный запрос. Сначала оптимизируйте получение данных, затем добавляйте кеш.

Не помещайте изменяемый объект Bitrix\Sale\Order и его коллекции в долговременный кеш. После оплаты, отгрузки, изменения корзины или параллельного сохранения кеш может содержать устаревшее состояние заказа.

При проектировании кеша:

  • включите в ключ сайт, права, валюту, фильтр и другие параметры, которые меняют результат,

  • назначьте срок хранения по допустимой задержке обновления,

  • определите событие или действие, после которого кеш нужно сбросить,

  • добавьте проверку доступа перед кешированием персональных данных,

  • измерьте отдельно первый запрос и обращение к прогретому кешу.

О сроке хранения, тегах и сбросе кеша читайте в статье Кеширование.

Выбрать один API для изменения заказа

Классический API CSale* продолжает работать в существующих проектах. Проблема возникает, когда один и тот же заказ одновременно изменяют через классический и объектный API. Загруженный объект в таком случае хранит устаревшие данные.

К проблемам могут привести следующие действия:

  • загрузить Bitrix\Sale\Order, затем изменить тот же заказ через \CSaleOrder и сохранить прежний объект,

  • изменить корзину через \CSaleBasket, пока в памяти остается ранее загруженная корзина объекта заказа,

  • записать поля через ORM-таблицу, а затем продолжить расчет на объекте с устаревшими значениями,

  • пересчитать скидки классическим API и повторно выполнить финальный расчет объекта заказа.

В новом коде изменяйте заказ только через объектную модель. Если нужен классический вызов, выполните его до загрузки объекта. После изменения уже загруженного заказа классическим API загрузите объект заново.

Частые ошибки

Ошибка

Последствие

Что сделать

Bitrix\Sale\Order::load() вызывается в цикле для списка

Загружаются все поля заказа. Обращения к коллекциям создают дополнительные запросы

Получить поля или идентификаторы через ORM

Корзина, оплаты или отгрузки читаются отдельно для каждого заказа

Возникает N+1

Запросить каждую таблицу один раз по массиву ORDER_ID

Несколько коллекций 1:N объединяются одним JOIN

Строки размножаются, растут память и время группировки

Получить коллекции отдельными пакетными запросами

В ORM не задан select

Загружаются лишние данные

Перечислить поля текущей задачи

Отчет читает только Bitrix\Sale\Internals\OrderTable за период с архивными заказами

Часть заказов отсутствует в результате

Добавить отдельную выборку через Bitrix\Sale\Archive\Manager::getList()

Массовая выборка не ограничена

Растут память и время выполнения

Читать по ID порциями и сохранять прогресс

Свойства читаются отдельным запросом для каждого заказа

Возникает N+1. При неосторожной диагностике значения попадают в журнал

Запросить Bitrix\Sale\Internals\OrderPropsValueTable по массиву ORDER_ID и не журналировать значения

Коллекции оплат и отгрузок загружаются только ради общего состояния заказа

Выполняются лишние запросы

Использовать поля PAYED и DEDUCTED класса Bitrix\Sale\Internals\OrderTable

Bitrix\Sale\BuyerStatistic::calculate() вызывается при каждом чтении отчета

Активные и архивные заказы пересчитываются повторно

Перенести пересчет в контролируемую служебную задачу

Последний обработанный ID сохраняется до успешной записи

После сбоя часть заказов пропускается

Обновлять прогресс только после подтвержденного результата

Bitrix\Sale\Order::doFinalAction(true) вызывается после каждого изменения

Повторно рассчитываются скидки и налоги

Подготовить изменения и выполнить один финальный расчет

Обновление корзины, расчет доставки и финальный расчет считаются одной операцией

Данные пересчитываются лишний раз или остаются устаревшими

Вызывать каждый этап только по его условиям

Заказ сохраняется несколько раз в одной операции

Повторно выполняются проверки, события и запись коллекций

Сохранить согласованный заказ один раз

Ошибки Bitrix\Sale\Result игнорируются

Код продолжает работу после неуспешной операции

Проверять Bitrix\Sale\Result::isSuccess() и сохранять сообщения ошибок

После успешного Bitrix\Sale\Result::isSuccess() не проверяются предупреждения

Внешний процесс использует неподтвержденное состояние

Обработать Bitrix\Sale\Result::getWarningMessages() и проверить важные данные

Таблицы заказа изменяются напрямую

Связанные данные и история могут рассинхронизироваться

Использовать Bitrix\Sale\Order и его коллекции

Цена или остаток запрашиваются в цикле по корзине

Возникает N+1 между sale и catalog

Собрать PRODUCT_ID и выполнить пакетные выборки

Все позиции корзины считаются товарами модуля catalog

Код неверно обрабатывает собственный провайдер

Разделять позиции по MODULE и провайдеру

В корзину добавляется родительский товар вместо предложения

Заказ содержит неверный покупаемый объект

Передавать идентификатор торгового предложения

Объектный и классический API меняют один заказ одновременно

Объект в памяти содержит устаревшее состояние

Выбрать одну модель или повторно загрузить заказ после внешнего изменения

Bitrix\Sale\Order::save() вызывается из события сохранения без защиты

Возникает рекурсивное сохранение

Изменить текущий объект до записи или защитить заказ от повторного сохранения

Динамические данные заказа кешируются без сброса и учета прав

Пользователь получает устаревший или недоступный результат

Настроить ключ, срок хранения, права и сброс кеша

Как проверить результат оптимизации

Повторите ту же операцию с тем же объемом данных и сравните показатели. Оптимизация прошла успешно, если целевые показатели уменьшились, а бизнес-результат сохранился.

Проверьте:

  • заказ содержит ожидаемые поля и суммы,

  • состав корзины, оплат и отгрузок не изменился без причины,

  • скидки и доставка рассчитаны только после влияющих на них изменений,

  • ошибки каждого заказа записаны отдельно,

  • предупреждения сохранения обработаны,

  • повторное выполнение не дублирует изменения и внешние действия,

  • число запросов не растет линейно из-за чтения связанных данных в цикле,

  • время и память не превышают целевые значения проекта на максимальном ожидаемом объеме,

  • первый запрос без данных в кеше и повторный запрос с кешем измерены отдельно,

  • параллельная обработка одного заказа не теряет изменения,

  • отчет учитывает архив, если выбранный период содержит архивные заказы,

  • свойства нескольких заказов не создают отдельный запрос для каждого заказа,

  • для общего состояния заказа не загружаются коллекции оплат и отгрузок,

  • статистика покупателя не пересчитывается при каждом чтении отчета,

  • для медленных обработчиков известны число вызовов, длительность запросов и внешних обращений,

  • журнал содержит идентификаторы и показатели, но не раскрывает персональные данные без необходимости.

Проверьте обычный заказ и заказ с большой корзиной. Отдельно проверьте заказ с двумя и более оплатами или отгрузками, повтор после ошибки и одновременную обработку. Для каждого набора сохраните исходные показатели и результат после оптимизации.

Разделите массовое чтение и изменение заказа. ORM читает только нужные поля. Объектная модель изменяет заказ и его коллекции как единое целое. Выполняйте расчеты только после изменения данных, которые влияют на стоимость.