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 — фиксируется новая версия.
Главное отличие от 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— слабоструктурированные текстовые поля.
Если включить шумную колонку в 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. Что произойдёт?
- dbt считает хеш по новому набору
(tier, customer_segment)для всех строк source. - Сравнивает с
dbt_scd_idактивных строк snapshot. Их хеш был посчитан по(tier)— это другой набор, хеши не совпадут. - Для всех строк создаются новые версии. Старые закрываются с
dbt_valid_to = run_started_at.
Результат: вся snapshot-таблица «изменилась» в момент добавления колонки, хотя бизнес-логически данные не менялись. История загрязнена синтетическими переходами.
Добавление колонки в 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 через staging —
NULLIF(category, '') AS category, чтобы пустые строки преобразовать в NULL. - Использовать dbt 1.9+ snapshot_meta_column_names — кастомизировать имена столбцов, отдельно проверить документацию вашего адаптера.
Schema evolution — что делать, когда source меняет схему
Source — это живая таблица. Колонки добавляются, удаляются, переименовываются. Snapshot должен пережить это.
| Изменение в source | check-стратегия | timestamp-стратегия |
|---|---|---|
| Добавили колонку (не в check_cols) | Игнорируется. Новая колонка не появится в snapshot до —full-refresh. | Аналогично — snapshot хранит только колонки на момент создания. |
| Добавили колонку, нужно добавить в check_cols | Synthetic-обновления (см. выше). | N/A. |
| Удалили колонку из source | Snapshot ломается при INSERT new versions — нет такой колонки. Нужен dbt snapshot --full-refresh или ручной DROP COLUMN. | Аналогично. |
| Переименовали колонку | Snapshot ломается. Нужен alias в relation SQL или full-refresh. | Аналогично. |
| Изменили тип колонки | Зависит от warehouse. Postgres / Snowflake часто implicit-cast, DuckDB строже. Может сломаться. | Аналогично. |
Общая стратегия:
- 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') }}
-
Тесты на snapshot контракт. В dbt 1.5+ есть
model contracts(модуль 10 курса) — можно жёстко задать колонки/типы. -
Документация изменений. Каждое изменение 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:
- Создайте
products_snapshotсcheck_cols: [price]. Запуститеdbt snapshot. - Измените цену одного product в source. Снова
dbt snapshot. Проверьте — появилась новая версия сdbt_valid_from = run_started_at(не реальное время изменения, а время snapshot). - Добавьте колонку
categoryв check_cols в YAML. Запуститеdbt snapshot. Что произошло? Должны увидеть, что все строки получили новые версии (synthetic-обновление). - Откатите изменение в YAML, запустите
dbt snapshot --full-refresh. Snapshot пересоздан с нуля. - Снова добавьте
category, но в этот раз сначала backup snapshot, потом измените YAML, потом запустите. Подумайте о том, можно ли «безопасно» добавить колонку без потери истории.
Это упражнение делает ощутимой главную проблему check-стратегии: hash коммерчески не stable при изменении набора колонок.
Ключевые выводы
- Check-стратегия сравнивает хеш по
check_cols. Дороже timestamp, но не требует updated_at. dbt_valid_fromставится вrun_started_at, не в реальное время изменения. Точность ограничена интервалом snapshot.check_cols: 'all'— антипаттерн. Всегда явный список business-critical колонок.- Добавление/удаление колонки в check_cols ломает хеш — все строки получают synthetic-обновления. Mitigation:
--full-refreshили ручная миграция с backup. - Schema evolution в source требует промежуточного
.sql-snapshot-relation как буфера. - NULL обрабатывается через sentinel в хеше. Избегайте sentinel-строк в реальных данных.
- Если у source появился стабильный
updated_at— мигрируйте на timestamp. Это надёжнее.