Learning Platform
Глоссарий Troubleshooting
Урок 15.01 · 22 мин
Средний
insertupsertbulk-loadingperformance

INSERT, INSERT OR REPLACE/IGNORE и производительность вставки

Большая часть курса до этого была про чтение: запросы, оптимизатор, out-of-core исполнение. Но данные нужно ещё и записывать — наполнять таблицы, обновлять их, экспортировать. Этот модуль про запись, и начинается он с самой базовой операции — INSERT.

INSERT кажется тривиальным, но в колоночной аналитической СУБД у него есть нюансы, которые на больших объёмах решают всё. Этот урок разбирает формы INSERT, варианты разрешения конфликтов (INSERT OR REPLACE / INSERT OR IGNORE) и — главное — почему способ вставки влияет на скорость в сотни раз.


Базовый INSERT

Простейшая форма — вставка явных значений:

CREATE TABLE products (id INTEGER, name VARCHAR, price DECIMAL(10,2));

INSERT INTO products VALUES (1, 'Keyboard', 49.99);

-- несколько строк за один INSERT
INSERT INTO products VALUES
  (2, 'Mouse', 24.50),
  (3, 'Monitor', 199.00),
  (4, 'Webcam', 79.90);

Можно указать колонки явно — тогда порядок значений привязан к указанному списку, а пропущенные колонки получат NULL или значение по умолчанию:

INSERT INTO products (id, name) VALUES (5, 'Headset');
-- price будет NULL

DuckDB поддерживает удобное расширение INSERT INTO ... BY NAME — сопоставление по именам колонок, а не по позиции. Это полезно, когда источник даёт колонки в другом порядке или не все:

INSERT INTO products BY NAME
  SELECT 'Cable' AS name, 6 AS id;
-- сопоставление по имени: name -> name, id -> id; price -> NULL

INSERT из запроса: основной способ загрузки

Вставка явных VALUES — для мелочи. Реальная загрузка данных в DuckDB — это INSERT INTO ... SELECT: результат запроса целиком отправляется в таблицу. Источником SELECT может быть другая таблица, файл, DataFrame — что угодно.

-- из другой таблицы
INSERT INTO products_archive SELECT * FROM products WHERE price < 50;

-- прямо из Parquet-файла, без промежуточных шагов
INSERT INTO sales SELECT * FROM 'new_sales_2026.parquet';

-- из CSV
INSERT INTO events SELECT * FROM read_csv('events.csv');

Это и есть штатный способ массовой загрузки: одна команда переносит миллионы строк. Внутри она обрабатывается векторизованно — данные идут чанками по ~2048 значений, сжимаются и пишутся колоночными сегментами.

Родственная конструкция — CREATE TABLE AS SELECT (CTAS): создаёт таблицу и сразу наполняет её результатом запроса.

CREATE TABLE top_products AS
SELECT * FROM products ORDER BY price DESC LIMIT 100;
Способы наполнить таблицу
INSERT VALUESЯвные значения; подходит для единичных строк
INSERT ... SELECTРезультат запроса целиком в существующую таблицу; основной способ массовой загрузки
CREATE TABLE AS SELECTСоздаёт таблицу и сразу наполняет её результатом запроса

Производительность вставки: почему пакетность критична

Здесь — главный практический урок. Способ вставки определяет скорость с разницей в сотни раз. Причина — в колоночной архитектуре DuckDB.

DuckDB хранит данные колоночными сегментами, сжатыми, с zonemap-статистикой. Запись эффективна, когда данные приходят большими порциями: можно набрать полный чанк, выбрать схему сжатия, записать сегмент. Каждая отдельная транзакция вставки несёт фиксированные накладные расходы — оформление транзакции, обновление метаданных, потенциальная запись в WAL.

Сравним два подхода загрузить миллион строк.

Антипаттерн — построчно, миллион транзакций:

# ПЛОХО: миллион отдельных INSERT, каждый в своей транзакции
for row in million_rows:
    con.execute("INSERT INTO t VALUES (?, ?)", row)
# фиксированные накладные расходы умножаются на 1 000 000

Правильно — одной пакетной операцией:

# ХОРОШО: одна команда, весь объём разом
con.execute("INSERT INTO t SELECT * FROM source_data")
# или вставка DataFrame целиком через replacement scan:
con.execute("INSERT INTO t SELECT * FROM my_dataframe")

Разница не «немного»: построчная загрузка миллиона строк может занять минуты, пакетная — доли секунды. На колоночном движке построчная вставка борется с самой архитектурой.

WARNING

Построчная вставка в цикле — самая частая причина «DuckDB медленно пишет». Это не DuckDB медленная — это построчный паттерн противоречит колоночному хранилищу. Всегда стремитесь вставлять данные пакетами: INSERT … SELECT, загрузка файла целиком, вставка DataFrame одной командой. Если данные поступают потоком — накапливайте их в буфер и вставляйте крупными порциями.

Если построчность неизбежна (данные приходят по одной записи), есть промежуточные решения: оборачивать много INSERT в одну транзакцию (BEGIN ... много INSERT ... COMMIT) — это убирает накладные расходы на транзакцию с каждой строки; или, в Python, использовать механизм Appender для эффективной потоковой дозаписи. Но базовый принцип неизменен: чем крупнее порция, тем быстрее.

ПодходСкорость загрузки 1 млн строкКогда применять
Построчный INSERT, транзакция на строкуминутыпрактически никогда
Много INSERT в одной транзакциизаметно лучшеесли построчность неизбежна
INSERT … SELECT / загрузка файла / DataFrameдоли секундывсегда, когда возможно

Разрешение конфликтов: INSERT OR REPLACE и INSERT OR IGNORE

Обычный INSERT при нарушении ограничения (например, дубликат по PRIMARY KEY или UNIQUE) завершается ошибкой и откатывается. Часто это не то, что нужно: при загрузке данных хочется либо пропустить дубликаты, либо перезаписать их. DuckDB даёт два модификатора.

INSERT OR IGNORE — при конфликте строка молча пропускается, остальные вставляются. Полезно для идемпотентной дозагрузки: «вставь то, чего ещё нет».

CREATE TABLE users (id INTEGER PRIMARY KEY, email VARCHAR);
INSERT INTO users VALUES (1, '[email protected]'), (2, '[email protected]');

-- id=2 уже есть, id=3 новый
INSERT OR IGNORE INTO users VALUES (2, '[email protected]'), (3, '[email protected]');
-- строка с id=2 ПРОПУЩЕНА (email остался [email protected]... нет, [email protected]),
-- строка с id=3 ВСТАВЛЕНА

SELECT * FROM users ORDER BY id;
-- 1  [email protected]
-- 2  [email protected]     <- не изменилась, конфликт проигнорирован
-- 3  [email protected]     <- вставлена

INSERT OR REPLACE — при конфликте существующая строка заменяется новой. Полезно, когда новые данные должны побеждать: «вставь или обнови».

INSERT OR REPLACE INTO users VALUES (2, '[email protected]'), (4, '[email protected]');
-- строка с id=2 ЗАМЕНЕНА на новую,
-- строка с id=4 ВСТАВЛЕНА

SELECT * FROM users ORDER BY id;
-- 1  [email protected]
-- 2  [email protected]   <- заменена
-- 3  [email protected]
-- 4  [email protected]         <- вставлена
Поведение при конфликте по ключу
INSERTОбычная вставка: при конфликте по ключу — ошибка и откат всей операции
INSERT OR IGNOREПри конфликте конфликтная строка молча пропускается, существующая не меняется
INSERT OR REPLACEПри конфликте существующая строка заменяется новой

Эти модификаторы — короткая форма более общего механизма INSERT ... ON CONFLICT, который позволяет точно задать, что делать при конфликте (вплоть до условного обновления отдельных колонок). INSERT OR IGNORE эквивалентен ON CONFLICT DO NOTHING, INSERT OR REPLACEON CONFLICT DO UPDATE со всеми колонками. Полная форма даёт больше контроля: можно обновлять не все колонки, а только некоторые, и ссылаться на конфликтующую строку:

-- обновить только email и счётчик, остальные колонки не трогать
INSERT INTO users (id, email, visits)
VALUES (2, '[email protected]', 1)
ON CONFLICT (id) DO UPDATE
  SET email = excluded.email,
      visits = users.visits + 1;
-- excluded — это строка, которую пытались вставить;
-- users — существующая строка

Для сложной логики слияния (произвольное условие, не только по ключу) в DuckDB 1.4 появился MERGE INTO — ему посвящён отдельный урок этого модуля.


Идемпотентность загрузки

INSERT OR IGNORE важен в практическом смысле — он делает загрузку идемпотентной. Идемпотентная операция даёт один и тот же результат, сколько бы раз её ни повторили. Для пайплайнов данных это ценное свойство: если загрузка упала на середине и её перезапустили, не должно появиться дублей.

Сравним. Обычный INSERT идемпотентным не является: повторный запуск либо упадёт на дубликатах (если есть PRIMARY KEY), либо насоздаёт дубли (если ключа нет). INSERT OR IGNORE при повторе просто пропустит уже существующие строки и доставит только недостающие — повторный запуск безопасен.

-- эту команду можно запускать сколько угодно раз подряд:
-- результат одинаков, дублей не будет
INSERT OR IGNORE INTO loaded_events
SELECT * FROM read_parquet('daily_batch.parquet');

Это конкретная причина, почему INSERT OR IGNORE (или MERGE INTO для более сложных случаев) предпочитают обычному INSERT в повторяемых пайплайнах: повтор после сбоя не ломает данные.

NOTE

INSERT OR IGNORE и INSERT OR REPLACE требуют, чтобы у таблицы было ограничение (PRIMARY KEY или UNIQUE) — именно по нему определяется конфликт. Без такого ограничения «конфликту» неоткуда взяться, и модификатор не на что опереться. MERGE INTO, напротив, работает по произвольному условию слияния и наличия ключа не требует — это его принципиальное отличие.


Попробуй сам

  1. Создай таблицу inventory(sku VARCHAR PRIMARY KEY, qty INTEGER) и вставь несколько строк через одну команду INSERT ... VALUES.
  2. Сделай INSERT OR IGNORE набора, где часть sku уже существует, а часть новые. Проверь SELECT *: пропустились ли существующие, добавились ли новые?
  3. Сделай INSERT OR REPLACE похожего набора. Сравни с предыдущим шагом: чем поведение при конфликте отличается?
  4. Замерь производительность. Создай таблицу t(id INTEGER, v DOUBLE). Вставь 200 тысяч строк двумя способами: (а) циклом по одной строке, (б) одной командой INSERT INTO t SELECT range, random() FROM range(200000). Засеки время каждого. Во сколько раз отличается?
  5. Попробуй обернуть построчный цикл из шага 4 в одну транзакцию (BEGIN перед циклом, COMMIT после). Стало ли быстрее построчной вставки без транзакции? Объясни, какие накладные расходы убрала единая транзакция.
dbt incremental: стратегии upsert через INSERT OR REPLACE
Проверка знанийKnowledge check
Почему построчная вставка в цикле работает в DuckDB на порядки медленнее пакетной, и какие способы вставки правильны для массовой загрузки?
ОтветAnswer
Построчная вставка медленна из-за колоночной архитектуры DuckDB. Данные хранятся колоночными сегментами — сжатыми, с zonemap-статистикой; запись эффективна, когда данные приходят большими порциями: можно набрать полный чанк (~2048 значений), выбрать схему сжатия, записать сегмент. Каждая отдельная транзакция вставки несёт фиксированные накладные расходы — оформление транзакции, обновление метаданных, потенциальную запись в WAL. При построчной вставке миллиона строк эти фиксированные расходы умножаются на миллион, и построчный паттерн борется с самой колоночной архитектурой. Разница огромна: построчная загрузка миллиона строк может занять минуты, пакетная — доли секунды. Правильные способы массовой загрузки: INSERT INTO ... SELECT (результат запроса целиком в таблицу — источником может быть таблица, файл, DataFrame), загрузка файла целиком (INSERT ... SELECT FROM 'file.parquet'), вставка DataFrame одной командой, CREATE TABLE AS SELECT. Если построчность неизбежна, промежуточные решения — обернуть много INSERT в одну транзакцию (BEGIN ... COMMIT убирает накладные расходы на транзакцию с каждой строки) или использовать Appender в Python. Но базовый принцип неизменен: чем крупнее порция, тем быстрее.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему построчная вставка в цикле работает в DuckDB на порядки медленнее пакетной?

Закончили урок?

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

Войдите чтобы оценить урок

Прогресс модуля
0 из 6