Learning Platform
Глоссарий Troubleshooting
Урок 08.02 · 18 мин
Начальный
unique_keyupsertdeduplicationmerge

В прошлом уроке мы поняли базовую механику incremental. Теперь — главный параметр, который определяет корректность инкрементальных обновлений: unique_key.

Если unique_key не задан или задан неправильно, incremental-модель быстро накапливает дубли или теряет обновления существующих строк. Это самая частая ошибка junior’ов в incremental.

Зачем нужен unique_key

Представь сценарий: ты делаешь incremental на orders. Каждая строка orders в raw имеет order_id и updated_at. Orders может обновиться в источнике: например, доставка изменила status. Тогда строка orders с тем же order_id приходит с новым status и новым updated_at.

Без unique_key dbt просто добавит новую строку — в твоей incremental-таблице будут две строки с одним и тем же order_id: старая и новая. Дубли.

С unique_key dbt знает: “если приходит строка с order_id=X, проверь, есть ли уже такой order_id, и если есть — обнови, если нет — добавь”. Это и есть upsert.

С unique_key vs без unique_key

Без unique_key — все новые строки идут INSERT'ом, даже если order_id уже существует. С unique_key — dbt различает новые и обновлённые, делает upsert (UPDATE существующих + INSERT новых).

Raw ordersrow1: order_id=5, status=shipped
Existing tablerow1: order_id=5, status=pending
Без unique_keyINSERT новую строку
Результат2 строки: order_id=5 pending и order_id=5 shipped
С unique_key='order_id'UPDATE существующую
Результат1 строка: order_id=5 shipped

Минимальный пример с unique_key

{{ config(
    materialized='incremental',
    unique_key='order_id'
) }}

SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}

{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}

При инкрементальном run dbt:

  1. Делает SELECT с WHERE — получает только новые/изменённые строки.
  2. Для каждой строки проверяет, есть ли уже такой order_id в таблице.
  3. Если есть — UPDATE (или DELETE+INSERT, в зависимости от стратегии).
  4. Если нет — INSERT.

Конкретная реализация зависит от incremental_strategy (следующий урок), но смысл unique_key одинаков: “вот колонка, по которой различаешь уникальные строки”.

unique_key может быть несколькими колонками

Иногда уникальность определяется композицией. Например, event_id сам по себе может повторяться, но (event_id, event_timestamp) уникален. Это пишется списком:

{{ config(
    materialized='incremental',
    unique_key=['event_id', 'event_timestamp']
) }}

dbt будет делать matching по обеим колонкам. Это эквивалент составного primary key.

Также unique_key может быть выражением:

{{ config(
    materialized='incremental',
    unique_key="coalesce(order_id, '-1')"
) }}

Тут coalesce обрабатывает NULL — все NULL сводятся к одной “псевдо-уникальной” строке. Это далеко не лучший паттерн (про NULL — ниже), но синтаксически возможно.

Что если unique_key не задан

Если unique_key не указать:

{{ config(materialized='incremental') }}

dbt используется стратегию append по умолчанию — каждый run просто INSERT новых строк. Никакой дедупликации, никаких updates. Подходит для append-only таблиц, где данные никогда не обновляются (event logs, page views, immutable events).

Если у тебя данные могут меняться (orders, users) — обязательно укажи unique_key.

WARNING

Самая частая ошибка: incremental без unique_key на таблице, где данные обновляются. Через неделю замечаешь, что в incremental-таблице 3M строк, а в raw — 1M. Дубли накопились, потому что dbt не понимал, как объединять. Решение: добавить unique_key и сделать —full-refresh.

NULL в unique_key — катастрофа

Главный gotcha: если unique_key колонка содержит NULL, поведение зависит от стратегии и warehouse. На большинстве:

  • merge — NULL не равен NULL, поэтому каждый NULL попадает как новая строка -> дубли.
  • delete+insert — то же самое, потому что DELETE WHERE col IN (…) не матчит NULL.
  • append — без unique_key всё равно, dubles.

Это значит, что колонка unique_key должна быть NOT NULL. Если в источнике могут быть NULL — отфильтруй их в SELECT:

SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}
WHERE order_id IS NOT NULL

Или используй coalesce с уникальным fallback:

SELECT
    coalesce(order_id, 'unknown-' || gen_random_uuid()) AS order_id,
    ...
FROM ...

На junior-курсе достаточно запомнить: NULL в unique_key = баги. Проверь источник, чтобы там было NOT NULL.

Сурогатный ключ для нетривиальной уникальности

Иногда естественного unique_key нет: в raw данные приходят без id, но уникальность определяется композицией нескольких колонок. Стандартный паттерн — surrogate key через хэш:

{{ config(
    materialized='incremental',
    unique_key='event_key'
) }}

SELECT
    {{ dbt_utils.generate_surrogate_key(['session_id', 'event_type', 'event_timestamp']) }} AS event_key,
    session_id,
    event_type,
    event_timestamp,
    payload
FROM {{ source('analytics', 'raw_events') }}

{% if is_incremental() %}
WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}

dbt_utils.generate_surrogate_key(['a', 'b', 'c']) — macro из пакета dbt_utils, создаёт md5-хэш конкатенации колонок. Это даёт стабильный, не-NULL ключ. Используется повсеместно в data engineering.

TIP

Surrogate key через хэш — стандартный паттерн в data engineering. Преимущество: стабильный ID, который не меняется при добавлении новых колонок (если используешь только смысловые поля). Минус: не читаемый, не отвечает на вопрос ‘это order_id 5’. Используй когда естественного ключа нет.

Как dbt применяет unique_key (упрощённо)

В зависимости от стратегии (delete+insert по умолчанию для DuckDB):

-- delete+insert: сначала удаляет matching, потом вставляет всё новое
DELETE FROM target
WHERE order_id IN (SELECT order_id FROM new_data);

INSERT INTO target
SELECT * FROM new_data;

Логика: “удали все строки, чьи unique_key есть в новых данных, и потом вставь всё новое”. Эквивалент upsert.

С merge (DuckDB 1.4+):

-- merge: атомарно UPDATE + INSERT в одной операции
MERGE INTO target
USING new_data
ON target.order_id = new_data.order_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...;

Семантика одинаковая, но merge атомарнее и часто быстрее. Подробнее — в следующем уроке.

Можно ли менять unique_key между runs

Нет, не рекомендуется. Если ты сделал dbt run с unique_key=‘order_id’, а потом сменил на unique_key=‘order_uuid’, dbt при следующем run попытается матчить по новому полю и:

  • Если order_uuid уже был в таблице — будет частичная дедупликация по новому ключу.
  • Если order_uuid — новая колонка, добавленная в SQL модели, схема может конфликтовать (см. on_schema_change).

Правильный workflow при смене unique_key:

dbt run --select fact_orders --full-refresh

Это сделает CREATE TABLE с нуля, используя новый unique_key.

Best practices

1. Всегда задавай unique_key, кроме чисто append-only. Event logs без обновлений могут без unique_key. Всё остальное — с ним.

2. unique_key должен быть NOT NULL. Фильтруй NULL в SELECT, или используй surrogate key.

3. Для составных ключей используй surrogate. Лучше один хэш-колонка, чем массив из нескольких. Меньше путаницы.

4. unique_key не обязательно primary key модели. Например, у тебя orders с order_id (PK) и order_uuid (UUID). Можешь использовать любую — главное, чтобы была уникальной и не-NULL.

5. Если unique_key изменяется — full-refresh. Не пытайся “поменять на лету”.

DuckDB-специфика для unique_key

В DuckDB unique_key работает на любой стратегии:

  • append (без unique_key) — простой INSERT, всегда supported.
  • delete+insert (с unique_key) — supported, стратегия по умолчанию.
  • merge (с unique_key) — supported в DuckDB 1.4+. Раньше — error.
  • microbatch (event_time, не unique_key) — supported в dbt-core 1.9+, но в dbt-duckdb с ограничением: microbatch с unique_key не поддерживается. Подробнее в уроке 04.

На DuckDB 1.4+ merge — наиболее эффективная стратегия. На более старых версиях DuckDB используй delete+insert.

Команды для проверки

После dbt run проверь количество строк vs ожидаемое:

-- В DuckDB CLI
SELECT COUNT(*), COUNT(DISTINCT order_id) FROM fact_orders;

Если COUNT(*) > COUNT(DISTINCT order_id) — у тебя дубли. Возможные причины: unique_key не задан, есть NULL в unique_key, стратегия append вместо merge.

# Тест на uniqueness через dbt
dbt test --select fact_orders

Если в YAML повешен tests: [unique] на колонку unique_key — он сразу покажет дубли.

Попробуй сам

Создай models/marts/dim_customers_inc.sql:

{{ config(
    materialized='incremental',
    unique_key='customer_id'
) }}

SELECT
    customer_id,
    first_name,
    last_name,
    updated_at
FROM {{ source('jaffle_shop', 'raw_customers') }}
WHERE customer_id IS NOT NULL

{% if is_incremental() %}
  AND updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}

Запусти первый раз:

dbt run --select dim_customers_inc

Затем измени одну строку в raw_customers — например, обнови имя у customer_id=1:

duckdb jaffle_shop.duckdb -c "UPDATE raw_customers SET first_name='UpdatedName', updated_at=now() WHERE customer_id=1;"

Запусти dbt run снова:

dbt run --select dim_customers_inc

Проверь:

duckdb jaffle_shop.duckdb -c "SELECT * FROM dim_customers_inc WHERE customer_id=1;"

Должна быть одна строка с обновлённым именем — не две. unique_key=‘customer_id’ заставил dbt обновить existing, а не добавить новую.

Теперь убери unique_key и сделай dbt run —full-refresh, потом обнови ту же строку и запусти dbt run. Увидишь, что без unique_key создаются дубли.

Что мы поняли

unique_key — параметр incremental, который определяет колонку (или композицию) уникальности. Без него — append (дубли), с ним — корректный upsert. NULL в unique_key ломает дедупликацию. Для нестандартных случаев используй surrogate key через dbt_utils.generate_surrogate_key. unique_key должен быть стабильным между runs — менять на лету не безопасно, нужен —full-refresh.

В следующем уроке разберём конкретные стратегии: append, delete+insert, merge — и когда какая лучше.

incremental_predicates: оптимизация MERGE
Проверка знанийKnowledge check
У тебя incremental fact_orders без unique_key. После недели работы в таблице 3M строк, в источнике 1M. Что произошло и как починить?
ОтветAnswer
Без unique_key dbt использует стратегию append — каждый дельта-run INSERT'ит новые/обновлённые строки, не дедуплицируя по существующим. Любая обновлённая в источнике строка создаёт дубликат. Решение: (1) добавить unique_key='order_id' в config; (2) сделать dbt run --select fact_orders --full-refresh, чтобы пересчитать таблицу с нуля и убрать дубли. Дальше следующие runs будут upsert'ить корректно. Также проверь, что order_id NOT NULL в источнике, иначе всё равно будут дубли по NULL.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Зачем нужен параметр unique_key в incremental-модели?

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

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

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

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