переписана реализация 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
+1
View File
@@ -1,2 +1,3 @@
.env .env
.idea .idea
.internal/
+1 -1
View File
@@ -293,7 +293,7 @@ PIT (Point-in-Time) решает очень конкретную боль:
> «Покажи, как объект выглядел **на дату X**, но так, чтобы запрос был простым». > «Покажи, как объект выглядел **на дату X**, но так, чтобы запрос был простым».
Если у нас есть несколько сателлитов с историей (например, `sat_customer_info`, `sat_customer_segment`, `sat_customer_risk`), то без PIT любой запрос превращается в пачку условий Если у нас есть несколько сателлитов с историей (например, `sat_customer_info`, `sat_customer_segment`, `sat_customer_risk`), то без PIT любой запрос превращается в пачку условий
`BETWEEN valid_from AND valid_to` (в effectivity-сателлитах или любых таблицах с периодами действия) или оконных функций. `as_of_date >= valid_from AND (valid_to IS NULL OR as_of_date < valid_to)` (в effectivity-сателлитах или любых таблицах с периодами действия) или оконных функций.
**Идея PIT:** **Идея PIT:**
@@ -31,7 +31,7 @@
```text ```text
customer_id,status,event_ts,_load_id,load_ts customer_id,status,event_ts,_load_id,load_ts
101,new,2024-01-01 09:00:00,batch_20250405_0800,2025-04-05 08:00:00 101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
... ...
``` ```
@@ -117,7 +117,6 @@ ORDER BY customer_id, event_ts;
- `status` — статус клиента; - `status` — статус клиента;
- `hashdiff` — хэш от атрибутов (здесь достаточно самого `status`); - `hashdiff` — хэш от атрибутов (здесь достаточно самого `status`);
- `valid_from` / `valid_to` — период, когда статус был актуален; - `valid_from` / `valid_to` — период, когда статус был актуален;
- `is_current` — флаг актуальной строки;
- `created_at` / `updated_at` — технические поля. - `created_at` / `updated_at` — технические поля.
### 4.1. Начальная загрузка SCD2 ### 4.1. Начальная загрузка SCD2
@@ -131,18 +130,18 @@ ORDER BY customer_id, event_ts;
- `event_ts` (как «время начала действия статуса»), - `event_ts` (как «время начала действия статуса»),
- `hashdiff` (например, `md5(status)`; можно вынести расчёт в отдельную функцию по аналогии с `dds.customer_hash` для клиентов). - `hashdiff` (например, `md5(status)`; можно вынести расчёт в отдельную функцию по аналогии с `dds.customer_hash` для клиентов).
2. Для каждого клиента отсортируйте события по `event_ts` и с помощью `LEAD()` посчитайте: 2. Для каждого клиента отсортируйте события по `event_ts` и с помощью `LEAD()` посчитайте (в учебном варианте считаем, что обновление DWH идёт раз в день, поэтому используем `DATE`):
- `valid_from` текущее `event_ts`, - `valid_from``event_ts::date`,
- `valid_to` — следующее `event_ts - 1 second` (или `9999-12-31`, если следующего нет). - `valid_to` — следующий `event_ts::date` (а у последней версии `valid_to = NULL`).
3. Вставьте получившиеся строки в `dds.dim_customer_status`. У актуальной строки для каждого клиента задайте `is_current = TRUE` (например, там, где `valid_to = '9999-12-31'`), у остальных — `FALSE`: 3. Вставьте получившиеся строки в `dds.dim_customer_status`. Актуальная строка для клиента — та, где `valid_to IS NULL`.
```sql ```sql
INSERT INTO dds.dim_customer_status ( INSERT INTO dds.dim_customer_status (
customer_bk, status, hashdiff, customer_bk, status, hashdiff,
valid_from, valid_to, valid_from, valid_to,
is_current, created_at, updated_at created_at, updated_at
) )
SELECT SELECT
... ...
@@ -159,7 +158,7 @@ ORDER BY customer_bk, valid_from;
Ожидаемое поведение: Ожидаемое поведение:
- у клиента 101 несколько строк с разными статусами и непересекающимися периодами; - у клиента 101 несколько строк с разными статусами и непересекающимися периодами;
- `is_current = TRUE` только у самой свежей строки для каждого клиента. - `valid_to IS NULL` только у самой свежей строки для каждого клиента.
### 4.2. Проверка себя ### 4.2. Проверка себя
@@ -182,8 +181,8 @@ ORDER BY customer_bk, valid_from;
- ориентируйтесь на пример из `03_demo_increment.sql` для `dds.dim_customer`; - ориентируйтесь на пример из `03_demo_increment.sql` для `dds.dim_customer`;
- важно: - важно:
- корректно «закрыть» старую актуальную строку (`valid_to`, `is_current = FALSE`); - корректно «закрыть» старую актуальную строку (заполнить `valid_to` датой начала новой версии);
- вставить новую строку с `is_current = TRUE`. - вставить новую строку с `valid_to = NULL`.
Эта часть особенно полезна, если вы хотите почувствовать, как SCD2 живёт в реальном DWH. Эта часть особенно полезна, если вы хотите почувствовать, как SCD2 живёт в реальном DWH.
+20 -19
View File
@@ -234,13 +234,12 @@ erDiagram
dim_customer { dim_customer {
bigint customer_sk PK "суррогатный ключ" bigint customer_sk PK "суррогатный ключ"
varchar customer_bk "бизнес-ключ, напр. '101'" int customer_bk "бизнес-ключ, напр. 101"
varchar customer_name varchar customer_name
varchar email varchar email
varchar city varchar city
date valid_from "SCD2: с какой даты запись актуальна" date valid_from "SCD2: с какой даты запись актуальна"
date valid_to "SCD2: по какую дату актуальна" date valid_to "SCD2: по какую дату актуальна (NULL = сейчас)"
boolean is_current "SCD2: текущая версия?"
} }
dim_product { dim_product {
@@ -269,15 +268,16 @@ erDiagram
В `dim_customer` это будет **три строки**: В `dim_customer` это будет **три строки**:
| customer_sk | customer_bk | email | city | valid_from | valid_to | is_current | | customer_sk | customer_bk | email | city | valid_from | valid_to |
|-------------|-------------|-------|------|------------|----------|------------| |-------------|-------------|-------|------|------------|----------|
| 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-15 | false | | 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-16 |
| 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-09-30 | false | | 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-10-01 |
| 1003 | 101 | b@ex.com | СПб | 2023-10-01 | 9999-12-31 | true | | 1003 | 101 | b@ex.com | СПб | 2023-10-01 | NULL |
Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где: Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где:
```sql ```sql
fact_sales.order_date BETWEEN dim_customer.valid_from AND dim_customer.valid_to fact_sales.order_date >= dim_customer.valid_from
AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid_to)
``` ```
и получаем актуальный на тот день email и город. и получаем актуальный на тот день email и город.
@@ -554,8 +554,8 @@ JOIN dds.dim_product p
ON f.product_sk = p.product_sk ON f.product_sk = p.product_sk
JOIN dds.dim_customer c JOIN dds.dim_customer c
ON f.customer_sk = c.customer_sk ON f.customer_sk = c.customer_sk
AND f.order_date BETWEEN c.valid_from AND c.valid_to -- SCD! AND f.order_date >= c.valid_from
WHERE c.is_current = true -- или не фильтровать — тогда будет история AND (c.valid_to IS NULL OR f.order_date < c.valid_to) -- SCD!
GROUP BY d.date_actual, p.product_name, c.customer_segment; GROUP BY d.date_actual, p.product_name, c.customer_segment;
``` ```
@@ -775,10 +775,11 @@ SELECT 'OK' WHERE EXISTS (
[`customers.csv`](data/customers.csv): [`customers.csv`](data/customers.csv):
```csv ```csv
customer_id,email,phone,city customer_id,email,phone,city,event_ts,_load_id,load_ts
101,a@ex.com,700,Москва 101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
101,b@ex.com,700,Москва 102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
102,c@ex.com,701,СПб 101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
``` ```
[`orders.csv`](data/orders.csv): [`orders.csv`](data/orders.csv):
@@ -807,7 +808,8 @@ product_id,name
```csv ```csv
product_id,valid_from,valid_to,price product_id,valid_from,valid_to,price
9001,2023-12-01,2024-01-31,100 9001,2023-12-01,2024-01-31,100
9001,2024-02-01,2999-12-31,110 9001,2024-02-01,,110
9002,2023-01-01,,50
``` ```
> 📂 Все SQL-скрипты для построения хранилища находятся в папке [`sql/`](sql/). > 📂 Все SQL-скрипты для построения хранилища находятся в папке [`sql/`](sql/).
@@ -824,13 +826,12 @@ product_id,valid_from,valid_to,price
-- DDS: измерение клиента (SCD Type 2) -- DDS: измерение клиента (SCD Type 2)
CREATE TABLE dds.dim_customer ( CREATE TABLE dds.dim_customer (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_bk VARCHAR(50) NOT NULL, -- напр. '101' customer_bk INT NOT NULL, -- напр. 101
email VARCHAR(100), email VARCHAR(100),
phone VARCHAR(20), phone VARCHAR(20),
city VARCHAR(50), city VARCHAR(50),
valid_from DATE NOT NULL, valid_from DATE NOT NULL,
valid_to DATE DEFAULT '9999-12-31', valid_to DATE
is_current BOOLEAN NOT NULL DEFAULT TRUE
); );
-- DDS: факт продаж (гранулярность: строка заказа) -- DDS: факт продаж (гранулярность: строка заказа)
+22 -47
View File
@@ -107,7 +107,6 @@ WHERE customer_id = 1;
- **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента. - **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента.
- **`valid_from`** — дата, **с которой** эта версия стала актуальной. - **`valid_from`** — дата, **с которой** эта версия стала актуальной.
- **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**. - **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**.
- **`is_current`** — флаг (`true`/`false`), который быстро показывает, какая строка — последняя. Удобен для запросов.
Пример структуры: Пример структуры:
@@ -118,33 +117,31 @@ CREATE TABLE customers_scd2 (
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT NOT NULL, category TEXT NOT NULL,
valid_from DATE NOT NULL, -- с какой даты действует valid_from DATE NOT NULL, -- с какой даты действует
valid_to DATE, -- по какую дату действовала (NULL = сейчас) valid_to DATE -- по какую дату действовала (NULL = сейчас)
is_current BOOLEAN NOT NULL -- true = актуальная версия
); );
``` ```
**Шаг 1.** Добавляем начальную запись (клиент зарегистрировался 1 января 2024): **Шаг 1.** Добавляем начальную запись (клиент зарегистрировался 1 января 2024):
```sql ```sql
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current) INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'Regular', '2024-01-01', NULL, true); VALUES (1, 'Иван Петров', 'Regular', DATE '2024-01-01', NULL);
``` ```
**Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**: **Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**:
1. **Закрываем старую запись**: указываем, что она была актуальна **до 14 апреля**. 1. **Закрываем старую запись**: указываем дату начала новой версии (интервал `[valid_from, valid_to)`).
2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна. 2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна.
```sql ```sql
-- 1. Завершаем предыдущую версию -- 1. Завершаем предыдущую версию
UPDATE customers_scd2 UPDATE customers_scd2
SET valid_to = '2025-04-14', SET valid_to = DATE '2025-04-15'
is_current = false WHERE customer_id = 1 AND valid_to IS NULL;
WHERE customer_id = 1 AND is_current = true;
-- 2. Вставляем новую версию -- 2. Вставляем новую версию
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current) INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true); VALUES (1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL);
``` ```
Теперь в таблице две строки для одного клиента. И мы можем спросить: Теперь в таблице две строки для одного клиента. И мы можем спросить:
@@ -155,36 +152,14 @@ VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true);
SELECT category SELECT category
FROM customers_scd2 FROM customers_scd2
WHERE customer_id = 1 WHERE customer_id = 1
AND '2025-04-10' >= valid_from AND DATE '2025-04-10' >= valid_from
AND '2025-04-10' <= COALESCE(valid_to, '9999-12-31'); AND (valid_to IS NULL OR DATE '2025-04-10' < valid_to);
``` ```
Результат: `'Regular'` — правильно! Результат: `'Regular'` — правильно!
> 💡 Почему `COALESCE(valid_to, '9999-12-31')`? > 💡 Почему не `BETWEEN valid_from AND valid_to`?
> Потому что у актуальной записи `valid_to IS NULL`. Чтобы условие работало, мы временно заменяем `NULL` на очень далёкую дату. > Потому что у актуальной записи `valid_to IS NULL`. В SQL сравнения с `NULL` не дают `TRUE`, поэтому для “текущей” версии обычно пишут `valid_to IS NULL OR ...`.
---
### 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 ### Type 2 через логику, похожую на UPSERT
@@ -206,21 +181,21 @@ WHERE CURRENT_DATE BETWEEN valid_from AND valid_to
-- Шаг 1: вставляем новую версию, только если есть изменения -- Шаг 1: вставляем новую версию, только если есть изменения
WITH last_version AS ( WITH last_version AS (
SELECT * FROM customers_scd2 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) INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
SELECT 1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true SELECT 1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM last_version WHERE category != 'VIP' SELECT 1 FROM last_version WHERE category != 'VIP'
); );
-- Шаг 2: если вставка произошла — закрываем старую запись -- Шаг 2: если вставка произошла — закрываем старую запись
UPDATE customers_scd2 UPDATE customers_scd2
SET valid_to = '2025-04-14', is_current = false SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND is_current = true WHERE customer_id = 1 AND valid_to IS NULL
AND EXISTS ( AND EXISTS (
SELECT 1 FROM customers_scd2 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) в системах без встроенной поддержки этой функции. Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции.
> **Почему не использовать `is_current`?** > **Почему не использовать флаг «текущая версия»?**
> В append-only системах флаг `is_current` становится устаревшим сразу после новой вставки. Его очень сложно поддерживать без `UPDATE`, поэтому в таких архитектурах его чаще **не используют**, полагаясь полностью на даты и оконные функции. Это делает модель данных более чистой и идемпотентной. > В append-only системах любой флаг актуальности становится устаревшим сразу после новой вставки. Без `UPDATE` его сложно поддерживать, поэтому в таких архитектурах чаще полагаются на даты и оконные функции — так модель остаётся идемпотентной.
Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении. Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении.
@@ -431,8 +406,8 @@ WHERE rn = 1;
## 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`-проблем. - Для условий “актуально на дату” используйте паттерн: `d >= valid_from AND (valid_to IS NULL OR d < valid_to)`.
- Type 2 увеличивает объём данных — но для аналитики это нормально. - Type 2 увеличивает объём данных — но для аналитики это нормально.
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией. - В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
+8 -9
View File
@@ -1,10 +1,9 @@
customer_id,status,event_ts,_load_id,load_ts customer_id,status,event_ts,_load_id,load_ts
101,new,2024-01-01 09:00:00,batch_20250405_0800,2025-04-05 08:00:00 101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
101,active,2024-02-15 10:30:00,batch_20250405_0800,2025-04-05 08:00:00 101,active,2024-02-15 10:30:00,batch_20240215_1100,2024-02-15 11:00:00
101,vip,2024-05-10 11:00:00,batch_20250405_1200,2025-04-05 12:00:00 101,vip,2024-05-10 11:00:00,batch_20240510_1200,2024-05-10 12:00:00
101,churned,2024-09-01 12:15:00,batch_20250405_1800,2025-04-05 18:00:00 101,churned,2024-09-01 12:15:00,batch_20240901_1300,2024-09-01 13:00:00
102,new,2024-03-05 14:00:00,batch_20250405_0800,2025-04-05 08:00:00 102,new,2024-03-05 14:00:00,batch_20240305_1500,2024-03-05 15:00:00
102,active,2024-04-01 09:45:00,batch_20250405_1200,2025-04-05 12:00:00 102,active,2024-04-01 09:45:00,batch_20240401_1000,2024-04-01 10:00:00
102,churned,2024-04-20 16:20:00,batch_20250405_1800,2025-04-05 18:00:00 102,churned,2024-04-20 16:20:00,batch_20240420_1700,2024-04-20 17:00:00
103,new,2024-03-10 10:10:00,batch_20250405_0800,2025-04-05 08:00:00 103,new,2024-03-10 10:10:00,batch_20240310_1100,2024-03-10 11:00:00
1 customer_id status event_ts _load_id load_ts
2 101 new 2024-01-01 09:00:00 batch_20250405_0800 batch_20240101_1000 2025-04-05 08:00:00 2024-01-01 10:00:00
3 101 active 2024-02-15 10:30:00 batch_20250405_0800 batch_20240215_1100 2025-04-05 08:00:00 2024-02-15 11:00:00
4 101 vip 2024-05-10 11:00:00 batch_20250405_1200 batch_20240510_1200 2025-04-05 12:00:00 2024-05-10 12:00:00
5 101 churned 2024-09-01 12:15:00 batch_20250405_1800 batch_20240901_1300 2025-04-05 18:00:00 2024-09-01 13:00:00
6 102 new 2024-03-05 14:00:00 batch_20250405_0800 batch_20240305_1500 2025-04-05 08:00:00 2024-03-05 15:00:00
7 102 active 2024-04-01 09:45:00 batch_20250405_1200 batch_20240401_1000 2025-04-05 12:00:00 2024-04-01 10:00:00
8 102 churned 2024-04-20 16:20:00 batch_20250405_1800 batch_20240420_1700 2025-04-05 18:00:00 2024-04-20 17:00:00
9 103 new 2024-03-10 10:10:00 batch_20250405_0800 batch_20240310_1100 2025-04-05 08:00:00 2024-03-10 11:00:00
+4 -4
View File
@@ -1,5 +1,5 @@
customer_id,email,phone,city,event_ts,_load_id,load_ts customer_id,email,phone,city,event_ts,_load_id,load_ts
101,a@ex.com,700,Москва,NULL,batch_20250405_0800,2025-04-05 08:00 101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
102,c@ex.com,701,СПб,NULL,batch_20250405_0800,2025-04-05 08:00 102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
101,b@ex.com,700,Москва,NULL,batch_20250405_1200,2025-04-05 12:00 101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
101,b@ex.com,700,Санкт-Петербург,NULL,batch_20250405_1800,2025-04-05 18:00 101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
1 customer_id email phone city event_ts _load_id load_ts
2 101 a@ex.com 700 Москва NULL 2024-01-01 batch_20250405_0800 batch_20240101_0800 2025-04-05 08:00 2024-01-01 08:00
3 102 c@ex.com 701 СПб NULL 2024-01-01 batch_20250405_0800 batch_20240101_0800 2025-04-05 08:00 2024-01-01 08:00
4 101 b@ex.com 700 Москва NULL 2024-05-16 batch_20250405_1200 batch_20240516_0800 2025-04-05 12:00 2024-05-16 08:00
5 101 b@ex.com 700 Санкт-Петербург NULL 2024-10-01 batch_20250405_1800 batch_20241001_0800 2025-04-05 18:00 2024-10-01 08:00
+2 -2
View File
@@ -1,4 +1,4 @@
product_id,valid_from,valid_to,price product_id,valid_from,valid_to,price
9001,2023-12-01,2024-01-31,100.00 9001,2023-12-01,2024-01-31,100.00
9001,2024-02-01,2999-12-31,110.00 9001,2024-02-01,,110.00
9002,2023-01-01,2999-12-31,50.00 9002,2023-01-01,,50.00
1 product_id valid_from valid_to price
2 9001 2023-12-01 2024-01-31 100.00
3 9001 2024-02-01 2999-12-31 110.00
4 9002 2023-01-01 2999-12-31 50.00
+3 -4
View File
@@ -111,9 +111,8 @@ CREATE TABLE dds.dim_customer (
phone TEXT, phone TEXT,
city TEXT, city TEXT,
hashdiff TEXT NOT NULL, -- md5 по нормализованным атрибутам hashdiff TEXT NOT NULL, -- md5 по нормализованным атрибутам
valid_from TIMESTAMP NOT NULL, valid_from DATE NOT NULL,
valid_to TIMESTAMP NOT NULL, valid_to DATE,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
@@ -123,7 +122,7 @@ ALTER TABLE dds.dim_customer
ADD CONSTRAINT uq_dim_customer_bk_from UNIQUE (customer_bk, valid_from); ADD CONSTRAINT uq_dim_customer_bk_from UNIQUE (customer_bk, valid_from);
-- ускорители -- ускорители
CREATE INDEX ix_dim_customer_bk_current ON dds.dim_customer (customer_bk) WHERE is_current; CREATE INDEX ix_dim_customer_bk_current ON dds.dim_customer (customer_bk) WHERE valid_to IS NULL;
CREATE INDEX ix_dim_customer_bk_from_to ON dds.dim_customer (customer_bk, valid_from, valid_to); CREATE INDEX ix_dim_customer_bk_from_to ON dds.dim_customer (customer_bk, valid_from, valid_to);
-- fact_sales: факт "Продажи" -- fact_sales: факт "Продажи"
+21 -24
View File
@@ -13,10 +13,10 @@ DELETE FROM stg.products_raw;
-- STG (пример вставки с метками времени) -- STG (пример вставки с метками времени)
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
('batch_20250405_0800', '2025-04-05 08:00', NULL, '101','a@ex.com','700','Москва'), ('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '101','a@ex.com','700','Москва'),
('batch_20250405_0800', '2025-04-05 08:00', NULL, '102','c@ex.com','701','СПб'), ('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '102','c@ex.com','701','СПб'),
('batch_20250405_1200', '2025-04-05 12:00', NULL, '101','b@ex.com','700','Москва'), ('batch_20240516_0800', '2024-05-16 08:00', '2024-05-16', '101','b@ex.com','700','Москва'),
('batch_20250405_1800', '2025-04-05 18:00', NULL, '101','b@ex.com','700','Санкт-Петербург'); ('batch_20241001_0800', '2024-10-01 08:00', '2024-10-01', '101','b@ex.com','700','Санкт-Петербург');
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
@@ -45,7 +45,7 @@ SELECT
FROM stg.orders_raw FROM stg.orders_raw
WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$'; WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$';
-- берём по BK самую позднюю запись (event_ts > _load_ts > _load_id) -- берём по BK самую позднюю запись (по дате события, иначе по дате загрузки)
WITH src AS ( WITH src AS (
SELECT SELECT
s.customer_id::INT AS customer_id, s.customer_id::INT AS customer_id,
@@ -55,21 +55,20 @@ WITH src AS (
NULLIF(s.event_ts, '')::timestamp AS event_ts, NULLIF(s.event_ts, '')::timestamp AS event_ts,
s._load_id, s._load_id,
s._load_ts, s._load_ts,
COALESCE(NULLIF(s.event_ts, '')::timestamp, s._load_ts, COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date
to_timestamp(regexp_replace(s._load_id,'^batch_',''),'YYYYMMDD_HH24MI'))
AS eff_ts
FROM stg.customers_raw s FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$' WHERE s.customer_id ~ '^\d+$'
), ),
last_per_bk AS ( ranked AS (
SELECT DISTINCT ON (customer_id) SELECT
customer_id, email, phone, city, event_ts, _load_id, _load_ts customer_id, email, phone, city, event_ts, _load_id, _load_ts,
row_number() OVER (PARTITION BY customer_id ORDER BY eff_date DESC, _load_ts DESC) AS rn
FROM src FROM src
ORDER BY customer_id, eff_ts DESC, _load_ts DESC
) )
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts) INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
FROM last_per_bk; FROM ranked
WHERE rn = 1;
INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale) INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale)
SELECT SELECT
@@ -143,16 +142,14 @@ WITH src AS (
NULLIF(trim(s.email), '') AS email, NULLIF(trim(s.email), '') AS email,
NULLIF(trim(s.phone), '') AS phone, NULLIF(trim(s.phone), '') AS phone,
NULLIF(trim(s.city), '') AS city, NULLIF(trim(s.city), '') AS city,
COALESCE(NULLIF(s.event_ts,'')::timestamp, s._load_ts, COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date,
to_timestamp(regexp_replace(s._load_id,'^batch_',''),'YYYYMMDD_HH24MI')) AS eff_ts,
dds.customer_hash(s.email, s.phone, s.city) AS hashdiff dds.customer_hash(s.email, s.phone, s.city) AS hashdiff
FROM stg.customers_raw s FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$' WHERE s.customer_id ~ '^\d+$'
), ),
ordered AS ( ordered AS (
SELECT *, SELECT *,
lag(hashdiff) OVER (PARTITION BY customer_bk ORDER BY eff_ts) AS prev_hash, lag(hashdiff) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS prev_hash
row_number() OVER (PARTITION BY customer_bk ORDER BY eff_ts) AS rn
FROM src FROM src
), ),
changes AS ( changes AS (
@@ -164,20 +161,19 @@ changes AS (
framed AS ( framed AS (
SELECT SELECT
customer_bk, email, phone, city, hashdiff, customer_bk, email, phone, city, hashdiff,
CASE WHEN rn = 1 THEN timestamp '1900-01-01' ELSE eff_ts END AS valid_from, -- первая версия: техническое "начало истории" eff_date AS valid_from,
lead(eff_ts) OVER (PARTITION BY customer_bk ORDER BY eff_ts) AS next_ts lead(eff_date) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS valid_to
FROM changes FROM changes
) )
INSERT INTO dds.dim_customer ( INSERT INTO dds.dim_customer (
customer_bk, email, phone, city, hashdiff, customer_bk, email, phone, city, hashdiff,
valid_from, valid_to, is_current, valid_from, valid_to,
created_at, updated_at created_at, updated_at
) )
SELECT SELECT
customer_bk, email, phone, city, hashdiff, customer_bk, email, phone, city, hashdiff,
valid_from, valid_from,
COALESCE(next_ts - interval '1 second', timestamp '9999-12-31') AS valid_to, valid_to,
(next_ts IS NULL) AS is_current,
now(), now() now(), now()
FROM framed FROM framed
ORDER BY customer_bk, valid_from; ORDER BY customer_bk, valid_from;
@@ -200,10 +196,11 @@ JOIN ods.products p ON oi.product_id = p.product_id
JOIN dds.dim_product dp ON p.product_id = dp.product_bk JOIN dds.dim_product dp ON p.product_id = dp.product_bk
JOIN dds.dim_customer dc JOIN dds.dim_customer dc
ON o.customer_id = dc.customer_bk ON o.customer_id = dc.customer_bk
AND o.order_date::timestamp BETWEEN dc.valid_from AND dc.valid_to; AND o.order_date >= dc.valid_from
AND (dc.valid_to IS NULL OR o.order_date < dc.valid_to);
-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки) -- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки)
-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды -- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды
SELECT 'dim_customer count = ' || COUNT(*) FROM dds.dim_customer WHERE is_current ; SELECT 'dim_customer current = ' || COUNT(*) FROM dds.dim_customer WHERE valid_to IS NULL;
SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales; SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales;
+68 -51
View File
@@ -5,8 +5,8 @@
-- 0. Новые события в STG (пример) -- 0. Новые события в STG (пример)
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
('batch_20250406_0900', '2025-04-06 09:00', NULL, '101','b@ex.com','700','Москва'), -- город вернулся ('batch_20241101_0800', '2024-11-01 08:00', '2024-11-01', '101','b@ex.com','700','Москва'), -- город вернулся
('batch_20250406_1200', '2025-04-06 12:00', NULL, '103','d@ex.com','702','Казань'); -- новый клиент ('batch_20240310_0800', '2024-03-10 08:00', '2024-03-10', '103','d@ex.com','702','Казань'); -- новый клиент
-- 1) UPSERT в ODS последнего снимка по BK -- 1) UPSERT в ODS последнего снимка по BK
WITH src AS ( WITH src AS (
@@ -18,20 +18,20 @@ WITH src AS (
NULLIF(s.event_ts,'')::timestamp AS event_ts, NULLIF(s.event_ts,'')::timestamp AS event_ts,
s._load_id, s._load_id,
s._load_ts, s._load_ts,
COALESCE(NULLIF(s.event_ts,'')::timestamp, s._load_ts, COALESCE(NULLIF(s.event_ts,'')::timestamp, s._load_ts) AS eff_ts
to_timestamp(regexp_replace(s._load_id,'^batch_',''),'YYYYMMDD_HH24MI')) AS eff_ts
FROM stg.customers_raw s FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$' WHERE s.customer_id ~ '^\d+$'
), ),
last_per_bk AS ( ranked AS (
SELECT DISTINCT ON (customer_id) SELECT
customer_id, email, phone, city, event_ts, _load_id, _load_ts, eff_ts customer_id, email, phone, city, event_ts, _load_id, _load_ts,
row_number() OVER (PARTITION BY customer_id ORDER BY eff_ts DESC, _load_ts DESC) AS rn
FROM src FROM src
ORDER BY customer_id, eff_ts DESC, _load_ts DESC
) )
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts) INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
FROM last_per_bk FROM ranked
WHERE rn = 1
ON CONFLICT (customer_id) DO UPDATE ON CONFLICT (customer_id) DO UPDATE
SET email = EXCLUDED.email, SET email = EXCLUDED.email,
phone = EXCLUDED.phone, phone = EXCLUDED.phone,
@@ -44,67 +44,84 @@ WHERE COALESCE(EXCLUDED.event_ts, EXCLUDED._load_ts) >
COALESCE(ods.customers.event_ts, ods.customers._load_ts); COALESCE(ods.customers.event_ts, ods.customers._load_ts);
-- 2) Инкрементальное SCD2 из ODS (в одной транзакции, по последнему снимку в ODS) -- 2) Инкрементальное SCD2 из ODS (в одной транзакции, по последнему снимку в ODS)
-- Канон для курса: daily-grain, интервалы [valid_from, valid_to), текущая версия = valid_to IS NULL
-- Предполагаем, что для клиента нет нескольких изменений в один день.
BEGIN; BEGIN;
-- 2.1) Закрываем предыдущую актуальную версию (только если реально изменились атрибуты)
WITH delta AS ( WITH delta AS (
SELECT SELECT
c.customer_id AS customer_bk, c.customer_id AS customer_bk,
c.email, c.phone, c.city, c.email, c.phone, c.city,
COALESCE(c.event_ts, c._load_ts) AS eff_ts, COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
FROM ods.customers c FROM ods.customers c
), ),
current AS ( current AS (
-- текущие версии клиентов в измерении
SELECT d.* SELECT d.*
FROM dds.dim_customer d FROM dds.dim_customer d
WHERE d.is_current = TRUE WHERE d.valid_to IS NULL
), )
to_upsert AS ( UPDATE dds.dim_customer d
-- только новые BK или реально изменившиеся атрибуты SET valid_to = x.eff_date,
updated_at = now()
FROM (
SELECT SELECT
d.customer_bk, t.customer_bk,
d.email, t.eff_date,
d.phone, c.customer_sk
d.city, FROM delta t
d.eff_ts, JOIN current c
d.hashdiff, ON c.customer_bk = t.customer_bk
c.customer_sk AS current_sk WHERE c.hashdiff <> t.hashdiff
FROM delta d AND t.eff_date > c.valid_from
LEFT JOIN current c ) x
ON c.customer_bk = d.customer_bk WHERE d.customer_sk = x.customer_sk
WHERE c.customer_sk IS NULL -- новый клиент AND d.valid_to IS NULL;
OR c.hashdiff <> d.hashdiff -- изменились атрибуты
-- 2.2) Вставляем новую версию (только если новая или изменившаяся)
WITH delta AS (
SELECT
c.customer_id AS customer_bk,
c.email, c.phone, c.city,
COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
FROM ods.customers c
), ),
inserted AS ( current AS (
-- вставляем новые версии (одна строка на BK) SELECT d.*
FROM dds.dim_customer d
WHERE d.valid_to IS NULL
),
to_insert AS (
SELECT
t.customer_bk,
t.email,
t.phone,
t.city,
t.hashdiff,
t.eff_date
FROM delta t
LEFT JOIN current c
ON c.customer_bk = t.customer_bk
WHERE c.customer_sk IS NULL
OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from)
)
INSERT INTO dds.dim_customer ( INSERT INTO dds.dim_customer (
customer_bk, email, phone, city, hashdiff, customer_bk, email, phone, city, hashdiff,
valid_from, valid_to, valid_from, valid_to,
is_current, created_at, updated_at created_at, updated_at
) )
SELECT SELECT
t.customer_bk, t.email, t.phone, t.city, t.hashdiff, t.customer_bk, t.email, t.phone, t.city, t.hashdiff,
CASE WHEN t.current_sk IS NULL t.eff_date, NULL,
THEN timestamp '1900-01-01' -- первая версия: техническое "начало истории" now(), now()
ELSE t.eff_ts FROM to_insert t
END AS valid_from, WHERE NOT EXISTS (
timestamp '9999-12-31' AS valid_to, SELECT 1
TRUE, now(), now() FROM dds.dim_customer d
FROM to_upsert t WHERE d.customer_bk = t.customer_bk
ON CONFLICT (customer_bk, valid_from) DO NOTHING AND d.valid_from = t.eff_date
RETURNING customer_bk, valid_from );
)
-- закрываем старые версии только для тех BK, по которым реально вставилась новая
UPDATE dds.dim_customer d
SET valid_to = LEAST(d.valid_to, t.eff_ts - interval '1 second'),
is_current = FALSE,
updated_at = now()
FROM to_upsert t
JOIN inserted i
ON i.customer_bk = t.customer_bk
WHERE d.customer_sk = t.current_sk
AND d.is_current = TRUE
AND t.eff_ts >= d.valid_from;
COMMIT; COMMIT;
-- (факты можно не перезаливать — даты заказов не поменялись) -- (факты можно не перезаливать — даты заказов не поменялись)
+30
View File
@@ -41,3 +41,33 @@ BEGIN
FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count); FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count);
RAISE NOTICE '✅ SCD Type 2: клиент 101 имеет % версий — история сохранена', version_count; RAISE NOTICE '✅ SCD Type 2: клиент 101 имеет % версий — история сохранена', version_count;
END $$; END $$;
-- 4. Проверка SCD Type 2: у каждого клиента ровно одна актуальная версия (valid_to IS NULL)
DO $$
DECLARE
customers_cnt BIGINT;
current_cnt BIGINT;
BEGIN
SELECT COUNT(DISTINCT customer_bk) INTO customers_cnt FROM dds.dim_customer;
SELECT COUNT(*) INTO current_cnt
FROM dds.dim_customer
WHERE valid_to IS NULL;
--
ASSERT current_cnt = customers_cnt,
FORMAT('ОШИБКА: актуальных строк %s, а уникальных клиентов %s (ожидается 1 current на клиента)',
current_cnt, customers_cnt);
RAISE NOTICE '✅ SCD Type 2: current-строки = количеству клиентов (%)', current_cnt;
END $$;
-- 5. Проверка SCD Type 2: периоды корректны (valid_to > valid_from или valid_to IS NULL)
DO $$
BEGIN
ASSERT NOT EXISTS (
SELECT 1
FROM dds.dim_customer
WHERE valid_to IS NOT NULL
AND valid_to <= valid_from
),
'ОШИБКА: найдены строки dim_customer с некорректным периодом (valid_to <= valid_from)';
RAISE NOTICE '✅ SCD Type 2: периоды valid_from/valid_to корректны';
END $$;
+1 -2
View File
@@ -55,6 +55,5 @@ SELECT
FROM dds.fact_sales f FROM dds.fact_sales f
JOIN dds.dim_date d ON f.date_key = d.date_key JOIN dds.dim_date d ON f.date_key = d.date_key
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk
-- Здесь НЕ фильтруем по is_current: нужна вся история фактов -- Здесь не фильтруем по valid_to: нужна вся история фактов
GROUP BY c.customer_bk; GROUP BY c.customer_bk;
@@ -34,10 +34,15 @@ CREATE TABLE dds.dim_customer_status (
customer_bk INT NOT NULL, customer_bk INT NOT NULL,
status VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL,
hashdiff TEXT NOT NULL, hashdiff TEXT NOT NULL,
valid_from TIMESTAMP NOT NULL, valid_from DATE NOT NULL,
valid_to TIMESTAMP NOT NULL, valid_to DATE,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
ALTER TABLE dds.dim_customer_status
ADD CONSTRAINT uq_dim_customer_status_bk_from UNIQUE (customer_bk, valid_from);
CREATE INDEX ix_dim_customer_status_bk_current
ON dds.dim_customer_status (customer_bk)
WHERE valid_to IS NULL;
@@ -12,7 +12,7 @@
-- (в STG/ODS эта колонка будет жить как _load_ts). -- (в STG/ODS эта колонка будет жить как _load_ts).
-- 3) Постройте из ods.customer_status измерение dds.dim_customer_status в стиле SCD2: -- 3) Постройте из ods.customer_status измерение dds.dim_customer_status в стиле SCD2:
-- - одна строка на период действия статуса (valid_from / valid_to); -- - одна строка на период действия статуса (valid_from / valid_to);
-- - is_current = TRUE только у актуальной строки для клиента; -- - актуальная строка для клиента — та, где valid_to IS NULL;
-- - hashdiff можно считать, например, от одного поля status. -- - hashdiff можно считать, например, от одного поля status.
-- 4) При желании добавьте инкрементальную логику (как в 03_demo_increment.sql). -- 4) При желании добавьте инкрементальную логику (как в 03_demo_increment.sql).
-- 5) Опционально: соберите витрину dm.mart_customer_status_daily -- 5) Опционально: соберите витрину dm.mart_customer_status_daily
@@ -31,7 +31,7 @@
-- Примерный план: -- Примерный план:
-- - рассчитать hashdiff по (status); -- - рассчитать hashdiff по (status);
-- - по каждому клиенту отсортировать события по времени; -- - по каждому клиенту отсортировать события по времени;
-- - построить для каждой строки valid_from и valid_to (LEAD() OVER ...); -- - построить для каждой строки valid_from и valid_to (LEAD() OVER ...), последняя valid_to = NULL;
-- - вставить в dds.dim_customer_status. -- - вставить в dds.dim_customer_status.
-- 3. DDS: инкрементальная загрузка (по желанию) -- 3. DDS: инкрементальная загрузка (по желанию)