Files
de-roadmap/SCD.md
T
2025-10-19 12:33:27 +03:00

312 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Медленно меняющиеся измерения (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 мы и так **не меняем старые данные**, а только **добавляем новые**!
Алгоритм:
1. Читаем последнюю версию измерения (например, через оконную функцию).
2. Сравниваем с новыми данными.
3. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`.
4. Старые строки остаются нетронутыми.
Пример в Trino/Hive-стиле (только INSERT):
```sql
-- new_customers — staging-таблица с обновлёнными данными
-- dim_customers_scd2 — основная таблица (append-only)
INSERT INTO dim_customers_scd2
WITH latest AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
FROM dim_customers_scd2
),
current AS (
SELECT * FROM latest WHERE rn = 1
)
SELECT
uuid() AS customer_key, -- или хеш, sequence и т.п.
n.customer_id,
n.name,
n.category,
current_date AS valid_from,
CAST(NULL AS DATE) AS valid_to,
true AS is_current
FROM new_customers n
LEFT JOIN current c ON n.customer_id = c.customer_id
WHERE c.category IS DISTINCT FROM n.category;
```
А чтобы «закрыть» предыдущую версию, **не нужно её обновлять** — просто при чтении всегда берите **актуальную на дату** версию через условия с `valid_from`/`valid_to`.
> Таким образом, SCD Type 2 **идеально подходит** для систем без поддержки UPDATE — потому что он по своей природе **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.