# Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных > **Для кого эта статья?** > Для тех, кто уже умеет писать базовые SQL-запросы в PostgreSQL, знаком с понятиями таблиц, строк и колонок, и теперь делает первые шаги в аналитике и проектировании хранилищ данных. --- ## 1. Введение: зачем вообще нужны измерения и почему они «медленно меняются»? Представьте, что вы строите отчёт по продажам. У вас есть таблица с фактами — например, «продано 10 единиц товара X клиенту Y 15 марта». Но чтобы понять, *кто такой клиент Y* или *что за товар X*, вам нужны **справочники** — таблицы с описанием клиентов, товаров, регионов и т.п. В мире аналитики такие справочники называют **измерениями** (*dimensions*), а таблицу с продажами — **фактами** (*facts*). А теперь представьте: клиент сменил адрес или перешёл в другую категорию (например, из «обычного» в «VIP»). Если вы просто обновите строку в таблице клиентов, то потеряете информацию о том, **какой статус у клиента был на момент продажи**. А это критично: отчёт «продажи VIP-клиентам в марте» окажется неверным! Такие атрибуты — которые **меняются со временем, но не каждый день** — и называются **медленно меняющимися измерениями** (*Slowly Changing Dimensions*, **SCD**). --- ## 2. Что такое Slowly Changing Dimensions (SCD)? SCD — это подход к хранению изменений в измерениях **с учётом времени**. Он позволяет отвечать на вопросы вроде: - Какой адрес у клиента был **на дату заказа**? - Сколько продаж пришлось на товары категории «Электроника» **до того, как её переименовали в «Гаджеты»**? Без SCD вы видите только **текущее состояние**, а с ним — **всю историю**. --- ## 3. Типы SCD — простыми словами Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные. ### **Type 0 — Никогда не меняется** Атрибут фиксирован навсегда. Например, дата рождения клиента. Такие поля не требуют специальной обработки — они просто не обновляются. ### **Type 1 — Просто перезаписать** Вы просто делаете `UPDATE`, и старое значение исчезает. ✅ Просто. ❌ История теряется. > Подходит, если изменение — это исправление ошибки (например, опечатка в имени). ### **Type 2 — Новая строка для новой версии** Каждое изменение порождает **новую строку** в таблице. Старая строка остаётся, но помечается как «устаревшая». ✅ Полная история. ✅ Можно восстановить состояние на любую дату. ❌ Больше данных, сложнее запросы. > Это **самый распространённый** подход в аналитике. ### **Type 3 — Добавить колонку «предыдущее значение»** В таблице появляются поля вроде `previous_category`, `category_change_date`. ✅ Простая история «до/после». ❌ Хранит только **одно** предыдущее значение. Не масштабируется. > Используется редко, чаще как компромисс в очень простых системах. ### **Type 4, 5, 6 — Продвинутые гибриды** Эти типы существуют, но **встречаются редко** и почти не используются новичками: - **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения). - **Type 5**: комбинация Type 4 и Type 1 — текущее значение в основной таблице, а история — отдельно. - **Type 6**: объединяет Type 1, 2 и 3 в одной таблице — очень гибко, но сложно. > Вам **не нужно запоминать** эти типы сейчас. Достаточно знать, что они бывают — на случай, если встретите их в документации. --- ## 4. Практический пример на PostgreSQL: Type 1 vs Type 2 Допустим, у нас есть таблица клиентов: ```sql -- Исходная таблица (до изменений) CREATE TABLE customers ( customer_id SERIAL PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL -- например: 'Regular', 'VIP' ); ``` ### Type 1: просто обновляем Клиент №1 стал VIP: ```sql UPDATE customers SET category = 'VIP' WHERE customer_id = 1; ``` Теперь в таблице только новое значение. Если продажа была сделана **до** этого UPDATE, вы не узнаете, что клиент тогда был «Regular». --- ### Type 2: сохраняем историю — подробнее Чтобы хранить историю, мы меняем структуру таблицы. Вот ключевые поля: - **`customer_key`** — искусственный (surrogate) первичный ключ. Он **уникален для каждой версии** клиента. - **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента. - **`valid_from`** — дата, **с которой** эта версия стала актуальной. - **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**. - **`is_current`** — флаг (`true`/`false`), который быстро показывает, какая строка — последняя. Удобен для запросов. Пример структуры: ```sql CREATE TABLE customers_scd2 ( customer_key SERIAL PRIMARY KEY, -- уникальный ID каждой версии customer_id INT NOT NULL, -- неизменный бизнес-ID клиента name TEXT NOT NULL, category TEXT NOT NULL, valid_from DATE NOT NULL, -- с какой даты действует valid_to DATE, -- по какую дату действовала (NULL = сейчас) is_current BOOLEAN NOT NULL -- true = актуальная версия ); ``` **Шаг 1.** Добавляем начальную запись (клиент зарегистрировался 1 января 2024): ```sql INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current) VALUES (1, 'Иван Петров', 'Regular', '2024-01-01', NULL, true); ``` **Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**: 1. **Закрываем старую запись**: указываем, что она была актуальна **до 14 апреля**. 2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна. ```sql -- 1. Завершаем предыдущую версию UPDATE customers_scd2 SET valid_to = '2025-04-14', is_current = false WHERE customer_id = 1 AND is_current = true; -- 2. Вставляем новую версию INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current) VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true); ``` Теперь в таблице две строки для одного клиента. И мы можем спросить: > Какой была категория клиента **на 10 апреля 2025**? ```sql SELECT category FROM customers_scd2 WHERE customer_id = 1 AND '2025-04-10' >= valid_from AND '2025-04-10' <= COALESCE(valid_to, '9999-12-31'); ``` Результат: `'Regular'` — правильно! > 💡 Почему `COALESCE(valid_to, '9999-12-31')`? > Потому что у актуальной записи `valid_to IS NULL`. Чтобы условие работало, мы временно заменяем `NULL` на очень далёкую дату. --- ### Type 2: можно ли обойтись без `is_current`? Поле `is_current` **не обязательно**. Оно удобно для быстрого поиска актуальной версии (например, `WHERE is_current = true`), но **всю ту же информацию несёт пара `valid_from` / `valid_to`**. Многие реализации SCD Type 2 обходятся без этого флага — особенно в системах, где важна нормализация или минимизация избыточности. В таких случаях актуальность определяют по условию: ```sql WHERE valid_to IS NULL ``` Или, если используется «закрытый» интервал (например, `valid_to = '2025-04-14'` для прошлой версии, а у новой — `valid_from = '2025-04-15'`, `valid_to = '9999-12-31'`), то: ```sql WHERE CURRENT_DATE BETWEEN valid_from AND valid_to ``` Так что `is_current` — это **опциональное упрощение**, а не часть стандарта. --- ### Type 2 через логику, похожую на UPSERT В реальных ETL-процессах часто используют **идемпотентные** операции: запуск скрипта дважды не должен ломать данные. Для этого удобно применять подход, похожий на *upsert* (update + insert), но адаптированный под логику SCD Type 2. В PostgreSQL классический `ON CONFLICT` не подходит напрямую, потому что мы **не обновляем существующую строку**, а **добавляем новую при изменении**. Поэтому логика выглядит так: 1. Сравнить входящие данные с последней версией в таблице. 2. Если атрибуты изменились — закрыть старую запись и вставить новую. 3. Если не изменились — ничего не делать. Пример (часто реализуется в Python, dbt, Airflow и т.п.): ```sql -- Предположим, новая версия: customer_id=1, category='VIP', effective_date='2025-04-15' -- Шаг 1: вставляем новую версию, только если есть изменения WITH last_version AS ( SELECT * FROM customers_scd2 WHERE customer_id = 1 AND is_current = true ) INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current) SELECT 1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true WHERE EXISTS ( SELECT 1 FROM last_version WHERE category != 'VIP' ); -- Шаг 2: если вставка произошла — закрываем старую запись UPDATE customers_scd2 SET valid_to = '2025-04-14', is_current = false WHERE customer_id = 1 AND is_current = true AND EXISTS ( SELECT 1 FROM customers_scd2 WHERE customer_id = 1 AND category = 'VIP' AND valid_from = '2025-04-15' ); ``` На практике такие логики чаще выносят в **ETL-инструменты** (например, dbt с пакетом `dbt-scd`), потому что чистый SQL быстро становится громоздким. > 💡 Главное: **SCD Type 2 — это не одна операция, а процесс**: сравнить → закрыть старое → добавить новое. --- ### А что, если СУБД не позволяет UPDATE? (Trino, Hive, ClickHouse в режиме append-only) Некоторые аналитические системы (например, **Hive в формате ORC/Parquet**, **Trino**, **ClickHouse в режиме только вставки**) **не поддерживают UPDATE старых строк**. Как тогда реализовать SCD Type 2? Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**! Алгоритм загрузки (ETL): 1. Сравнить входящие данные с последней версией в таблице. 2. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`. 3. Старые строки остаются нетронутыми. Пример в Trino/Hive-стиле (только INSERT): Давайте разберём этот запрос по шагам, чтобы понять его логику: ```sql -- new_customers — staging-таблица с обновлёнными данными -- dim_customers_scd2 — основная таблица (append-only) INSERT INTO dim_customers_scd2 WITH current_customers AS ( SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn FROM dim_customers_scd2 ) WHERE rn = 1 -- отбираем первую, самую свежую, запись ) SELECT uuid() AS customer_key, -- уникальный ID каждой версии n.customer_id, -- неизменный бизнес-ID клиента n.name, n.category, COALESCE(n.effective_date, current_date) AS valid_from -- дата начала действия новой версии FROM new_customers n LEFT JOIN current_customers c ON n.customer_id = c.customer_id WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялись ``` ### 🎯 Как работает этот запрос: пошаговое объяснение #### Шаг 1: Подготовка данных (CTE current_customers) CTE `current_customers` находит **последнюю версию** каждого клиента из таблицы `dim_customers_scd2`: ```sql SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn FROM dim_customers_scd2 ``` - `PARTITION BY customer_id` — группируем по клиентам - `ORDER BY valid_from DESC` — сортируем версии от самой новой к самой старой - `WHERE rn = 1` — выбираем только самую свежую версию #### Шаг 2: Сравнение данных (LEFT JOIN + WHERE) Теперь сравниваем новые данные с текущими: ```sql FROM new_customers n LEFT JOIN current_customers c ON n.customer_id = c.customer_id ``` **Возможные сценарии после JOIN:** | Сценарий | n.customer_id | c.customer_id | Условие WHERE | Результат | |----------|---------------|---------------|---------------|-----------| | Новый клиент | 2 | NULL | ✅ `c.customer_id IS NULL` | Вставляется | | Категория изменилась | 1 | 1 | ✅ `c.category != n.category` | Вставляется | | Без изменений | 3 | 3 | ❌ оба условия ложны | Пропускается | #### Шаг 3: Вставка новых версий Для подходящих записей создаём новую версию: - `uuid()` — генерируем уникальный ключ для новой версии (функция, скобки обязательны) - `COALESCE(n.effective_date, current_date)` — устанавливаем дату начала действия новой версии > 💡 **Правильный подход к датам**: В реальных ETL-процессах важно использовать дату из исходных данных, когда она доступна. Мы используем `COALESCE(n.effective_date, current_date)`, что означает: > - Если в `new_customers` есть поле `effective_date` — используем его > - Если нет — используем текущую дату (`current_date`) > > **Почему это важно:** > - `effective_date` отражает реальную дату изменения (например, когда клиент стал VIP) > - `current_date` — это дата загрузки данных, которая может не совпадать с датой изменения > - Использование правильной даты критично для точного исторического анализа > > **Пример правильной структуры исходных данных:** > ```sql > new_customers: > customer_id | name | category | effective_date > 1 | Иван Петров | VIP | 2025-04-15 ← дата реального изменения > ``` #### Практический пример **До выполнения запроса:** ``` dim_customers_scd2: customer_id | name | category | valid_from 1 | Иван Петров | Regular | 2024-01-01 ``` **Новые данные:** ``` new_customers: customer_id | name | category 1 | Иван Петров | VIP ← изменилась категория 2 | Мария Иванова| Regular ← новый клиент ``` **После выполнения запроса:** ``` dim_customers_scd2: customer_id | name | category | valid_from 1 | Иван Петров | Regular | 2024-01-01 ← старая версия 1 | Иван Петров | VIP | 2025-11-04 ← новая версия 2 | Мария Иванова| Regular | 2025-11-04 ← новый клиент ``` > 💡 **Ключевой момент**: В append-only системах мы **не обновляем** старые записи, а только **добавляем новые**. История сохраняется автоматически! **Главный вопрос после загрузки: как же читать эти данные?** Поскольку мы не можем обновлять `valid_to` у предыдущей версии (у неё останется `NULL`), стандартный подход с `BETWEEN` не сработает. Вместо этого, для поиска нужной версии мы полагаемся на **оконные функции** или на логику «ближайшей даты, но не позже». #### Паттерн 1: Найти последнюю (актуальную) версию на сегодня Это самый частый запрос. Мы хотим видеть самую свежую информацию о клиенте. ```sql -- Вариант с оконной функцией (универсальный и надежный) WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn FROM dim_customers_scd2 ) SELECT customer_id, name, category, valid_from FROM ranked WHERE rn = 1; ``` Этот запрос берёт строку с самой поздней датой начала действия для каждого клиента. #### Паттерн 2: Найти версию, которая была актуальна на конкретную дату Это основная цель SCD2. Например, «какой статус клиента был на дату заказа `2025-03-15`?». В append-only мире у нас нет `valid_to`, поэтому мы ищем **последнюю версию, у которой `valid_from` ≤ целевой даты**. ```sql -- Запрос для получения состояния на '2025-03-15' WITH as_of_date AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn FROM dim_customers_scd2 WHERE valid_from <= DATE '2025-03-15' ) SELECT customer_id, name, category, valid_from FROM as_of_date WHERE rn = 1; ``` **Как это работает:** 1. `WHERE valid_from <= DATE '2025-03-15'` отфильтровывает все версии, которые появились **после** нашей целевой даты. 2. `ORDER BY valid_from DESC` сортирует оставшиеся версии от самой свежей к самой старой. 3. `ROW_NUMBER() ... WHERE rn = 1` выбирает самую свежую из **актуальных на ту дату** версий. Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции. > **Почему не использовать `is_current`?** > В append-only системах флаг `is_current` становится устаревшим сразу после новой вставки. Его очень сложно поддерживать без `UPDATE`, поэтому в таких архитектурах его чаще **не используют**, полагаясь полностью на даты и оконные функции. Это делает модель данных более чистой и идемпотентной. Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении. --- ## 5. Когда что использовать? | Сценарий | Рекомендуемый тип | |--------|------------------| | Исправление опечатки | Type 1 | | Юридически значимые изменения (статус, тариф, регион) | Type 2 | | Очень простая аналитика без требований к истории | Type 1 | | Нужна только «последняя смена» и ничего больше | Type 3 (осторожно!) | **Совет новичку**: если сомневаетесь — выбирайте **Type 2**. Лучше иметь историю и не использовать её, чем не иметь и не суметь ответить на важный вопрос. --- ## 6. Подводные камни и советы - **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key). - Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает запросы. - Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем. - Type 2 увеличивает объём данных — но для аналитики это нормально. - В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией. --- ## 7. Заключение Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени. Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своём PostgreSQL. Задайте себе вопрос: > «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?» Если нет — вам нужен SCD Type 2. И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.