Проблема: атрибуты меняются
В OLTP-системе клиент может изменить адрес, email, segment. Простой UPDATE:
UPDATE customers SET country = 'CA' WHERE id = 42; -- был US
В DWH этого недостаточно. Возникают аналитические вопросы:
- Сколько мы продали клиентам в US на момент Q1 2025?
- Если клиент изменил country с US на CA — куда отнести его прошлогодние заказы?
- Что было raw-атрибутом клиента, когда он размещал заказ 12345?
Если мы просто UPDATE-нем dim_customer — мы перепишем историю. Прошлогодний отчёт “продажи в US” станет неверным, потому что country клиента уже стал CA.
Эта проблема — Slowly Changing Dimensions (SCD). Кимбалл определил несколько типов SCD, каждый — стратегия обработки изменений в dimension над временем.
SCD Type 0 и Type 1: крайние случаи
Type 0: Original — никогда не меняется
Атрибут заморожен. Используется для immutable значений: created_date, original_signup_country. Самый простой тип, используется редко.
Type 1: Overwrite
При изменении атрибута просто перезаписываем в DWH. История не сохраняется.
-- Источник: customer 42 сменил email
UPDATE dim_customer
SET email = '[email protected]'
WHERE customer_id = 42;
Подходит для:
- Опечаток и corrections (исправление неправильно введённого имени).
- Атрибутов без аналитического значения (phone number).
- Когда history не важна.
Не подходит для атрибутов, по которым делается аналитика во времени (country, segment, salary band).
SCD Type 2: Add row — сохраняем историю
Самый используемый тип. При изменении атрибута создаётся новая строка в dim. Старая помечается как expired, новая — current.
CREATE TABLE dim_customer (
customer_key BIGINT PRIMARY KEY, -- surrogate, разный для каждой версии
customer_id BIGINT, -- natural key, одинаковый для всех версий
name TEXT,
email TEXT,
country TEXT,
segment TEXT,
-- SCD Type 2 metadata
valid_from DATE, -- когда строка стала актуальной
valid_to DATE, -- когда стала не актуальной (NULL = current)
is_current BOOLEAN -- shortcut для WHERE is_current
);
Жизненный цикл строк
День 1: customer 42 регистрируется, country = US.
customer_key | customer_id | country | valid_from | valid_to | is_current
1 | 42 | US | 2025-01-01 | NULL | TRUE
День 100: customer 42 переехал в CA.
customer_key | customer_id | country | valid_from | valid_to | is_current
1 | 42 | US | 2025-01-01 | 2025-04-11 | FALSE
2 | 42 | CA | 2025-04-11 | NULL | TRUE
Старая строка получила valid_to = дата изменения, is_current = FALSE. Создана новая с новым surrogate customer_key. valid_to = NULL означает “актуальная сейчас”. Обрати внимание: valid_to старой строки совпадает с valid_from новой — это полуоткрытый интервал, поэтому point-in-time join valid_from <= order_date AND order_date < valid_to не теряет граничный день и не даёт перекрытий.
Связь с fact-таблицами
Это где surrogate keys критичны. Fact записи указывают на конкретную версию dimension.
Заказ 12345 от 2025-02-15 (когда customer был US): customer_key = 1.
Заказ 67890 от 2025-06-01 (когда customer стал CA): customer_key = 2.
fact_orders:
order_id | customer_key | order_date | amount
12345 | 1 | 2025-02-15 | 100
67890 | 2 | 2025-06-01 | 200
Теперь запрос “продажи в US в 2025” корректно возвращает заказ 12345 (в момент заказа клиент был US-resident), а заказ 67890 идёт в CA. История сохранена точно.
Каждое изменение — новая строка с новым surrogate key
Реализация: dbt snapshot
В практике SCD Type 2 автоматизирует dbt через snapshot:
{% snapshot dim_customer_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='check',
check_cols=['country', 'segment', 'email']
)
}}
SELECT customer_id, name, email, country, segment
FROM {{ source('postgres', 'customers') }}
{% endsnapshot %}
При каждом run dbt сравнивает source с предыдущим snapshot. Если check_cols изменились — expires old row (dbt_valid_to = NOW()) и inserts new row (dbt_valid_from = NOW()). Создаёт metadata колонки: dbt_valid_from, dbt_valid_to, dbt_scd_id. Это стандарт Modern Data Stack — большинство production dbt-проектов используют snapshots для SCD.
SCD Type 3: Previous column
Хранится предыдущее значение в отдельной колонке. Только last change запоминается, более глубокая история теряется.
CREATE TABLE dim_customer (
customer_key BIGINT PRIMARY KEY,
customer_id BIGINT,
country TEXT, -- current
previous_country TEXT, -- one step back
country_change_date DATE
);
После переезда US -> CA:
customer_key | country | previous_country | country_change_date
1 | CA | US | 2025-04-11
Подходит когда нужно простое сравнение “before/after” без полной истории. Используется редко — Type 2 в большинстве случаев лучше.
SCD Type 6: Hybrid (кратко)
Type 6 совмещает Type 1, Type 2 и Type 3 в одной dim:
- Type 2 строки для исторической accuracy.
- Type 1 атрибут с current value (для быстрых current-запросов без поиска active row).
- Type 3 атрибут с previous value (для before/after сравнений).
Это даёт максимальную гибкость, но требует поддержки трёх типов одновременно. Используется в зрелых DWH для критичных dimensions.
Какой type когда использовать
Какой type подходит для каждого атрибута
| Атрибут | Type | Почему |
|---|---|---|
| Имя клиента (опечатка) | Type 1 | Correction, история не нужна |
| Country | Type 2 | Используется в historical analytics |
| Segment | Type 2 | Tracking customer journey |
| Type 1 | Не аналитический атрибут | |
| Signup date | Type 0 | Immutable |
| Salary band | Type 2 | Compensation analysis |
| Phone | Type 1 | Не для аналитики |
| Subscription tier | Type 2 | Critical for revenue analytics |
| Address (точный) | Type 1 | Для current shipping, не для history |
| Country (для tax) | Type 2 | Critical for tax history |
Производительность и storage
SCD Type 2 раздувает dim_customer. Если customer обновляется в среднем 2 раза в год, и компания работает 10 лет — для каждого customer примерно 20 строк. На 1M customers — это 20M строк dim. Не катастрофа, но dim становится больше fact. Современные columnar DWH справляются.
Оптимизации:
- Не делать Type 2 на атрибутах, которые часто меняются (выносить в mini-dim).
- Использовать смысловые change detection — не каждое CDC event = SCD изменение.
- Архивировать старые versions, если глубокая история не нужна.
Попробуй сам
- Реализуй SCD Type 2 руками в DuckDB. Создай table
customers, симулируй 3-4 изменения country/segment. Напиши SQL для update dim_customer с правильной экспирацией. - Используй dbt snapshot для того же — посмотри, как dbt автоматически creates valid_from/valid_to.
- Создай fact_orders с FK к dim_customer (через customer_key). Сделай запрос “продажи по country” за период, когда customer был US, и убедись что результаты корректны после change.
- Спроектируй: какие атрибуты dim_employee в HR DWH — Type 1, Type 2, Type 0? (salary, department, manager, hire_date, name, address).
Дизайн dimensional моделей разбираем в будущем dwh-design-course.