From ed5a18d655b9a75aa0f8361fb9c9ddf50746f8d7 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 19 Oct 2025 12:33:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=20SCD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SCD.md | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 SCD.md diff --git a/SCD.md b/SCD.md new file mode 100644 index 0000000..f51a7f8 --- /dev/null +++ b/SCD.md @@ -0,0 +1,311 @@ + +# Медленно меняющиеся измерения (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.