переписана реализация scd2

This commit is contained in:
2025-12-21 18:50:50 +03:00
parent 87301921a9
commit ae00475df5
15 changed files with 205 additions and 183 deletions
+22 -47
View File
@@ -107,7 +107,6 @@ WHERE customer_id = 1;
- **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента.
- **`valid_from`** — дата, **с которой** эта версия стала актуальной.
- **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**.
- **`is_current`** — флаг (`true`/`false`), который быстро показывает, какая строка — последняя. Удобен для запросов.
Пример структуры:
@@ -118,33 +117,31 @@ CREATE TABLE customers_scd2 (
name TEXT NOT NULL,
category TEXT NOT NULL,
valid_from DATE NOT NULL, -- с какой даты действует
valid_to DATE, -- по какую дату действовала (NULL = сейчас)
is_current BOOLEAN NOT NULL -- true = актуальная версия
valid_to DATE -- по какую дату действовала (NULL = сейчас)
);
```
**Шаг 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);
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'Regular', DATE '2024-01-01', NULL);
```
**Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**:
1. **Закрываем старую запись**: указываем, что она была актуальна **до 14 апреля**.
1. **Закрываем старую запись**: указываем дату начала новой версии (интервал `[valid_from, valid_to)`).
2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна.
```sql
-- 1. Завершаем предыдущую версию
UPDATE customers_scd2
SET valid_to = '2025-04-14',
is_current = false
WHERE customer_id = 1 AND is_current = true;
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL;
-- 2. Вставляем новую версию
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true);
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL);
```
Теперь в таблице две строки для одного клиента. И мы можем спросить:
@@ -155,36 +152,14 @@ VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true);
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');
AND DATE '2025-04-10' >= valid_from
AND (valid_to IS NULL OR DATE '2025-04-10' < valid_to);
```
Результат: `'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` — это **опциональное упрощение**, а не часть стандарта.
---
> 💡 Почему не `BETWEEN valid_from AND valid_to`?
> Потому что у актуальной записи `valid_to IS NULL`. В SQL сравнения с `NULL` не дают `TRUE`, поэтому для “текущей” версии обычно пишут `valid_to IS NULL OR ...`.
### Type 2 через логику, похожую на UPSERT
@@ -206,21 +181,21 @@ WHERE CURRENT_DATE BETWEEN valid_from AND valid_to
-- Шаг 1: вставляем новую версию, только если есть изменения
WITH last_version AS (
SELECT * FROM customers_scd2
WHERE customer_id = 1 AND is_current = true
WHERE customer_id = 1 AND valid_to IS NULL
)
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
SELECT 1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
SELECT 1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL
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
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL
AND EXISTS (
SELECT 1 FROM customers_scd2
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = '2025-04-15'
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = DATE '2025-04-15'
);
```
@@ -408,8 +383,8 @@ WHERE rn = 1;
Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции.
> **Почему не использовать `is_current`?**
> В append-only системах флаг `is_current` становится устаревшим сразу после новой вставки. Его очень сложно поддерживать без `UPDATE`, поэтому в таких архитектурах его чаще **не используют**, полагаясь полностью на даты и оконные функции. Это делает модель данных более чистой и идемпотентной.
> **Почему не использовать флаг «текущая версия»?**
> В append-only системах любой флаг актуальности становится устаревшим сразу после новой вставки. Без `UPDATE` его сложно поддерживать, поэтому в таких архитектурах чаще полагаются на даты и оконные функции — так модель остаётся идемпотентной.
Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении.
@@ -431,8 +406,8 @@ WHERE rn = 1;
## 6. Подводные камни и советы
- **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key).
- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает запросы.
- Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем.
- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает модель.
- Для условий “актуально на дату” используйте паттерн: `d >= valid_from AND (valid_to IS NULL OR d < valid_to)`.
- Type 2 увеличивает объём данных — но для аналитики это нормально.
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.