Расширен раздел про SCD2 для append-only систем

This commit is contained in:
2025-11-04 17:20:35 +03:00
parent 1730f6801b
commit f789839643
+81 -27
View File
@@ -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` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.
---
Готово! Статья теперь включает полный цикл: от концепции — к реализации в традиционной СУБД — и далее к адаптации для современных аналитических платформ.