Learning Platform
Глоссарий Troubleshooting
Урок 05.03 · 25 мин
Средний
SnapshotsCheck strategycheck_colsSchema evolutionProduction

Check strategy: что и как сравнивать

Стратегия check нужна, когда у source нет надёжного updated_at. dbt сравнивает значения указанных колонок (через хеш или построчно) и фиксирует новую SCD2-версию, если хотя бы одна отличается.

Звучит просто. На прод сложнее: какие колонки выбрать, как переживать schema evolution, как не перегрузить snapshot шумом. И главное — что произойдёт, когда вы добавите колонку в check_cols через год после первого запуска.


Базовый YAML

snapshots:
  - name: products_snapshot
    relation: source('app', 'products')
    config:
      schema: snapshots
      unique_key: product_id
      strategy: check
      check_cols:
        - price
        - category
        - is_active
      dbt_valid_to_current: "to_date('9999-12-31')"

dbt при сравнении считает хеш по перечисленным колонкам — обычно через MD5(CONCAT(coalesce(col1::text,''), '|', coalesce(col2::text,''), ...)). Если хеш отличается от dbt_scd_id-хеша активной строки snapshot — фиксируется новая версия.

source.products
dbt
products_snapshot
1. SELECT * + hash(check_cols)2. SELECT active rows + their dbt_scd_id3. JOIN by unique_key, compare hashes4. UPDATE valid_to = run_started_at for changed5. INSERT new versions

Главное отличие от timestamp:

  • dbt_valid_to и dbt_valid_from ставятся в run_started_at — момент запуска snapshot, не момент реального изменения. Мы не знаем, когда реально поменялось — только видим, что сейчас отличается.
  • Скорость медленнее — нужно посчитать хеш по нескольким колонкам для всех строк.

Как выбрать check_cols

Главное правило: только бизнес-значимые колонки.

Хорошие кандидаты:

  • price для products — влияет на revenue.
  • tier для customers — влияет на сегментацию.
  • is_active / status — влияет на фильтрацию.
  • email для users — если нужна история contact info.
  • manager_id, department_id для employees — для атрибуции.

Плохие кандидаты:

  • last_login_at — шумная, меняется при каждом входе пользователя. Snapshot забьётся.
  • total_orders — счётчик, растёт при каждом заказе. Snapshot увеличит размер кратно числу заказов.
  • updated_at — это техническая колонка, не бизнес. Если она есть — берите timestamp-стратегию вместо check.
  • notes, metadata_json — слабоструктурированные текстовые поля.
WARNING

Если включить шумную колонку в check_cols, snapshot будет расти линейно по числу транзакций / событий. После полугода dim_customers_snapshot может оказаться больше fct_orders по объёму.


check_cols: ‘all’ — антипаттерн

В dbt есть синтаксис check_cols: 'all' — отслеживать все колонки source. Звучит удобно («не нужно думать что включать»), но порождает три проблемы.

Проблема 1: schema drift. Source добавил колонку last_modified_by. На следующем snapshot run dbt считает хеш по новому набору колонок и все строки считаются изменёнными — массовое создание дубликатов с одинаковым content.

Проблема 2: шумные колонки. Какая-нибудь view_count или last_seen_at обновляется каждую минуту. Snapshot забьётся.

Проблема 3: нет контроля семантики. Snapshot работает «магически», но при ревью PR не очевидно, что именно отслеживается.

Производственное правило: никогда check_cols: 'all'. Всегда явный список. Если коллега предложил — попросите list business reasons для каждой колонки.


Edge case: добавление колонки в check_cols

Это самый коварный сценарий. Snapshot работал год с check_cols: [tier]. Бизнес хочет также отслеживать customer_segment — вы добавляете её в check_cols:

config:
  strategy: check
  check_cols:
    - tier
    - customer_segment  # ← добавили

Запускаете dbt snapshot. Что произойдёт?

  1. dbt считает хеш по новому набору (tier, customer_segment) для всех строк source.
  2. Сравнивает с dbt_scd_id активных строк snapshot. Их хеш был посчитан по (tier) — это другой набор, хеши не совпадут.
  3. Для всех строк создаются новые версии. Старые закрываются с dbt_valid_to = run_started_at.

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

DANGER

Добавление колонки в check_cols без --full-refresh создаёт массовое поддельное обновление SCD2. Все строки получают новые версии с dbt_valid_from = run_started_at. История tier до этого момента остаётся, но появляется лишний шум.

Решения:

Вариант A — --full-refresh (потерять историю).

Если история до изменения не критична — dbt snapshot --full-refresh. dbt пересоздаст snapshot с нуля по текущему состоянию source. Новые dbt_valid_from = now() для всех строк. История потеряна.

Подходит, если customer_segment — это новое поле в источнике, которое раньше не существовало. Старые SCD2-версии без customer_segment не несут ценности.

Вариант B — миграция через бекап + UPDATE.

-- 1. Backup snapshot перед изменением
CREATE TABLE snapshots.customers_snapshot_pre_migration AS
SELECT * FROM snapshots.customers_snapshot;

-- 2. Изменить check_cols в YAML.
-- 3. Запустить dbt snapshot — он создаст дубликаты.
-- 4. UPDATE: для всех записей, где dbt_valid_from = run_started_at,
-- вернуть dbt_valid_from к оригинальному значению из бекапа.

UPDATE snapshots.customers_snapshot s
SET dbt_valid_from = b.dbt_valid_from,
    dbt_valid_to = b.dbt_valid_to,
    dbt_scd_id = b.dbt_scd_id
FROM snapshots.customers_snapshot_pre_migration b
WHERE s.customer_id = b.customer_id
  AND b.dbt_valid_to IS NULL
  AND s.dbt_valid_from = '{{ run_started_at }}';

Это hand-crafted фикс. Применяйте только если история критична.

Вариант C — переход на timestamp стратегию.

Если у source появилась стабильная колонка updated_at — лучше мигрировать на timestamp. Тогда добавление колонок в snapshot не создаст synthetic-обновлений (dbt сравнивает по updated_at, а не по hash columns).


Edge case: удаление колонки из check_cols

Симметрично добавлению. Удалили колонку — хеши не совпадут — все строки получат новые версии. Та же стратегия: full-refresh или ручная миграция.

Если колонка удалена в source (source перестал её писать), это уже schema evolution — обсуждается отдельно ниже.


Edge case: NULL и check_cols

Если значение колонки NULL, dbt должен иметь возможность отличить «NULL» от других значений в хеше. dbt-core делает это через COALESCE(col, '__null__') (или адаптерно-специфический sentinel) — NULL превращается в строку при конкатенации.

Проблема: если в source у одной строки category = NULL, а у другой category = '__null__' (буквально такая строка), они получат одинаковый хеш. Это коллизия.

Решения:

  • Никогда не хранить sentinel-строки в source. Если уж надо — используйте такой sentinel, чтобы был невозможен в реальных данных (например, _NULL_dbt_internal_4f8a3b).
  • Чистить source через stagingNULLIF(category, '') AS category, чтобы пустые строки преобразовать в NULL.
  • Использовать dbt 1.9+ snapshot_meta_column_names — кастомизировать имена столбцов, отдельно проверить документацию вашего адаптера.

Schema evolution — что делать, когда source меняет схему

Source — это живая таблица. Колонки добавляются, удаляются, переименовываются. Snapshot должен пережить это.

Изменение в sourcecheck-стратегияtimestamp-стратегия
Добавили колонку (не в check_cols)Игнорируется. Новая колонка не появится в snapshot до —full-refresh.Аналогично — snapshot хранит только колонки на момент создания.
Добавили колонку, нужно добавить в check_colsSynthetic-обновления (см. выше).N/A.
Удалили колонку из sourceSnapshot ломается при INSERT new versions — нет такой колонки. Нужен dbt snapshot --full-refresh или ручной DROP COLUMN.Аналогично.
Переименовали колонкуSnapshot ломается. Нужен alias в relation SQL или full-refresh.Аналогично.
Изменили тип колонкиЗависит от warehouse. Postgres / Snowflake часто implicit-cast, DuckDB строже. Может сломаться.Аналогично.

Общая стратегия:

  1. Snapshot relation как буфер. Не делайте snapshot прямо из source. Делайте .sql-snapshot, в котором явно перечислены колонки и сделаны нужные cast/alias. Тогда schema evolution фильтруется в этом промежуточном слое.
-- snapshots/customers_snapshot.sql
{{
    config(
      target_schema='snapshots',
      unique_key='customer_id',
      strategy='check',
      check_cols=['tier', 'customer_segment', 'is_active']
    )
}}

SELECT
    customer_id,
    name,
    email,
    tier,
    customer_segment,
    is_active::BOOLEAN AS is_active,
    -- если в source есть лишние колонки — они сюда не попадут
    updated_at  -- даже если используем check, всё равно полезно иметь
FROM {{ source('app', 'customers') }}
  1. Тесты на snapshot контракт. В dbt 1.5+ есть model contracts (модуль 10 курса) — можно жёстко задать колонки/типы.

  2. Документация изменений. Каждое изменение snapshot — это event в release notes. Не делайте по-тихому.


Когда timestamp лучше check

dbt-i: timestamp vs check — базовое сравнение стратегий
  • Если есть надёжный updated_at — всегда timestamp. Дешевле и точнее.

Когда check всё равно нужна:

  • Source не пишет updated_at и никогда не будет (legacy app без maintainer).
  • Source пишет updated_at, но он ненадёжен (часто NULL, иногда в будущем).
  • Нужна семантика «отслеживаем именно эти бизнес-колонки», а не «любое UPDATE».

Иногда комбинация: timestamp + check — нет в dbt из коробки. Если нужна — пишите два snapshot и JOIN-те в mart.


Полный production пример

# snapshots/products_snapshot.yml
snapshots:
  - name: products_snapshot
    description: |
      SCD2-история products. Source — app.products, нет updated_at.
      Отслеживаем price, category, is_active. Расписание — 1 раз/день.
    relation: source('app', 'products')
    config:
      schema: snapshots
      unique_key: product_id
      strategy: check
      check_cols:
        - price
        - category
        - is_active
      dbt_valid_to_current: "to_date('9999-12-31')"
      tags: [snapshot, daily, dimension]
    columns:
      - name: product_id
        data_tests:
          - not_null
      - name: dbt_scd_id
        data_tests:
          - unique
          - not_null
      - name: dbt_valid_from
        data_tests:
          - not_null

Попробуй сам

Эксперимент на check-стратегии. В labs:

  1. Создайте products_snapshot с check_cols: [price]. Запустите dbt snapshot.
  2. Измените цену одного product в source. Снова dbt snapshot. Проверьте — появилась новая версия с dbt_valid_from = run_started_at (не реальное время изменения, а время snapshot).
  3. Добавьте колонку category в check_cols в YAML. Запустите dbt snapshot. Что произошло? Должны увидеть, что все строки получили новые версии (synthetic-обновление).
  4. Откатите изменение в YAML, запустите dbt snapshot --full-refresh. Snapshot пересоздан с нуля.
  5. Снова добавьте category, но в этот раз сначала backup snapshot, потом измените YAML, потом запустите. Подумайте о том, можно ли «безопасно» добавить колонку без потери истории.

Это упражнение делает ощутимой главную проблему check-стратегии: hash коммерчески не stable при изменении набора колонок.


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

  1. Check-стратегия сравнивает хеш по check_cols. Дороже timestamp, но не требует updated_at.
  2. dbt_valid_from ставится в run_started_at, не в реальное время изменения. Точность ограничена интервалом snapshot.
  3. check_cols: 'all' — антипаттерн. Всегда явный список business-critical колонок.
  4. Добавление/удаление колонки в check_cols ломает хеш — все строки получают synthetic-обновления. Mitigation: --full-refresh или ручная миграция с backup.
  5. Schema evolution в source требует промежуточного .sql-snapshot-relation как буфера.
  6. NULL обрабатывается через sentinel в хеше. Избегайте sentinel-строк в реальных данных.
  7. Если у source появился стабильный updated_at — мигрируйте на timestamp. Это надёжнее.
Проверка знанийKnowledge check
snapshot products_snapshot работает 8 месяцев с check_cols=[price, category]. Бизнес хочет добавить отслеживание is_active. На production среде нельзя терять историю. Стратегия миграции?
ОтветAnswer
Это классическая ситуация миграции snapshot без потери истории. Шаги:\n\n**Шаг 1 — Backup перед изменением:**\n```sql\nCREATE TABLE snapshots.products_snapshot_backup_2026_05_19 AS\nSELECT * FROM snapshots.products_snapshot;\n```\nЭто on-warehouse copy, занимает столько же storage. Нужно для возможности отката и для ручного фикса дальше.\n\n**Шаг 2 — Добавить колонку в check_cols в YAML:**\n```yaml\ncheck_cols:\n - price\n - category\n - is_active\n```\n\n**Шаг 3 — Запустить dbt snapshot.** Все строки получат «новые версии» с одинаковым content. Старые версии закроются с dbt_valid_to = run_started_at. Это synthetic shift.\n\n**Шаг 4 — Ручной фикс через UPDATE:**\nДля каждой строки, у которой new_dbt_valid_from = run_started_at и есть match с backup по unique_key + content (price, category одинаковые) — восстановить оригинальные dbt_valid_from / dbt_valid_to / dbt_scd_id из backup. UPDATE на десятки тысяч строк (приемлемо).\n\n```sql\nUPDATE snapshots.products_snapshot s\nSET\n dbt_valid_from = b.dbt_valid_from,\n dbt_valid_to = b.dbt_valid_to\nFROM snapshots.products_snapshot_backup_2026_05_19 b\nWHERE s.product_id = b.product_id\n AND s.price = b.price\n AND s.category = b.category\n AND b.dbt_valid_to IS NULL\n AND s.dbt_valid_from не меньше run_started_at - interval '1 minute';\n```\n\nЗатем удалить дубликаты «закрытых» старых версий, у которых dbt_valid_to ~ run_started_at и есть совпадающая открытая запись в восстановленных.\n\n**Шаг 5 — Проверка тестов:**\n```bash\ndbt test --select snapshots.products_snapshot\n```\nДолжен пройти unique(dbt_scd_id), not_null(product_id), not_null(dbt_valid_from).\n\n**Шаг 6 — Hold backup на 30 дней.** Если что-то пошло не так — есть откат.\n\n**Альтернатива (проще, но компромисс):** `dbt snapshot --full-refresh` и принять потерю истории до этого момента. На прод обычно нельзя, но если бизнес согласен — это в 100 раз быстрее ручного фикса.\n\nСамое правильное в long-term: **миграция на timestamp-стратегию.** Если в источнике появится надёжный updated_at — снижаем риск этого сценария в будущем.
Проверка знанийKnowledge check
Команда хочет snapshot для table fct_user_sessions (id, user_id, session_start, last_active_at, page_views). Какие колонки разумно в check_cols?
ОтветAnswer
Trick question. \n\n**На самом деле snapshot для fct_user_sessions, скорее всего, не нужен.** Это **fact-таблица** (события), не dimension. Каждая сессия — это уже append-only event с фиксированным session_start. Не надо отслеживать SCD2 для facts.\n\nЕсли действительно нужна история изменений сессии (например, last_active_at растёт как клиент серфит сайт) — обычно это **incremental fact** или **streaming aggregate**, а не snapshot. Snapshot будет дублировать строки на каждый update last_active_at, это превратит таблицу в кошмар.\n\nЕсли всё-таки snapshot — что точно НЕ в check_cols:\n- `last_active_at` — шумная, меняется при каждом запросе. Тысячи версий на пользователя.\n- `page_views` — счётчик, растёт. Аналогично.\n- `session_start` — immutable, никогда не меняется. Бесполезно в check_cols.\n\nЧто может быть в check_cols (если применимо):\n- Какой-нибудь `user_agent` (если меняется в течение сессии — что странно).\n- `is_active` flag (если приложение помечает session как ended).\n- `subscription_tier_at_session` если этим обогащается сессия в источнике.\n\nПравильное решение для этого case: incremental model + JOIN с snapshot dimensions (customers_snapshot) для атрибуции атрибутов на момент. Snapshot — только на медленно меняющиеся dimensions.

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

Результат: 0 из 0
Аналитический
Вопрос 1 из 6. Snapshot products_snapshot работает 8 месяцев с check_cols=[price, category]. Бизнес просит добавить отслеживание is_active. Что произойдёт после добавления is_active в check_cols и запуска dbt snapshot?

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

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

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

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