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

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

Сначала файл: что мы на самом деле прочитали

Импорт начинается не с базы, а с чтения CSV, и именно здесь теряется первое доверие к данным. CSV — не один формат, а семейство: разные разделители, разные правила экранирования. Модуль csv в Python поддерживает диалекты как раз поэтому (документация Python). Выгрузка из 1С, из таблицы бухгалтера и из маркетплейса может выглядеть похоже и всё же отличаться точкой с запятой вместо запятой или кавычками внутри названия.

Вторая особенность важнее для ключей: reader обычно отдаёт строки как строки, без автоматического преобразования типов, а DictReader сопоставляет поля с именами столбцов (там же). Для каталога это не недостаток, а подарок. Артикул 00123 остаётся артикулом 00123, а не числом 123. Если приведение типов делает табличный редактор или самодельный код с int(), ведущие нули пропадают, и два разных товара сливаются в один.

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

Заодно решите, что у вас является ключом. Обычно это артикул продавца или внешний код из учётной системы. Название товара ключом быть не может: оно меняется, дублируется и пишется с опечатками. Ключ должен быть обязательным и уникальным. В PostgreSQL для этого есть PRIMARY KEY, который задаёт уникальность и запрещает NULL, а UNIQUE может охватывать несколько столбцов (документация PostgreSQL). Составной ключ пригодится, если артикул уникален только внутри поставщика: тогда уникальной будет пара «поставщик + артикул».

Есть и тонкость, которую легко пропустить: пустая строка в PostgreSQL не равна NULL (там же). Ячейка CSV без значения приходит в Python как ''. Если записать её в столбец как есть, запрет NULL не сработает, и в таблице окажется товар с «пустым, но существующим» ключом. Значит, пустой ключ нужно отсекать явной проверкой до записи, а не рассчитывать на ограничение.

Проверка строки — одно, фиксация — другое

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

  • Годится ли строка? Есть ключ, цена разбирается как число, остаток не отрицательный, обязательные поля заполнены. Это чистая функция: она ничего не меняет в мире и даёт либо нормализованную запись, либо причину отказа.
  • Принята ли строка? Это факт о базе данных: изменения зафиксированы командой COMMIT и переживут перезапуск.

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

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

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

Порция: ограниченный кусок, а не «сколько влезет»

Теперь понятно, зачем делить 100 тысяч на порции. Одна гигантская операция плоха двумя вещами. Во-первых, при сбое откатывается всё: транзакция по определению «всё или ничего», и ROLLBACK отменяет изменения незавершённой транзакции (документация PostgreSQL). Если упали на 99-тысячной записи, потеряны все предыдущие минуты работы. Во-вторых, если же фиксировать каждую строку отдельно, вы получаете максимальную гранулярность, но платите числом отдельных фиксаций и обращений.

Порция — компромисс между этими полюсами. Принцип простой: порция настолько велика, чтобы накладные расходы на фиксацию не доминировали, и настолько мала, чтобы потеря одной порции при сбое была терпимой. Конкретное число зависит от ширины строки, индексов, нагрузки на базу и места, где работает скрипт. Универсального размера не существует, и честный способ его найти — прогнать пробный файл на нескольких значениях (скажем, 200, 1000, 5000) и посмотреть на время и на поведение при искусственной остановке. Ориентируйтесь на свои измерения, а не на чужие рекомендации.

Практически порция собирается читателем файла: он накапливает N годных записей (плохие уходят в отклонённые) и отдаёт их писателю. Удобно, чтобы порция имела собственный номер и диапазон записей исходного файла: «порция 64, записи 63001–64000». Этот диапазон потом пригодится для отчёта.

Запись, которую можно повторить

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

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

Граница, которая двигается только после COMMIT

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

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

Покажем на маленьком фрагменте. Допустим, порция — записи 4–6, граница стоит на 3, таблица товаров содержит A1, A2, A3.

BEGIN;
INSERT INTO product (sku, name) VALUES ('A4','...'), ('A5','...'), ('A6','...');
UPDATE import_checkpoint SET last_record = 6 WHERE load_id = 42;
COMMIT;

Теперь разберём два момента сбоя.

Что видит база после остановки в разные моменты
Момент сбояТоварыГраницаЧто делать
После INSERT, до COMMITA1–A3: незавершённая транзакция отменяется, A4–A6 нет3Продолжить с записи 4, ничего чистить не нужно
После COMMITA1–A66Продолжить с записи 7

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

Одно предостережение. Гарантия относится к операциям самой базы. Если вместе с записью в БД вы вызываете внешний HTTP API (например, отправляете товар в чужую торговую площадку), локальная транзакция его не охватывает: документация описывает гарантии для операций базы данных, а не для произвольного внешнего API (там же). ROLLBACK не отзовёт уже отправленный запрос. Для внешней отправки нужен отдельный механизм: например, сначала зафиксировать намерение в своей базе, затем отправлять и отмечать результат, а у внешней стороны — опираться на ключ товара, чтобы повтор не создавал второй экземпляр. О том, как продолжать большую выгрузку из API, есть отдельный разбор: большая выгрузка из API в n8n с продолжением после сбоя.

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

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

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

Сбой внутри порции

Что делать, если проблема не в падении процесса, а в одной плохой строке посреди порции? Допустим, из 1000 записей одна нарушает ограничение базы: слишком длинное значение или конфликт, который не предусмотрела предварительная проверка. Транзакция порции откатится целиком, и граница не сдвинется. Это правильно, но тянуть так бесконечно нельзя: один дефект блокирует остальные 999.

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

  1. Строгая предварительная проверка. Если валидатор ловит почти все проблемы до записи, ошибки на уровне базы редки, и их можно считать аварией: остановить загрузку и разобраться.
  2. Деление порции пополам. При ошибке откатить порцию, разделить её на две половины и пробовать каждую отдельно, пока не останется одна строка. Её отправляют в отклонённые с текстом ошибки базы. Остальные принимаются. Стоит это дополнительных попыток, зато граница двигается честно.
  3. Подтранзакции по строкам. Каждая строка записывается в защищённом блоке, и при ошибке откатывается только она. Это точнее, но сложнее в реализации и дороже, поэтому оправдано, если плохих строк много.

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

Заметьте, как это меняет смысл границы. Теперь «последняя подтверждённая запись» означает «всё до неё либо принято, либо занесено в отклонённые». Это надо записать в контрольной точке явно: не просто номер, а пару счётчиков — сколько принято и сколько отклонено на данный момент. Сумма должна совпадать с номером границы, и это первая встроенная проверка целостности.

Другая версия файла

Самый коварный сбой не про падение, а про подмену. Вы остановились на записи 63417, а утром менеджер присылает «исправленный» файл с тем же именем. Скрипт честно читает границу и продолжает с записи 63418 нового файла. Но если в новом файле вставили десять строк в начало, то записи 63418 в нём уже соответствует другой товар. Часть товаров потеряется, часть прочитается дважды.

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

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

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

Сверка: количество строк ничего не доказывает

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

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

Проверка сводится к трём вопросам.

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

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

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

Где всё это живёт

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

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

Продолжение: как выглядит возобновление на практике

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

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

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

Так импорт превращается из «запустили и молимся» в процесс, где в любой момент можно ответить на вопрос, с которого мы начали. Что принято? Всё до подтверждённой границы. Что не принято? Строки из списка отклонённых с причинами. А чего нет ни там, ни там, показывает сверка по ключам. Всё остальное, включая размер порции, скорость и тариф сервера, вы подберёте на своих данных, не меняя саму схему.