Введение
Массовая вставка PostgreSQL Go - это не выбор между COPY и INSERT. Это проектирование конвейера загрузки, который держится под нагрузкой с повторными попытками, предсказуемо обрабатывает дубликаты и нормально работает с пулом соединений. В боевых высоконагруженных системах наивный "быстрый" подход часто обменивает краткосрочную пропускную способность на долгосрочный операционный долг. Согласно документации PostgreSQL по наполнению базы данных, COPY - рекомендованный метод для массовой загрузки, но эта рекомендация сопровождается важными оговорками, которые большинство туториалов полностью обходят стороной.
В этой статье мы разбираем паттерны, которые реально используем в Вебдело при построении и сопровождении высоконагруженных бэкенд-систем. Покрываем PostgreSQL COPY Go, INSERT ON CONFLICT PostgreSQL, pgx CopyFrom, промежуточные таблицы PostgreSQL и загрузку данных при высокой нагрузке в целом: выбор подхода к загрузке данных, подводные камни session scope с временными таблицами, давление на WAL, UNNEST как практичный компромисс и кодовые паттерны, которые мы рекомендуем для B2B боевых окружений.
Почему массовая вставка в PostgreSQL из Go - это не только про скорость
Первый вопрос к любому подходу к загрузке данных - не "как быстро он пишет?", а "что произойдёт, если он запустится дважды?" В событийно-ориентированных архитектурах, очередях сообщений и распределённых конвейерах загрузки пакет может быть доставлен повторно. Если путь вставки не обеспечивает идемпотентность, повторная доставка искажает данные. Скорость без корректности - не свойство боевого уровня.
Дублирующиеся записи во входящем потоке данных - отдельная проблема. Даже одна доставка может содержать дубликаты, если вы агрегируете из нескольких источников, обрабатываете сетевые повторы или обрабатываете пакеты из очереди «доставка хотя бы раз» (at-least-once). INSERT без какой-либо обработки конфликтов с удовольствием запишет каждый дубликат, и с последствиями будут разбираться ваши нижестоящие системы.
Поведение пула соединений добавляет ещё одно измерение. Сессионные объекты PostgreSQL - прежде всего временные таблицы - привязаны к одному бэкенд-соединению. Это важно, как только в схеме появляется pgxpool, потому что пул может направлять последовательные запросы к разным бэкенд-соединениям. Временная таблица, созданная в одном соединении, невидима в другом. Это не баг конфигурации, это то, как PostgreSQL работает на уровне протокола. Команды, занимающиеся разработкой сайтов и систем на PostgreSQL, сталкиваются с этой проблемой ежедневно.
Наконец, массовая вставка создаёт давление на весь кластер, а не только на целевую таблицу. Усиление записи в WAL, задержка репликации, обслуживание индексов, планирование autovacuum и статистика планировщика запросов - всё это меняется при вставке миллионов строк. Рассматривать массовую вставку как локальную SQL-задачу и игнорировать операционный контекст - один из наиболее распространённых источников инцидентов в боевом окружении, которые мы видим при аудите существующих систем.
COPY, INSERT, UNNEST и промежуточные таблицы: как мы выбираем подход к загрузке данных
Не существует единственного лучшего подхода к массовой вставке в PostgreSQL из Go. Правильный паттерн зависит от пяти вопросов: нужна ли идемпотентность? Каков размер пакета? Стоит ли PgBouncer перед базой данных? Насколько сложна логика разрешения конфликтов? Нужна ли устойчивость к сбоям (crash-safety) на уровне промежуточного хранения? Ответы ведут к разным частям таблицы выбора.
COPY - самый быстрый вариант с большим отрывом. Бенчмарки на 100 миллионах строк показывают, что COPY завершается за 316 секунд против 2 653 секунд для пакетного INSERT и 94 623 секунд для одиночного INSERT. Но COPY не имеет встроенной поддержки ON CONFLICT. Он пишет данные или завершается с ошибкой - семантики вставки с обновлением (upsert) нет.
Параметризованный INSERT VALUES с пакетной обработкой прост и хорошо работает для небольших пакетов. Ограничение - на уровне протокола: согласно спецификации frontend/backend-протокола PostgreSQL, количество параметров в Bind-сообщении кодируется как INT16, что ограничивает extended protocol до 65 535 параметров. При 10 столбцах на строку потолок достигается примерно на 6 500 строках. При 20 столбцах - около 3 200 строк.
| Сценарий | Рекомендованный паттерн |
|---|---|
| Массовая загрузка, обработка конфликтов не нужна | COPY через CopyFrom |
| Массовая загрузка с дедупликацией или вставкой с обновлением | COPY в промежуточную таблицу + INSERT ON CONFLICT |
| Средние пакеты (1k-50k строк) с ON CONFLICT | UNNEST + INSERT ON CONFLICT |
| Сложная политика конфликтов (PostgreSQL 15+) | COPY в промежуточную таблицу + MERGE |
| Поток событий или данные временных рядов | COPY в партиционированную таблицу |
Почему мы не формулируем это как "COPY против INSERT"
Представлять массовую вставку как бинарный выбор между COPY и INSERT - значит упускать главный вопрос. Подход через промежуточную таблицу - COPY во временную таблицу, затем SQL-шаг с разрешением конфликтов - сочетает скорость COPY на первом шаге с полным контролем политики конфликтов на втором. Он также открывает промежуточный слой для нормализации, дедупликации внутри пакета, валидации и обогащения до того, как данные вообще касаются целевой таблицы. Это подход, который мы используем по умолчанию для конвейеров загрузки, требующих и производительности, и операционной корректности.
Почему промежуточная таблица даёт и скорость, и контроль конфликтов
Подход через промежуточную таблицу работает в три шага: массовая загрузка данных во временную таблицу через COPY, применение разрешения конфликтов через INSERT ... ON CONFLICT или MERGE, затем автоматическая очистка временной таблицы. Каждый шаг имеет чёткую зону ответственности - именно в этом разделении источник производственной зрелости паттерна. Это подход, который мы отрабатывали на проектах в области интернет-маркетинга с высокими требованиями к надёжности.
Первый шаг пользуется полной скоростью COPY - без обслуживания индексов в промежуточной таблице, без проверок ограничений, без оценки конфликтов. Второй шаг работает со стабильным снимком входящего пакета, используя обычную SQL-семантику. Можно дедуплицировать внутри пакета до того, как он достигнет целевой таблицы, применять условные обновления, обрабатывать случаи MATCHED и NOT MATCHED независимо или обогащать записи через поиск по ключу. А поскольку INSERT ... ON CONFLICT обеспечивает атомарную семантику вставки с обновлением, весь конвейер загрузки безопасен для повторного запуска. Это особенно критично для систем, требующих профессионального SEO-продвижения в Москве.
Кодовый паттерн: COPY в промежуточную таблицу с INSERT ON CONFLICT через pgx
pgx реализует COPY через pgx.Conn.CopyFrom() или через pgxpool.Conn.CopyFrom() после явного вызова Acquire(). Перед использованием CopyFrom с enum-столбцами стоит проверить одну деталь: pgx использует бинарный wire-формат для COPY, что может вызывать ошибки несоответствия типов, если enum-типы не обрабатываются корректно на стороне Go.
// Получаем выделенное соединение из пула
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Создаём временную таблицу без копирования индексов из целевой
_, err = tx.Exec(ctx, `
CREATE TEMP TABLE staging_events (
id BIGINT,
user_id BIGINT,
event_type TEXT,
created_at TIMESTAMPTZ
) ON COMMIT DROP
`)
if err != nil {
return err
}
// Массовая загрузка через COPY
rows := make([][]interface{}, len(batch))
for i, e := range batch {
rows[i] = []interface{}{e.ID, e.UserID, e.EventType, e.CreatedAt}
}
_, err = conn.Conn().CopyFrom(
ctx,
pgx.Identifier{"staging_events"},
[]string{"id", "user_id", "event_type", "created_at"},
pgx.CopyFromRows(rows),
)
if err != nil {
return err
}
// Шаг разрешения конфликтов
_, err = tx.Exec(ctx, `
INSERT INTO events (id, user_id, event_type, created_at)
SELECT id, user_id, event_type, created_at FROM staging_events
ON CONFLICT (id) DO NOTHING
`)
if err != nil {
return err
}
return tx.Commit(ctx)
Условие ON COMMIT DROP гарантирует, что промежуточная таблица удаляется при коммите или откате транзакции. Никакого загрязнения сессии, никакого разрастания каталога от накопленных временных таблиц в соединениях пула.
MERGE для сложной политики конфликтов (PostgreSQL 15+)
Когда ON CONFLICT недостаточно выразителен - например, когда нужна разная логика обновления в зависимости от того, новее или старше входящая запись по сравнению с существующей - MERGE предоставляет единый атомарный оператор, способный обработать INSERT, UPDATE и DELETE за один проход. Он появился в PostgreSQL 15 (выпущен в октябре 2022 года) и является правильным инструментом, когда подход через промежуточную таблицу + ON CONFLICT потребовал бы нескольких условных операторов.
MERGE INTO events t
USING staging_events s ON (t.id = s.id)
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET
event_type = s.event_type,
updated_at = s.updated_at
WHEN NOT MATCHED THEN
INSERT (id, user_id, event_type, updated_at)
VALUES (s.id, s.user_id, s.event_type, s.updated_at);
Согласно документации PostgreSQL по MERGE, оператор также поддерживает условие WHEN MATCHED THEN DELETE, что делает его практичным вариантом для конвейеров загрузки полной синхронизации, которым нужно удалять записи, больше не присутствующие в источнике.
Что ломается в Go, если забыть про пул соединений и session scope
Временные таблицы в PostgreSQL - это объекты, привязанные к сессии. Они существуют в течение всего времени жизни бэкенд-соединения, которое их создало, и невидимы для всех других соединений. Когда приложение использует pgxpool, каждый вызов pool.Exec() или pool.Query() может попасть на другое бэкенд-соединение. Если создать временную таблицу в одном вызове и попытаться использовать её в следующем, появится ошибка "relation does not exist" - не потому что таблица была удалена, а потому что второй запрос ушёл на другое соединение, которое её никогда не видело. При работе с дизайном сайта это особенно важно, если используются сложные бэкенд-системы.
Для управления этим в Go существуют два корректных паттерна. Первый - pool.Begin(ctx): транзакция удерживает одно соединение от захвата до Commit или Rollback, поэтому все операции внутри транзакции видят одно и то же состояние сессии. Второй - pool.Acquire(ctx) с явным conn.Release() по завершении: полезен, когда нужно выполнить несколько операций в отдельных транзакциях на одном соединении.
Паттерн ON COMMIT DROP
Использование CREATE TEMP TABLE ... ON COMMIT DROP внутри транзакции - самый чистый способ работы с промежуточным хранением в пуловом окружении. Таблица создаётся и уничтожается в рамках одной транзакции, поэтому нет риска коллизий имён при параллельных вызовах загрузки и никакие записи о временных таблицах не накапливаются в системном каталоге. Для конвейеров загрузки с интенсивной записью разрастание каталога от неочищенных временных таблиц - реальная операционная проблема, которую этот паттерн полностью устраняет.
PgBouncer и кеш prepared statements в pgx
pgx по умолчанию кеширует prepared statements через механизм statement cache. В режиме transaction pooling PgBouncer исторически делал это несовместимым: операторы, подготовленные в одном соединении, недоступны после того, как PgBouncer переназначает вас на другой бэкенд. До PgBouncer 1.21 использование pgx с PgBouncer в transaction mode требовало полного отключения statement cache.
PgBouncer 1.21 ввёл отслеживание prepared statements на уровне протокола через max_prepared_statements, но, как объясняет анализ Crunchy Data, ручные вызовы DEALLOCATE могут по-прежнему нарушить схему. Самая безопасная конфигурация для pgx за любой версией PgBouncer в transaction mode остаётся следующей:
conn, err := pgx.Connect(ctx, connString)
// или через конфиг pgxpool:
config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
Simple protocol отключает statement cache и отправляет запросы как обычный текст, что работает с любой конфигурацией PgBouncer. Компромисс по производительности - планирование запроса при каждом выполнении, поэтому оцените это относительно ваших реальных паттернов запросов перед глобальным применением.
Почему LIKE INCLUDING ALL для промежуточной таблицы - плохая идея по умолчанию
CREATE TEMP TABLE staging LIKE target INCLUDING ALL - распространённое сокращение, которое копирует полную структуру целевой таблицы в промежуточный слой, включая все индексы, CHECK-ограничения, ограничения внешних ключей и статистику. Для промежуточной таблицы, существующей только для хранения данных во время массовой загрузки, это неправильный компромисс. COPY будет обслуживать каждый индекс при записи каждой строки, что устраняет большую часть преимущества в скорости, ради которого COPY вообще был привлекателен.
Обслуживание индексов при массовой загрузке имеет сложность O(N * количество_индексов). Если в целевой таблице пять индексов и вставляется миллион строк, COPY в промежуточную таблицу с LIKE INCLUDING ALL платит стоимость обновления индексов для каждого из пяти индексов на каждой строке. Промежуточная таблица без индексов пишет чистые, быстрые страницы данных и ничего больше. Это понимание критично при оптимизации для продвижения в Ai.
Когда включать отдельные опции
Есть законные случаи для выборочного включения. INCLUDING DEFAULTS полезен, когда промежуточная таблица должна наследовать значения столбцов по умолчанию из целевой. INCLUDING GENERATED важен при работе с вычисляемыми столбцами, которые должны рассчитываться во время переноса из промежуточной таблицы в целевую. Индексы в промежуточной таблице стоит создавать только если нужно дедуплицировать строки внутри пакета перед шагом разрешения конфликтов - и даже тогда создавайте только тот индекс, который поддерживает этот конкретный запрос дедупликации.
Практическое правило: прописывайте DDL промежуточной таблицы явно, с только теми столбцами и ограничениями, которые реально нужны. Это несколько лишних строк кода, которые избавят от трудноотлаживаемой регрессии производительности при эволюции структуры таблицы.
Где у bulk insert настоящее узкое место: индексы, foreign keys, WAL, ANALYZE и autovacuum
Большинство проблем производительности массовой вставки - не в самом SQL-операторе. Они в операционном окружении вокруг него. Индексы, внешние ключи, давление на WAL, устаревшая статистика планировщика и планирование autovacuum в совокупности определяют реальную пропускную способность конвейера загрузки - и именно это мы проверяем в первую очередь при анализе системы с проблемами производительности массовой вставки.
Индексы - наиболее прямой фактор. Каждая строка, вставляемая в таблицу, обновляет все её индексы. Для массовой вставки в существующую таблицу с несколькими индексами это операция O(N * количество_индексов). Документация PostgreSQL по наполнению базы данных явно рекомендует загружать данные в таблицы без индексов и добавлять индексы после при загрузке в пустые таблицы. Для рабочих таблиц подход через промежуточную таблицу достигает того же эффекта: промежуточная таблица не имеет индексов, поэтому COPY работает быстро, а обновления индексов происходят только на шаге INSERT в целевую таблицу.
Внешние ключи добавляют проверки ссылочной целостности для каждой вставляемой строки. Временное отключение триггеров FK-ограничений через ALTER TABLE DISABLE TRIGGER ALL - известная техника для больших массовых загрузок, но она требует аккуратного повторного включения и последующего ANALYZE. Используйте её только когда гарантия корректности данных обеспечивается в другом месте конвейера загрузки.
WAL-давление влияет на весь кластер. Большие массовые вставки генерируют значительный WAL-трафик, что увеличивает частоту контрольных точек и создаёт I/O-всплески. Увеличение max_wal_size позволяет PostgreSQL откладывать контрольные точки дольше, сглаживая I/O-паттерн. Увеличение maintenance_work_mem ускоряет любое создание индексов или перестройку FK, следующие за массовой загрузкой.
ANALYZE и autovacuum после загрузки данных
После большой массовой вставки статистика планировщика запросов для целевой таблицы устарела. Планы, оптимальные до загрузки, могут оказаться полностью неверными для вновь наполненных данных. Явный запуск ANALYZE после массовой вставки гарантирует, что планировщик видит точные оценки количества строк и распределения. Для автоматизированных конвейеров загрузки это стоит добавить как стандартный шаг после загрузки.
Планирование autovacuum - более тонкая проблема. Массовая вставка создаёт много живых строк, а autovacuum не запускается немедленно после операции с интенсивной записью. Накопленный долг проявляется позже: следующая волна операций UPDATE или DELETE на этой таблице порождает удалённые строки, и autovacuum может не справляться, если не настроен под таблицы с интенсивной записью. Мониторинг pg_stat_user_tables.n_dead_tup и корректировка autovacuum_vacuum_scale_factor для таблиц с высокой записью - часть операционной работы, которую требует зрелый конвейер загрузки данных.
Когда UNNEST, MERGE, UNLOGGED и партиционирование реально уместны
UNNEST - практичный компромисс для размеров пакетов от тысячи до пятидесяти тысяч строк. Вместо N*M параметров (строки * столбцы) UNNEST принимает один массив-параметр на столбец вне зависимости от количества строк в пакете. Текст запроса остаётся постоянным для всех размеров пакета, что делает кеширование prepared statements эффективным, а совместимость с PgBouncer - straightforward. Бенчмарки показывают, что UNNEST обрабатывает 100 миллионов строк за 533 секунды - медленнее 316 секунд COPY, но с полной поддержкой ON CONFLICT и без накладных расходов на промежуточную таблицу.
ids := make([]int64, len(batch))
userIDs := make([]int64, len(batch))
types := make([]string, len(batch))
timestamps := make([]time.Time, len(batch))
for i, e := range batch {
ids[i] = e.ID
userIDs[i] = e.UserID
types[i] = e.EventType
timestamps[i] = e.CreatedAt
}
_, err = pool.Exec(ctx, `
INSERT INTO events (id, user_id, event_type, created_at)
SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::text[], $4::timestamptz[])
AS t(id, user_id, event_type, created_at)
ON CONFLICT (id) DO NOTHING
`, ids, userIDs, types, timestamps)
Ограничение UNNEST - память: весь массив пакета материализуется в Go до отправки запроса. При очень больших пакетах (100k+ строк) подход через промежуточную таблицу + COPY будет быстрее и эффективнее по памяти. Правильный порог зависит от ширины строк и доступной памяти, но обычно мы переходим на промежуточную таблицу около 50k строк.
UNLOGGED-таблицы для промежуточного хранения
UNLOGGED-таблицы полностью пропускают записи в WAL, что делает их значительно быстрее для промежуточного хранения данных. Согласно документации PostgreSQL по CREATE TABLE, UNLOGGED-таблицы не являются устойчивыми к сбоям (crash-safety) (данные теряются при некорректном завершении работы) и не реплицируются на standby-серверы. Для промежуточной таблицы, которая хранит данные только на время операции массовой загрузки, эти компромиссы приемлемы. Для всего, что должно пережить перезапуск базы данных или появиться на read-реплике, используйте вместо этого обычную временную таблицу.
Партиционирование в конвейерах загрузки данных
Партиционирование таблиц помогает конвейерам загрузки данных способами, выходящими за рамки производительности запросов. Загрузка данных в конкретную партицию позволяет PostgreSQL пропускать проверки ограничений и обслуживание индексов для несвязанных партиций. Управление жизненным циклом данных через операции на уровне партиций - присоединение новой партиции, отключение и удаление старой - кардинально быстрее, чем выполнение DELETE на огромной таблице. Как отмечает документация PostgreSQL по партиционированию, ANALYZE на отдельных партициях также быстрее, чем ANALYZE на монолитной таблице, что важно для высокочастотных конвейеров загрузки данных, где актуальность статистики операционно значима.
Какие кодовые паттерны мы рекомендуем для B2B высоконагруженных проектов
Три паттерна для боевого окружения, которые мы используем, покрывают большинство сценариев загрузки данных. У всех трёх одно общее требование: явное управление соединениями. Нельзя корректно выполнять массовую вставку через удобные методы pgxpool, когда задействованы временные таблицы или COPY.
Паттерн 1: CopyFrom с явным Acquire
Используйте для чистой массовой загрузки без обработки конфликтов. Даёт максимальную пропускную способность записи при минимальной сложности кода.
func bulkInsert(ctx context.Context, pool *pgxpool.Pool, rows []EventRow) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire connection: %w", err)
}
defer conn.Release()
_, err = conn.Conn().CopyFrom(
ctx,
pgx.Identifier{"events"},
[]string{"id", "user_id", "event_type", "created_at"},
pgx.CopyFromSlice(len(rows), func(i int) ([]interface{}, error) {
r := rows[i]
return []interface{}{r.ID, r.UserID, r.EventType, r.CreatedAt}, nil
}),
)
return err
}
Паттерн 2: Промежуточная таблица через транзакцию с ON COMMIT DROP
Используйте, когда нужна семантика вставки с обновлением, дедупликация или любое разрешение конфликтов. Транзакция гарантирует привязку к соединению для всех трёх шагов (create, load, merge), а ON COMMIT DROP жёстко ограничивает жизненный цикл временной таблицы.
func upsertBatch(ctx context.Context, pool *pgxpool.Pool, rows []EventRow) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
CREATE TEMP TABLE staging_events (
id BIGINT, user_id BIGINT,
event_type TEXT, created_at TIMESTAMPTZ
) ON COMMIT DROP
`)
if err != nil {
return fmt.Errorf("create staging: %w", err)
}
// CopyFrom требует *pgx.Conn, а не pgx.Tx
// Получаем базовое соединение через pgxpool.Tx
ptx := tx.(*pgxpool.Tx)
_, err = ptx.Conn().CopyFrom(
ctx,
pgx.Identifier{"staging_events"},
[]string{"id", "user_id", "event_type", "created_at"},
pgx.CopyFromSlice(len(rows), func(i int) ([]interface{}, error) {
r := rows[i]
return []interface{}{r.ID, r.UserID, r.EventType, r.CreatedAt}, nil
}),
)
if err != nil {
return fmt.Errorf("copy: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO events (id, user_id, event_type, created_at)
SELECT id, user_id, event_type, created_at FROM staging_events
ON CONFLICT (id) DO NOTHING
`)
if err != nil {
return fmt.Errorf("merge: %w", err)
}
return tx.Commit(ctx)
}
Паттерн 3: UNNEST + ON CONFLICT
Используйте для пакетов от 1k до 50k строк, когда нужна простота единственного запроса, совместимость с prepared statement и ON CONFLICT без накладных расходов на промежуточную таблицу. Чисто работает с PgBouncer в transaction mode при настроенном simple protocol.
func unnestUpsert(ctx context.Context, pool *pgxpool.Pool, rows []EventRow) error {
ids := make([]int64, len(rows))
userIDs := make([]int64, len(rows))
types := make([]string, len(rows))
timestamps := make([]time.Time, len(rows))
for i, r := range rows {
ids[i] = r.ID
userIDs[i] = r.UserID
types[i] = r.EventType
timestamps[i] = r.CreatedAt
}
_, err := pool.Exec(ctx, `
INSERT INTO events (id, user_id, event_type, created_at)
SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::text[], $4::timestamptz[])
AS t(id, user_id, event_type, created_at)
ON CONFLICT (id) DO NOTHING
`, ids, userIDs, types, timestamps)
return err
}
Антипаттерны, которых стоит избегать
- N одиночных INSERT в цикле: один цикл запрос-ответ на строку, никакой пакетной обработки. Даже при быстром соединении это на порядки медленнее любого пакетного подхода.
- CREATE TEMP TABLE вне транзакции через пул: последующие операции попадут на другие соединения и упадут с "relation does not exist".
- LIKE INCLUDING ALL для промежуточной таблицы: импортирует все индексы и ограничения из целевой таблицы, превращая COPY в медленную индексированную операцию записи.
- Игнорирование лимита 65 535 параметров: когда размер пакета или количество столбцов растёт, параметризованный INSERT VALUES незаметно упирается в потолок протокола и возвращает ошибку.
Практический вывод: как выбрать безопасный путь массовой загрузки под свою систему
Правильный подход к загрузке данных для высоконагруженной системы определяется не синтетическим бенчмарком, а вашими операционными ограничениями. Пройдите по точкам принятия решений по порядку: нужна ли идемпотентность? Есть ли в стеке PgBouncer? Какова семантика конфликтов? Каковы требования к устойчивости к сбоям и репликации для промежуточных данных? Ответы сузят варианты до одного-двух паттернов, и выбор между ними станет очевидным.
| Сценарий | Паттерн |
|---|---|
| Простая массовая загрузка, без обработки конфликтов | COPY через CopyFrom с явным Acquire |
| Массовая загрузка с дедупликацией или вставкой с обновлением | COPY в промежуточную таблицу + INSERT ON CONFLICT внутри транзакции |
| Средние пакеты (1k-50k) с ON CONFLICT | UNNEST + INSERT ON CONFLICT |
| Сложная условная политика конфликтов (PG 15+) | COPY в промежуточную таблицу + MERGE |
| Высокочастотные временные ряды или поток событий | COPY в партиционированную таблицу |
| Промежуточное хранение без требований к устойчивости к сбоям | UNLOGGED промежуточная таблица |
Перед развёртыванием любого конвейера загрузки данных в боевое окружение пройдитесь по этому чеклисту:
- Операции с временными таблицами выполняются внутри транзакции или с явным Acquire/Release
- DDL промежуточной таблицы задаёт только необходимые столбцы, без LIKE INCLUDING ALL
- Размер пакета для UNNEST не превышает 50k строк, иначе используется промежуточная таблица + COPY
- pgx настроен на simple protocol при наличии PgBouncer в стеке
- ANALYZE вызывается после больших batch insert как шаг после загрузки
- Настройки autovacuum для таблиц с интенсивной записью проверены и откорректированы
- max_wal_size и maintenance_work_mem установлены подходящим образом для bulk-нагрузок
- Разрешение конфликтов идемпотентно - конвейер можно безопасно перезапустить
Операционная сторона - WAL-задержка, накопленный долг autovacuum, задержка репликации, дрейф статистики планировщика - требует такого же внимания при проектировании, как и сам путь записи. Системы, которые хорошо справляются с загрузкой данных в разработке, часто деградируют в боевом окружении, потому что операционный мониторинг и настройка были пропущены. Мониторинг pg_stat_replication на задержку, pg_stat_user_tables на накопление удалённых строк и pg_stat_progress_copy во время активных загрузок даёт раннюю видимость проблем, которые обычно проявляются через недели после запуска системы.
Если вы строите или проверяете высоконагруженную бэкенд-систему и ищете инженерного партнёра, который понимает подобную сложность боевого окружения - от архитектуры конвейера загрузки до долгосрочной эксплуатации PostgreSQL - команда Вебдело открыта для обсуждения вашего проекта. Мы работаем с B2B-компаниями, которым нужны надёжные, поддерживаемые системы, а не просто быстро отгруженный код. Свяжитесь с нами, если хотите обсудить дизайн своего конвейера загрузки данных или получить технический анализ текущего подхода.
Часто задаваемые вопросы
В чём главное различие между COPY и INSERT при массовой загрузке в PostgreSQL?
COPY работает значительно быстрее - бенчмарки показывают выполнение 100 миллионов строк за 316 секунд против 2 653 секунд для batch INSERT. Однако COPY не имеет встроенной поддержки ON CONFLICT для обработки конфликтов. Staging-паттерн сочетает скорость COPY с полным контролем разрешения конфликтов, сначала загружая во временную таблицу, затем используя INSERT ON CONFLICT для upsert-семантики.
Почему временные таблицы, созданные в одном соединении пула, становятся невидимы для других соединений?
Временные таблицы в PostgreSQL - это объекты, привязанные к сессии и существующие только в течение жизни соединения, которое их создало. При использовании pgxpool каждый вызов pool.Exec() или pool.Query() может обратиться к другому соединению. Для корректной работы держите операции с временными таблицами в рамках одной транзакции (pool.Begin) или используйте явный Acquire/Release для сохранения affinity соединения.
Что такое лимит в 65 535 параметров в extended protocol PostgreSQL и как он влияет на batch INSERT?
Frontend/backend протокол PostgreSQL кодирует количество параметров в Bind-сообщении как Int16, что ограничивает extended protocol до 65 535 параметров. При 10 столбцах на строку это означает, что batch INSERT достигает потолка примерно на 6 500 строках в одном statement; при 20 столбцах - примерно на 3 200 строках. Использование UNNEST или staging + COPY паттерна избегает этого ограничения, так как не полагается на параметризованный multi-row INSERT.
Почему следует избегать CREATE TEMP TABLE ... LIKE INCLUDING ALL при создании staging-таблиц?
Использование LIKE INCLUDING ALL копирует все индексы, ограничения и статистику из целевой таблицы в staging-таблицу. Это заставляет COPY обслуживать каждый индекс при записи каждой строки, превращая массовую загрузку в O(N * количество_индексов) операцию и устраняя большую часть преимущества скорости COPY. Вместо этого явно определите только те столбцы и ограничения, которые вам действительно нужны в staging-таблице.
Какие основные операционные факторы влияют на производительность массовой вставки помимо самого SQL-statement?
Настоящие bottleneck'и - это индексы, ограничения FK, давление на WAL, устаревшая статистика планировщика и планирование autovacuum. Обслуживание индексов - это O(N * количество_индексов), проверки FK добавляют overhead валидации, давление WAL влияет на весь кластер, устаревшая статистика вызывает неоптимальные планы запросов. Выполнение ANALYZE после массовой вставки и настройка параметров autovacuum для таблиц с высокой нагрузкой записи - это необходимые части production ingestion-пайплайнов.
Когда следует использовать UNNEST вместо staging + COPY паттерна для массовой вставки?
Используйте UNNEST для батчей размером 1 000-50 000 строк, когда вам нужна поддержка ON CONFLICT и вы хотите избежать overhead staging-таблицы. UNNEST требует один array-параметр на столбец независимо от размера батча, что делает подготовленные statement'ы эффективными и сохраняет текст запроса постоянным для совместимости с PgBouncer. При 100 миллионах строк UNNEST завершается за 533 секунды - медленнее, чем COPY (316 секунд), но с полной обработкой конфликтов и без сложности staging.
Каковы компромиссы использования UNLOGGED staging-таблиц для массовой вставки?
UNLOGGED таблицы полностью пропускают запись в WAL, что значительно ускоряет staging-данные. Однако они не crash-safe - данные теряются при некорректном завершении базы данных - и не реплицируются на standby-серверы. Для staging-таблиц, которые содержат данные только во время операции массовой загрузки, эти компромиссы приемлемы, так как данные временные. Для production-систем, требующих durability и репликации, используйте обычные временные таблицы с ON COMMIT DROP вместо этого.