Стандартный импорт цен и остатков через 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
