Excel как внешний API без контракта: безопасный импорт данных в Go и PostgreSQL
- вторник, 27 октября 2026 г. в 00:00:26
Импорт Excel-файлов обычно выглядит несложной задачей. Получили XLSX, прочитали строки, проверили обязательные поля и записали данные в PostgreSQL. Если запись существует, обновили её через ON CONFLICT DO UPDATE.
Для одноразового скрипта этого часто достаточно. Проблемы начинаются, когда импорт становится частью продукта.
Допустим, в SaaS есть справочник товаров, который клиенты периодически обновляют из Excel. В одном файле 30 тысяч позиций, в другом 100 тысяч. Пользователь загружает новую версию и ожидает, что приложение приведёт справочник в актуальное состояние.
При этом файл может содержать дубликаты, неправильные коды, пустые строки и изменённые наименования. Какие-то позиции могут исчезнуть из новой выгрузки. Иногда это означает, что их действительно нужно удалить, а иногда человек просто выгрузил часть справочника.
Если обрабатывать файл построчно и сразу обновлять рабочие таблицы, можно получить неприятную ситуацию: половина данных уже изменилась, а на очередной строке импорт завершился ошибкой.
Но даже транзакция не решает всех проблем. Она обеспечивает атомарность изменений, однако не может определить, означает ли отсутствие товара в файле необходимость его удаления.
Поэтому я бы рассматривал импорт XLSX не как операцию чтения файла, а как отдельный процесс согласования внешних данных с текущим состоянием системы.
Когда мы интегрируемся с REST или gRPC, у нас хотя бы есть формальный контракт. Мы знаем типы полей, допустимые значения, правила идентификации сущностей и семантику ошибок.
У Excel-файла такого контракта обычно нет.
Сегодня пользователь прислал таблицу с тремя столбцами, завтра переименовал один из них, а послезавтра добавил новую колонку между существующими.
Сам Excel при этом вполне успешно откроет файл.
Ещё хуже обстоят дела с типами данных.
Представим товарный код 000741. Для нашего приложения это идентификатор, поэтому он должен храниться строкой.
Но если Excel интерпретировал значение как число, ведущие нули могут исчезнуть. В результате приложение получит 741, хотя пользователь подразумевал другой идентификатор.
Исправить такую ситуацию простым приведением числа обратно к строке невозможно. Мы уже не знаем, сколько нулей было в исходном значении.
Поэтому для кодов, артикулов и других идентификаторов я бы явно требовал текстовый формат ячеек.
Похожая проблема возникает с датами, денежными суммами и формулами. Для человека в интерфейсе Excel всё может выглядеть нормально, но библиотека чтения получит значение, которое не соответствует бизнес-смыслу поля.
Есть ещё менее очевидный случай: пустая ячейка.
Предположим, в справочнике у товара заполнено описание, а в новом файле соответствующая ячейка пустая.
Что должен сделать backend?
Удалить прежнее описание? Оставить без изменений? Считать строку ошибочной?
Ответ зависит не от XLSX, а от контракта импорта.
Если файл представляет полный снимок справочника, пустое значение может означать удаление поля. Если это частичное обновление, пустая ячейка может означать отсутствие изменений.
Без заранее определённых правил обе интерпретации выглядят допустимыми.
Именно поэтому первый шаг при разработке импорта я бы делал не в Go-коде, а в описании семантики данных.
Рассмотрим простой пример.
В PostgreSQL хранится справочник:
Код | Наименование | Активен |
|---|---|---|
000741 | Кабель 2 м | Да |
000742 | Адаптер USB-C | Да |
000743 | Зарядное устройство | Да |
Пользователь загружает новый XLSX:
Код | Наименование |
|---|---|
000741 | Кабель 3 м |
000742 | Адаптер USB-C |
000744 | Сетевой фильтр |
Уже здесь возникает несколько разных операций.
Товар 000741 нужно обновить. 000742 не изменился. 000744 необходимо создать.
Но что делать с 000743?
Если файл содержит полный справочник, товар, возможно, нужно деактивировать. Если это частичная выгрузка, трогать его нельзя.
Поэтому вместо непосредственного обновления catalog_items я бы сначала сохранял данные в staging-таблицу.
Например:
CREATE TABLE catalog_import_rows ( import_id UUID NOT NULL, row_no INTEGER NOT NULL, code TEXT NOT NULL, title TEXT NOT NULL, PRIMARY KEY (import_id, row_no) ); CREATE INDEX catalog_import_rows_code_idx ON catalog_import_rows (import_id, code);
Здесь каждая строка привязана к определённому импорту.
import_id позволяет одновременно обрабатывать несколько файлов, а row_no нужен для диагностики.
Если пользователь загрузил неправильную таблицу, мы сможем сообщить, что ошибка находится, например, в строке 842, а не просто вернуть invalid input.
Это небольшая деталь, но она сильно влияет на удобство работы с импортом.
Важно и то, что staging-таблица не должна быть доступна обычной бизнес-логике как актуальный справочник. Пока файл не проверен, его содержимое остаётся кандидатом на изменение данных.
Для работы с Excel в Go можно использовать Excelize.
У библиотеки есть метод GetRows, который возвращает строки листа в виде [][]string. Это удобно, если файлы небольшие и весь набор данных можно без проблем хранить в памяти.
Но для больших файлов я бы предпочёл итератор Rows.
Он позволяет обрабатывать строки последовательно, без формирования общего [][]string со всем листом. Это не означает строго постоянное потребление памяти: сама структура XLSX, общие строки и особенности содержимого всё равно могут потребовать дополнительных ресурсов. Go Packages
Простейший обработчик можно организовать следующим образом:
type RowWriter interface { WriteRow( ctx context.Context, rowNumber int, code string, title string, ) error } func ImportSheet( ctx context.Context, file *excelize.File, writer RowWriter, ) error { rows, err := file.Rows("Catalog") if err != nil { return err } defer rows.Close() rowNumber := 0 for rows.Next() { rowNumber++ if rowNumber > 100_001 { return fmt.Errorf("row limit exceeded") } columns, err := rows.Columns() if err != nil { return fmt.Errorf( "row %d: %w", rowNumber, err, ) } if rowNumber == 1 { continue } if len(columns) == 0 { continue } if len(columns) < 2 { return fmt.Errorf( "row %d: missing required columns", rowNumber, ) } code := strings.TrimSpace(columns[0]) title := strings.TrimSpace(columns[1]) if code == "" || title == "" { return fmt.Errorf( "row %d: empty required field", rowNumber, ) } if err := writer.WriteRow( ctx, rowNumber, code, title, ); err != nil { return err } } return rows.Error() }
Здесь RowWriter намеренно отделён от парсера.
Сам обработчик ничего не знает о PostgreSQL. Его задача заключается в чтении строк и проверке базовых ограничений.
В реальной реализации потребуются дополнительные проверки формата заголовков, типов ячеек, ограничений длины строк и правил нормализации идентификаторов. Также нужно отдельно обработать ошибку закрытия итератора, если она возникнет.
Но даже этот пример показывает полезное разделение ответственности.
Парсер извлекает данные из файла.
Валидатор проверяет их на соответствие контракту.
А компонент записи сохраняет строки во временное хранилище для дальнейшей обработки.
Для загрузки большого количества строк в PostgreSQL можно использовать pgx.CopyFrom. Он поддерживает передачу данных через PostgreSQL COPY protocol. При этом CopyFromSource позволяет не собирать весь набор записей в один большой срез перед отправкой в базу. Go Packages
Я бы избегал выполнения отдельного SQL INSERT для каждой строки, особенно если речь идёт о десятках тысяч записей.
Но и загрузка через COPY сама по себе не означает, что данные готовы к применению.
Допустим, в файле встретились две строки с одинаковым товарным кодом:
000741 | Кабель 2 м 000741 | Кабель 3 м
Обе строки корректны по отдельности.
Но вместе они создают неоднозначность.
Какую из них считать правильной?
Можно договориться, что последняя строка имеет приоритет. Однако это уже бизнес-правило, а не техническая особенность PostgreSQL.
Я бы для справочников по умолчанию отклонял такие файлы, чтобы пользователь самостоятельно исправил конфликт.
Обнаружить дубликаты после загрузки в staging довольно просто:
SELECT code, COUNT(*) AS duplicates FROM catalog_import_rows WHERE import_id = $1 GROUP BY code HAVING COUNT(*) > 1;
Почему не оставить эту работу ON CONFLICT DO UPDATE?
Потому что PostgreSQL не позволяет одной команде UPSERT изменять одну и ту же целевую строку несколько раз.
Если исходный набор содержит несколько строк с одинаковым конфликтующим ключом, операция может завершиться ошибкой нарушения cardinality.
Это не баг PostgreSQL. Это часть семантики INSERT ... ON CONFLICT DO UPDATE. PostgreSQL
Поэтому проверять уникальность нужно ещё до применения импорта.
Причём проверка должна выполняться по нормализованным бизнес-ключам.
Если коды ABC123 и abc123 считаются одинаковыми, то обнаруживать конфликт нужно с учётом этого правила.
А вот автоматически удалять ведущие нули или другие значимые символы нельзя, если такая нормализация не предусмотрена контрактом справочника.
После загрузки и валидации staging-таблицы появляется возможность сравнить файл с актуальным состоянием справочника.
Предположим, рабочая таблица устроена так:
CREATE TABLE catalog_items ( tenant_id UUID NOT NULL, catalog_id UUID NOT NULL, code TEXT NOT NULL, title TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY ( tenant_id, catalog_id, code ) );
Теперь можно получить предварительные изменения:
SELECT s.code, CASE WHEN c.code IS NULL THEN 'added' WHEN c.title IS DISTINCT FROM s.title OR NOT c.active THEN 'changed' ELSE 'unchanged' END AS change_type FROM catalog_import_rows s LEFT JOIN catalog_items c ON c.code = s.code AND c.tenant_id = $2 AND c.catalog_id = $3 WHERE s.import_id = $1;
Здесь мы ещё ничего не меняем.
Только определяем, какие записи новые, какие нужно обновить, а какие уже совпадают.
Отдельным запросом можно найти товары, присутствующие в рабочем справочнике, но отсутствующие в загруженном файле.
Я бы не называл их удалёнными. Пока это только кандидаты на деактивацию.
Теперь пользователь может увидеть результат проверки:
Файл: catalog_october.xlsx Всего строк: 82 417 Новых товаров: 1 203 Изменённых: 3 481 Без изменений: 77 733 Отсутствуют в файле: 926 Ошибок: 0
Цифры здесь условные, просто пример ответа API.
Для небольшого справочника такой предварительный просмотр может показаться избыточным.
Но представим файл, после загрузки которого система собирается деактивировать 90% товаров.
Возможно, пользователь действительно хочет провести массовое обновление.
А возможно, он случайно загрузил неполную выгрузку.
Лучше показать это до изменения данных.
Причём для особо опасных операций я бы вводил дополнительные ограничения. Например, если доля предполагаемых деактиваций превышает заданный порог, требовать отдельного подтверждения или вообще переводить импорт на ручную проверку.
Сам порог должен зависеть от продукта, а не быть универсальным числом.
Теперь рассмотрим менее очевидную проблему.
Пользователь загрузил файл и получил предварительный diff.
На момент проверки в справочнике было 10 тысяч товаров.
Система показала, что будут обновлены 500 позиций.
Пользователь оставил страницу открытой и вернулся к ней через десять минут.
За это время другой сотрудник организации успел применить ещё один импорт.
Теперь предварительный diff уже не соответствует актуальному состоянию справочника.
Если первый пользователь нажмёт «Применить», мы рискуем затереть более свежие изменения.
Это обычная гонка между чтением и записью.
И здесь я бы использовал версионирование справочника.
Например, у каждого справочника есть поле revision, которое увеличивается при изменении данных.
Когда приложение рассчитывает diff, оно сохраняет номер текущей ревизии.
Допустим:
import_id: 7b8e... catalog_revision: 42 status: ready_to_apply
Перед применением изменений backend открывает транзакцию, блокирует запись соответствующего справочника через SELECT ... FOR UPDATE и проверяет его текущую ревизию.
Если ревизия по-прежнему равна 42, можно продолжать.
Если уже стала 43, старый preview считается недействительным.
Нужно пересчитать изменения и показать пользователю новый результат.
Важно, чтобы все операции записи, способные изменить этот справочник, использовали один и тот же механизм управления ревизией. Иначе блокировка только в обработчике импорта не защитит от изменений, выполненных через другие API.
Так мы избегаем ситуации, когда два пользователя одновременно подтверждают изменения, рассчитанные относительно одного и того же устаревшего состояния.
Предположим, файл прошёл валидацию, предварительные изменения подтверждены, а ревизия справочника не изменилась.
Теперь можно применить новые и изменённые записи.
Для этого достаточно UPSERT:
INSERT INTO catalog_items ( tenant_id, catalog_id, code, title, active ) SELECT $2, $3, s.code, s.title, TRUE FROM catalog_import_rows s WHERE s.import_id = $1 ON CONFLICT ( tenant_id, catalog_id, code ) DO UPDATE SET title = EXCLUDED.title, active = TRUE WHERE catalog_items.title IS DISTINCT FROM EXCLUDED.title OR NOT catalog_items.active;
Такой запрос создаст отсутствующие товары и обновит изменившиеся.
При этом записи, которые уже совпадают с данными файла, не будут получать лишнее обновление.
Но ещё раз: перед этим staging-таблица должна пройти проверку уникальности кодов.
В реальной системе сама транзакция применения должна включать проверку ревизии, необходимые изменения справочника, запись истории импорта и увеличение ревизии.
Если одна из операций не удалась, транзакция откатывается.
Я бы не выполнял чтение XLSX внутри этой транзакции. Это лишь увеличит время удержания соединения и блокировок.
Загрузка файла, парсинг и подготовка staging-данных происходят заранее.
Транзакция нужна непосредственно для применения уже проверенного набора изменений.
Для очень крупных справочников, где единый массовый UPSERT создаёт неприемлемую нагрузку, есть другой подход.
Можно формировать новую неизменяемую версию справочника, а затем атомарно переключать активную версию в отдельной таблице.
Читатели при этом должны всегда обращаться к данным через идентификатор активной версии.
Такой вариант требует больше места и усложняет модель хранения, но позволяет отделить длительную подготовку данных от короткой операции переключения версии.
Вернёмся к товару 000743, которого не оказалось в новом файле.
Самое опасное решение выглядит примерно так:
DELETE FROM catalog_items WHERE code NOT IN ( SELECT code FROM catalog_import_rows WHERE import_id = $1 );
Этот запрос я бы вообще не использовал в многопользовательском SaaS в таком виде.
Во-первых, он не учитывает границы организации и справочника.
Во-вторых, предполагает, что загруженный файл содержит абсолютно все актуальные позиции.
В-третьих, физическое удаление может нарушить связи с заказами, документами или историческими операциями.
Вместо этого я бы явно различал два режима импорта.
Частичное обновление добавляет и изменяет записи, но не трогает отсутствующие.
Полный снимок может дополнительно деактивировать записи, которых нет в новой версии.
Причём режим должен быть выбран и подтверждён заранее. Нельзя пытаться угадать его только по содержимому файла.
Для полного снимка операция деактивации может выглядеть так:
UPDATE catalog_items c SET active = FALSE WHERE c.tenant_id = $2 AND c.catalog_id = $3 AND c.active = TRUE AND NOT EXISTS ( SELECT 1 FROM catalog_import_rows s WHERE s.import_id = $1 AND s.code = c.code );
Этот запрос имеет смысл выполнять только после проверки, что импорт относится к нужному справочнику, содержит полный снимок и получил необходимое подтверждение.
Разумеется, в рабочем API нельзя позволять клиенту самостоятельно подставлять произвольные tenant_id, catalog_id и import_id.
Связь импорта с организацией и справочником должна быть проверена на сервере.
С повторными запросами тоже есть интересный нюанс.
Допустим, пользователь нажал «Применить».
Backend успешно выполнил транзакцию, но соединение прервалось до того, как frontend получил ответ.
Пользователь нажимает кнопку ещё раз.
Простой UPSERT, скорее всего, не изменит уже совпадающие записи, но это не означает, что вся операция безопасна для повторения.
Например, применение импорта может дополнительно отправлять уведомления, создавать записи аудита или запускать другие процессы.
Поэтому я бы фиксировал состояние самого импорта и не допускал повторного применения одной и той же подтверждённой операции.
Условно:
uploaded | v validated | v ready_to_apply | v applied
В состоянии applied повторный запрос должен возвращать существующий результат, а не создавать новое выполнение.
При этом важно различать идентичность файла и идентичность операции.
SHA-256 исходного XLSX полезен для определения, что пользователь повторно загрузил тот же бинарный файл.
Но два файла с одинаковыми таблицами могут иметь разные хеши из-за форматирования, метаданных или порядка внутренних XML-структур.
Если нужна дедупликация именно содержимого, придётся рассчитывать отдельный хеш нормализованных данных.
Например, отсортировать строки по стабильному бизнес-ключу и хешировать каноническое представление значений.
Но такой хеш имеет смысл только после валидации и определения правил нормализации.
Иначе можно случайно признать одинаковыми два файла, которые на самом деле отличаются значимыми для бизнеса данными.
До сих пор речь шла в основном о корректности данных.
Но есть и другая сторона: безопасность обработки самого XLSX.
Файл Excel внутри представляет собой ZIP-архив с XML и другими ресурсами.
Поэтому ограничение размера HTTP-загрузки ещё не гарантирует, что обработка файла потребует приемлемое количество памяти.
Небольшой архив может содержать значительно больший объём распакованных данных.
Кроме того, ошибки могут находиться внутри самой библиотеки парсинга.
Например, 16 сентября 2026 года в базе уязвимостей Go был опубликован отчёт GO-2026-6453, связанный с Excelize.
В версиях до 2.11.0 специально подготовленный XLSX мог содержать некорректно большой индекс строки. При определённых условиях это приводило к чрезмерному выделению памяти во время обработки листа.
В Excelize 2.11.0 проблема исправлена. Go Packages
Для меня это дополнительный аргумент в пользу того, чтобы не обрабатывать произвольные пользовательские XLSX непосредственно внутри критичных HTTP-обработчиков.
Разумнее выносить импорт в отдельный процесс или worker с ограничениями времени, памяти, размера файла и количества обрабатываемых строк.
Обновление библиотеки тоже необходимо, но само по себе не заменяет ресурсные ограничения.
Особенно если речь идёт о SaaS, где файл загружает не доверенный администратор сервера, а обычный пользователь приложения.
Если убрать детали реализации, основное отличие надёжного импорта от простого скрипта заключается в моменте изменения рабочих данных.
В простом варианте мы читаем строку и сразу применяем изменение.
В более устойчивом варианте сначала формируем полный набор потенциальных изменений, проверяем его и только потом применяем.
Это позволяет решить несколько проблем, которые невозможно надёжно обработать внутри обычного цикла чтения XLSX.
Можно обнаружить дубликаты до записи. Можно понять, какие данные действительно изменятся. Можно показать пользователю неожиданно большое количество удаляемых позиций. Можно проверить, не устарело ли состояние справочника с момента предварительного просмотра.
И главное, ошибочный файл не должен автоматически менять рабочие данные только потому, что все его строки удалось прочитать без ошибок.
Здесь нет какой-то особенной магии Excel или Go.
По сути, это тот же подход, который мы применяем при работе с внешними API, миграциями и пакетными обновлениями данных.
Сначала проверяем входные данные и определяем предполагаемые изменения.
Затем проверяем, допустимы ли они в текущем состоянии системы.
И только после этого фиксируем результат.
Поэтому я бы не начинал проектирование XLSX-импорта с вопроса, какую библиотеку использовать.
Гораздо важнее заранее ответить на другой вопрос: что произойдёт с существующими данными, если пользователь загрузит неполный, устаревший или просто неправильный файл?
Если ответ на него определён, выбрать библиотеку и написать SQL обычно уже не самая сложная часть.