Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~20 мин +20 XP

COPY FROM: миллионы строк за секунды

Массовая вставка в Postgres через цикл INSERT упирается в арифметику сетевых рейсов, и здесь CopyFrom из pgx меняет порядок величин: бинарный поток строк по протоколу COPY заливает сотни тысяч строк за секунды. Разберём сигнатуру с pgx.Identifier, источники CopyFromRows и CopyFromFunc, границы COPY и staging-паттерн для дубликатов.

Содержание урока · 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

GO
const 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

GO
func 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 на одной шкале#

КритерийЦикл Execpgx.BatchCopyFrom
Рейсов на строкуодин на каждуюодин на пачкуодин на всё
Разбор 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, и незнакомый тип ловится на первом запуске, а не в проде.

Правила больших загрузок#

  • Десятки тысяч строк и больше — COPY, а не цикл и не batch
  • Порядок значений источника обязан совпадать со списком колонок
  • CopyFromRows — для данных в памяти, CopyFromFunc — для стрима из файла или API
  • Дубликаты уронят загрузку: staging-таблица плюс INSERT ... ON CONFLICT
  • Триггеры COPY выполняет, правила не запускает — поведение отличается от INSERT
  • Собственные enum-типы требуют LoadType и регистрации до COPY
  • COPY атомарен: сбой откатывает всё, ретрай безопасен
  • Дедлайн на контексте: миллионная загрузка без таймаута зависает вместе с базой

Проверка: staging-паттерн

Прайс поставщика приходит с возможными дубликатами. Как загрузить его через COPY?

Соберите вызов CopyFrom

Восстановите вызов: идентификатор таблицы, колонки, источник.

n, err := conn.(ctx, pgx.{"public", "events"}, []string{"name", "seq"}, pgx.(rows)) if err != nil { log.Fatal(err) } fmt.Println(n)

Соберите потоковую загрузку

Расставьте строки: источник-стрим и вызов CopyFrom.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: COPY FROM

Термин

CopyFrom

нажмите, чтобы перевернуть
Определение

Массовая вставка через протокол COPY: (ctx, Identifier, колонки, CopyFromSource) — возвращает число строк. Только вставка.

1 / 6

Гигиена массовых загрузок#

Скорость 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 и сравните план запроса по загруженной таблице.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · COPY FROM: миллионы строк за секунды)

Вопросы и ответы по уроку

Почему COPY быстрее обычных INSERT?

Три слагаемых: одна поездка потока данных вместо рейса на каждую строку, отсутствие разбора и планирования SQL на строку, бинарная упаковка значений по типам. Драйвер собирает буферы примерно по 64 КБ. Выигрыш на порядки, но он упирается в диск, индексы и триггеры сервера — их цена остаётся, поэтому две таблицы с разной схемой дадут разные цифры на одном и том же наборе строк.

Можно ли COPY с ON CONFLICT?

Нет: протокол COPY не понимает разрешение конфликтов, любой дубликат завершает загрузку откатом. Решение — staging: COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT DO NOTHING или DO UPDATE в целевую. Так массовый объём переживает дубликаты, а гарантии целостности остаются на целевой таблице.

CopyFrom или batch — что выбрать?

Разнородные команды и десятки-сотни вставок — batch: разные типы результатов и гибкость. Однотипная вставка от тысяч строк — COPY: нет разбора SQL и рейсов на команду. Граница плавающая и зависит от данных; замер на своей базе решает быстрее любых правил.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup