Расширен раздел про SCD2 для append-only систем
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных
|
# Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных
|
||||||
|
|
||||||
> **Для кого эта статья?**
|
> **Для кого эта статья?**
|
||||||
@@ -34,7 +33,7 @@ SCD — это подход к хранению изменений в измер
|
|||||||
Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.
|
Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.
|
||||||
|
|
||||||
### **Type 0 — Никогда не меняется**
|
### **Type 0 — Никогда не меняется**
|
||||||
Атрибут фиксирован навсегда. Например, дата рождения клиента или идентификатор паспорта.
|
Атрибут фиксирован навсегда. Например, дата рождения клиента.
|
||||||
Такие поля не требуют специальной обработки — они просто не обновляются.
|
Такие поля не требуют специальной обработки — они просто не обновляются.
|
||||||
|
|
||||||
### **Type 1 — Просто перезаписать**
|
### **Type 1 — Просто перезаписать**
|
||||||
@@ -62,7 +61,7 @@ SCD — это подход к хранению изменений в измер
|
|||||||
|
|
||||||
> Используется редко, чаще как компромисс в очень простых системах.
|
> Используется редко, чаще как компромисс в очень простых системах.
|
||||||
|
|
||||||
### **Type 4, 5, 6 — Продвинутые гибриды (для справки)**
|
### **Type 4, 5, 6 — Продвинутые гибриды**
|
||||||
Эти типы существуют, но **встречаются редко** и почти не используются новичками:
|
Эти типы существуют, но **встречаются редко** и почти не используются новичками:
|
||||||
|
|
||||||
- **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
|
- **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
|
||||||
@@ -237,12 +236,10 @@ WHERE customer_id = 1 AND is_current = true
|
|||||||
|
|
||||||
Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**!
|
Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**!
|
||||||
|
|
||||||
Алгоритм:
|
Алгоритм загрузки (ETL):
|
||||||
|
1. Сравнить входящие данные с последней версией в таблице.
|
||||||
1. Читаем последнюю версию измерения (например, через оконную функцию).
|
2. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`.
|
||||||
2. Сравниваем с новыми данными.
|
3. Старые строки остаются нетронутыми.
|
||||||
3. Если есть изменения — **пишем новую строку** с новыми `valid_from` / `valid_to`.
|
|
||||||
4. Старые строки остаются нетронутыми.
|
|
||||||
|
|
||||||
Пример в Trino/Hive-стиле (только INSERT):
|
Пример в Trino/Hive-стиле (только INSERT):
|
||||||
|
|
||||||
@@ -251,30 +248,81 @@ WHERE customer_id = 1 AND is_current = true
|
|||||||
-- dim_customers_scd2 — основная таблица (append-only)
|
-- dim_customers_scd2 — основная таблица (append-only)
|
||||||
|
|
||||||
INSERT INTO dim_customers_scd2
|
INSERT INTO dim_customers_scd2
|
||||||
WITH latest AS (
|
WITH current_customers AS (
|
||||||
SELECT *,
|
SELECT *
|
||||||
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
FROM (
|
||||||
FROM dim_customers_scd2
|
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
|
WHERE rn = 1 -- отбираем первую, самую свежую, запись
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
uuid() AS customer_key, -- или хеш, sequence и т.п.
|
uuid() AS customer_key, -- уникальный ID каждой версии
|
||||||
n.customer_id,
|
n.customer_id, -- неизменный бизнес-ID клиента
|
||||||
n.name,
|
n.name,
|
||||||
n.category,
|
n.category,
|
||||||
current_date AS valid_from,
|
valid_from
|
||||||
CAST(NULL AS DATE) AS valid_to,
|
|
||||||
true AS is_current
|
|
||||||
FROM new_customers n
|
FROM new_customers n
|
||||||
LEFT JOIN current c ON n.customer_id = c.customer_id
|
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
|
||||||
WHERE c.category IS DISTINCT FROM n.category;
|
WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялись
|
||||||
```
|
```
|
||||||
|
|
||||||
А чтобы «закрыть» предыдущую версию, **не нужно её обновлять** — просто при чтении всегда берите **актуальную на дату** версию через условия с `valid_from`/`valid_to`.
|
**Главный вопрос после загрузки: как же читать эти данные?**
|
||||||
|
|
||||||
> Таким образом, SCD Type 2 **идеально подходит** для систем без поддержки UPDATE — потому что он по своей природе **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 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 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 системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -294,7 +342,7 @@ WHERE c.category IS DISTINCT FROM n.category;
|
|||||||
## 6. Подводные камни и советы
|
## 6. Подводные камни и советы
|
||||||
|
|
||||||
- **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key).
|
- **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key).
|
||||||
- Всегда задавайте `valid_to` как `NULL` для актуальной записи — это упрощает запросы.
|
- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает запросы.
|
||||||
- Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем.
|
- Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем.
|
||||||
- Type 2 увеличивает объём данных — но для аналитики это нормально.
|
- Type 2 увеличивает объём данных — но для аналитики это нормально.
|
||||||
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
|
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
|
||||||
@@ -303,9 +351,15 @@ WHERE c.category IS DISTINCT FROM n.category;
|
|||||||
|
|
||||||
## 7. Заключение
|
## 7. Заключение
|
||||||
|
|
||||||
Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени.
|
Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени.
|
||||||
|
|
||||||
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своём PostgreSQL. Задайте себе вопрос:
|
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своём PostgreSQL. Задайте себе вопрос:
|
||||||
> «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
|
> «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
|
||||||
|
|
||||||
Если нет — вам нужен SCD Type 2.
|
Если нет — вам нужен SCD Type 2.
|
||||||
|
|
||||||
|
И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Готово! Статья теперь включает полный цикл: от концепции — к реализации в традиционной СУБД — и далее к адаптации для современных аналитических платформ.
|
||||||
Reference in New Issue
Block a user