Содержание урока · 10 разделов
Загрузка логов за сутки, импорт прайса поставщика, перенос строк из другой системы — цикл INSERT здесь сдувается: сотни тысяч команд, каждая со своим рейсом, разбором и планом запроса. В Postgres есть встроенный механизм для такого объёма — команда COPY, а в pgx её обёртка CopyFrom. Инструмент честно измеряется: одна и та же загрузка на сто тысяч строк занимает через цикл Exec десятки секунд, а через CopyFrom — единицы, и разница растёт с объёмом; замер кодом покажу сразу, механику разберём после. Контракт: CopyFrom(ctx, tableName, columns, rowSrc) возвращает число вставленных строк и ошибку; tableName — pgx.Identifier из одной-двух частей, columns — список колонок, rowSrc — источник строк. COPY — не апгрейд batch из восемнадцатого урока, а другой инструмент: он умеет только вставку в таблицу, зато делает это на скорости, недостижимой для SQL-команд. И у скорости есть цена в жёсткости: конфликты уникальности COPY не разрешает — один дубликат откатывает всю загрузку, и решение через staging-таблицу станет финальным аккордом урока. Порядок такой: сигнатура и идентификатор таблицы, бенчмарк, механика выигрыша, источники строк, границы COPY и гигиена больших загрузок.
CopyFrom: сигнатура и Identifier#
Полная сигнатура: CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error). Identifier — тип на основе среза строк: одна часть — таблица, две — схема и таблица: pgx.Identifier{"public", "events"}. Метод Sanitize оборачивает каждую часть в кавычки и экранирует вложенные кавычки — имя с дефисом или заглавной буквой приезжает на сервер корректно, склейка SQL из строк не нужна. Список колонок задаётся явно: COPY вставляет только их, остальные получают дефолты, поэтому порядок значений в источнике обязан совпадать с порядком колонок — рассинхрон ловится ошибкой вида expected N values, got M. Возвращаемое число — RowsAffected команды COPY, количество реально вставленных строк. Вызов живёт и на пуле, и на транзакции: pool.CopyFrom берёт коннект на время копирования и возвращает его после, tx.CopyFrom делает загрузку частью транзакции из тринадцатого урока — атомарной с остальными изменениями. Привилегии жёстче, чем кажется: сервер требует INSERT на целевую таблицу, и это проверяется на первом прогоне, а не на ревью. NULL передаётся nil в слоте []any — на стороне сервера это честный NULL, типизированные нулевые обёртки из шестого урока здесь не нужны. Контекст действует как везде в pgx: дедлайн из пятнадцатого урока прерывает копирование, и операция откатывается целиком.
Бенчмарк на глазах: цикл против COPY
GOconst n = 100000
rows := make([][]any, 0, n)
for i := 0; i < n; i++ {
rows = append(rows, []any{fmt.Sprintf("event-%d", i), i})
}
start := time.Now()
for _, r := range rows {
_, err := conn.Exec(ctx,
"INSERT INTO events (name, seq) VALUES ($1, $2)",
r[0], r[1])
if err != nil {
log.Fatal(err)
}
}
fmt.Println("цикл INSERT:", time.Since(start))
start = time.Now()
count, err := conn.CopyFrom(ctx,
pgx.Identifier{"events"},
[]string{"name", "seq"},
pgx.CopyFromRows(rows))
fmt.Printf("CopyFrom: %s, строк %d, ошибка %v\n",
time.Since(start), count, err)Один и тот же набор строк едет двумя маршрутами с замером time.Since. Ожидание на локальной базе: цикл — десятки секунд, CopyFrom — единицы; на удалённой базе разница шире из-за рейсов. Цифры зависят от железа, схемы и индексов, поэтому запускайте замер сами и фиксируйте свой порядок величин — он пригодится в каждом споре об архитектуре загрузки.
Почему COPY обгоняет INSERT в разы#
Экономия складывается из трёх слагаемых. Первое — рейсы: один поток данных вместо сотни тысяч запросов-ответов, сеть перестаёт быть затычкой. Второе — разбор: INSERT сервер парсит и планирует каждый раз, даже с кэшем стейтментов из восемнадцатого урока; COPY разбирается один раз и дальше принимает строки без SQL вообще. Третье — формат: pgx шлёт данные в бинарном представлении COPY, значения кодируются по OID типов напрямую в байты, а не в текст протокола. Драйвер собирает строки в буферы примерно по 64 килобайта и отправляет потоком — в исходниках pgx это порог в 65536 байт минус заголовок пакета, деталь, объясняющая, почему память приложения не раздувается на миллионных выборках. Между циклом и COPY есть средняя ступенька: INSERT ... SELECT FROM unnest(массивов) — одна команда на пачку строк, без рейсов, но с разбором и планом; удобно на тысячах строк, когда данные уже лежат в Go-срезах. Далеко COPY не резиновый: сервер всё равно пишет страницы, строит индексы и выполняет триггеры, поэтому рост упирается в диск, а не в драйвер. Серверная сторона тоже выигрывает по сопровождению: один оператор в журнале вместо сотни тысяч — парсер, планировщик и лог медленных запросов перестают захлёбываться INSERT-ами. Для практики достаточно порядка: тысячи строк в секунду через цикл против сотен тысяч через COPY — и замер на своих данных.
Проверка: возвращаемое значение
Что возвращает CopyFrom при успешном завершении?
CopyFromSource: Rows, Slice, Func#
Источник строк — интерфейс CopyFromSource из трёх методов: Next(), Values() и Err(). Готовых реализаций три. CopyFromRows(rows [][]any) оборачивает срез строк — всё в памяти, удобно для готовых данных и бенчмарков, но миллиард строк в RAM не поместится. CopyFromSlice(length, next func(i int) ([]any, error)) генерирует значения по индексу — вариант для вычисляемых наборов, где размер известен заранее. CopyFromFunc(next func() ([]any, error)) — потоковая передача: функция возвращает очередную строку, сигнализирует конец парой nil и nil, а ошибка из замыкания доезжает до Err() интерфейса и обрывает копирование. Именно CopyFromFunc делает загрузку из файла честным стримом: читаешь CSV построчно через bufio.Scanner и отдаёшь строки в COPY, не загружая файл в память целиком. Расхождение длины строки и списка колонок ловится на лету ошибкой expected N values — источник отвечает за согласованность с самим собой. Собственная реализация интерфейса тоже дёшево: три метода поверх вашего курсора, если данные тянутся из API, очереди или другого соединения — срез нужен не всегда. Для собственной реализации держите в голове контракт Next: ложь означает конец, поэтому ошибка должна возвращаться и в Err() — иначе драйвер примет сбой за конец данных и загрузка молча обрежется; встроенный CopyFromFunc прокладывает ошибку замыкания в Err автоматически.
Потоковая загрузка CSV
GOfunc importCSV(ctx context.Context, conn *pgx.Conn,
path string) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
src := pgx.CopyFromFunc(func() ([]any, error) {
if !sc.Scan() {
return nil, sc.Err()
}
parts := strings.Split(sc.Text(), ",")
return []any{parts[0], parts[1]}, nil
})
return conn.CopyFrom(ctx, pgx.Identifier{"events"},
[]string{"name", "seq"}, src)
}CopyFromFunc замыкается на сканере файла: каждая строка CSV превращается в []any на лету, пара nil и nil сигнализирует конец потока, ошибка сканера прерывает копирование через Err источника. Память приложения не зависит от размера файла — данные текут порциями, буфер драйвера держит около 64 килобайт. CopyFrom вернёт число загруженных строк.
Что вернёт CopyFrom?
Прочитайте код и выберите, что он выведет:
rows := [][]any{
{"a", 1},
{"b", 2},
{"c", 3, 4},
}
n, err := conn.CopyFrom(ctx,
pgx.Identifier{"events"},
[]string{"name", "seq"},
pgx.CopyFromRows(rows))
fmt.Println(n, err)Цикл, batch и COPY на одной шкале#
| Критерий | Цикл Exec | pgx.Batch | CopyFrom |
|---|---|---|---|
| Рейсов на строку | один на каждую | один на пачку | один на всё |
| Разбор SQL | на каждую команду | на каждую постановку | нет — поток данных |
| UPDATE и DELETE | да | да | нет — только вставка |
| Конфликты уникальности | ошибка строки | откат пачки | откат всего COPY |
| Память приложения | O(1) | O(N) очереди | O(1) с CopyFromFunc |
| Реалистичный объём | единицы | десятки–тысячи | тысячи–миллионы |
Границы COPY: только вставка и только в таблицу#
COPY умеет одно действие — вставлять строки, и от этого пляшут ограничения. UPDATE и DELETE командой COPY не выражаются: инкременты и синхронизации остаются за upsert из семнадцатого урока и batch из восемнадцатого. Цель — таблица: обычная, секционированная или foreign; обычное представление COPY не принимает — сервер ответит ошибкой, если только на вьюхе не объявлен INSTEAD OF INSERT-триггер. Вставка идёт напрямую и не проходит через те же пути, что INSERT: сервер выполняет триггеры и CHECK-ограничения на каждую строку — тяжёлый AFTER-триггер съест часть выигрыша, — а вот правила, rules, не запускает вовсе, и это документированное отличие от INSERT. Генерируемые колонки COPY не пишет: указать их в списке колонок нельзя — сервер ответит ошибкой; без списка колонок они пропускаются и вычисляются сами. Таблицы с row-level security COPY FROM пока не поддерживает — придётся разбивать на INSERT. Конфликтов уникальности COPY не разрешает: 23505 на любой строке обрывает операцию, всё скопированное откатывается, ретрай упрётся в тот же дубликат. В свежих версиях сервера у COPY появилась опция ON_ERROR для пропуска ошибочных строк, но pgx.CopyFrom строит команду сам и опций не выставляет — staging остаётся переносимым решением. Наконец, свои enum-типы требуют LoadType и регистрации в карте типов до копирования — значения кодируются по OID, и незнакомый тип ловится на первом запуске, а не в проде.
Девятьсот девяносто девять тысяч строк заехали, последняя оказалась дубликатом — и вся загрузка откатилась: время и диск впустую, оператор смотрит на 23505. Если источник данных не вычищен, то упадёт ровно на первом дубликате, каким бы дальним он ни был. Лекарство — staging: COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT DO NOTHING из неё в целевую — массовая загрузка переживает дубликаты, а целевая таблица защищена. Второй вариант — дедупликация в приложении по тем же ключам до копирования.
Правила больших загрузок#
- Десятки тысяч строк и больше — COPY, а не цикл и не batch
- Порядок значений источника обязан совпадать со списком колонок
- CopyFromRows — для данных в памяти, CopyFromFunc — для стрима из файла или API
- Дубликаты уронят загрузку: staging-таблица плюс INSERT ... ON CONFLICT
- Триггеры COPY выполняет, правила не запускает — поведение отличается от INSERT
- Собственные enum-типы требуют LoadType и регистрации до COPY
- COPY атомарен: сбой откатывает всё, ретрай безопасен
- Дедлайн на контексте: миллионная загрузка без таймаута зависает вместе с базой
Проверка: staging-паттерн
Прайс поставщика приходит с возможными дубликатами. Как загрузить его через COPY?
Соберите вызов CopyFrom
Восстановите вызов: идентификатор таблицы, колонки, источник.
Соберите потоковую загрузку
Расставьте строки: источник-стрим и вызов CopyFrom.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: COPY FROM
CopyFrom держит коннект с первого байта потока до финального CommandTag — досрочно отдавать его нечем, и транзакция вокруг него живёт столько же. Если гоните несколько миллионов строк параллельно из пула на восемь коннектов, то обычные запросы начнут ждать, а health-check — краснеть. Лечится порционированием: загрузка режется на куски по несколько сотен тысяч строк с передышкой между ними, либо под COPY выделяется отдельный пул с собственным MaxConns. Бюджет времени ставится контекстом: дедлайн из пятнадцатого урока обрывает копирование и откатывает его.
Гигиена массовых загрузок#
Скорость COPY сдвигает узкое место, и заботы меняются. Контекст получает дедлайн из пятнадцатого урока: загрузка без бюджета при затыке сети держит коннект и транзакцию неопределённо долго. Прогресс логируется по строкам из CopyFromFunc — на миллионе строк тишина в логе выглядит как зависание: счётчик через каждые сто тысяч строк превращает долгую загрузку из загадки в прогресс-бар. Индексы и уникальные ограничения замедляют каждую вставку: на известных тяжёлых загрузках применяют приём снятия индексов и пересоздания после, но это изменение схемы с рисками — сначала измерьте, окупается ли оно у вас. После первой большой загрузки прогоните ANALYZE, чтобы планировщик увидел новую статистику таблицы, — иначе планы строятся по старым оценкам; на порционных загрузках цикл повторяется, поэтому эти два шага стоит повесить на конец самой процедуры, а не на память дежурному. Итоговый чек-лист операции: staging для дубликатов, дедлайн, прогресс в логе, ANALYZE после — и загрузка становится скучной рутиной, чем и должна быть.
COPY — инструмент, после которого цикл INSERT для объёмов выглядит расточительством. Песочница урока не исполняет pgx: интерактивы и разбор кода она покажет, а бенчмарк гоняйте локально или в Docker с живым Postgres из первого урока. Дальше по курсу: в двадцатом — LISTEN/NOTIFY и события из базы; в семнадцатом — upsert, на котором держится staging-паттерн; в восемнадцатом — batch для разнородных команд, когда объём ещё не тянет на COPY; в шестнадцатом — разбор PgError, который вернёт сбой посреди потока. Домашнее задание: замерить свои цифры, поймать дубликат на последней строке и починить загрузку staging-таблицей.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- CopyFrom гонит бинарный поток строк через протокол COPY — на порядки быстрее цикла INSERT.
- Сигнатура: CopyFrom(ctx, pgx.Identifier, []string, CopyFromSource) (int64, error); int64 — число строк.
- CopyFromRows держит данные в памяти, CopyFromFunc стримит: nil и nil — конец, ошибка прерывает копирование.
- Порядок значений источника обязан совпадать со списком колонок — рассинхрон ловится ошибкой.
- COPY умеет только вставку в таблицу: UPDATE и синхронизации — за upsert и batch.
- Триггеры COPY выполняет, правила — нет; генерируемые колонки в список не указываются.
- Один дубликат откатывает всю загрузку: staging-таблица плюс INSERT ... ON CONFLICT спасает поток.
- Дедлайн на контексте, прогресс в логе и ANALYZE после — гигиена массовых загрузок.
Домашнее задание
Проведите бенчмарк из урока на своей машине: сто тысяч строк через цикл Exec и через CopyFrom, зафиксируйте разницу в комментарии. Вставьте дубликат в последнюю строку набора и убедитесь, что вся загрузка откатилась с 23505. Перепишите загрузку через staging-таблицу и INSERT ... SELECT ... ON CONFLICT — дубликат больше не мешает. Затем замените CopyFromRows на CopyFromFunc со сканером файла и проверьте память процесса на файле, который не влезает в RAM. В конце прогоните ANALYZE и сравните план запроса по загруженной таблице.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему COPY быстрее обычных INSERT?
Три слагаемых: одна поездка потока данных вместо рейса на каждую строку, отсутствие разбора и планирования SQL на строку, бинарная упаковка значений по типам. Драйвер собирает буферы примерно по 64 КБ. Выигрыш на порядки, но он упирается в диск, индексы и триггеры сервера — их цена остаётся, поэтому две таблицы с разной схемой дадут разные цифры на одном и том же наборе строк.
Можно ли COPY с ON CONFLICT?
Нет: протокол COPY не понимает разрешение конфликтов, любой дубликат завершает загрузку откатом. Решение — staging: COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT DO NOTHING или DO UPDATE в целевую. Так массовый объём переживает дубликаты, а гарантии целостности остаются на целевой таблице.
CopyFrom или batch — что выбрать?
Разнородные команды и десятки-сотни вставок — batch: разные типы результатов и гибкость. Однотипная вставка от тысяч строк — COPY: нет разбора SQL и рейсов на команду. Граница плавающая и зависит от данных; замер на своей базе решает быстрее любых правил.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup