Импорт цен закончился, скрипт бодро написал «готово», а в витрине у половины новых товаров вместо фотографии серый квадрат-заглушка. С точки зрения базы всё в порядке: строка в таблице есть, цена записана, статус включён. С точки зрения покупателя товара пока нет, потому что карточка без картинки выглядит как объявление на заборе. Карточка существует в базе раньше, чем её можно нормально показать, и вся задача этой статьи как раз в том, чтобы это расхождение сделать управляемым, а не случайным.

Ниже разобран путь товара от строки прайс-листа до готовой карточки и предложена архитектура, в которой обновление каталога и тяжёлая работа с изображениями идут раздельно. Сразу важная оговорка: это архитектура проекта, а не штатная функция. Встроенная фоновая очередь в OpenCart нам не подтверждена, как и какой-либо готовый консольный импортёр. Поэтому дальше речь о внешнем обработчике, который вы пишете или подключаете сами и который общается с магазином через те точки, что у OpenCart действительно есть: база данных, каталог изображений и стандартные механизмы показа картинок.

Один товар, два разных темпа

Возьмём одну строку прайс-листа: артикул, название, цена, остаток и ссылка на фотографию у поставщика. Если обработать её в лоб, в одном запросе произойдёт сразу несколько вещей: запись в таблицы товара, скачивание файла по сети, проверка, что это действительно изображение, сохранение, подготовка уменьшенных копий. Первые два шага быстрые и предсказуемые, они упираются в базу. Всё остальное зависит от внешнего мира: скорость сервера поставщика, размер файла, формат, процессор вашей машины.

Отсюда главная мысль: у данных товара и у его картинок разные темпы и разные причины сбоев. Цена не может «наполовину скачаться». Фотография может: оборвалась связь, ссылка отдаёт страницу ошибки вместо файла, поставщик положил в архив TIFF на сорок мегабайт. Если связать эти процессы в один, медленная и капризная часть начинает тормозить быструю и надёжную, а любая ошибка файла откатывает или портит обновление цен.

Поэтому проще всего представить у товара два независимых состояния:

  • состояние данных: цена, остаток, название обновлены;
  • состояние изображения: ссылка известна, оригинал получен, варианты созданы, карточка может показывать картинку.

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

Что штатный OpenCart берёт на себя, а что нет

Для администратора изображения в OpenCart живут вокруг Image Manager: через него загружают и выбирают файлы для товаров и других сущностей (документация OpenCart 4, Settings). Это удобный ручной инструмент, но это интерфейс человека, а не конвейер для десятков тысяч позиций. Там же отмечено, что тема и кеш могут влиять на то, увидите ли вы обновлённые изображения и настройки сразу. Для нашей задачи это практический сигнал: даже когда файл лёг на диск и путь записан в базу, витрина способна показывать старое, и проверять результат нужно именно на публичной странице, а не по наличию файла.

Всё остальное, то есть очередь заданий, повторы, ограничение скорости и учёт ошибок, в этой документации не описано, и считать, что оно «где-то есть в настройках», нельзя. Эту часть мы строим сами. Хорошая новость в том, что строится она просто: небольшая таблица заданий, один-два рабочих процесса и каталог на диске.

Три разных объекта: ссылка, оригинал, варианты

Самая частая ошибка в таких импортах — слить три разные вещи в одну. Разведём их.

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

Оригинал. Это тот самый файл, который вы один раз получили и сохранили у себя без изменений. Он нужен, чтобы при смене размеров в теме, добавлении нового формата или обнаружении брака в обработке можно было пересоздать всё с нуля, не ходя к поставщику. Если поставщик через полгода удалит фото, а вы заранее «сэкономили диск» и оставили только маленькие копии, восстановить качество будет нечем. Поэтому оригиналы мы не выбрасываем, а экономим диск другими способами: например, не хранить промежуточные варианты, которые никто не запрашивает.

Варианты. Это производные: миниатюра для списка, средняя картинка для карточки, крупная для увеличения. Они воспроизводимы из оригинала, поэтому их можно пересоздавать, заменять и при необходимости удалять.

На диске это удобно выразить структурой каталогов. Например, оригиналы лежат в отдельной папке вне публичного доступа, а варианты в папке, откуда их отдаёт веб-сервер. Конкретные пути здесь иллюстрация, подберите свои:

/srv/shop-media/originals/<id-товара>/<хеш-файла>.orig
/var/www/shop/image/catalog/import/<id-товара>/main-800.webp
/var/www/shop/image/catalog/import/<id-товара>/main-300.webp

Обратите внимание на устойчивый идентификатор товара в пути. К нему мы сейчас вернёмся.

Идентификатор, к которому всё привязано

Номер строки в прайс-листе — плохой ключ: поставщик вставит новую позицию в середину, и номера съедут. Название тоже плохой ключ: его правят. Нужен устойчивый идентификатор — как правило, артикул или внутренний код поставщика, который вы сопоставляете с товаром в магазине. Для картинок к нему добавляется порядковая роль: основная, дополнительная 1, дополнительная 2.

Так получается ключ задания вида «товар + роль изображения». Он решает сразу две проблемы.

  • Повтор одного изображения. Если фото основного снимка не скачалось, вы перезапускаете задание только для этой пары, а остальной каталог не трогаете.
  • Повторный импорт того же прайса. Когда файл прайса пришёл заново, вы видите, что для пары «товар + роль» уже есть задание с той же ссылкой и готовым результатом, и не создаёте работу дважды.

Важно не переоценивать этот механизм. Сам по себе новый случайный идентификатор попытки идемпотентность не даёт: если каждая попытка получает свой номер, повтор будет считаться новой работой. Идемпотентность держится на стабильном ключе (товар, роль, ссылка или хеш содержимого) и на проверке «результат уже есть» перед тем, как что-то делать. Запись результата лучше делать так, чтобы повтор перезаписывал то же самое, а не копил дубли: писать во временный файл и переименовывать в итоговое имя.

Таблица заданий как скромная очередь

Раз готовой очереди у OpenCart нет, достаточно отдельной таблицы в базе рядом с магазином. Это ваша структура, поэтому дайте ей собственный префикс, чтобы не перепутать с таблицами движка. Минимальный набор полей: идентификатор товара, роль, исходная ссылка, статус, число попыток, время следующей попытки, текст последней ошибки, хеш оригинала.

Статусов нужно немного, и каждый должен отвечать на конкретный вопрос:

Статусы задания изображения
СтатусЧто означаетЧто делает обработчик
ожидаетСсылка записана, файла у нас ещё нетБерёт в работу при свободном месте в лимите
полученОригинал сохранён и проверенСоздаёт варианты
готовВсе нужные варианты есть, путь записан в товарНичего, карточка может показывать картинку
ошибка, повторСбой временный: сеть, таймаутЖдёт времени следующей попытки
ошибка, остановленоФайл битый или ссылка мертваЖдёт решения человека или новой ссылки

Разделение на «повтор» и «остановлено» принципиально. Таймаут стоит повторить через несколько минут. Ответ «404» после нескольких попыток повторять бесконечно бессмысленно, он только сжигает ресурсы и засоряет журнал.

Работу берёт отдельный процесс, запускаемый по расписанию или как постоянно работающая служба. Он не является частью веб-запроса, поэтому никакой браузер или PHP-таймаут его не прервёт. Ещё один плюс: перезапуск обработчика безопасен, потому что состояние лежит в таблице, а не в памяти.

Создание вариантов: где нужен Sharp

Для самой обработки подойдёт любая библиотека, которой вы доверяете. Если рабочий процесс у вас на Node.js, удобен Sharp: он умеет выводить, среди прочего, WebP и AVIF (Sharp: Output options). Это даёт выбор: WebP как более привычный и быстрый вариант, AVIF как возможность сжать сильнее ценой более тяжёлого кодирования. Какой формат выбирать, решает ваша аудитория и ваши замеры, а не документация. Разумный путь — начать с одного формата и добавлять второй только когда увидите, что он оправдан.

Практически полезна ещё одна возможность: результат операции может сообщить формат, размер, ширину и высоту. Эти данные стоит записывать в задание. Тогда на ранней стадии видно подозрительное: «картинка» шириной в 12 пикселей или с размером в ноль байт, которая формально является файлом, но показывать её нельзя. Каталоги и права записи при этом остаются на вашей стороне: библиотека не создаст нужную папку и не выдаст права пользователю веб-сервера. Хорошо проверить это один раз вручную на тестовом товаре, чтобы потом не искать причину серых квадратов в правах доступа.

Для каждой роли изображения определите набор размеров заранее и держите его в конфигурации обработчика, а не в голове. Размеры должны совпадать с тем, что запрашивает ваша тема. Если тема запрашивает 228 на 228, а вы подготовили 300 на 300, браузеру придётся масштабировать, а вы получите лишний трафик без выгоды.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Пока покупатели смотрят каталог

Кодирование изображений — одна из самых прожорливых по процессору операций в магазине. Если запустить её на всех ядрах сразу, веб-сервер и база получат остатки, и страницы начнут отвечать медленнее. Значит, обработчику нужны границы.

Есть несколько рычагов, и они независимы:

  1. Число параллельных заданий. Начните с одного-двух и повышайте, наблюдая за откликом витрины. Правило простое: количество одновременных обработок меньше числа ядер, чтобы хотя бы одно оставалось покупателям.
  2. Приоритет процесса. Рабочий процесс можно запускать с пониженным приоритетом средствами операционной системы, чтобы при конкуренции за процессор веб-сервер побеждал.
  3. Темп скачивания. Не стоит одновременно открывать десятки соединений к одному поставщику: это грубо по отношению к нему, и вас могут начать отсекать.
  4. Время суток. Если основной поток покупателей приходится на вечер, пакет тяжёлых заданий можно разгонять ночью, а днём держать минимальный режим.

Чтобы это не оставалось абстракцией, возьмём условный расчёт: в прайсе 20 000 позиций, у каждой одна основная фотография и два варианта размера. Это 40 000 операций создания вариантов. Допустим, обработчик справляется с одной операцией в среднем за полсекунды на одном потоке. Тогда один поток пройдёт всю работу примерно за 20 000 секунд, то есть около пяти с половиной часов. Цифра полсекунды выдумана для примера: реальная зависит от размера оригиналов и формата, и её надо измерять на ваших файлах. Но сам способ счёта полезен: он показывает, что «быстрее» не обязательно значит «все ядра», а ночное окно в несколько часов вполне достаточно для одного-двух потоков.

Как это связано с ресурсами сервера

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

На VPS MAATRIX доступны линейки от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ), с NVMe-дисками и root-доступом на всех. Эти параметры — ориентир для расчёта, а не обещание ёмкости: сколько заданий потянет конкретная конфигурация, покажет только замер на ваших файлах. Практический вывод такой. Если каталог небольшой и оригиналов немного, для магазина и фонового обработчика на одной машине хватит скромной конфигурации, но тогда параллельность придётся держать на единице. Если каталог большой и картинки тяжёлые, имеет смысл заложить запас по диску и памяти или вынести обработчик на отдельный VPS, который читает оригиналы и кладёт готовые варианты туда, откуда их отдаёт магазин. Root-доступ позволяет настроить такую схему самому: службу обработчика, расписание, пользователей и права на каталоги. Настройку самого магазина на сервере описывает материал про установку и настройку OpenCart на VPS.

Что видит покупатель, пока картинка не готова

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

Политики показа карточки без готовой картинки
ПолитикаПлюсМинусКогда подходит
Скрывать товар до готовности картинкиВ витрине нет пустых карточекТовар с новой ценой временно недоступен, остатки не продаютсяНовые позиции, которых раньше не было в каталоге
Показывать с общей заглушкойТовар можно купить сразуВид у витрины неряшливый, покупатель остороженРасходники и позиции, где фото вторично
Оставлять старую картинку, пока не готова новаяВитрина не ломается при обновленииНужно аккуратно заменять путь только после готовностиОбновление уже существующих товаров

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

Есть и техническая оговорка из документации: тема и кеш могут влиять на видимость обновлённых изображений. Если после замены файла на сайте всё ещё показывается старое, сначала очистите кеши и проверьте именно публичную страницу. Хорошая привычка — давать новому варианту новое имя, например с коротким хешем содержимого, чтобы браузер и промежуточные кеши не держали устаревшую версию.

Неполная поставка: цена пришла, файл не пришёл

Поставщики присылают прайсы не идеальными. Разберём типичные сбои и то, как раздельная архитектура с ними справляется.

Цена изменилась, а ссылка на фото устарела

Данные обновляются сразу: цена и остаток не должны зависеть от картинки. Задание изображения уходит в «ошибка, повтор», а затем, после нескольких неудачных попыток, в «остановлено» с понятным текстом причины. Карточка продолжает показывать прежнюю картинку, если она была, или заглушку, если товар новый. Ваша задача — чтобы такие остановленные задания собирались в короткий список для человека: «эти двенадцать товаров ждут новой ссылки».

Файл скачался, но повреждён

Это худший вариант, потому что формально всё получилось. Спасает проверка на этапе «получен»: попытка открыть файл как изображение и прочитать его размеры. Если библиотека не смогла декодировать файл или ширина и высота нереалистичны, задание не должно переходить дальше. Оригинал при этом лучше сохранить отдельно в карантине, а не удалять: он пригодится, когда придётся объяснять поставщику, что именно пришло.

Файл тот же адрес, содержимое другое

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

Прайс пришёл дважды или частично

Если загрузка оборвалась на середине файла, часть товаров обновилась, часть нет. Раздельная схема это терпит, потому что обновление каждой строки самостоятельно, а повтор того же прайса просто дойдёт до оставшихся. Но для этого запись строки должна быть повторяемой: «установить цену X у товара с артикулом Y» безопасно повторять, а «увеличить остаток на 5» — нет. Формулируйте операции импорта так, чтобы повтор давал тот же результат.

Заметьте, что запись в базу магазина и сохранение файла — это два разных хранилища, и общей транзакции у них нет. Нельзя рассчитывать, что обновление строки в базе и создание файла на диске либо оба случатся, либо оба откатятся. Поэтому состояние задания и порядок действий (сначала файл, потом путь в товаре) — это и есть ваш механизм согласованности, а не гарантия, которую даст база.

Порядок событий для одной карточки от начала до конца

Соберём всё в один сквозной пример: товар с артикулом из прайса, у которого одна основная фотография.

  1. Импортёр читает строку, находит товар по артикулу или создаёт новый, записывает цену, остаток и название. В поле изображения пока ничего нового.
  2. Для пары «товар + основная» в таблице заданий создаётся запись «ожидает» с исходной ссылкой, если такой записи с той же ссылкой ещё нет.
  3. Обработчик, у которого есть свободный слот в лимите параллельности, скачивает файл во временное место и проверяет, что это изображение с разумными размерами.
  4. Оригинал переносится в хранилище оригиналов под именем с хешем содержимого, задание получает статус «получен».
  5. Создаются варианты нужных размеров в нужном формате; каждый пишется во временный файл и затем переименовывается в итоговое имя.
  6. Только после того, как все варианты на месте, в данные товара записывается путь к изображению. Задание становится «готов».
  7. Проверяется публичная страница карточки: картинка открывается, и это новая версия, а не закешированная старая.

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

Как понять, что схема работает

Несколько простых наблюдений заменяют любые громкие метрики:

  • сколько заданий в каждом статусе: растёт ли «ожидает» быстрее, чем уменьшается;
  • сколько заданий застряло в «остановлено» и по какой причине;
  • как отвечает витрина во время активной обработки: открывается ли каталог с прежней скоростью;
  • сколько свободного места осталось под оригиналы и варианты.

Если очередь растёт, а витрина при этом спокойна, можно осторожно поднять параллельность. Если витрина замедляется, параллельность надо снижать, не дожидаясь, пока пожалуются покупатели. И все пороги выбирайте по собственным замерам: общих «правильных» чисел для всех магазинов нет.

Серый квадрат, который больше не нужен

Вернёмся к исходной карточке. Цена обновилась в первые минуты импорта, а фотография прошла свой путь отдельно: получена, сохранена без потерь, превращена в нужные размеры, и только потом путь к ней появился в товаре. Покупатель не застал ни серого квадрата, ни ссылки на файл, которого ещё нет. А у соседнего товара ссылка оказалась мёртвой: его задание тихо ушло в список на ручной разбор, и ради этого одного изображения никому не пришлось заново прогонять двадцать тысяч строк прайса. Когда поставщик пришлёт новую ссылку, будет перезапущено единственное задание, а оригинал, который мы сохранили, при необходимости позволит пересоздать варианты в любой момент.