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