dbtsnapshotsSCD2SnowflakePostgreSQL

dbt Snapshots: SCD Type 2 для отслеживания исторических изменений

2026-06-02 12 мин

Что было user.subscription_plan 3 месяца назад? Сколько клиентов downgrade'нулись с Pro на Free в марте? Когда конкретно изменился их status?

Если users table в твоей DWH хранит только current state — ответа нет. Историю съел UPDATE.

dbt Snapshots — это механизм SCD Type 2 (Slowly Changing Dimension) для отслеживания каждого изменения строки во времени. Этот гайд — про практическое использование.


SCD Type 2 vs Type 1: быстрый flow

TypeЧто хранитсяКогда
Type 1Только current state (overwrite)Не важно прошлое: «текущая страна юзера»
Type 2Каждая версия с valid_from/valid_toВажно прошлое: «план в момент покупки»
Type 3Previous + current valueРедко, только 1 history-уровень

Snapshot dbt = Type 2.

Real-world пример: ARR Retention Cohort. Если cohort-анализ строится по «plan на момент signup», а users.plan переписывается → данные за прошлое исчезают.


dbt snapshot architecture

dbt snapshots создают таблицу с 4 мета-колонками:

ColumnОписание
dbt_valid_fromКогда этот рекод стал актуальным
dbt_valid_toКогда устарел (NULL = текущий active)
dbt_updated_atКогда последний раз менялся
dbt_scd_idMD5 hash unique_key + valid_from (composite PK)

Запрос «какие были users 2024-03-15»:

SELECT *
FROM users_snapshot
WHERE '2024-03-15' BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, '9999-12-31');

Шаг 1-3: первый snapshot через strategy='timestamp'

Если в source table есть updated_at — это самая надёжная стратегия.

Шаг 1. Создать snapshot definition

-- snapshots/users_snapshot.sql
{% snapshot users_snapshot %}

{{
  config(
    target_schema='snapshots',
    unique_key='user_id',
    strategy='timestamp',
    updated_at='updated_at',
    invalidate_hard_deletes=True
  )
}}

SELECT
  user_id,
  email,
  plan,
  status,
  country,
  created_at,
  updated_at
FROM {{ source('raw', 'users') }}

{% endsnapshot %}

Шаг 2. Запустить snapshot

dbt snapshot --select users_snapshot

dbt сравнивает каждую строку source vs snapshot. Если updated_at в source > dbt_updated_at в snapshot → новая версия запись.

Шаг 3. Запросить historical data

-- Текущее состояние:
SELECT * FROM snapshots.users_snapshot WHERE dbt_valid_to IS NULL;

-- Снапшот на 15 марта 2024:
SELECT *
FROM snapshots.users_snapshot
WHERE '2024-03-15' BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, '9999-12-31');

Шаг 1-4: snapshot через strategy='check' (без updated_at)

Если updated_at нет — используй check strategy. Она хеширует переданные колонки и сравнивает.

Шаг 1. Snapshot config

-- snapshots/users_check_snapshot.sql
{% snapshot users_check_snapshot %}

{{
  config(
    target_schema='snapshots',
    unique_key='user_id',
    strategy='check',
    check_cols=['plan', 'status', 'country']
  )
}}

SELECT
  user_id,
  email,
  plan,
  status,
  country,
  created_at
FROM {{ source('raw', 'users') }}

{% endsnapshot %}

Шаг 2. check_cols: какие колонки трекать

# dbt видит изменения только в этих колонках:
check_cols=['plan', 'status', 'country']

# Email и created_at могут меняться — snapshot их игнорирует
# (быстрее, меньше storage)

Шаг 3. Все колонки через 'all'

check_cols='all'  -- любое изменение → новая запись

Медленнее (нужен hash всех колонок), но проще для small dimension tables.

Шаг 4. Тонкая настройка

{{
  config(
    target_schema='snapshots',
    unique_key='user_id',
    strategy='check',
    check_cols=['plan', 'status'],
    invalidate_hard_deletes=True,   -- если row удалена в source → close в snapshot
    updated_at='dbt_updated_at'     -- кастомное имя для меты
  )
}}

Production patterns: snapshot frequency

Cron через Airflow

# DAG: snapshot_dimensions
@dag(schedule='0 */6 * * *')  # каждые 6 часов
def snapshot_dimensions():
    BashOperator(
        task_id='snapshot_users',
        bash_command='cd /dbt && dbt snapshot --select users_snapshot'
    )

Trade-off: чаще → точнее, но больше storage. Реальные benchmarks:

Hard-delete handling

invalidate_hard_deletes=True

Если row удалена из source → dbt_valid_to = now(). Без флага snapshot будет считать row актуальной навсегда.

Но! Hard-delete check медленный (full anti-join с source). Для больших таблиц (10M+) рассмотри alternative: soft-delete через колонку deleted_at + регулярный snapshot.

Snapshot perf на 100M+ rows

dbt snapshot --select users_snapshot --threads 8

Distinguish: snapshot — это MERGE-based, не append. Performance падает с ростом snapshot table.

Рассмотри:


Querying historical data: time-travel

-- Customer journey: какой план был у юзера в каждый месяц 2024?
WITH date_spine AS (
  SELECT date_month FROM (
    VALUES ('2024-01-01'), ('2024-02-01'), ('2024-03-01'), ('2024-04-01'),
           ('2024-05-01'), ('2024-06-01'), ('2024-07-01'), ('2024-08-01')
  ) AS t(date_month)
)
SELECT
  ds.date_month,
  s.user_id,
  s.plan,
  s.status
FROM date_spine ds
LEFT JOIN snapshots.users_snapshot s
  ON ds.date_month BETWEEN s.dbt_valid_from AND COALESCE(s.dbt_valid_to, '9999-12-31')
WHERE s.user_id = '12345'
ORDER BY ds.date_month;

Output:

date_month    user_id   plan   status
2024-01-01    12345     Free   active
2024-02-01    12345     Pro    active   ← upgrade!
2024-03-01    12345     Pro    active
2024-04-01    12345     Free   active   ← downgrade :(

Теперь можно cohort-анализ: «retention Pro-юзеров cohort февраль-2024».


Common bugs

Race condition при concurrent snapshot

dbt snapshot не блокирует source. Если в source строки меняются ПОКА бежит snapshot — некоторые изменения пропадут.

Решение: или SELECT ... FOR UPDATE (PG), или snapshot на CDC-stream вместо source live, или freeze source через transaction isolation.

Schema drift

Если в source появился новый column, а в snapshot он не определён — silently ignored.

# Solution: dbt-utils star() помощник
{% snapshot users_snapshot %}
  {{ config(...) }}
  SELECT {{ dbt_utils.star(from=ref('source_users')) }}
  FROM {{ ref('source_users') }}
{% endsnapshot %}

Повторный run уже завершённого snapshot

При повторном dbt snapshot без новых изменений → ничего не должно меняться. Но если updated_at неправильный (NULL или fake update без реальных изменений) → создаются дубликаты записи с одинаковыми колонками.

Detection:

SELECT user_id, COUNT(*) FROM users_snapshot
GROUP BY 1, plan, status, country
HAVING COUNT(*) > 1;

Performance на 100M+ rows

Каждый snapshot — это MERGE. Без правильных indices/cluster keys на target — full scan каждый раз.

Solution:

-- Snowflake
ALTER TABLE users_snapshot CLUSTER BY (user_id, dbt_valid_from);

-- PostgreSQL
CREATE INDEX idx_users_snapshot_uid_valid
  ON snapshots.users_snapshot (user_id, dbt_valid_from);

FAQ

Snapshot vs Incremental: какой выбрать?

Как мигрировать existing данные в snapshot?

Если уже есть current users → запустить dbt snapshot — создаст snapshot со всеми текущими записями (valid_from = now()). Historical записи не появятся (их нет в source). Чтобы получить history → backfill из логов / event store.

Можно ли snapshot на view?

Да, если view содержит unique_key + timestamp. Но perf может быть плохой — каждый snapshot материализует view + сравнивает.

Какая partition strategy для snapshot table?

PARTITION BY dbt_valid_from (по месяцам или неделям). Это позволяет filter «active rows» (dbt_valid_to IS NULL) с partition pruning.

Как откатить плохой snapshot?

dbt не предоставляет rollback напрямую. Решение:


Что дальше

Источники

Snapshot — это страховка от потерянной истории. Открой SQL-тренажёр и тренируй time-travel queries — это reusable навык для любых SCD-данных независимо от dbt.

dbt + SQL = middle+ оффер
Тренажёр SQL с PostgreSQL 16 в браузере, 3000+ вопросов с собесов, AI-разбор кейсов. Бесплатный старт.
Открыть тренажёр →