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

Если повторный импорт тех же данных занимает столько же времени, сколько первый, узкое место может быть не в чтении файла, а в лишних обновлениях таблицы. На PostgreSQL 16 можно сохранить идемпотентность и сократить ненужную запись в таблицу, но универсальных цифр «до и после» нет: результат зависит от размера пакета, индексов, диска и доли действительно изменившихся строк.
Сначала определите, что именно должно повторяться безопасно
Идемпотентная загрузка даёт тот же итог в базе, если запустить её несколько раз с одинаковыми данными. Для этого у записи должен быть устойчивый ключ: например, идентификатор объекта во внешней системе. Если ключа нет или он нестабилен, ни пакетная вставка, ни upsert сами по себе не исключают дубли.
В примере ниже используется PostgreSQL 16 и таблица событий. Внешний идентификатор — первичный ключ, поэтому база не примет две строки с одним ключом. При конфликте строка обновится, но только если значение действительно изменилось.
CREATE TABLE IF NOT EXISTS imported_events (
external_id text PRIMARY KEY,
payload jsonb NOT NULL,
source_updated_at timestamptz NOT NULL
);
INSERT INTO imported_events AS current (
external_id,
payload,
source_updated_at
)
VALUES
('evt-1001', '{"status":"paid"}'::jsonb, '2025-01-15 10:00:00+00'),
('evt-1002', '{"status":"new"}'::jsonb, '2025-01-15 10:05:00+00')
ON CONFLICT (external_id) DO UPDATE
SET
payload = EXCLUDED.payload,
source_updated_at = EXCLUDED.source_updated_at
WHERE (current.payload, current.source_updated_at)
IS DISTINCT FROM (EXCLUDED.payload, EXCLUDED.source_updated_at);
Это рабочий фрагмент для PostgreSQL 16: два значения в `VALUES` показывают формат пакета, а приложение может передавать больше строк. Условие `IS DISTINCT FROM` сравнивает значения с учётом `NULL`; здесь поля объявлены `NOT NULL`, но такой способ сравнения остаётся корректным и при nullable-столбцах. Не переносите пример в прод без проверки типов, ключей и правил выбора актуальной версии записи.
Откуда берётся выигрыш — и чем за него платят
Upsert без условия обновляет конфликтующую строку даже тогда, когда новые значения совпадают со старыми. Это создаёт лишнюю работу для базы: запись новой версии строки, обслуживание индексов, WAL и последующая очистка устаревших версий. Условие в примере пропускает обновление, если сравниваемые поля не изменились. Оно не отменяет проверку уникального ключа и не делает импорт бесплатным.
Вариант загрузки выбирайте по форме потока и размеру пакета, а не по обещанию «быстрее в разы».
| Подход | Когда подходит | Цена и риск |
|---|---|---|
| Одна вставка на запись | Малый поток, простая обработка отдельных ошибок | Много запросов и накладных расходов на обмен с сервером |
| Пакетный INSERT с ON CONFLICT | Умеренные пакеты, когда данные помещаются в запрос и нужны атомарные upsert-операции | Большой запрос сложнее ограничивать по памяти; дубль ключа внутри одного пакета может привести к ошибке при обновлении конфликта |
| Загрузка во staging-таблицу, затем перенос | Крупный импорт, который можно обрабатывать отдельными этапами | Нужны дополнительное место, очистка staging и логика выбора одной строки на ключ |
Для больших входных файлов staging обычно удобнее, чем собирать гигантский `INSERT`: данные сначала попадают во временную или отдельную промежуточную таблицу, затем дедуплицируются по ключу и переносятся в целевую. Но сам переход на staging не гарантирует ускорения. Если основное время уходит на индексы целевой таблицы или ожидание блокировок, выигрыш будет небольшим.
Оптимизация здесь — не «записать побольше строк за раз», а перестать оплачивать повторную запись тех данных, которые не изменились.
Как получить цифры до и после без самообмана
Сначала зафиксируйте исходное поведение на одной и той же выборке, затем меняйте только один фактор. Если одновременно изменить размер пакета, индексы и способ загрузки, цифра улучшения не объяснит, что именно сработало.
- Соберите профиль входных данных. Запишите общее число строк, число уникальных ключей и долю новых, изменённых и неизменившихся записей. Проверьте, бывают ли несколько версий одного ключа в одном файле. Результат проверки — известны состав тестовой выборки и правило выбора актуальной строки.
- Измерьте исходный импорт. Зафиксируйте длительность от начала до завершения транзакции, число обработанных строк и ошибки. Повторите один и тот же сценарий несколько раз на одинаковой базе; отдельно отмечайте первый запуск и повторный, чтобы не смешивать прогрев кеша с эффектом изменения. Результат — есть сравнимые исходные замеры.
- Измените только способ записи. Например, добавьте условие, исключающее no-op обновления, не меняя одновременно размер пакета. Повторите тест с тем же входом и теми же условиями. Результат — видно, изменились ли время и число реально обновлённых строк.
- Проверьте варианты размера пакета. Сравнивайте несколько значений, выбранных под ограничения приложения и базы, а не объявляйте одно число универсальным. Учитывайте время сериализации, объём памяти и ошибки при повторе. Результат — найден рабочий диапазон, а не случайный рекорд одного прогона.
- Посмотрите на серверную сторону. Сопоставьте длительность запросов, ожидания блокировок и нагрузку на диск и процессор; для статистики запросов можно использовать расширение `pg_stat_statements`, если оно установлено и настроено. Результат — понятно, ускорился ли сам SQL или задержка осталась в приложении, сети либо ожидании ресурсов.
В отчёте укажите версию PostgreSQL, конфигурацию машины, размер данных, параллельность, состояние кеша и способ загрузки. Показывайте не только секунды, но и строки в секунду: `число обработанных строк / длительность в секундах`. Для сравнения времени можно считать изменение как `(время до − время после) / время до × 100%`. Это формула для ваших замеров, не обещание конкретного процента ускорения.
Что ломается первым
Одинаковый ключ встречается несколько раз в пакете
При `ON CONFLICT DO UPDATE` одна команда не может обновить одну и ту же целевую строку дважды. В результате запрос завершится ошибкой, если входной пакет содержит повтор ключа. До upsert оставьте одну запись на ключ — по явному правилу, например по максимальному времени изменения источника. Если две версии нельзя упорядочить надёжно, это проблема качества входных данных, а не настройка размера пакета.
Старое событие перетирает новое
Идемпотентность не равна защите от данных, пришедших не по порядку. Если пакет может содержать устаревшую версию объекта, сравнивайте время или номер версии источника и не позволяйте старой записи заменить новую. Нужная логика зависит от гарантий источника: время на разных системах может быть несопоставимо, а одинаковые временные метки не всегда задают порядок.
Параллельные запуски спорят за одни строки
Уникальный ключ защищает от дублей, но несколько импортов могут конкурировать за одни и те же записи. Если выросло время ожидания, проверьте параллельность, порядок обработки ключей и длительность транзакций. Сокращение пакета иногда уменьшает время удержания блокировок, но увеличивает число запросов — измеряйте оба эффекта.
Условие сравнения пропускает важное поле
Если в `WHERE` сравниваются не все поля, влияющие на состояние записи, импорт может оставить устаревшее значение. Состав сравнения должен соответствовать контракту данных. Если строка содержит крупный JSON, сравнение самого JSON тоже может иметь заметную цену: измерьте её отдельно и не заменяйте проверку хешем без анализа коллизий и правил расчёта хеша.
Идемпотентность — свойство операции, при котором повторное выполнение с теми же входными данными не меняет итоговое состояние после первого успешного выполнения. Она не означает, что повторный запуск не потребует ресурсов или не может завершиться ошибкой.
Что делать дальше
Начните с повторного импорта неизменившегося набора: это самый прямой способ увидеть цену пустых обновлений. Проверьте уникальный ключ, добавьте условие на фактическое изменение и сравните длительность, строки в секунду и нагрузку на диск. Затем отдельно испытайте staging и пакетирование, если данные велики. На проде следите за временем импорта, долей конфликтов, ожиданиями блокировок и размером WAL; инвалидация данных и проектирование повторных запусков после частичного сбоя — отдельные темы.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PostgreSQL 16: INSERT, PostgreSQL 16: pg_stat_statements
Частые вопросы
Можно ли считать загрузку идемпотентной, если в таблице есть уникальный ключ?
Насколько быстрее станет импорт после условия IS DISTINCT FROM?
Что делать, если PostgreSQL сообщает, что одна строка не может быть затронута повторно?
Подойдёт ли пример для другой версии PostgreSQL?
Безопасно ли запускать два импорта одновременно в проде?
Нужно ли удалять или пересоздавать индексы ради ускорения загрузки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.