Стандартный импорт цен и остатков через XML или API может занимать от 1 до 8 часов на больших каталогах. Если данных много, а обновлять их нужно часто, единственный путь - работа напрямую с базой данных.

В этой статье я поделюсь проверенным решением, которое работает у нас на проде более 4 лет. Время обработки 1 млн товаров на 19 складах - около 13 секунд.

Внешняя система формирует полную выгрузку остатков и цен в tsv, пересылает по sftp на сервер

Подготовка таблиц

Используем движок MEMORY для максимальной скорости. Если у вас несколько типов цен, в tmp_import_new_price добавьте поле CATALOG_GROUP_ID

CREATE TABLE `tmp_import_new_price` (
  `PRODUCT_ID` int NOT NULL,
  `PRICE` decimal(18,2) NOT NULL,
  PRIMARY KEY (`PRODUCT_ID`) USING BTREE
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci

CREATE TABLE `tmp_import_new_rest` (
  `PRODUCT_ID` int NOT NULL,
  `STORE_ID` int NOT NULL,
  `AMOUNT` double NOT NULL,
  PRIMARY KEY (`PRODUCT_ID`, `STORE_ID`) USING BTREE
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci

Я намеренно не стал городить сложную архитектуру с классами и паттернами. Задача была максимально быстро пробросить данные из TSV в БД. Код оформлен в виде одного метода.
Код не универсальный, но его легко можете доработать под свой проект.
Этот метод обходит стандартные события Битрикса. Если у вас на них завязана сложная бизнес-логика, ее нужно будет вызвать отдельно или адаптировать решение.
Сейчас у нас цены и остатки в одном файле. Рекомендую их разделить: сделать один файл чисто под цены, а второй - под остатки.

Скрытый текст
$importPath = P_DR . '/upload/import/fpm.tsv';

if (!file_exists($importPath)) {
    return;
}

$connection = Application::getConnection();
$connection->query('TRUNCATE TABLE tmp_import_new_price;');
$connection->query('TRUNCATE TABLE tmp_import_new_rest;');

$products = \Bitrix\Iblock\ElementTable::query()
    ->setFilter(['IBLOCK_ID' => IBLOCK_CATALOG])
    ->setSelect(['ID', 'XML_ID'])
    ->exec();

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

$stores = Bitrix\Catalog\StoreTable::query()
    ->setSelect(['ID', 'XML_ID'])
    ->fetchAll();

$storeXmlIdToId = array_column($stores, 'ID', 'XML_ID');

$sqlInsertPrice = '';
$sqlInsertRest = '';

define('PRICE',         0);
define('STOCK',         1);
define('STORE_XML_ID',  2);
define('XML_ID',        3);

$handle = fopen($importPath, 'r');

//пропустить первую строку заголовков
stream_get_line($handle, 512, "\n");

while (($line = stream_get_line($handle, 512, "\n")) !== false) {
    $data = explode("\t", $line);

    if (!isset($productsXmlIdToId[$data[XML_ID]])) {
        continue;
    }

    $productId = $productsXmlIdToId[$data[XML_ID]];
    $price = $data[PRICE];

    $sqlInsertPrice .= "($productId," . ceil($price) . '),';

    $storeXmlId = $data[STORE_XML_ID];
    if (!isset($storeXmlIdToId[$storeXmlId])) {
        continue;
    }

    $quantity = (int)$data[STOCK];

    $sqlInsertRest .= "($productId,$quantity,$storeXmlIdToId[$storeXmlId]),";
}
fclose($handle);

$sqlInsertPrice[strlen($sqlInsertPrice) - 1] = ' ';
$sqlInsertRest[strlen($sqlInsertRest) - 1] = ' ';

$connection->query('INSERT IGNORE INTO tmp_import_new_price (`PRODUCT_ID`, `PRICE`) VALUES ' . $sqlInsertPrice . ';');
$connection->query('INSERT IGNORE INTO tmp_import_new_rest (`PRODUCT_ID`, `AMOUNT`, `STORE_ID`) VALUES ' . $sqlInsertRest . ';');

//Обновляем существующие цены
$connection->query('
    UPDATE b_catalog_price p
    INNER JOIN tmp_import_new_price t ON p.PRODUCT_ID = t.PRODUCT_ID
    SET p.PRICE = t.PRICE, p.PRICE_SCALE = t.PRICE
    WHERE p.CATALOG_GROUP_ID = 3
');

//Добавляем новые цены
$connection->query('
    INSERT INTO b_catalog_price (PRODUCT_ID, PRICE, PRICE_SCALE, CATALOG_GROUP_ID, CURRENCY)
    SELECT t.PRODUCT_ID, t.PRICE, t.PRICE, 3, \'RUB\'
    FROM tmp_import_new_price t
    LEFT JOIN b_catalog_price p ON t.PRODUCT_ID = p.PRODUCT_ID AND p.CATALOG_GROUP_ID = 3
    WHERE p.ID IS NULL
');

//Обновляем остатки складов
$connection->query('
    UPDATE b_catalog_store_product sp
    INNER JOIN tmp_import_new_rest t ON sp.PRODUCT_ID = t.PRODUCT_ID AND sp.STORE_ID = t.STORE_ID
    SET sp.AMOUNT = t.AMOUNT
');

//Добавляем остатки складов
$connection->query('
    INSERT INTO b_catalog_store_product (PRODUCT_ID, STORE_ID, AMOUNT)
    SELECT t.PRODUCT_ID, t.STORE_ID, t.AMOUNT
    FROM tmp_import_new_rest t
    LEFT JOIN b_catalog_store_product sp ON t.PRODUCT_ID = sp.PRODUCT_ID AND t.STORE_ID = sp.STORE_ID
    WHERE sp.ID IS NULL
');

$connection->query('
    UPDATE b_catalog_product cp
    INNER JOIN (
        SELECT PRODUCT_ID, SUM(AMOUNT) as TOTAL 
        FROM b_catalog_store_product 
        GROUP BY PRODUCT_ID
    ) t ON cp.ID = t.PRODUCT_ID
    SET cp.QUANTITY = t.TOTAL, 
        cp.AVAILABLE = CASE WHEN t.TOTAL > 0 THEN \'Y\' ELSE \'N\' END
');

unlink($importPath);

Результаты

На сервере (VDS Ice Lake, 6 vCPU, 32 Гб ОЗУ, php 8.2, mysql 8.0, centos 7) время выполнения составляет стабильные 12-13 секунд для миллиона позиций. Это позволяет запускать обновление цен хоть каждые 15 минут без вреда для производительности сайта.
Недавно переехали на (VDS amd zen 4, 4 vCPU, 32 Гб ОЗУ, php 8.5, mysql 8.4, centos 9)
Производительность выросла и время выполнения составляет стабильные 7-9 секунд
Что именно дало прирост не замерял.
Так же у нас есть узкое место сетевой диск, если у вас локальный, намного быстрее будет.

LOAD DATA INFILE

Хотя insert из TSV объемом в 1 млн строк сейчас занимает около 1 секунды и не является «бутылочным горлышком», при дальнейшем росте объема стоит рассмотреть переход на команду MySQL - LOAD DATA INFILE