Learning Platform
Глоссарий Troubleshooting
Урок 14.03 · 25 мин
Начальный
SnapshotsSCD2Slowly Changing Dimensionsdbt snapshotHistory tracking

Snapshots: что такое SCD2 и зачем нужны

Большинство таблиц в source-системах хранят только текущее состояние. У клиента изменился адрес — старый адрес перетёрся. У товара поменялась цена — старая цена потеряна. Если вам нужно отвечать на вопросы вида «по какой цене был продан этот товар год назад?» или «в каком городе жил клиент в момент покупки?», current-state данных не хватает.

Snapshot в dbt — это механизм, который отслеживает изменения в source-таблице и хранит историю. Концептуально это SCD2 (Slowly Changing Dimension Type 2) — паттерн из теории data warehousing.

Этот урок — про концепт. Следующий — про конкретные стратегии (timestamp/check) и YAML-конфигурацию.


SCD: типы slowly changing dimensions

Slowly Changing Dimension — это измерение, которое медленно меняется во времени. Адрес клиента, цена товара, статус сотрудника. В отличие от facts (транзакции, события), измерения не пишутся каждую секунду, а обновляются раз в неделю или месяц.

В классической теории Кимбала есть шесть типов SCD (Type 0..6). На практике используются три:

Типы Slowly Changing Dimensions
Type 1 — overwriteSCD Type 1: перезаписываем старое значение. История теряется. customers.address = новый адрес, старый исчез. Простой паттерн, но не отвечает на исторические вопросы.
Type 2 — history rowsSCD Type 2: добавляем новую строку с новыми значениями + валидно_с / валидно_до. Старая строка остаётся, но с заполненным valid_to. Идеальный паттерн для истории.
Type 3 — extra columnsSCD Type 3: добавляем колонки previous_address, current_address. Сохраняем только N последних значений. Малорапространён, ограничения по числу версий.

Type 1 — это то, что делает большинство source-систем. Никакой истории.

Type 2 — то, что делает dbt snapshot. Каждая версия записи хранится как отдельная строка с дополнительными колонками dbt_valid_from / dbt_valid_to. Это стандарт data warehousing для истории.

Type 3 — добавление колонок previous_X, previous_previous_X. Редко.


Конкретный пример: customers.address

В source-таблице app.customers лежит:

customer_idnameaddressupdated_at
1AliceMoscow, RU2026-01-15 10:00:00
2BobLondon, GB2026-02-20 11:00:00

Сегодня — 2026-03-15. Алиса переехала. Source-система делает UPDATE:

customer_idnameaddressupdated_at
1AliceYerevan, AM2026-03-15 09:00:00
2BobLondon, GB2026-02-20 11:00:00

Старый адрес Алисы — потерян навсегда. Запросы вида «в каком городе жила Алиса в феврале?» не ответятся.

После dbt snapshot таблица snapshots.customers_snapshot выглядит так:

customer_idnameaddressupdated_atdbt_scd_iddbt_updated_atdbt_valid_fromdbt_valid_to
1AliceMoscow, RU2026-01-15 10:00:00a8e3…2026-01-15 10:00:002026-01-15 10:00:002026-03-15 09:00:00
1AliceYerevan, AM2026-03-15 09:00:00b7f2…2026-03-15 09:00:002026-03-15 09:00:00NULL
2BobLondon, GB2026-02-20 11:00:00c9d1…2026-02-20 11:00:002026-02-20 11:00:00NULL

Что произошло:

  • У Алисы две строки: старая Moscow и новая Yerevan.
  • В старой строке dbt_valid_to = 2026-03-15 09:00:00 — момент, когда зафиксировалось изменение.
  • В новой dbt_valid_to = NULL — это текущее значение.
  • У Боба одна строка с dbt_valid_to = NULL, потому что Боб не менялся.
  • dbt_scd_id — уникальный хеш версии записи (для дедупликации).

Теперь запросы про историю работают:

-- В каком городе жила Алиса 1 февраля 2026?
SELECT address
FROM customers_snapshot
WHERE customer_id = 1
  AND '2026-02-01' BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, '9999-01-01');
-- -> Moscow, RU

Зачем это нужно в реальности

Истории клиента / продукта нужны везде, где есть аналитика на момент:

  1. Финансовая отчётность. «Сколько мы выручили в первом квартале по тарифным планам, действовавшим тогда?» Если план поменялся в апреле — нужна цена на момент транзакции, а не текущая.
  2. Атрибуция продаж. «Какой sales rep вёл этого клиента в момент сделки?» Reps меняются; снапшот хранит, кто отвечал за клиента когда.
  3. Compliance / audit. «Кто был VIP-клиентом на день X?» Регуляторы требуют воспроизводимости.
  4. A/B-эксперименты. «В каком сегменте был клиент на старте эксперимента?» Сегмент мог поменяться к концу эксперимента.

Без snapshots — вы реконструируете эти данные из логов изменений (если они есть) или просто врёте: «вот текущая цена, забейте на то, что в момент сделки она была другой».


Как работает dbt snapshot — high-level

dbt snapshot — отдельная команда, не dbt run. Что она делает:

Source
dbt snapshot
Snapshot table
1. SELECT * FROM source2. SELECT current rows (valid_to IS NULL)3. Compare: find changed/new/deleted rows4a. UPDATE old rows: SET dbt_valid_to = now() for changed4b. INSERT new versions: dbt_valid_to = NULL

Ключевые моменты:

  1. Snapshot нужен unique_key — без него dbt не может сказать «эта строка та же, что вчера, только изменилась». unique_key обычно = primary key source-таблицы.
  2. Snapshot хранится отдельно — это не view над source, это физическая таблица, которая аккумулирует историю.
  3. Snapshot никогда не сжимается — старые строки лежат вечно. Это feature, не bug.
  4. Snapshot прогоняется по расписанию — типично раз в час / раз в день. Чем чаще — тем точнее история, но тем больше I/O.

Куда складываются snapshots

В стандартном dbt-проекте — директория snapshots/:

jaffle_shop/
  dbt_project.yml
  models/
  seeds/
  snapshots/
    customers_snapshot.yml      ← конфиг snapshot 1.9+ синтаксис
    products_snapshot.yml
  tests/
  macros/

Путь настраивается в dbt_project.yml:

snapshot-paths: ["snapshots"]

В версиях dbt менее 1.9 snapshots определялись в .sql файлах с Jinja-блоком {% snapshot %} ... {% endsnapshot %}. Этот синтаксис ещё поддерживается, но deprecated. Новый рекомендуемый формат — YAML (с 1.9+).

Об этом — следующий урок.


Snapshot — это нода в DAG

Как и seed, snapshot — это полноценный узел в графе зависимостей:

-- models/marts/customers_history.sql
SELECT
    customer_id,
    name,
    address,
    dbt_valid_from,
    dbt_valid_to
FROM {{ ref('customers_snapshot') }}

dbt build запустит snapshots в правильной последовательности по DAG. dbt runне запустит snapshots, нужна команда dbt snapshot или dbt build.

WARNING

Snapshot работает с source или с model, но не с другим snapshot. На source/model можно ссылаться через source() / ref(). Если попытаться построить snapshot поверх другого snapshot — теряется грань между «активной строкой» и «исторической», и логика SCD2 ломается.


Когда snapshot, когда — нет

Snapshot or not

DuckDB-специфика snapshots

В курсе используется DuckDB. Несколько важных моментов:

  1. Стратегии timestamp и check работают. Поддержка полная.
  2. hard_deletes НЕ работает в DuckDB. Это feature dbt 1.9+, которая отмечает удалённые строки. На DuckDB не реализована.
  3. Snapshots на external материализации НЕ работают. Если source — это read_parquet(...) без физической таблицы, snapshot упадёт.
  4. Concurrency: DuckDB — single-writer на файл. Если snapshot выполняется, параллельные модели на эту таблицу подождут.

Эти ограничения важны на проде с DuckDB (есть проекты, которые так делают). В Snowflake/BigQuery/Postgres работает всё.


Что НЕ делает snapshot

Самое частое заблуждение junior’ов: «snapshot — это бекап». Нет.

  1. Snapshot не сохраняет deleted rows (без hard_deletes config, которого нет в DuckDB). Если строка пропала из source — dbt просто не обновит её dbt_valid_to. Она будет «зависшая активная» с устаревшими данными.
  2. Snapshot не работает на partial source. Если source-запрос отфильтровал часть строк (WHERE), dbt подумает что эти строки удалены, и закроет их в snapshot. Урок: snapshot-source должен быть полным dataset’ом.
  3. Snapshot не делает audit log. История — только для конфигурированных колонок. Если изменилась колонка вне check_cols (для check-стратегии) — изменение пропустится.

Попробуй сам

Откройте свой dbt-проект. Создайте файл snapshots/customers_snapshot.yml (на следующем уроке конкретный синтаксис будет; сейчас просто продумайте дизайн):

  1. Какая source-таблица? Скорее всего stg_jaffle__customers (staging).
  2. Какой unique_key? customer_id.
  3. Что отслеживать? Все колонки или конкретные? (address, email, tier)
  4. Как часто запускать? (dbt snapshot раз в день в cron’е)
  5. Как использовать в моделях? {{ ref('customers_snapshot') }} — затем JOIN-ы с фактами по customer_id и периоду действия.

Подумайте о двух кейсах:

  • Аналитик хочет revenue per customer tier за квартал. Нужна история tier на момент каждой транзакции. JOIN orders ON customer_id AND order_date BETWEEN dbt_valid_from AND dbt_valid_to.
  • Compliance просит знать, кто был VIP-клиентом на 2026-04-01. Тот же snapshot, фильтр WHERE '2026-04-01' BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, '9999-12-31') AND tier = 'VIP'.

Это упражнение на mental model: snapshot — это таблица с периодами действия, и JOIN-ы по периодам — обычный SQL.


Ключевые выводы

  1. SCD2 — паттерн data warehousing для отслеживания истории dimension-данных. Каждая версия записи — отдельная строка с valid_from / valid_to.
  2. dbt snapshot реализует SCD2. После запуска в snapshot-таблице две строки на каждое изменение: старая с dbt_valid_to = время_изменения и новая с dbt_valid_to = NULL.
  3. Snapshot нужен unique_key и стратегия определения изменений (timestamp или check).
  4. Snapshot хранится физически в warehouse, не view. Никогда не сжимается, растёт со временем.
  5. Используется для slowly changing dimensions: customers, products, employees. Не для transaction-facts (они append-only сами по себе).
  6. dbt snapshot — отдельная команда. dbt run не запускает snapshots. dbt build запускает в порядке DAG.
  7. В DuckDB: timestamp/check стратегии работают, hard_deletes не реализован, snapshots на external материализации не работают.
SCD Type 2: full история изменений — теория из data modeling SCD2 и snapshots в production: deep dive
Проверка знанийKnowledge check
Аналитик попросил: 'хочу видеть, в каком тарифном плане был клиент на момент каждой его покупки'. У вас есть source-таблица `app.customers` с current state (без истории). Какое решение?
ОтветAnswer
Решение — `dbt snapshot` на `app.customers`. Шаги: \n\n1. Создать `snapshots/customers_snapshot.yml` с `unique_key: customer_id`, стратегией `timestamp` (если у source есть `updated_at`) или `check` (если нет — отслеживаем колонку `tier`). \n2. `dbt snapshot` — запустить хотя бы раз; добавить в cron/scheduler (раз в день). \n3. В `models/marts/orders_with_tier.sql` сделать JOIN: \n```sql\nSELECT o.order_id, o.order_date, s.tier\nFROM {{ ref('stg_orders') }} o\nJOIN {{ ref('customers_snapshot') }} s\n ON o.customer_id = s.customer_id\n AND o.order_date не меньше s.dbt_valid_from\n AND o.order_date < COALESCE(s.dbt_valid_to, '9999-12-31')\n```\n\nВажно: snapshot должен **существовать** до момента, когда хотим начать отслеживать историю. Все изменения **до первого запуска snapshot** потеряны — их не восстановить.
Проверка знанийKnowledge check
Snapshot работал нормально, но команда заметила: после deletion клиента в source, его строка в snapshot осталась с `dbt_valid_to = NULL`, как будто он всё ещё активен. Это баг dbt?
ОтветAnswer
Это **не баг, а дизайн**. dbt snapshot **по умолчанию не отслеживает удаления** — если строка пропала из source, dbt просто не находит её для сравнения и оставляет последнюю версию как «активную». \n\nРешения:\n\n1. В dbt 1.9+ есть конфиг `hard_deletes: 'invalidate'` — закрывает строку (`dbt_valid_to = now()`) при исчезновении из source. **Но это НЕ работает в DuckDB** на момент 2026. \n2. Альтернатива: добавить колонку `is_deleted` в source-таблицу (soft delete), включить её в `check_cols` или `updated_at`. Тогда переключение `is_deleted: false -> true` будет нормальным изменением SCD2. \n3. Workaround на DuckDB: запускать post-hook в snapshot, который сам делает UPDATE `SET dbt_valid_to = now() WHERE customer_id NOT IN (SELECT ...)`. \n\nВывод: на DuckDB и других warehouse без hard_deletes — **никогда не делайте hard DELETE в source**, используйте soft delete с flag.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Аналитик попросил: 'хочу видеть, в каком тарифном плане был клиент на момент каждой его покупки за последний год'. В source `app.customers` — только current state, без истории. Что нужно сделать?

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

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

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

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