Чистовик статьи + материалы
This commit is contained in:
1 parent
fa42a4508f
commit
dfc5748358
15 files changed
+1912
-1
No files matched your search
@@ -0,0 +1,663 @@
|
||||
Отлично! Вот **связный, литературный и педагогически выстроенный** вариант статьи, адаптированный под новичков (студентов с базовым SQL), но без упрощения сути. Я убрал «рыхлые» места, усилил плавность повествования, добавил пояснения «на пальцах» — и сохранил все важные детали: архитектурные слои, модели данных, SCD, ключи, антипаттерны.
|
||||
|
||||
Статья оформлена как **обучающий материал** — с логикой «от боли к решению», с живым примером (интернет-магазин), с акцентом на *почему*, а не только *как*.
|
||||
Готова к публикации на Habr, с приложенным GitHub-репозиторием.
|
||||
|
||||
---
|
||||
|
||||
# **Хранилище данных: как устроена аналитика «под капотом»**
|
||||
*Для тех, кто знает SQL, но хочет понять, как хранить данные не в Excel, а по-взрослому*
|
||||
|
||||
---
|
||||
|
||||
## **Что вы уже умеете — и что узнаете здесь**
|
||||
|
||||
✅ Уже знаете:
|
||||
- `SELECT`, `JOIN`, `GROUP BY`;
|
||||
- как посчитать сумму/среднее/количество по таблице.
|
||||
|
||||
🆕 Узнаете в этой статье:
|
||||
- **слои хранилища** (STG → ODS → DDS → DM) и *зачем они нужны*;
|
||||
- **факты и измерения** — основные кирпичики аналитики;
|
||||
- **SCD Type 2** — как хранить историю изменений клиента (например, смену email или города);
|
||||
- **суррогатные ключи (SK)** и чем они отличаются от обычных `id`;
|
||||
- **четыре модели данных**: 3NF, Звезда (Star), Data Vault, Anchor Modeling — и когда какую использовать.
|
||||
|
||||
⛔ **Не будем говорить** здесь о:
|
||||
- физическом хранении (партиции, индексы, ClickHouse-движки);
|
||||
- распределённых кластерах (Kafka, Spark, Airflow — это отдельный курс);
|
||||
- настройке производительности (`EXPLAIN`, кэши и т.п.).
|
||||
Это — про *логику*, структуру и здравый смысл.
|
||||
|
||||
---
|
||||
|
||||
## **1. Введение: почему нельзя просто SELECT из базы заказов?**
|
||||
|
||||
Представьте: вы — аналитик в интернет-магазине. Вам нужно ответить на вопрос:
|
||||
> **«Сколько заказов сделал клиент с email `a@ex.com` за 2023 год, и сколько он потратил?»**
|
||||
|
||||
Вы идёте в базу заказов — и… не находите email. Он в CRM. Идёте в CRM — там нет сумм заказов. Возвращаетесь в заказы — сумма есть, но *только текущая цена товара*. А в 2023 году цена была другой!
|
||||
|
||||
Знакомо? Это — **проблема OLTP-систем** (оперативного учёта):
|
||||
- **CRM**, **склад**, **платёжка** — это разные базы;
|
||||
- каждая оптимизирована под *быструю запись операций* («добавить заказ», «списать товар»);
|
||||
- историю там не хранят — email меняется «в лоб»: старое значение перезаписывается.
|
||||
|
||||
Такие системы называют **OLTP** (*Online Transaction Processing* — обработка транзакций в реальном времени).
|
||||
А для аналитики нужна **OLAP** (*Online Analytical Processing* — обработка запросов на анализ).
|
||||
|
||||
➡️ **Хранилище данных (Data Warehouse, DWH)** — это как «единая карта сокровищ», куда собирают данные из всех источников, *сохраняя историю*, *выравнивая термины* и *готовя их к анализу*.
|
||||
|
||||
И вот главный секрет его успеха: **слоистая архитектура**.
|
||||
|
||||
---
|
||||
|
||||
## **2. Учебный пример: интернет-магазин**
|
||||
|
||||
Чтобы всё было на пальцах — разберём простой, но живой пример.
|
||||
|
||||
У нас есть 6 таблиц из трёх источников:
|
||||
|
||||
| Таблица | Источник | Что содержит |
|
||||
|---------|----------|--------------|
|
||||
| `customers` | CRM | Клиенты: `customer_id`, `email`, `phone`, `city` |
|
||||
| `orders`, `order_items` | Заказы | Заказы и позиции в них |
|
||||
| `products` | Склад | Товары: `product_id`, `name` |
|
||||
| `prices` | Склад | История цен: `product_id`, `valid_from`, `valid_to`, `price` |
|
||||
| `promos` | Маркетинг | Акции: `promo_id`, `code` |
|
||||
|
||||
⚠️ Обратите внимание:
|
||||
- `customer_id = 101` в одном месяце — `a@ex.com`, в другом — `b@ex.com`;
|
||||
- цена на товар `9001` (Phone) в январе — 100 ₽, в феврале — 110 ₽;
|
||||
- `order_items` содержит `price_at_sale` — *цену в момент покупки*, а не текущую.
|
||||
|
||||
Это уже **намёк**: чтобы посчитать выручку 2023 года, нам нужна не текущая цена, а *та, что была в день заказа*.
|
||||
|
||||
(ER-диаграмма и DDL-примеры — в конце статьи, в разделе «Для практики».)
|
||||
|
||||
---
|
||||
|
||||
## **3. Зачем делить DWH на слои?**
|
||||
|
||||
Представьте, что вы строите дом. Вы же не будете сразу вбивать гвозди в стены — сначала:
|
||||
1. Привезли стройматериалы (песок, доски, кирпич) — **сырьё**;
|
||||
2. Очистили, просеяли, нарезали — **обработка**;
|
||||
3. Собрали каркас, провели коммуникации — **интеграция**;
|
||||
4. Сделали отделку под конкретную квартиру — **готовое решение**.
|
||||
|
||||
В DWH — то же самое. Каждый слой отвечает за *одну задачу*:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["Источники"]
|
||||
A["CRM"]
|
||||
B["Заказы"]
|
||||
C["Склад"]
|
||||
end
|
||||
|
||||
subgraph STG["STG — «Сырьё»"]
|
||||
D["Таблицы-дубликаты</br>в формате источника"]
|
||||
end
|
||||
|
||||
subgraph ODS["ODS — «Очистка»"]
|
||||
E["Типы:</br>даты → DATE,</br>числа → INT/DECIMAL</br>Валидация: email, phone"]
|
||||
end
|
||||
|
||||
subgraph DDS["DDS — «Интеграция»"]
|
||||
F["Общие сущности:</br>клиент, товар, дата</br>История (SCD),</br>суррогатные ключи"]
|
||||
end
|
||||
|
||||
subgraph DM["DM — «Готовые решения»"]
|
||||
G["Витрина продаж: дата, товар, клиент, сумма</br>+ агрегаты (выручка/день)"]
|
||||
end
|
||||
|
||||
A --> STG
|
||||
B --> STG
|
||||
C --> STG
|
||||
|
||||
STG --> ODS
|
||||
ODS --> DDS
|
||||
DDS --> DM
|
||||
DM --> BI["BI-системы</br>(Power BI, Tableau,</br>Metabase)"]
|
||||
```
|
||||
|
||||
👉 **Почему так лучше, чем «одна большая таблица»?**
|
||||
1. **Управляемость**: если в `customers` пришёл битый `email` — ошибка локализована в STG/ODS, DDS не пострадает.
|
||||
2. **Прозрачность**: можно посмотреть: «а как выглядел исходник?», «а как мы его почистили?».
|
||||
3. **Производительность**: в DDS и DM — только то, что нужно для анализа. Никаких `JSON`-полей, `TEXT` без причины.
|
||||
|
||||
---
|
||||
|
||||
## **4. Путешествие данных: от STG до DM**
|
||||
|
||||
Давайте проследим, как превращается строка заказа.
|
||||
|
||||
### **STG (Staging / «Бронза»)** — «как пришло»
|
||||
|
||||
- Таблицы: `stg.orders_raw`, `stg.customers_raw`;
|
||||
- Структура — *точно как в источнике* (может быть `VARCHAR` даже у дат);
|
||||
- Добавлены технические поля:
|
||||
- `_ingest_id` — идентификатор загрузки;
|
||||
- `_load_ts` — время получения данных;
|
||||
- Главное правило: **неизменяемость**. Если пришла новая порция — либо добавляем новые строки, либо *полностью перезагружаем* слой (идемпотентность).
|
||||
- Дедупликация: если два раза пришёл один и тот же заказ — оставляем один (по `order_id + _load_ts`).
|
||||
|
||||
> 💡 *Пример:* `stg.orders_raw` содержит `"2024-01-10"` как строку — это нормально. Главное — не потерять оригинал.
|
||||
|
||||
---
|
||||
|
||||
### **ODS (Operational Data Store / «Серебро»)** — «почистили, но не трогали смысл»
|
||||
|
||||
- Таблицы: `ods.orders`, `ods.customers`;
|
||||
- Здесь:
|
||||
- привели `order_date` к типу `DATE`;
|
||||
- убрали заказы без клиента (`customer_id IS NULL` → ошибка или флаг);
|
||||
- привели телефоны к формату `79991112233`;
|
||||
- проверили email на валидность (регуляркой или простой проверкой).
|
||||
- **Но!** Не объединяем клиента из CRM и клиента из заказов — это будет позже.
|
||||
- Пока — никакой бизнес-логики. Только *техническая* очистка.
|
||||
|
||||
> 🎯 Цель ODS — дать «надёжную платформу» для следующего слоя. Как сухое, чистое бревно перед сборкой дома.
|
||||
|
||||
---
|
||||
|
||||
### **DDS (Data Delivery Store / «Ядро», «Золото»)** — «интеграция + история»
|
||||
|
||||
Здесь рождается *единая бизнес-модель*.
|
||||
Появляются понятия: **измерения**, **факты**, **суррогатные ключи**, **SCD**.
|
||||
|
||||
Например:
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|------------|
|
||||
| `dds.dim_customer` | Измерение «Клиент» с историей (SCD Type 2) |
|
||||
| `dds.dim_product` | Измерение «Товар» |
|
||||
| `dds.dim_date` | Готовый календарь на 10 лет вперёд (день/неделя/месяц/квартал) |
|
||||
| `dds.fact_sales` | Факт «Продажа» — строка заказа с суммой и количеством |
|
||||
|
||||
💡 **Суррогатный ключ (Surrogate Key, SK)** — это `BIGINT`, который мы генерируем сами (например, `customer_sk = 1001`).
|
||||
**Бизнес-ключ (Business Key, BK)** — это `customer_id = 101` из источника.
|
||||
Мы храним и то, и другое — чтобы можно было и джойнить, и понимать, откуда строка.
|
||||
|
||||
> ✅ Почему не использовать `customer_id` напрямую?
|
||||
> — Потому что в одном источнике `customer_id` — целое число, в другом — строка `CUST-101`.
|
||||
> — Потому что ID могут повторяться (например, в тестовой и продовой базах).
|
||||
> — Потому что нам нужна *связь* с историей: у клиента с BK = `101` может быть 3 версии в `dim_customer`.
|
||||
|
||||
---
|
||||
|
||||
### **DM (Data Mart / «Витрины»)** — «готово к употреблению»
|
||||
|
||||
Здесь — таблицы и представления для конкретных задач:
|
||||
- `dm.mart_daily_sales` — ежедневные продажи по товарам и сегментам;
|
||||
- `dm.mart_customer_360` — полный портрет клиента: сколько потратил, когда заходил, какие товары любит.
|
||||
|
||||
Они построены по модели **Звезда (Star Schema)** — потому что BI-инструментам так удобнее всего.
|
||||
|
||||
---
|
||||
|
||||
## **5. Базовые понятия: факты, измерения, SCD**
|
||||
|
||||
Представьте отчёт:
|
||||
> *«10 января 2024 года клиент из Москвы (сегмент Premium) купил Phone за 100 ₽»*.
|
||||
|
||||
В DWH это разложится на:
|
||||
- **Факт (Fact)** — событие, которое можно измерить: *покупка*.
|
||||
Хранится в `fact_sales`: `quantity = 1`, `amount = 100`.
|
||||
- **Измерения (Dimensions)** — *контекст* факта:
|
||||
- `dim_date` → 10 января 2024;
|
||||
- `dim_customer` → Москва, Premium;
|
||||
- `dim_product` → Phone.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
dim_date ||--o{ fact_sales : "дата"
|
||||
dim_customer ||--o{ fact_sales : "клиент"
|
||||
dim_product ||--o{ fact_sales : "товар"
|
||||
|
||||
dim_customer {
|
||||
bigint customer_sk PK "суррогатный ключ"
|
||||
varchar customer_bk "бизнес-ключ, напр. '101'"
|
||||
varchar customer_name
|
||||
varchar email
|
||||
varchar city
|
||||
date valid_from
|
||||
date valid_to
|
||||
boolean is_current
|
||||
}
|
||||
|
||||
fact_sales {
|
||||
bigint sale_id PK
|
||||
bigint customer_sk FK
|
||||
bigint product_sk FK
|
||||
int date_key FK "ссылка на dim_date.date_key"
|
||||
int quantity
|
||||
decimal amount
|
||||
}
|
||||
```
|
||||
|
||||
### **SCD Type 2 — как хранить историю**
|
||||
|
||||
Клиент №101:
|
||||
- с 1 янв по 15 мая — `email = a@ex.com`, `city = Москва`;
|
||||
- с 16 мая — `email = b@ex.com`, `city = Москва`;
|
||||
- с 1 окт — `email = b@ex.com`, `city = Санкт-Петербург`.
|
||||
|
||||
В `dim_customer` это будет **три строки**:
|
||||
|
||||
| customer_sk | customer_bk | email | city | valid_from | valid_to | is_current |
|
||||
|-------------|-------------|-------|------|------------|----------|------------|
|
||||
| 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-15 | false |
|
||||
| 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-09-30 | false |
|
||||
| 1003 | 101 | b@ex.com | СПб | 2023-10-01 | 9999-12-31 | true |
|
||||
|
||||
Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где:
|
||||
```sql
|
||||
fact_sales.order_date BETWEEN dim_customer.valid_from AND dim_customer.valid_to
|
||||
```
|
||||
и получаем актуальный на тот день email и город.
|
||||
|
||||
> 🔍 Подробнее про SCD — в отдельной статье [SQC](SCD.md) (сравнение Type 1/2/3, паттерны обновления).
|
||||
|
||||
---
|
||||
|
||||
## **6. Модели данных для слоя DDS: 4 подхода — и когда какой выбрать**
|
||||
|
||||
В DDS мы можем хранить данные по-разному. Это не «правильно/неправильно», а **выбор под задачу**.
|
||||
|
||||
### **1. 3NF (третья нормальная форма)**
|
||||
*Источник: Билл Инмон (Bill Inmon)*
|
||||
|
||||
✅ **Плюсы**:
|
||||
- Максимальная **целостность** — дубликатов нет (все атрибуты — там, где должны быть);
|
||||
- Легко **расширять** — добавили новый источник? Расширили связанный справочник.
|
||||
|
||||
❌ **Минусы**:
|
||||
- Запросы сложные: много JOIN’ов даже для простого отчёта;
|
||||
- Тяжело новичкам: «а где город клиента?» → нужно пройти `customer → address → city`.
|
||||
|
||||
📌 **Когда выбирать**:
|
||||
→ Корпоративные DWH, где важна *единая терминология* и *долгосрочная поддержка*.
|
||||
→ Когда источников — десятки, и нужно гарантировать консистентность.
|
||||
|
||||
---
|
||||
|
||||
### **2. Звезда (Star Schema)**
|
||||
*Источник: Ральф Кимболл (Ralph Kimball)*
|
||||
|
||||
✅ **Плюсы**:
|
||||
- **Простота**: факт + несколько «плоских» измерений;
|
||||
- **Скорость**: BI-системы любят звезду — запросы пишутся за 5 минут;
|
||||
- **Понятно бизнесу**: «продажи по товарам и клиентам» — это ровно то, что в таблицах.
|
||||
|
||||
❌ **Минусы**:
|
||||
- Дублирование: город будет повторяться в каждой строке клиента;
|
||||
- Изменение структуры измерения — дорого (перестроить всю витрину).
|
||||
|
||||
📌 **Когда выбирать**:
|
||||
→ Витрины (DM), а не ядро (DDS);
|
||||
→ Начинающим командам и MVP;
|
||||
→ Когда отчёты — главная цель.
|
||||
|
||||
---
|
||||
|
||||
### **3. Data Vault 2.0**
|
||||
*Источник: Дэн Линстедт (Dan Linstedt)*
|
||||
|
||||
Модель основана на трёх типах таблиц:
|
||||
|
||||
| Тип | Назначение | Пример |
|
||||
|-----|------------|--------|
|
||||
| **Hub (Хаб)** | Хранит бизнес-ключи (BK) | `hub_customer`: `customer_id`, `load_dttm` |
|
||||
| **Link (Связь)** | Фиксирует отношения | `link_order_customer`: `order_id + customer_id` |
|
||||
| **Satellite (Сателлит)** | Хранит атрибуты + историю | `sat_customer_info`: имя, email, дата начала/окончания |
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
hub_customer ||--o{ sat_customer_info : "хаб → сателлит"
|
||||
hub_order ||--o{ sat_order_details : "хаб → сателлит"
|
||||
hub_customer ||--o{ link_order_customer : "участвует в"
|
||||
hub_order ||--o{ link_order_customer : "создан клиентом"
|
||||
```
|
||||
|
||||
✅ **Плюсы**:
|
||||
- **История «из коробки»** — каждое изменение — новая строка в сателлите;
|
||||
- **Масштабируемость**: легко подключить новый источник — добавили хаб/линк/сателлит;
|
||||
- **Аудит**: откуда пришёл каждый факт — видно по `_load_dttm`.
|
||||
|
||||
❌ **Минусы**:
|
||||
- Сложно читать (20 таблиц вместо 3);
|
||||
- Нужно писать сложные запросы (или использовать автоматическую генерацию витрин).
|
||||
|
||||
📌 **Когда выбирать**:
|
||||
→ Большие проекты с 10+ источниками;
|
||||
→ Когда критична **трассировка** и **соответствие регуляторным требованиям** (например, финтех).
|
||||
|
||||
> 📌 *DV 1.0* не поддерживал SCD Type 2 «из коробки» — в DV 2.0 это решено.
|
||||
|
||||
---
|
||||
|
||||
### **4. Anchor Modeling (анкерное моделирование)**
|
||||
*Источник: Ларс Рёне (Lars Rönnbäck)*
|
||||
|
||||
Ещё более атомарный подход:
|
||||
- **Anchor** — сущность (клиент, товар);
|
||||
- **Attribute** — атрибут (email, имя);
|
||||
- **Tie** — связь (как Link в DV);
|
||||
- Все таблицы — 2–3 столбца.
|
||||
|
||||
✅ **Плюсы**:
|
||||
- **Максимальная гибкость**: поменяли модель — не трогали старые таблицы;
|
||||
- **Бесконечная эволюция**: можно добавлять атрибуты «задним числом».
|
||||
|
||||
❌ **Минусы**:
|
||||
- Очень сложные запросы (JOIN’ов — десятки);
|
||||
- Почти не используется «в чистом виде» — чаще как концепция.
|
||||
|
||||
📌 **Когда выбирать**:
|
||||
→ Экспериментальные проекты;
|
||||
→ Когда схема данных *каждый месяц* радикально меняется.
|
||||
|
||||
---
|
||||
|
||||
### **Сравнение моделей — наглядно**
|
||||
|
||||
```mermaid
|
||||
quadrantChart
|
||||
title Где какая модель? (интуитивно)
|
||||
x-axis "Низкая сложность → Высокая сло́жность"
|
||||
y-axis "Низкая гибкость → Высокая гибкость"
|
||||
"Звезда": [0.2, 0.3]
|
||||
"3NF": [0.6, 0.5]
|
||||
"Data Vault": [0.8, 0.8]
|
||||
"Anchor": [0.95, 0.95]
|
||||
```
|
||||
|
||||
> 🎯 **Вывод**: нет «лучшей» модели. Есть **подходящая под контекст**.
|
||||
> — Для обучения — **Звезда** (просто, наглядно).
|
||||
> — Для корпоративного DWH — **3NF + Звезда на выходе**.
|
||||
> — Для масштабируемой интеграции — **Data Vault**.
|
||||
|
||||
---
|
||||
|
||||
## **7. Практикум: как собрать первую витрину**
|
||||
|
||||
Покажем на примере `mart_daily_sales` — таблицу, которую можно сразу подключить к Power BI.
|
||||
|
||||
### **Этапы сборки**
|
||||
|
||||
1. Из STG → ODS:
|
||||
- `stg.orders_raw` → `ods.orders` (привели `order_date` к `DATE`);
|
||||
2. Из ODS → DDS:
|
||||
- `ods.customers` → `dds.dim_customer` (SCD Type 2);
|
||||
- `ods.products` → `dds.dim_product`;
|
||||
- `ods.orders` + `ods.order_items` → `dds.fact_sales`;
|
||||
3. Из DDS → DM:
|
||||
- `fact_sales` + `dim_*` → `mart_daily_sales`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
STG[stg.orders_raw] --> ODS[ods.orders]
|
||||
ODS --> DDS[dds.fact_sales]
|
||||
dds.dim_customer --> DDS
|
||||
dds.dim_product --> DDS
|
||||
dds.dim_date --> DDS
|
||||
DDS --> DM[mart_daily_sales]
|
||||
DM --> BI[Power BI]
|
||||
```
|
||||
|
||||
### **Пример SQL-запроса для витрины**
|
||||
|
||||
```sql
|
||||
-- mart_daily_sales: ежедневные продажи с сегментацией
|
||||
CREATE MATERIALIZED VIEW dm.mart_daily_sales AS
|
||||
SELECT
|
||||
d.date_actual AS order_date,
|
||||
p.product_name,
|
||||
c.customer_segment, -- например: 'Premium', 'Basic'
|
||||
SUM(f.quantity) AS total_qty,
|
||||
SUM(f.amount) AS total_revenue
|
||||
FROM dds.fact_sales f
|
||||
JOIN dds.dim_date d
|
||||
ON f.date_key = d.date_key
|
||||
JOIN dds.dim_product p
|
||||
ON f.product_sk = p.product_sk
|
||||
JOIN dds.dim_customer c
|
||||
ON f.customer_sk = c.customer_sk
|
||||
AND f.order_date BETWEEN c.valid_from AND c.valid_to -- SCD!
|
||||
WHERE c.is_current = true -- или не фильтровать — тогда будет история
|
||||
GROUP BY d.date_actual, p.product_name, c.customer_segment;
|
||||
```
|
||||
|
||||
> 💡 **Материализованное представление (MATERIALIZED VIEW)** — это «кэш» результата. Обновляется по расписанию (например, ночью).
|
||||
|
||||
---
|
||||
|
||||
## **8. Как выбрать модель? Дерево решений**
|
||||
|
||||
Вот простой алгоритм:
|
||||
|
||||
```
|
||||
Нужна ли история изменений? ── Нет → Звезда (просто и быстро)
|
||||
│
|
||||
Да
|
||||
│
|
||||
Много ли источников (>5)? ── Нет → 3NF (надёжно, понятно)
|
||||
│
|
||||
Да
|
||||
│
|
||||
Нужна ли аудиторская трассировка? ── Нет → 3NF + SCD
|
||||
│
|
||||
Да → Data Vault
|
||||
```
|
||||
|
||||
> 🛑 Не делайте «гибрид» без причины:
|
||||
> — Звезда в DDS — плохо (потеряете гибкость);
|
||||
> — DV в DM — плохо (BI не потянет 50 JOIN’ов).
|
||||
|
||||
---
|
||||
|
||||
## **9. Эксплуатация: качество данных — это не «опция»**
|
||||
|
||||
Самая красивая архитектура бессмысленна, если в `mart_daily_sales` — нули.
|
||||
Поэтому в каждом слое — **контроль качества (DQ, Data Quality)**.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Данные поступили] --> B{Проверка качества}
|
||||
|
||||
B --> C["Уникальность: order_id — уникален?"]
|
||||
B --> D["Полнота: email не NULL?"]
|
||||
B --> E["Валидность: order_date — дата?"]
|
||||
B --> F["Свежесть: данные за сегодня?"]
|
||||
|
||||
C --> G{OK?}
|
||||
D --> G
|
||||
E --> G
|
||||
F --> G
|
||||
|
||||
G -->|Да| H[Загрузить в следующий слой]
|
||||
G -->|Нет| I[Оповещение + остановка пайплайна]
|
||||
```
|
||||
|
||||
Примеры проверок (на SQL):
|
||||
|
||||
```sql
|
||||
-- Проверка уникальности order_id в ODS
|
||||
SELECT order_id, COUNT(*)
|
||||
FROM ods.orders
|
||||
GROUP BY order_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Проверка свежести: есть ли данные за вчера?
|
||||
SELECT 'OK' WHERE EXISTS (
|
||||
SELECT 1 FROM ods.orders
|
||||
WHERE order_date = CURRENT_DATE - INTERVAL '1 day'
|
||||
);
|
||||
```
|
||||
|
||||
> 🔔 **Совет**: делайте DQ-тесты частью CI/CD — как unit-тесты в коде.
|
||||
|
||||
---
|
||||
|
||||
## **10. Заключение: главное — понимать «почему»**
|
||||
|
||||
Хранилище данных — это не про «крутые технологии», а про **мышление**:
|
||||
|
||||
- **Слои (STG→ODS→DDS→DM)** — это про *разделение ответственности*.
|
||||
Не смешивайте сырые данные и аналитические — иначе не найдёте, где ошибка.
|
||||
|
||||
- **Факты и измерения** — это про *структуру мышления*.
|
||||
События (факты) и контекст (измерения) — две стороны одного процесса.
|
||||
|
||||
- **SCD Type 2** — это про *уважение к истории*.
|
||||
Бизнес меняется — и данные должны это отражать.
|
||||
|
||||
- **Модели (Star/3NF/DV)** — это про *выбор под задачу*.
|
||||
Нет «серебряной пули» — есть компромиссы.
|
||||
|
||||
> 🎁 **Финальный подарок**:
|
||||
> Запомните **5 золотых правил DWH**:
|
||||
> 1. Всегда храните BK (бизнес-ключ) — иначе потеряете связь с источником.
|
||||
> 2. В DDS — только интегрированные, «чистые» сущности.
|
||||
> 3. В DM — только то, что нужно для отчёта.
|
||||
> 4. Проверяйте качество *на каждом слое*.
|
||||
> 5. Собирайте витрины *итеративно*: MVP → доработка → новые метрики.
|
||||
|
||||
---
|
||||
|
||||
## **Приложения**
|
||||
|
||||
### 📚 Мини-глоссарий (RU / EN)
|
||||
|
||||
| Термин | Пояснение |
|
||||
|-------|-----------|
|
||||
| **Слой (Layer)** | Логический уровень в DWH: STG/ODS/DDS/DM |
|
||||
| **Витрина (Data Mart)** | Готовый набор таблиц для конкретной аналитики (например, финансы или маркетинг) |
|
||||
| **Факт (Fact)** | Таблица событий или измерений: продажи, клики, звонки |
|
||||
| **Измерение (Dimension)** | Справочник контекста: клиенты, товары, дата |
|
||||
| **Суррогатный ключ (SK)** | Искусственный `BIGINT`, генерируемый в DWH |
|
||||
| **Бизнес-ключ (BK)** | Естественный идентификатор из источника (`customer_id`, `order_number`) |
|
||||
| **SCD (Slowly Changing Dimension)** | Подход к хранению истории атрибутов измерения |
|
||||
| **CDC (Change Data Capture)** | Техника инкрементальной загрузки «только изменений» |
|
||||
| **Conformed Dimension** | Измерение, единое для нескольких витрин (например, `dim_date`) |
|
||||
|
||||
---
|
||||
|
||||
### 🧱 Синонимы слоёв в индустрии
|
||||
|
||||
| Название | Синонимы |
|
||||
|----------|----------|
|
||||
| **STG** | Staging, Raw, Bronze, Landing Zone |
|
||||
| **ODS** | Cleaned, Integrated, Silver |
|
||||
| **DDS** | Core, Conformed, Golden Layer, Enterprise Data Model |
|
||||
| **DM** | Data Mart, Semantic Layer, Gold, Analytics Layer |
|
||||
|
||||
> ⚠️ Названия могут отличаться — смотрите на *содержание*, а не на ярлыки.
|
||||
|
||||
---
|
||||
|
||||
### 🚫 Антипаттерны (чего избегать)
|
||||
|
||||
| Антипаттерн | Почему плохо |
|
||||
|-------------|--------------|
|
||||
| **«Одна огромная история заказов»** | Запросы тормозят, нет истории атрибутов (клиент сменил email — и всё прошлое «перекрасилось») |
|
||||
| **STG и ODS в одной таблице** | Невозможно понять: ошибка в источнике или при очистке? |
|
||||
| **Факт с текстовыми атрибутами** (`customer_name` в `fact_sales`) | Дублирование, нарушение нормализации, «спрятанная» бизнес-логика |
|
||||
| **SCD без BK** | История «отвязана» от бизнеса: удалили клиента — и вся его история исчезла |
|
||||
|
||||
---
|
||||
|
||||
### 📖 Что почитать дальше?
|
||||
|
||||
| Книга / Ресурс | Для кого |
|
||||
|----------------|----------|
|
||||
| **Кимболл, «Техника создания хранилищ данных»** | Начинающим: звезда, SCD, витрины |
|
||||
| **Инмон, «Построение хранилищ данных»** | Для понимания 3NF и корпоративного подхода |
|
||||
| **Linstedt & Olschimke, «Data Vault 2.0»** | Практическое руководство по DV |
|
||||
| **[anchor-modeling.com](https://www.anchormodeling.com/)** | Официальный сайт Anchor Modeling |
|
||||
| **Курс «Аналитика в Postgres» (Stepik / Postgres Pro)** | Практика SQL + DWH на реальных данных |
|
||||
|
||||
---
|
||||
|
||||
## **ЧАСТЬ C. Мини-датасет (для практики)**
|
||||
|
||||
Положите эти файлы в папку `data/` — и тренируйтесь:
|
||||
|
||||
**`customers.csv`**
|
||||
```csv
|
||||
customer_id,email,phone,city
|
||||
101,a@ex.com,700,Москва
|
||||
101,b@ex.com,700,Москва
|
||||
102,c@ex.com,701,СПб
|
||||
```
|
||||
|
||||
**`orders.csv`**
|
||||
```csv
|
||||
order_id,order_date,customer_id
|
||||
5001,2024-01-10,101
|
||||
5002,2024-02-05,102
|
||||
```
|
||||
|
||||
**`order_items.csv`**
|
||||
```csv
|
||||
order_item_id,order_id,product_id,qty,price_at_sale
|
||||
1,5001,9001,2,100.00
|
||||
2,5001,9002,1,50.00
|
||||
3,5002,9001,1,100.00
|
||||
```
|
||||
|
||||
**`products.csv`**
|
||||
```csv
|
||||
product_id,name
|
||||
9001,Phone
|
||||
9002,Case
|
||||
```
|
||||
|
||||
**`prices.csv`**
|
||||
```csv
|
||||
product_id,valid_from,valid_to,price
|
||||
9001,2023-12-01,2024-01-31,100
|
||||
9001,2024-02-01,2999-12-31,110
|
||||
```
|
||||
|
||||
> 📂 Примеры DDL, SQL-загрузки, SCD-скрипты — в [GitHub-репозитории к статье](https://github.com/yourname/dwh-basics) (реальный репо — по вашему усмотрению).
|
||||
|
||||
---
|
||||
|
||||
## **ЧАСТЬ D. DDL-скелеты (PostgreSQL)**
|
||||
|
||||
```sql
|
||||
-- DDS: измерение клиента (SCD Type 2)
|
||||
CREATE TABLE dds.dim_customer (
|
||||
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_bk VARCHAR(50) NOT NULL, -- напр. '101'
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
city VARCHAR(50),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE DEFAULT '9999-12-31',
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- DDS: факт продаж (гранулярность: строка заказа)
|
||||
CREATE TABLE dds.fact_sales (
|
||||
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_sk BIGINT NOT NULL REFERENCES dds.dim_customer(customer_sk),
|
||||
product_sk BIGINT NOT NULL,
|
||||
date_key INT NOT NULL, -- YYYYMMDD, ссылка на dim_date.date_key
|
||||
quantity INT NOT NULL CHECK (quantity > 0),
|
||||
amount DECIMAL(18,2) NOT NULL CHECK (amount >= 0)
|
||||
);
|
||||
```
|
||||
|
||||
> 💡 `date_key` — это `20240110`, а не `DATE`, чтобы не делать JOIN по диапазону в `fact → dim_date`.
|
||||
|
||||
---
|
||||
|
||||
Если нужно — могу:
|
||||
- добавить **интерактивные схемы** (например, кликабельные Mermaid → SVG);
|
||||
- подготовить **полный SQL-скрипт загрузки** (STG→ODS→DDS);
|
||||
- сделать **вариант статьи в формате Jupyter Notebook** (с исполняемыми ячейками).
|
||||
|
||||
Готов дорабатывать под ваш стиль и аудиторию.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных
|
||||
|
||||
> **Для кого эта статья?**
|
||||
> Для тех, кто уже умеет писать базовые SQL-запросы в PostgreSQL, знаком с понятиями таблиц, строк и колонок, и теперь делает первые шаги в аналитике и проектировании хранилищ данных.
|
||||
|
||||
---
|
||||
|
||||
## 1. Введение: зачем вообще нужны измерения и почему они «медленно меняются»?
|
||||
|
||||
Представьте, что вы строите отчёт по продажам. У вас есть таблица с фактами — например, «продано 10 единиц товара X клиенту Y 15 марта». Но чтобы понять, *кто такой клиент Y* или *что за товар X*, вам нужны **справочники** — таблицы с описанием клиентов, товаров, регионов и т.п.
|
||||
|
||||
В мире аналитики такие справочники называют **измерениями** (*dimensions*), а таблицу с продажами — **фактами** (*facts*).
|
||||
|
||||
А теперь представьте: клиент сменил адрес или перешёл в другую категорию (например, из «обычного» в «VIP»). Если вы просто обновите строку в таблице клиентов, то потеряете информацию о том, **какой статус у клиента был на момент продажи**. А это критично: отчёт «продажи VIP-клиентам в марте» окажется неверным!
|
||||
|
||||
Такие атрибуты — которые **меняются со временем, но не каждый день** — и называются **медленно меняющимися измерениями** (*Slowly Changing Dimensions*, **SCD**).
|
||||
|
||||
---
|
||||
|
||||
## 2. Что такое Slowly Changing Dimensions (SCD)?
|
||||
|
||||
SCD — это подход к хранению изменений в измерениях **с учётом времени**. Он позволяет отвечать на вопросы вроде:
|
||||
|
||||
- Какой адрес у клиента был **на дату заказа**?
|
||||
- Сколько продаж пришлось на товары категории «Электроника» **до того, как её переименовали в «Гаджеты»**?
|
||||
|
||||
Без SCD вы видите только **текущее состояние**, а с ним — **всю историю**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Типы SCD — простыми словами
|
||||
|
||||
Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.
|
||||
|
||||
### **Type 0 — Никогда не меняется**
|
||||
Атрибут фиксирован навсегда. Например, дата рождения клиента.
|
||||
Такие поля не требуют специальной обработки — они просто не обновляются.
|
||||
|
||||
### **Type 1 — Просто перезаписать**
|
||||
Вы просто делаете `UPDATE`, и старое значение исчезает.
|
||||
|
||||
✅ Просто.
|
||||
❌ История теряется.
|
||||
|
||||
> Подходит, если изменение — это исправление ошибки (например, опечатка в имени).
|
||||
|
||||
### **Type 2 — Новая строка для новой версии**
|
||||
Каждое изменение порождает **новую строку** в таблице. Старая строка остаётся, но помечается как «устаревшая».
|
||||
|
||||
✅ Полная история.
|
||||
✅ Можно восстановить состояние на любую дату.
|
||||
❌ Больше данных, сложнее запросы.
|
||||
|
||||
> Это **самый распространённый** подход в аналитике.
|
||||
|
||||
### **Type 3 — Добавить колонку «предыдущее значение»**
|
||||
В таблице появляются поля вроде `previous_category`, `category_change_date`.
|
||||
|
||||
✅ Простая история «до/после».
|
||||
❌ Хранит только **одно** предыдущее значение. Не масштабируется.
|
||||
|
||||
> Используется редко, чаще как компромисс в очень простых системах.
|
||||
|
||||
### **Type 4, 5, 6 — Продвинутые гибриды**
|
||||
Эти типы существуют, но **встречаются редко** и почти не используются новичками:
|
||||
|
||||
- **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
|
||||
- **Type 5**: комбинация Type 4 и Type 1 — текущее значение в основной таблице, а история — отдельно.
|
||||
- **Type 6**: объединяет Type 1, 2 и 3 в одной таблице — очень гибко, но сложно.
|
||||
|
||||
> Вам **не нужно запоминать** эти типы сейчас. Достаточно знать, что они бывают — на случай, если встретите их в документации.
|
||||
|
||||
---
|
||||
|
||||
## 4. Практический пример на PostgreSQL: Type 1 vs Type 2
|
||||
|
||||
Допустим, у нас есть таблица клиентов:
|
||||
|
||||
```sql
|
||||
-- Исходная таблица (до изменений)
|
||||
CREATE TABLE customers (
|
||||
customer_id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL -- например: 'Regular', 'VIP'
|
||||
);
|
||||
```
|
||||
|
||||
### Type 1: просто обновляем
|
||||
|
||||
Клиент №1 стал VIP:
|
||||
|
||||
```sql
|
||||
UPDATE customers
|
||||
SET category = 'VIP'
|
||||
WHERE customer_id = 1;
|
||||
```
|
||||
|
||||
Теперь в таблице только новое значение. Если продажа была сделана **до** этого UPDATE, вы не узнаете, что клиент тогда был «Regular».
|
||||
|
||||
---
|
||||
|
||||
### Type 2: сохраняем историю — подробнее
|
||||
|
||||
Чтобы хранить историю, мы меняем структуру таблицы. Вот ключевые поля:
|
||||
|
||||
- **`customer_key`** — искусственный (surrogate) первичный ключ. Он **уникален для каждой версии** клиента.
|
||||
- **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента.
|
||||
- **`valid_from`** — дата, **с которой** эта версия стала актуальной.
|
||||
- **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**.
|
||||
- **`is_current`** — флаг (`true`/`false`), который быстро показывает, какая строка — последняя. Удобен для запросов.
|
||||
|
||||
Пример структуры:
|
||||
|
||||
```sql
|
||||
CREATE TABLE customers_scd2 (
|
||||
customer_key SERIAL PRIMARY KEY, -- уникальный ID каждой версии
|
||||
customer_id INT NOT NULL, -- неизменный бизнес-ID клиента
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
valid_from DATE NOT NULL, -- с какой даты действует
|
||||
valid_to DATE, -- по какую дату действовала (NULL = сейчас)
|
||||
is_current BOOLEAN NOT NULL -- true = актуальная версия
|
||||
);
|
||||
```
|
||||
|
||||
**Шаг 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);
|
||||
```
|
||||
|
||||
**Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**:
|
||||
|
||||
1. **Закрываем старую запись**: указываем, что она была актуальна **до 14 апреля**.
|
||||
2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна.
|
||||
|
||||
```sql
|
||||
-- 1. Завершаем предыдущую версию
|
||||
UPDATE customers_scd2
|
||||
SET valid_to = '2025-04-14',
|
||||
is_current = false
|
||||
WHERE customer_id = 1 AND is_current = true;
|
||||
|
||||
-- 2. Вставляем новую версию
|
||||
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
|
||||
VALUES (1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true);
|
||||
```
|
||||
|
||||
Теперь в таблице две строки для одного клиента. И мы можем спросить:
|
||||
|
||||
> Какой была категория клиента **на 10 апреля 2025**?
|
||||
|
||||
```sql
|
||||
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');
|
||||
```
|
||||
|
||||
Результат: `'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` — это **опциональное упрощение**, а не часть стандарта.
|
||||
|
||||
---
|
||||
|
||||
### Type 2 через логику, похожую на UPSERT
|
||||
|
||||
В реальных ETL-процессах часто используют **идемпотентные** операции: запуск скрипта дважды не должен ломать данные. Для этого удобно применять подход, похожий на *upsert* (update + insert), но адаптированный под логику SCD Type 2.
|
||||
|
||||
В PostgreSQL классический `ON CONFLICT` не подходит напрямую, потому что мы **не обновляем существующую строку**, а **добавляем новую при изменении**.
|
||||
|
||||
Поэтому логика выглядит так:
|
||||
|
||||
1. Сравнить входящие данные с последней версией в таблице.
|
||||
2. Если атрибуты изменились — закрыть старую запись и вставить новую.
|
||||
3. Если не изменились — ничего не делать.
|
||||
|
||||
Пример (часто реализуется в Python, dbt, Airflow и т.п.):
|
||||
|
||||
```sql
|
||||
-- Предположим, новая версия: customer_id=1, category='VIP', effective_date='2025-04-15'
|
||||
|
||||
-- Шаг 1: вставляем новую версию, только если есть изменения
|
||||
WITH last_version AS (
|
||||
SELECT * FROM customers_scd2
|
||||
WHERE customer_id = 1 AND is_current = true
|
||||
)
|
||||
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to, is_current)
|
||||
SELECT 1, 'Иван Петров', 'VIP', '2025-04-15', NULL, true
|
||||
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
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM customers_scd2
|
||||
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = '2025-04-15'
|
||||
);
|
||||
```
|
||||
|
||||
На практике такие логики чаще выносят в **ETL-инструменты** (например, dbt с пакетом `dbt-scd`), потому что чистый SQL быстро становится громоздким.
|
||||
|
||||
> 💡 Главное: **SCD Type 2 — это не одна операция, а процесс**: сравнить → закрыть старое → добавить новое.
|
||||
|
||||
---
|
||||
|
||||
#### А что, если СУБД не позволяет UPDATE? (Trino, Hive, ClickHouse в режиме append-only)
|
||||
|
||||
Некоторые аналитические системы (например, **Hive в формате ORC/Parquet**, **Trino**, **ClickHouse в режиме только вставки**) **не поддерживают UPDATE старых строк**. Как тогда реализовать SCD Type 2?
|
||||
|
||||
Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**!
|
||||
|
||||
Алгоритм загрузки (ETL):
|
||||
1. Сравнить входящие данные с последней версией в таблице.
|
||||
2. Если есть изменения — **пишем новую строку** с новыми `valid_from`.
|
||||
3. Старые строки остаются нетронутыми.
|
||||
|
||||
Пример в Trino/Hive-стиле (только INSERT):
|
||||
|
||||
Давайте разберём этот запрос по шагам, чтобы понять его логику:
|
||||
|
||||
```sql
|
||||
-- new_customers — staging-таблица с обновлёнными данными
|
||||
-- dim_customers_scd2 — основная таблица (append-only)
|
||||
|
||||
INSERT INTO dim_customers_scd2
|
||||
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
|
||||
)
|
||||
WHERE rn = 1 -- отбираем первую, самую свежую, запись
|
||||
)
|
||||
SELECT
|
||||
uuid() AS customer_key, -- уникальный ID каждой версии
|
||||
n.customer_id, -- неизменный бизнес-ID клиента
|
||||
n.name,
|
||||
n.category,
|
||||
COALESCE(n.effective_date, current_date) AS valid_from -- дата начала действия новой версии
|
||||
FROM new_customers n
|
||||
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
|
||||
WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялись
|
||||
```
|
||||
|
||||
##### 🎯 Как работает этот запрос: пошаговое объяснение
|
||||
|
||||
###### Шаг 1: Подготовка данных (CTE current_customers)
|
||||
CTE `current_customers` находит **последнюю версию** каждого клиента из таблицы `dim_customers_scd2`:
|
||||
|
||||
```sql
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||
FROM dim_customers_scd2
|
||||
```
|
||||
|
||||
- `PARTITION BY customer_id` — группируем по клиентам
|
||||
- `ORDER BY valid_from DESC` — сортируем версии от самой новой к самой старой
|
||||
- `WHERE rn = 1` — выбираем только самую свежую версию
|
||||
|
||||
###### Шаг 2: Сравнение данных (LEFT JOIN + WHERE)
|
||||
Теперь сравниваем новые данные с текущими:
|
||||
|
||||
```sql
|
||||
FROM new_customers n
|
||||
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
|
||||
```
|
||||
|
||||
**Возможные сценарии после JOIN:**
|
||||
|
||||
| Сценарий | n.customer_id | c.customer_id | Условие WHERE | Результат |
|
||||
|----------|---------------|---------------|---------------|-----------|
|
||||
| Новый клиент | 2 | NULL | ✅ `c.customer_id IS NULL` | Вставляется |
|
||||
| Категория изменилась | 1 | 1 | ✅ `c.category != n.category` | Вставляется |
|
||||
| Без изменений | 3 | 3 | ❌ оба условия ложны | Пропускается |
|
||||
|
||||
###### Шаг 3: Вставка новых версий
|
||||
Для подходящих записей создаём новую версию:
|
||||
- `uuid()` — генерируем уникальный ключ для новой версии
|
||||
- `current_date` - функция, возвращающая текущую даты
|
||||
- `COALESCE(n.effective_date, current_date)` — устанавливаем дату начала действия новой версии
|
||||
|
||||
> 💡 **Правильный подход к датам**: В реальных ETL-процессах важно использовать дату из исходных данных, когда она доступна. Мы используем `COALESCE(n.effective_date, current_date)`, что означает:
|
||||
> - Если в `new_customers` есть поле `effective_date` — используем его
|
||||
> - Если нет — используем текущую дату (`current_date`)
|
||||
>
|
||||
> **Почему это важно:**
|
||||
> - `effective_date` отражает реальную дату изменения (например, когда клиент стал VIP)
|
||||
> - `current_date` — это дата загрузки данных, которая может не совпадать с датой изменения
|
||||
> - Использование правильной даты критично для точного исторического анализа
|
||||
>
|
||||
> **Пример правильной структуры исходных данных:**
|
||||
> ```sql
|
||||
> new_customers:
|
||||
> customer_id | name | category | effective_date
|
||||
> 1 | Иван Петров | VIP | 2025-04-15 ← дата реального изменения
|
||||
> ```
|
||||
|
||||
##### Практический пример
|
||||
|
||||
**До выполнения запроса:**
|
||||
```
|
||||
dim_customers_scd2:
|
||||
customer_id | name | category | valid_from
|
||||
1 | Иван Петров | Regular | 2024-01-01
|
||||
```
|
||||
|
||||
**Новые данные:**
|
||||
```
|
||||
new_customers:
|
||||
customer_id | name | category
|
||||
1 | Иван Петров | VIP ← изменилась категория
|
||||
2 | Мария Иванова| Regular ← новый клиент
|
||||
```
|
||||
|
||||
**После выполнения запроса:**
|
||||
```
|
||||
dim_customers_scd2:
|
||||
customer_id | name | category | valid_from
|
||||
1 | Иван Петров | Regular | 2024-01-01 ← старая версия
|
||||
1 | Иван Петров | VIP | 2025-11-04 ← новая версия
|
||||
2 | Мария Иванова| Regular | 2025-11-04 ← новый клиент
|
||||
```
|
||||
|
||||
> 💡 **Ключевой момент**: В append-only системах мы **не обновляем** старые записи, а только **добавляем новые**. История сохраняется автоматически!
|
||||
|
||||
##### Главный вопрос после загрузки: как же читать эти данные?
|
||||
|
||||
Поскольку мы не можем обновлять `valid_to` у предыдущей версии, стандартный подход с `BETWEEN` не сработает. Вместо этого, для поиска нужной версии мы полагаемся на **оконные функции** или на логику «ближайшей даты, но не позже».
|
||||
|
||||
###### Паттерн 1: Найти последнюю (актуальную) версию на сегодня
|
||||
|
||||
Это самый частый запрос. Мы хотим видеть самую свежую информацию о клиенте.
|
||||
|
||||
```sql
|
||||
-- Вариант с оконной функцией (универсальный и надежный)
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||
FROM dim_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 dim_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 системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении.
|
||||
|
||||
---
|
||||
|
||||
## 5. Когда что использовать?
|
||||
|
||||
| Сценарий | Рекомендуемый тип |
|
||||
|--------|------------------|
|
||||
| Исправление опечатки | Type 1 |
|
||||
| Юридически значимые изменения (статус, тариф, регион) | Type 2 |
|
||||
| Очень простая аналитика без требований к истории | Type 1 |
|
||||
| Нужна только «последняя смена» и ничего больше | Type 3 (осторожно!) |
|
||||
|
||||
**Совет новичку**: если сомневаетесь — выбирайте **Type 2**. Лучше иметь историю и не использовать её, чем не иметь и не суметь ответить на важный вопрос.
|
||||
|
||||
---
|
||||
|
||||
## 6. Подводные камни и советы
|
||||
|
||||
- **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key).
|
||||
- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает запросы.
|
||||
- Используйте `COALESCE(valid_to, '9999-12-31')` в условиях, чтобы избежать `NULL`-проблем.
|
||||
- Type 2 увеличивает объём данных — но для аналитики это нормально.
|
||||
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
|
||||
|
||||
---
|
||||
|
||||
## 7. Заключение
|
||||
|
||||
Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени.
|
||||
|
||||
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своей БД. Задайте себе вопрос:
|
||||
> «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
|
||||
|
||||
Если нет — вам нужен SCD Type 2.
|
||||
|
||||
И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.
|
||||
@@ -0,0 +1,4 @@
|
||||
customer_id,email,phone,city
|
||||
101,a@ex.com,700,Москва
|
||||
101,b@ex.com,700,Москва
|
||||
102,c@ex.com,701,СПб
|
||||
|
@@ -0,0 +1,4 @@
|
||||
order_item_id,order_id,product_id,qty,price_at_sale
|
||||
1,5001,9001,2,100.00
|
||||
2,5001,9002,1,50.00
|
||||
3,5002,9001,1,100.00
|
||||
|
@@ -0,0 +1,3 @@
|
||||
order_id,order_date,customer_id
|
||||
5001,2024-01-10,101
|
||||
5002,2024-02-05,102
|
||||
|
@@ -0,0 +1,4 @@
|
||||
product_id,valid_from,valid_to,price
|
||||
9001,2023-12-01,2024-01-31,100.00
|
||||
9001,2024-02-01,2999-12-31,110.00
|
||||
9002,2023-01-01,2999-12-31,50.00
|
||||
|
@@ -0,0 +1,3 @@
|
||||
product_id,name
|
||||
9001,Phone
|
||||
9002,Case
|
||||
|
@@ -0,0 +1,255 @@
|
||||
-- ===============================================
|
||||
-- 1. Создание схем
|
||||
-- ===============================================
|
||||
DROP SCHEMA IF EXISTS stg CASCADE;
|
||||
DROP SCHEMA IF EXISTS ods CASCADE;
|
||||
DROP SCHEMA IF EXISTS dds CASCADE;
|
||||
CREATE SCHEMA stg;
|
||||
CREATE SCHEMA ods;
|
||||
CREATE SCHEMA dds;
|
||||
|
||||
-- ===============================================
|
||||
-- 2. STG: таблицы «как пришло»
|
||||
-- ===============================================
|
||||
|
||||
-- Сырые данные: строки, как из CSV/API
|
||||
CREATE TABLE stg.customers_raw (
|
||||
customer_id TEXT, -- может быть '101' или 'CUST-101'
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
city TEXT,
|
||||
_load_ts TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE stg.orders_raw (
|
||||
order_id TEXT,
|
||||
order_date TEXT, -- формат: '2024-01-10'
|
||||
customer_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE stg.order_items_raw (
|
||||
order_item_id TEXT,
|
||||
order_id TEXT,
|
||||
product_id TEXT,
|
||||
qty TEXT, -- может быть '2', 'NULL'
|
||||
price_at_sale TEXT -- может быть '100.00'
|
||||
);
|
||||
|
||||
CREATE TABLE stg.products_raw (
|
||||
product_id TEXT,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
-- Загрузка тестовых данных (вместо COPY FROM CSV)
|
||||
INSERT INTO stg.customers_raw (customer_id, email, phone, city) VALUES
|
||||
('101', 'a@ex.com', '700', 'Москва'),
|
||||
('101', 'b@ex.com', '700', 'Москва'), -- изменение email
|
||||
('102', 'c@ex.com', '701', 'СПб');
|
||||
|
||||
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
|
||||
('5001', '2024-01-10', '101'),
|
||||
('5002', '2024-02-05', '102');
|
||||
|
||||
INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES
|
||||
('1', '5001', '9001', '2', '100.00'),
|
||||
('2', '5001', '9002', '1', '50.00'),
|
||||
('3', '5002', '9001', '1', '100.00');
|
||||
|
||||
INSERT INTO stg.products_raw (product_id, name) VALUES
|
||||
('9001', 'Phone'),
|
||||
('9002', 'Case');
|
||||
|
||||
-- ===============================================
|
||||
-- 3. ODS: очистка и типизация
|
||||
-- ===============================================
|
||||
|
||||
-- Очищенные клиенты
|
||||
CREATE TABLE ods.customers AS
|
||||
SELECT
|
||||
customer_id::INT AS customer_id, -- приведение к INT
|
||||
NULLIF(TRIM(email), '') AS email, -- пустые → NULL
|
||||
NULLIF(TRIM(phone), '') AS phone,
|
||||
NULLIF(TRIM(city), '') AS city
|
||||
FROM stg.customers_raw
|
||||
WHERE customer_id ~ '^\d+$'; -- валидация: только цифры
|
||||
|
||||
-- Очищенные заказы
|
||||
CREATE TABLE ods.orders AS
|
||||
SELECT
|
||||
order_id::INT,
|
||||
TO_DATE(order_date, 'YYYY-MM-DD') AS order_date,
|
||||
customer_id::INT
|
||||
FROM stg.orders_raw
|
||||
WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$';
|
||||
|
||||
-- Очищенные позиции заказа
|
||||
CREATE TABLE ods.order_items AS
|
||||
SELECT
|
||||
order_item_id::INT,
|
||||
order_id::INT,
|
||||
product_id::INT,
|
||||
NULLIF(qty, '')::INT AS qty,
|
||||
NULLIF(price_at_sale, '')::NUMERIC(10,2) AS price_at_sale
|
||||
FROM stg.order_items_raw
|
||||
WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$';
|
||||
|
||||
-- Очищенные товары
|
||||
CREATE TABLE ods.products AS
|
||||
SELECT
|
||||
product_id::INT,
|
||||
TRIM(name) AS name
|
||||
FROM stg.products_raw
|
||||
WHERE product_id ~ '^\d+$';
|
||||
|
||||
-- Добавим первичные ключи (для ускорения JOIN и проверки)
|
||||
ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id);
|
||||
ALTER TABLE ods.orders ADD PRIMARY KEY (order_id);
|
||||
ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id);
|
||||
ALTER TABLE ods.products ADD PRIMARY KEY (product_id);
|
||||
|
||||
-- ===============================================
|
||||
-- 4. DDS: интеграция + SCD Type 2
|
||||
-- ===============================================
|
||||
|
||||
-- 4.1 dim_date — справочник дат (на 5 лет: 2023–2027)
|
||||
CREATE TABLE dds.dim_date AS
|
||||
WITH RECURSIVE dates AS (
|
||||
SELECT DATE '2023-01-01' AS d
|
||||
UNION ALL
|
||||
SELECT d + INTERVAL '1 day'
|
||||
FROM dates
|
||||
WHERE d + INTERVAL '1 day' <= DATE '2027-12-31'
|
||||
)
|
||||
SELECT
|
||||
CAST(TO_CHAR(d, 'YYYYMMDD') AS INT) AS date_key,
|
||||
d AS date_actual,
|
||||
EXTRACT(YEAR FROM d) AS year,
|
||||
EXTRACT(QUARTER FROM d) AS quarter,
|
||||
EXTRACT(MONTH FROM d) AS month,
|
||||
EXTRACT(DAY FROM d) AS day,
|
||||
TO_CHAR(d, 'Day') AS weekday_name,
|
||||
EXTRACT(DOW FROM d) AS weekday_num, -- 0 = воскресенье
|
||||
d BETWEEN
|
||||
DATE_TRUNC('month', d)
|
||||
AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day'
|
||||
AND EXTRACT(DAY FROM d) <= 7
|
||||
AS is_first_week
|
||||
FROM dates;
|
||||
|
||||
ALTER TABLE dds.dim_date ADD PRIMARY KEY (date_key);
|
||||
|
||||
-- 4.2 dim_product — измерение «Товар» (без истории — атрибуты стабильны)
|
||||
CREATE TABLE dds.dim_product (
|
||||
product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_bk INT NOT NULL,
|
||||
product_name VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO dds.dim_product (product_bk, product_name)
|
||||
SELECT product_id, name
|
||||
FROM ods.products;
|
||||
|
||||
-- 4.3 dim_customer — SCD Type 2 (с историей)
|
||||
CREATE TABLE dds.dim_customer (
|
||||
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_bk INT NOT NULL,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
city VARCHAR(50),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE NOT NULL DEFAULT '9999-12-31',
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Загрузка первой версии (SCD Type 2 — простой алгоритм)
|
||||
-- Группируем по customer_bk и сортируем по времени (по _load_ts из STG)
|
||||
-- Для учебного примера имитируем хронологию через порядок строк
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
customer_id AS customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY customer_id
|
||||
ORDER BY _load_ts, email -- хронология изменений
|
||||
) AS rn
|
||||
FROM stg.customers_raw
|
||||
WHERE customer_id ~ '^\d+$'
|
||||
),
|
||||
changes AS (
|
||||
SELECT
|
||||
customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
-- начальная дата — день первого появления в источнике
|
||||
'2023-01-01'::DATE + (rn - 1) * INTERVAL '1 day' AS eff_from
|
||||
FROM ranked
|
||||
),
|
||||
final AS (
|
||||
SELECT
|
||||
customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
eff_from::DATE AS valid_from,
|
||||
COALESCE(
|
||||
LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) - INTERVAL '1 day',
|
||||
'9999-12-31'::DATE
|
||||
) AS valid_to,
|
||||
CASE WHEN
|
||||
LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) IS NULL
|
||||
THEN TRUE ELSE FALSE END AS is_current
|
||||
FROM changes
|
||||
)
|
||||
INSERT INTO dds.dim_customer (customer_bk, email, phone, city, valid_from, valid_to, is_current)
|
||||
SELECT customer_bk, email, phone, city, valid_from, valid_to, is_current
|
||||
FROM final;
|
||||
|
||||
-- 4.4 fact_sales — факт продаж
|
||||
CREATE TABLE dds.fact_sales (
|
||||
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_sk BIGINT NOT NULL, -- ссылка на DDS-измерение
|
||||
product_sk BIGINT NOT NULL,
|
||||
date_key INT NOT NULL, -- YYYYMMDD
|
||||
quantity INT NOT NULL CHECK (quantity > 0),
|
||||
amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0)
|
||||
);
|
||||
|
||||
-- Заполнение fact_sales с учётом истории клиента (SCD!)
|
||||
INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount)
|
||||
SELECT
|
||||
dc.customer_sk,
|
||||
dp.product_sk,
|
||||
CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT) AS date_key,
|
||||
oi.qty,
|
||||
oi.price_at_sale * oi.qty AS amount
|
||||
FROM ods.orders o
|
||||
JOIN ods.order_items oi ON o.order_id = oi.order_id
|
||||
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_customer dc
|
||||
ON o.customer_id = dc.customer_bk
|
||||
AND o.order_date BETWEEN dc.valid_from AND dc.valid_to; -- ← ключевая строка SCD!
|
||||
|
||||
-- Добавим внешние ключи (опционально, для целостности)
|
||||
ALTER TABLE dds.fact_sales
|
||||
ADD FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk),
|
||||
ADD FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk),
|
||||
ADD FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key);
|
||||
|
||||
-- ===============================================
|
||||
-- 5. Проверка: посчитаем выручку по клиентам
|
||||
-- ===============================================
|
||||
SELECT
|
||||
dc.customer_bk,
|
||||
dc.email,
|
||||
dc.city,
|
||||
SUM(f.amount) AS total_revenue
|
||||
FROM dds.fact_sales f
|
||||
JOIN dds.dim_customer dc
|
||||
ON f.customer_sk = dc.customer_sk
|
||||
AND dc.is_current -- только актуальная версия
|
||||
GROUP BY dc.customer_bk, dc.email, dc.city
|
||||
ORDER BY total_revenue DESC;
|
||||
@@ -0,0 +1,288 @@
|
||||
# Структура хранилища данных (для студентов SQL/Postgres)
|
||||
|
||||
> **Цель**: объяснить, как раскладывать данные в аналитической БД и почему именно так принято. Рус/Eng термины приводятся вместе (например, «витрина (Data Mart)»).
|
||||
Статья для учениковп про то, чтобы понимать модели и уметь пользоваться моделированнаыми хранилищами, знать их структуру. У нее нет цели научить проектировать хранилища. Цель - научить понимать структуру и ее смысл
|
||||
|
||||
---
|
||||
|
||||
## 0. Навигация по статье
|
||||
|
||||
* [1. Введение: зачем слои и почему не «всё в одну таблицу»](#1)
|
||||
* [2. Учебный домен/пример](#2)
|
||||
* [3. Типичная структура слоёв (STG → ODS → DDS → DM)](#3)
|
||||
* [4. Слои по отдельности](#4)
|
||||
* [5. Модели данных для DDS/DM: 3NF, Звезда/Снежинка, Data Vault, Anchor](#5)
|
||||
* [6. Ключевые понятия: факт/измерение, зерно, SK vs BK, SCD](#6)
|
||||
* [7. Выбор подхода: дерево решений](#7)
|
||||
* [8. Эволюция схемы и эксплуатации](#8)
|
||||
* [9. MVP учебного проекта](#9)
|
||||
* [10. Заключение](#10)
|
||||
|
||||
---
|
||||
|
||||
## 1. Введение: зачем слои и почему не «всё в одну таблицу»
|
||||
|
||||
**Коротко**: OLTP vs OLAP, управляемость, прозрачность, стоимость.
|
||||
|
||||
**Заглушки для текста**:
|
||||
|
||||
* Что такое слои и какую проблему решают.
|
||||
* Почему «одна большая таблица» ломается на истории и изменениях.
|
||||
* 3–4 тезиса о выгодах послойной архитектуры.
|
||||
|
||||
---
|
||||
|
||||
## 2. Учебный домен/пример (интернет-магазин)
|
||||
|
||||
**Описание**: используем один домен сквозь статью: клиенты, товары, заказы, позиции заказа, цены, акции.
|
||||
|
||||
**Диаграмма ER (эскиз)**:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
CUSTOMER ||--o{ ORDER : places
|
||||
ORDER ||--|{ ORDER_ITEM : contains
|
||||
PRODUCT ||--o{ ORDER_ITEM : referenced
|
||||
PRODUCT ||--o{ PRICE : has
|
||||
PROMO ||--o{ ORDER_ITEM : applied
|
||||
CUSTOMER {
|
||||
int customer_id PK
|
||||
string email
|
||||
string phone
|
||||
}
|
||||
PRODUCT {
|
||||
int product_id PK
|
||||
string name
|
||||
}
|
||||
ORDER {
|
||||
int order_id PK
|
||||
date order_date
|
||||
int customer_id FK
|
||||
}
|
||||
ORDER_ITEM {
|
||||
int order_item_id PK
|
||||
int order_id FK
|
||||
int product_id FK
|
||||
int qty
|
||||
numeric price_at_sale
|
||||
}
|
||||
PRICE {
|
||||
int product_id FK
|
||||
date valid_from
|
||||
date valid_to
|
||||
numeric price
|
||||
}
|
||||
PROMO {
|
||||
int promo_id PK
|
||||
string code
|
||||
}
|
||||
```
|
||||
|
||||
**Заглушки**: 2–3 абзаца с пояснениями domain-гранулярности и бизнес-ключей (BK).
|
||||
|
||||
---
|
||||
|
||||
## 3. Типичная структура слоёв (STG → ODS → DDS → DM)
|
||||
|
||||
**Картинка-конвейер (эскиз)**:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources[Источники]
|
||||
A[CRM] --> STG
|
||||
B[Billing] --> STG
|
||||
C[E-comm] --> STG
|
||||
end
|
||||
STG[STG<br/>Staging / Bronze] --> ODS[ODS<br/>Operational / Silver]
|
||||
ODS --> DDS[DDS<br/>Integrated / Conformed]
|
||||
DDS --> DM[DM<br/>Data Marts / Gold]
|
||||
DM --> BI[BI / Отчёты / Дашборды]
|
||||
```
|
||||
|
||||
**Заглушки**:
|
||||
|
||||
* Соответствие Bronze/Silver/Gold.
|
||||
* Что происходит на каждом переходе в 1–2 фразы.
|
||||
|
||||
---
|
||||
|
||||
## 4. Слои по отдельности
|
||||
|
||||
### 4.1 STG (Staging/Bronze)
|
||||
|
||||
**Коротко**: «как пришло». Идемпотентность, дедупликация, неизменяемость/переигрузка.
|
||||
|
||||
**TODO**: список «что можно» / «что нельзя»; форматы; контроль качества на входе.
|
||||
|
||||
### 4.2 ODS (Operational Data Store/Silver)
|
||||
|
||||
**Коротко**: чистка и выравнивание типов, базовая унификация кодов, ещё без тяжёлой бизнес-логики.
|
||||
|
||||
**TODO**: правила именования, ключи, простая история.
|
||||
|
||||
### 4.3 DDS (Integrated/Conformed Layer)
|
||||
|
||||
**Коротко**: интеграция источников, общие справочники, SK/BK, SCD. Здесь уже начинаются модели данных. Какие модели испольуют.
|
||||
|
||||
**TODO**: где хранить историю, антидубли, конформные измерения.
|
||||
|
||||
### 4.4 DM (Data Marts/Gold)
|
||||
|
||||
**Коротко**: модели под задачи BI (звезда/снежинка). Агрегаты, материализации.
|
||||
|
||||
**TODO**: границы ответственности витрин.
|
||||
|
||||
---
|
||||
|
||||
## 5. Модели данных для DDS/DM: 3NF, Звезда/Снежинка, Data Vault, Anchor
|
||||
|
||||
### 5.1 3NF (по Инмону, 3-я нормальная форма)
|
||||
|
||||
**Идея**: целостная интегрированная модель предприятия.
|
||||
|
||||
**Мини-эскиз (ER, нормализовано)**:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
CUSTOMER ||--o{ ORDER : places
|
||||
ORDER ||--|{ ORDER_ITEM : contains
|
||||
PRODUCT ||--o{ ORDER_ITEM : referenced
|
||||
CATEGORY ||--o{ PRODUCT : classifies
|
||||
PRICE ||--o{ PRODUCT : defines
|
||||
```
|
||||
|
||||
**TODO**: плюсы/минусы, когда выбирать.
|
||||
|
||||
### 5.2 Звезда/Снежинка (по Кимбаллу)
|
||||
|
||||
**Идея**: простые и быстрые аналитические запросы.
|
||||
Упомянуть, что самая часто испольуемая модель.
|
||||
|
||||
**Эскиз звезды: факт + измерения**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F[fact_orders<br/>order_id, date_key, customer_key, product_key, qty, amount]
|
||||
D1[dim_date] --> F
|
||||
D2[dim_customer] --> F
|
||||
D3[dim_product] --> F
|
||||
D4[dim_store] --> F
|
||||
```
|
||||
|
||||
**Эскиз снежинки (нормализация измерения продукта)**:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
D3[dim_product] --> C[dim_category]
|
||||
```
|
||||
|
||||
**TODO**: зерно факта, типы измерений, агрегаты.
|
||||
|
||||
### 5.3 Data Vault 2.0 (Hub–Link–Satellite)
|
||||
|
||||
**Идея**: масштабируемая интеграция многоисточниковых данных с полной историей.
|
||||
|
||||
**Эскиз DV (упорядоченная колонками)**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Hubs (слева)
|
||||
HC[Hub_Customer]
|
||||
HO[Hub_Order]
|
||||
HP[Hub_Product]
|
||||
|
||||
%% Links (центр)
|
||||
LOC[Link_OrderCustomer]
|
||||
LOP[Link_OrderProduct]
|
||||
|
||||
%% Satellites (справа)
|
||||
SC[SAT_Customer]
|
||||
SO[SAT_Order]
|
||||
SP[SAT_Product]
|
||||
|
||||
%% Связи к сателлитам
|
||||
HC --> SC
|
||||
HO --> SO
|
||||
HP --> SP
|
||||
|
||||
%% Связи между хабами и линками
|
||||
HC --> LOC
|
||||
HO --> LOC
|
||||
HO --> LOP
|
||||
HP --> LOP
|
||||
```
|
||||
|
||||
**TODO**: Raw Vault vs Business Vault, плюс/минус.
|
||||
На каких слоях используем, зачем там оно нам.
|
||||
|
||||
Схема часто используется. Пишем, почему.
|
||||
|
||||
### 5.4 Anchor Modeling (Анкерное моделирование)
|
||||
|
||||
**Идея**: эволюционируемость атрибутов с версионированием на уровне «якорей/атрибутов/узлов».
|
||||
|
||||
**Эскиз (упрощённый)**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AC[Anchor CUSTOMER]
|
||||
KC[Knot CUSTOMER_ID]
|
||||
AC --> KC
|
||||
AC --> AN[Attr NAME]
|
||||
AC --> AE[Attr EMAIL]
|
||||
AN --> NV[Name values history]
|
||||
AE --> EV[Email values history]
|
||||
```
|
||||
|
||||
**TODO**: где уместно, порог входа.
|
||||
|
||||
**TODO**: где уместно, порог входа.
|
||||
|
||||
Редко используется. Сложна для людей и для машин. Обосновываем.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ключевые понятия (минимум для практики)
|
||||
|
||||
* **Факты (Facts)** и **Измерения (Dimensions)**; **зерно (grain)** факта.
|
||||
* **Натуральные ключи (BK)** и **суррогатные ключи (SK)**.
|
||||
* **SCD (Slowly Changing Dimensions)**: типы 1 / 2 / 4 / 6.
|
||||
* **Bridge/Junk dimensions**, календарь, валюта, часовой пояс.
|
||||
* **Поздно прибывающие события (late arriving)**.
|
||||
|
||||
**Эскиз SCD‑истории (Type 2) как лента времени**:
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
title SCD Type 2: dim_customer.name
|
||||
section Customer 42
|
||||
Version_1 :active, v1, 2023-01-01, 2023-06-14
|
||||
Version_2 : v2, 2023-06-15, 2024-02-28
|
||||
Version_3 : v3, 2024-03-01, 2025-10-26
|
||||
```
|
||||
|
||||
**TODO**: короткие примеры для Type 1/2/4/6.
|
||||
|
||||
---
|
||||
|
||||
## 7. Эволюция схемы и эксплуатационные практики
|
||||
|
||||
* Совместимость назад/вперёд, view‑based миграции.
|
||||
* Идемпотентные пайплайны, дедупликация, инкрементальные загрузки (CDC).
|
||||
* Data Quality: уникальность, ссылочная целостность, распределения, «contracts».
|
||||
* Каталог/линейка (Data Catalog / Lineage), словарь данных (Business Glossary), владение.
|
||||
|
||||
**TODO**: чек-лист из 8–10 пунктов.
|
||||
|
||||
---
|
||||
## 10. Заключение
|
||||
|
||||
**Заглушки**: повторить ключевые тезисы, дать ссылки на дополнительные темы: CDC, оркестрация, тестирование данных, наблюдаемость.
|
||||
|
||||
---
|
||||
|
||||
## Примечания к иллюстрациям
|
||||
|
||||
* Диаграммы — **эскизы**: заменить/уточнить, когда будет готов текст.
|
||||
* При необходимости добавить отдельные рисунки: «что можно/нельзя в слоях», SCD типы на одном полотне, пример late arriving events.
|
||||
@@ -0,0 +1,549 @@
|
||||
Что нас здесь ждет
|
||||
|
||||
* **Уже умеете:** SQL-основы (SELECT/JOIN/GROUP BY), простые агрегаты.
|
||||
* **Узнаете:** слои (STG/ODS/DDS/DM), модели (3NF/Star/DV/Anchor), SCD и суррогатные ключи.
|
||||
* **Не рассматриваем здесь:** физический дизайн, производительность, партиционирование, распределённые кластеры — это отдельная тема.
|
||||
|
||||
# **Структура статьи про хранилище данных **
|
||||
|
||||
1. Введение. Проблема аналитики в OLTP
|
||||
2. Учебный пример. Интернет-магазин
|
||||
3. Архитектура. Зачем слои?
|
||||
4. Путешествие данных. STG→ODS→DDS→DM
|
||||
5. Базовые понятия. Факты, Измерения, SCD
|
||||
6. Модели данных. 3NF, DV, Звезда
|
||||
7. Практикум. Собираем витрину
|
||||
8. Выбор моделию Дерево решений
|
||||
9. Эксплуатация. Качество и эволюция
|
||||
10. Заключение
|
||||
|
||||
---
|
||||
|
||||
# **1. Введение: Аналитика — это не оперативный учет**
|
||||
|
||||
**OLTP (оперативный учёт)**: CRM, заказы, склад.
|
||||
**DWH (аналитическое хранилище)**: единая модель для анализа.
|
||||
|
||||
Проблема: в OLTP для исторической аналитики — сложные JOIN, тяжёлые агрегации, нестабильная производительность.
|
||||
|
||||
**Особенности DWH**:
|
||||
|
||||
* Слоистая архитектура
|
||||
* Оптимизированные под аналитику модели
|
||||
|
||||
**Ключевые тезисы**:
|
||||
|
||||
* OLTP vs OLAP: транзакции против анализа
|
||||
* Почему «одна большая таблица» не работает на истории
|
||||
* 3 преимущества слоёв: управляемость, производительность, прозрачность
|
||||
|
||||
---
|
||||
|
||||
# **2. Учебный пример: интернет-магазин**
|
||||
|
||||
ER-диаграмма сущностей источника (упрощённо):
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
CUSTOMER ||--o{ ORDER : places
|
||||
ORDER ||--|{ ORDER_ITEM : contains
|
||||
PRODUCT ||--o{ ORDER_ITEM : referenced
|
||||
PRODUCT ||--o{ PRICE : has
|
||||
PROMO ||--o{ ORDER_ITEM : applied
|
||||
CUSTOMER {
|
||||
int customer_id PK
|
||||
string email
|
||||
string phone
|
||||
}
|
||||
PRODUCT {
|
||||
int product_id PK
|
||||
string name
|
||||
}
|
||||
ORDER {
|
||||
int order_id PK
|
||||
date order_date
|
||||
int customer_id FK
|
||||
}
|
||||
ORDER_ITEM {
|
||||
int order_item_id PK
|
||||
int order_id FK
|
||||
int product_id FK
|
||||
int qty
|
||||
numeric price_at_sale
|
||||
}
|
||||
PRICE {
|
||||
int product_id FK
|
||||
date valid_from
|
||||
date valid_to
|
||||
numeric price
|
||||
}
|
||||
PROMO {
|
||||
int promo_id PK
|
||||
string code
|
||||
}
|
||||
```
|
||||
|
||||
Описание примера текстом
|
||||
|
||||
---
|
||||
|
||||
# **3. Архитектура хранилища: зачем делить на слои?**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources[Источники OLTP]
|
||||
A[CRM]
|
||||
B[Заказы]
|
||||
C[Склад]
|
||||
end
|
||||
|
||||
A --> STG
|
||||
B --> STG
|
||||
C --> STG
|
||||
|
||||
subgraph STG[STG - Сырые данные]
|
||||
D[Таблицы-клоны<br>сырые данные]
|
||||
end
|
||||
|
||||
STG --> ODS
|
||||
|
||||
subgraph ODS[ODS - Очищенные данные]
|
||||
E[Стандартизированные<br>типы и форматы]
|
||||
end
|
||||
|
||||
ODS --> DDS
|
||||
|
||||
subgraph DDS[DDS - Интегрированная модель]
|
||||
F[Бизнес-сущности<br>SCD, интеграция]
|
||||
end
|
||||
|
||||
DDS --> DM
|
||||
|
||||
subgraph DM[DM - Витрины для аналитики]
|
||||
G[Звезда/снежинка<br>агрегаты]
|
||||
end
|
||||
|
||||
DM --> BI[BI-системы<br>и отчеты]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **4. Путешествие данных по слоям**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph STG[STG - Сырье]
|
||||
A[raw_orders.json]
|
||||
B[raw_customers.csv]
|
||||
C[Формат как в источнике]
|
||||
end
|
||||
|
||||
subgraph ODS[ODS - Очистка]
|
||||
D[stg_orders]
|
||||
E[stg_customers]
|
||||
F[Типизация, валидация]
|
||||
end
|
||||
|
||||
subgraph DDS[DDS - Интеграция]
|
||||
G[dim_customer<br>SCD Type 2]
|
||||
H[fact_orders<br>с суррогатными ключами]
|
||||
end
|
||||
|
||||
subgraph DM[DM - Готовые решения]
|
||||
I[mart_sales<br>агрегированные данные]
|
||||
J[mart_customer_360<br>обзор по клиентам]
|
||||
end
|
||||
|
||||
STG --> ODS
|
||||
ODS --> DDS
|
||||
DDS --> DM
|
||||
```
|
||||
|
||||
STG (Staging/Bronze)
|
||||
|
||||
Коротко: «как пришло». Идемпотентность, дедупликация, неизменяемость/переигрузка.
|
||||
|
||||
ODS (Operational Data Store/Silver)
|
||||
|
||||
Коротко: чистка и выравнивание типов, базовая унификация кодов, ещё без тяжёлой бизнес-логики.
|
||||
|
||||
DDS (Integrated/Conformed)
|
||||
|
||||
Коротко: интеграция источников, общие справочники, SK/BK, SCD. Здесь живут модели данных (3NF/DV/Anchor/Star).
|
||||
|
||||
DM (Data Marts/Gold)
|
||||
|
||||
Коротко: модели под задачи BI (звезда/снежинка). Агрегаты, материализации.
|
||||
|
||||
* **STG (Bronze):** как пришло; неизменяемо/переигрузка; идемпотентность; дедуп по `(BK, load_ts)`; только тех. обогащения (например, `_ingest_id`).
|
||||
* **ODS (Silver):** типизация; базовая очистка и стандартные коды; **без** тяжёлой бизнес-логики; стабильные схемы/имена.
|
||||
* **DDS (Conformed/Core):** интеграция источников; единая терминология; SK/BK; SCD; здесь живут модели (3NF/Star/DV/Anchor).
|
||||
* **DM (Gold):** под конкретные вопросы BI/продукта; агрегаты/материализации; доступные метрики и измерения.
|
||||
|
||||
Нейминг-гайд (пример)
|
||||
|
||||
* `stg.*` — как в источнике, добавочные тех. поля: `_ingest_id`, `_load_ts`.
|
||||
* `ods.*` — очищенные «плоские» таблицы, стаб. схемы.
|
||||
* `dds.dim_*`, `dds.fact_*` — интегрированная модель.
|
||||
* `dm.mart_*` — витрины/представления.
|
||||
|
||||
---
|
||||
|
||||
## **5. Базовые понятия: Факты, Измерения, Ключи**
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
dim_date ||--o{ fact_sales : "дата"
|
||||
dim_customer ||--o{ fact_sales : "клиент"
|
||||
dim_product ||--o{ fact_sales : "товар"
|
||||
|
||||
dim_customer {
|
||||
bigint customer_sk PK
|
||||
varchar customer_bk
|
||||
varchar customer_name
|
||||
varchar email
|
||||
date valid_from
|
||||
date valid_to
|
||||
boolean is_current
|
||||
}
|
||||
|
||||
fact_sales {
|
||||
bigint sale_id PK
|
||||
bigint customer_sk FK
|
||||
bigint product_sk FK
|
||||
date date_sk FK
|
||||
int quantity
|
||||
decimal amount
|
||||
}
|
||||
```
|
||||
|
||||
Что из базвой модели данных
|
||||
|
||||
**SCD Type 2 - Визуализация истории**:
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title SCD Type 2: История изменений клиента (ID = 123)
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %Y-%m
|
||||
|
||||
section Москва, premium@email.com
|
||||
Версия 1 :active, 2023-01-01, 2023-05-15
|
||||
|
||||
section Москва, new_premium@email.com
|
||||
Версия 2 :active, 2023-05-16, 2023-09-30
|
||||
|
||||
section Санкт-Петербург, new_premium@email.com
|
||||
Версия 3 :active, 2023-10-01, 2024-12-31
|
||||
```
|
||||
в fact берём версию dim_customer по дате факта (между valid_from и valid_to)
|
||||
|
||||
Краткое описание текстом. Указание, что подробно про scd можно почитать в соседней статье SCD.md
|
||||
|
||||
---
|
||||
|
||||
## **6. Модели данных для DDS**
|
||||
|
||||
### 3NF (третья нормальная форма)
|
||||
|
||||
Классическая нормализованная модель, сильная целостность и интеграция, но SQL для аналитики сложнее.
|
||||
|
||||
### Снежинка (Snowflake)
|
||||
|
||||
Нормализованные измерения поверх звезды — компромисс между читаемостью и дублированием.
|
||||
|
||||
### Звезда (Star Schema)
|
||||
|
||||
Факт + денормализованные измерения — просто и быстро для BI/SQL.
|
||||
|
||||
### **Data Vault 2.0**:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
hub_customer ||--o{ sat_customer_info : "хаб"
|
||||
hub_order ||--o{ sat_order_details : "хаб"
|
||||
hub_product ||--o{ sat_product_info : "хаб"
|
||||
|
||||
hub_customer ||--o{ link_order_customer : "участвует"
|
||||
hub_order ||--o{ link_order_customer : "включает"
|
||||
|
||||
hub_order ||--o{ link_order_product : "содержит"
|
||||
hub_product ||--o{ link_order_product : "входит в"
|
||||
|
||||
hub_customer {
|
||||
string customer_hash_key PK
|
||||
string customer_id BK
|
||||
datetime load_dttm
|
||||
}
|
||||
|
||||
sat_customer_info {
|
||||
string customer_hash_key PK,FK
|
||||
datetime load_dttm PK
|
||||
string customer_name
|
||||
string email
|
||||
string phone
|
||||
}
|
||||
|
||||
link_order_customer {
|
||||
string order_customer_hash_key PK
|
||||
string order_hash_key FK
|
||||
string customer_hash_key FK
|
||||
datetime load_dttm
|
||||
}
|
||||
```
|
||||
|
||||
Коротко: хабы (BK), линки (связи), сателлиты (история атрибутов). Этот раздел обзорный.
|
||||
|
||||
Упомянуть про DV 1
|
||||
|
||||
### Anchor Modeling (анкерное моделирование)
|
||||
|
||||
Атомарная декомпозиция сущностей и атрибутов, гибкая эволюция схемы; высокая гранулярность усложняет чтение.
|
||||
|
||||
> **Сравнение (интуитивно):** Звезда — проще/быстрее для BI; 3NF — целостность и интеграция; DV/Anchor — масштабируемая интеграция из многих источников и «история по умолчанию», но сложнее читать и писать.
|
||||
|
||||
**Сравнение моделей**:
|
||||
|
||||
```mermaid
|
||||
quadrantChart
|
||||
title Сравнение моделей данных по сложности и гибкости
|
||||
x-axis "Низкая сложность" --> "Высокая сложность"
|
||||
y-axis "Низкая гибкость" --> "Высокая гибкость"
|
||||
"Звезда": [0.2, 0.3]
|
||||
"3NF": [0.6, 0.5]
|
||||
"Data Vault": [0.8, 0.8]
|
||||
"Anchor": [0.9, 0.9]
|
||||
```
|
||||
Рассказать, что выбор модели для данных далеко не всегда однозначен, и является компромисснным.
|
||||
|
||||
3NF - преимущества и недостатки
|
||||
Снежинка - преимущества и недостатки
|
||||
DV
|
||||
Anchor Modeling
|
||||
|
||||
---
|
||||
|
||||
#### **7. Практикум: собираем витрину**
|
||||
|
||||
Раздел мне не нравится, надо бы переработать.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Источники] --> STG
|
||||
STG[STG: сырые заказы, товары] --> ODS
|
||||
ODS[ODS: очищенные данные] --> DDS
|
||||
|
||||
subgraph DDS[DDS: интегрированная модель]
|
||||
B[dim_customer<br>SCD Type 2]
|
||||
C[dim_product]
|
||||
D[dim_date]
|
||||
E[fact_sales]
|
||||
end
|
||||
|
||||
DDS --> F{Сборка витрины}
|
||||
|
||||
F --> G[mart_daily_sales]
|
||||
F --> H[mart_customer_lifetime]
|
||||
|
||||
G --> I[Дашборд продаж]
|
||||
H --> J[Отчет по клиентам]
|
||||
```
|
||||
|
||||
**Пример SQL для витрины**:
|
||||
```sql
|
||||
-- Витрина ежедневных продаж
|
||||
CREATE TABLE mart_daily_sales AS
|
||||
SELECT
|
||||
d.date,
|
||||
p.product_name,
|
||||
c.customer_segment,
|
||||
SUM(f.quantity) as total_quantity,
|
||||
SUM(f.amount) as total_amount
|
||||
FROM fact_sales f
|
||||
JOIN dim_date d ON f.date_key = d.date_key
|
||||
JOIN dim_product p ON f.product_key = p.product_key
|
||||
JOIN dim_customer c ON f.customer_key = c.customer_key
|
||||
WHERE c.is_current = true
|
||||
GROUP BY d.date, p.product_name, c.customer_segment;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **9. Эксплуатация: качество и эволюция**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Данные] --> B{Контроль качества}
|
||||
|
||||
B --> C[Проверка уникальности]
|
||||
B --> D[Проверка полноты]
|
||||
B --> E[Валидация форматов]
|
||||
B --> F[Свежесть данных]
|
||||
|
||||
C --> G[✅ Успех]
|
||||
D --> G
|
||||
E --> G
|
||||
F --> G
|
||||
|
||||
C --> H[❌ Ошибка]
|
||||
D --> H
|
||||
E --> H
|
||||
F --> H
|
||||
|
||||
G --> I[Загрузка в слой]
|
||||
H --> J[Оповещение<br>и остановка]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **10. Заключение: главное — понимать "почему"**
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((Хранилище данных))
|
||||
Архитектура
|
||||
Слои STG→ODS→DDS→DM
|
||||
Разделение ответственности
|
||||
Управляемость изменений
|
||||
Модели данных
|
||||
3NF: Целостность
|
||||
Data Vault: Масштабируемость
|
||||
Звезда: Производительность
|
||||
Ключевые понятия
|
||||
Факты и измерения
|
||||
Суррогатные ключи
|
||||
SCD Type 2
|
||||
Практика
|
||||
Понимать бизнес-задачу
|
||||
Выбирать подходящую модель
|
||||
Строить итеративно
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **10. Заключение: главное — понимать «почему»**
|
||||
|
||||
Ключевые идеи: разделение на слои (STG → ODS → DDS → DM), разные модели для разных задач (3NF, Star, DV, Anchor), факты/измерения/SCD, итеративная сборка витрин под конкретные вопросы бизнеса.
|
||||
|
||||
# Приложения
|
||||
|
||||
### 2) Мини-глоссарий RU/EN (по 1–2 строки)
|
||||
|
||||
* **Слой (Layer)** — логический уровень в DWH: STG/ODS/DDS/DM.
|
||||
* **Витрина (Data Mart)** — предметно-ориентированный набор таблиц/представлений для конкретной аналитики.
|
||||
* **Факт (Fact)** — таблица событий/измерений величин (кол-во, сумма).
|
||||
* **Измерение (Dimension)** — справочник контекста фактов (клиенты, товары, даты).
|
||||
* **Суррогатный ключ (Surrogate Key, SK)** — искусственный технический ключ (int/bigint).
|
||||
* **Бизнес-ключ (Business Key, BK)** — естественный ключ из источника (например, `customer_id`).
|
||||
* **SCD (Slowly Changing Dimension)** — подход к хранению истории атрибутов измерения.
|
||||
* **CDC (Change Data Capture)** — техника инкрементальной загрузки изменений.
|
||||
* **Conformed Dimension** — «конформное» измерение, общее для нескольких витрин.
|
||||
|
||||
### 3) Отображение синонимов слоёв
|
||||
|
||||
* **STG (Bronze)** → **ODS (Silver)** → **DDS (Conformed/Core)** → **DM (Gold)**.
|
||||
|
||||
### 4) «Правила слоя» — короткие чек-листы
|
||||
|
||||
|
||||
|
||||
### 5) Антипаттерны (короткий бокс)
|
||||
|
||||
* «Одна огромная историческая таблица» → медленные запросы, нет истории атрибутов.
|
||||
* «Смешали STG и ODS» → потеря трассировки ошибок и инцидентов качества.
|
||||
* «Факт с текстовыми атрибутами без причин» → раздутая таблица и неявные бизнес-правила.
|
||||
* «SCD без BK» → история «плывёт», не привязана к бизнес-идентификатору.
|
||||
|
||||
|
||||
|
||||
|
||||
### 10) «Зачем разные модели» (без выбора)
|
||||
|
||||
* **Star (звезда):** быстро писать отчёты, учим новичков на ней.
|
||||
* **3NF:** лучшая консистентность/интеграция понятий, тяжелее для BI.
|
||||
* **Data Vault:** масштабируемая интеграция множества источников + «история по умолчанию».
|
||||
* **Anchor:** гибкая эволюция схемы, максимальная атомарность, цена — сложность.
|
||||
|
||||
### 11) Что почитать дальше
|
||||
|
||||
* **Kimball, The Data Warehouse Toolkit** — модели «звезды», SCD.
|
||||
* **Inmon, Building the Data Warehouse** — корпоративный DWH и нормализация.
|
||||
* **Linstedt & Olschimke, The Data Vault 2.0** — практический DV.
|
||||
* **Anchor Modeling** — официальный сайт/документация по Anchor.
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ C. Мини-датасет (для примеров в статье)
|
||||
|
||||
> Опционально приложить к репозиторию/приложению статьи.
|
||||
|
||||
**customers.csv**
|
||||
|
||||
```
|
||||
customer_id,email,phone,city
|
||||
101,a@ex.com,700,Москва
|
||||
101,b@ex.com,700,Москва
|
||||
102,c@ex.com,701,СПб
|
||||
```
|
||||
|
||||
**orders.csv**
|
||||
|
||||
```
|
||||
order_id,order_date,customer_id
|
||||
5001,2024-01-10,101
|
||||
5002,2024-02-05,102
|
||||
```
|
||||
|
||||
**order_items.csv**
|
||||
|
||||
```
|
||||
order_item_id,order_id,product_id,qty,price_at_sale
|
||||
1,5001,9001,2,100.00
|
||||
2,5001,9002,1,50.00
|
||||
3,5002,9001,1,100.00
|
||||
```
|
||||
|
||||
**products.csv**
|
||||
|
||||
```
|
||||
product_id,name
|
||||
9001,Phone
|
||||
9002,Case
|
||||
```
|
||||
|
||||
**prices.csv**
|
||||
|
||||
```
|
||||
product_id,valid_from,valid_to,price
|
||||
9001,2023-12-01,2024-01-31,100
|
||||
9001,2024-02-01,2999-12-31,110
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ D. DDL-скелеты (минимально)
|
||||
|
||||
```sql
|
||||
-- DDS: измерение клиента (Type 2)
|
||||
CREATE TABLE dds.dim_customer (
|
||||
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_bk VARCHAR NOT NULL,
|
||||
email VARCHAR,
|
||||
phone VARCHAR,
|
||||
city VARCHAR,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE,
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- DDS: факт продаж (гранулярность: строка заказа)
|
||||
CREATE TABLE dds.fact_sales (
|
||||
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_sk BIGINT NOT NULL,
|
||||
product_sk BIGINT NOT NULL,
|
||||
date_sk INT NOT NULL,
|
||||
quantity INT NOT NULL,
|
||||
amount DECIMAL(18,2) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
# Модели данных и слои DWH (для менти)
|
||||
|
||||
**Цель и аудитория:** эта статья для начинающих аналитиков/инженеров данных, которые уже умеют базовый SQL (SELECT/JOIN/GROUP BY/WHERE) и переходят к моделированию данных и архитектуре хранилища (Data Warehouse, DWH). Здесь даём общее представление: **зачем слои**, **что такое модели**, **чем они отличаются**, где обычно живут **факты/измерения/SCD**. **Выбор модели** в продакшен за вас делать не будем — на этом этапе важно понять, *зачем разные модели существуют* и *какие задачи они решают*.
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ A. Исходный черновик статьи (переделан в чистовик с лёгкой правкой опечаток)
|
||||
|
||||
Ниже — аккуратно отредактированная версия вашего плана с диаграммами Mermaid. Сохранили исходную структуру и формулировки, поправили опечатки и выровняли стиль именования.
|
||||
|
||||
---
|
||||
|
||||
# **Структура статьи про хранилище данных**
|
||||
|
||||
1. Введение. Проблема аналитики в OLTP
|
||||
2. Учебный пример. Интернет-магазин
|
||||
3. Архитектура. Зачем слои?
|
||||
4. Путешествие данных. STG → ODS → DDS → DM
|
||||
5. Базовые понятия. Факты, Измерения, SCD
|
||||
6. Модели данных. 3NF, Data Vault, Звезда
|
||||
7. Практикум. Собираем витрину
|
||||
8. Зачем разные модели (демо-«дерево решений»)
|
||||
9. Эксплуатация. Качество и эволюция
|
||||
10. Заключение
|
||||
|
||||
> Примечание про Mermaid: диаграммы `mindmap` и `quadrantChart` поддерживаются не всеми рендерами. Если планируете публикацию на площадке со строгим Markdown (например, Habr), оставьте текстовую альтернативу.
|
||||
|
||||
---
|
||||
|
||||
## **1. Введение: аналитика — это не оперативный учёт**
|
||||
|
||||
**OLTP (оперативный учёт)**: CRM, заказы, склад.
|
||||
**DWH (аналитическое хранилище)**: единая модель для анализа.
|
||||
|
||||
Проблема: в OLTP для исторической аналитики — сложные JOIN, тяжёлые агрегации, нестабильная производительность.
|
||||
|
||||
**Особенности DWH**:
|
||||
|
||||
* Слоистая архитектура
|
||||
* Оптимизированные под аналитику модели
|
||||
|
||||
**Ключевые тезисы**:
|
||||
|
||||
* OLTP vs OLAP: транзакции против анализа
|
||||
* Почему «одна большая таблица» не работает на истории
|
||||
* 3 преимущества слоёв: управляемость, производительность, прозрачность
|
||||
|
||||
---
|
||||
|
||||
## **2. Учебный пример: интернет-магазин**
|
||||
|
||||
ER-диаграмма сущностей источника (упрощённо):
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
CUSTOMER ||--o{ ORDER : places
|
||||
ORDER ||--|{ ORDER_ITEM : contains
|
||||
PRODUCT ||--o{ ORDER_ITEM : referenced
|
||||
PRODUCT ||--o{ PRICE : has
|
||||
PROMO ||--o{ ORDER_ITEM : applied
|
||||
CUSTOMER {
|
||||
int customer_id PK
|
||||
string email
|
||||
string phone
|
||||
}
|
||||
PRODUCT {
|
||||
int product_id PK
|
||||
string name
|
||||
}
|
||||
ORDER {
|
||||
int order_id PK
|
||||
date order_date
|
||||
int customer_id FK
|
||||
}
|
||||
ORDER_ITEM {
|
||||
int order_item_id PK
|
||||
int order_id FK
|
||||
int product_id FK
|
||||
int qty
|
||||
numeric price_at_sale
|
||||
}
|
||||
PRICE {
|
||||
int product_id FK
|
||||
date valid_from
|
||||
date valid_to
|
||||
numeric price
|
||||
}
|
||||
PROMO {
|
||||
int promo_id PK
|
||||
string code
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **3. Архитектура хранилища: зачем делить на слои?**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources[Источники OLTP]
|
||||
A[CRM]
|
||||
B[Заказы]
|
||||
C[Склад]
|
||||
end
|
||||
|
||||
A --> STG
|
||||
B --> STG
|
||||
C --> STG
|
||||
|
||||
subgraph STG[STG (Bronze) — сырые данные]
|
||||
D[Таблицы-клоны \n неизменяемые/переигрузка]
|
||||
end
|
||||
|
||||
STG --> ODS
|
||||
|
||||
subgraph ODS[ODS (Silver) — очищенные]
|
||||
E[Типизация, валидация, \n стандартизация кодов]
|
||||
end
|
||||
|
||||
ODS --> DDS
|
||||
|
||||
subgraph DDS[DDS — интегрированная модель]
|
||||
F[Бизнес-сущности, SK/BK, SCD]
|
||||
end
|
||||
|
||||
DDS --> DM
|
||||
|
||||
subgraph DM[DM (Gold) — витрины]
|
||||
G[Звезда/Снежинка, агрегаты]
|
||||
end
|
||||
|
||||
DM --> BI[BI/Отчёты]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **4. Путешествие данных по слоям**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph STG[STG — сырьё]
|
||||
A[raw_orders.json]
|
||||
B[raw_customers.csv]
|
||||
C[Формат как в источнике]
|
||||
end
|
||||
|
||||
subgraph ODS[ODS — очистка]
|
||||
D[stg_orders]
|
||||
E[stg_customers]
|
||||
F[Типизация, валидация]
|
||||
end
|
||||
|
||||
subgraph DDS[DDS — интеграция]
|
||||
G[dim_customer — SCD2]
|
||||
H[fact_orders — с суррогатными ключами]
|
||||
end
|
||||
|
||||
subgraph DM[DM — готовые решения]
|
||||
I[mart_sales — агрегаты]
|
||||
J[mart_customer_360]
|
||||
end
|
||||
|
||||
STG --> ODS
|
||||
ODS --> DDS
|
||||
DDS --> DM
|
||||
```
|
||||
|
||||
### 4.1 STG (Staging/Bronze)
|
||||
|
||||
Коротко: «как пришло». Идемпотентность, дедупликация, неизменяемость/переигрузка.
|
||||
|
||||
### 4.2 ODS (Operational Data Store/Silver)
|
||||
|
||||
Коротко: чистка и выравнивание типов, базовая унификация кодов, ещё без тяжёлой бизнес-логики.
|
||||
|
||||
### 4.3 DDS (Integrated/Conformed)
|
||||
|
||||
Коротко: интеграция источников, общие справочники, SK/BK, SCD. Здесь живут модели данных (3NF/DV/Anchor/Star).
|
||||
|
||||
### 4.4 DM (Data Marts/Gold)
|
||||
|
||||
Коротко: модели под задачи BI (звезда/снежинка). Агрегаты, материализации.
|
||||
|
||||
---
|
||||
|
||||
## **5. Базовые понятия: факты, измерения, ключи**
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
dim_date ||--o{ fact_sales : "дата"
|
||||
dim_customer ||--o{ fact_sales : "клиент"
|
||||
dim_product ||--o{ fact_sales : "товар"
|
||||
|
||||
dim_customer {
|
||||
bigint customer_sk PK
|
||||
varchar customer_bk
|
||||
varchar customer_name
|
||||
varchar email
|
||||
date valid_from
|
||||
date valid_to
|
||||
boolean is_current
|
||||
}
|
||||
|
||||
fact_sales {
|
||||
bigint sale_id PK
|
||||
bigint customer_sk FK
|
||||
bigint product_sk FK
|
||||
int date_sk FK
|
||||
int quantity
|
||||
decimal amount
|
||||
}
|
||||
```
|
||||
|
||||
**SCD Type 2 — визуализация истории:**
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title SCD Type 2: История изменений клиента (ID = 123)
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %Y-%m
|
||||
|
||||
section Москва, premium@email.com
|
||||
Версия 1 :active, 2023-01-01, 2023-05-15
|
||||
|
||||
section Москва, new_premium@email.com
|
||||
Версия 2 :active, 2023-05-16, 2023-09-30
|
||||
|
||||
section Санкт-Петербург, new_premium@email.com
|
||||
Версия 3 :active, 2023-10-01, 2024-12-31
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **6. Модели данных для DDS**
|
||||
|
||||
### 3NF (третья нормальная форма)
|
||||
|
||||
Классическая нормализованная модель, сильная целостность и интеграция, но SQL для аналитики сложнее.
|
||||
|
||||
### Снежинка (Snowflake)
|
||||
|
||||
Нормализованные измерения поверх звезды — компромисс между читаемостью и дублированием.
|
||||
|
||||
### Звезда (Star Schema)
|
||||
|
||||
Факт + денормализованные измерения — просто и быстро для BI/SQL.
|
||||
|
||||
### **Data Vault 2.0**
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
HUB_CUSTOMER ||--o{ SAT_CUSTOMER : has_history
|
||||
HUB_ORDER ||--o{ SAT_ORDER : has_history
|
||||
HUB_PRODUCT ||--o{ SAT_PRODUCT : has_history
|
||||
|
||||
HUB_CUSTOMER ||--o{ LNK_ORDER_CUSTOMER : participates
|
||||
HUB_ORDER ||--o{ LNK_ORDER_CUSTOMER : includes
|
||||
|
||||
HUB_ORDER ||--o{ LNK_ORDER_PRODUCT : contains
|
||||
HUB_PRODUCT ||--o{ LNK_ORDER_PRODUCT : involved
|
||||
|
||||
HUB_CUSTOMER {
|
||||
string customer_hk PK
|
||||
string customer_bk
|
||||
datetime load_dts
|
||||
string src
|
||||
}
|
||||
|
||||
LNK_ORDER_PRODUCT {
|
||||
string order_product_hk PK
|
||||
string order_hk FK
|
||||
string product_hk FK
|
||||
datetime load_dts
|
||||
string src
|
||||
}
|
||||
|
||||
SAT_CUSTOMER {
|
||||
string customer_hk PK,FK
|
||||
datetime load_dts PK
|
||||
string email
|
||||
string phone
|
||||
string city
|
||||
hashdiff hash
|
||||
}
|
||||
```
|
||||
|
||||
Коротко: хабы (BK), линки (связи), сателлиты (история атрибутов). Этот раздел обзорный.
|
||||
|
||||
### Anchor Modeling (анкерное моделирование)
|
||||
|
||||
Атомарная декомпозиция сущностей и атрибутов, гибкая эволюция схемы; высокая гранулярность усложняет чтение.
|
||||
|
||||
> **Сравнение (интуитивно):** Звезда — проще/быстрее для BI; 3NF — целостность и интеграция; DV/Anchor — масштабируемая интеграция из многих источников и «история по умолчанию», но сложнее читать и писать.
|
||||
|
||||
---
|
||||
|
||||
## **7. Практикум: собираем витрину**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Источники] --> STG
|
||||
STG[STG: сырые заказы, товары] --> ODS
|
||||
ODS[ODS: очищенные данные] --> DDS
|
||||
|
||||
subgraph DDS[DDS: интегрированная модель]
|
||||
B[dim_customer (SCD2)]
|
||||
C[dim_product]
|
||||
D[dim_date]
|
||||
E[fact_sales]
|
||||
end
|
||||
|
||||
DDS --> F{Сборка витрины}
|
||||
|
||||
F --> G[mart_daily_sales]
|
||||
F --> H[mart_customer_lifetime]
|
||||
|
||||
G --> I[Дашборд продаж]
|
||||
H --> J[Отчёт по клиентам]
|
||||
```
|
||||
|
||||
**Пример SQL для витрины:**
|
||||
|
||||
```sql
|
||||
-- Витрина ежедневных продаж (пример)
|
||||
CREATE TABLE mart_daily_sales AS
|
||||
SELECT
|
||||
d.date,
|
||||
p.product_name,
|
||||
c.customer_segment,
|
||||
SUM(f.quantity) AS total_quantity,
|
||||
SUM(f.amount) AS total_amount
|
||||
FROM fact_sales f
|
||||
JOIN dim_date d ON f.date_sk = d.date_sk
|
||||
JOIN dim_product p ON f.product_sk = p.product_sk
|
||||
JOIN dim_customer c ON f.customer_sk = c.customer_sk AND c.is_current = TRUE
|
||||
GROUP BY d.date, p.product_name, c.customer_segment;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **9. Эксплуатация: качество и эволюция**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Данные] --> B{Контроль качества}
|
||||
B --> C[Проверка уникальности]
|
||||
B --> D[Проверка полноты]
|
||||
B --> E[Валидация форматов]
|
||||
B --> F[Свежесть данных]
|
||||
C --> G[✅ Успех]
|
||||
D --> G
|
||||
E --> G
|
||||
F --> G
|
||||
C --> H[❌ Ошибка]
|
||||
D --> H
|
||||
E --> H
|
||||
F --> H
|
||||
G --> I[Загрузка в слой]
|
||||
H --> J[Оповещение и остановка]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **10. Заключение: главное — понимать «почему»**
|
||||
|
||||
Ключевые идеи: разделение на слои (STG → ODS → DDS → DM), разные модели для разных задач (3NF, Star, DV, Anchor), факты/измерения/SCD, итеративная сборка витрин под конкретные вопросы бизнеса.
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ B. Наши предложения к статье (что усилено и что добавить)
|
||||
|
||||
Ниже — блоки, которые можно встроить в соответствующие разделы или вынести в отдельные боксы/врезки.
|
||||
|
||||
### 1) Рамочный ввод «что вы знаете / что узнаете / чего не будет»
|
||||
|
||||
* **Уже умеете:** SQL-основы (SELECT/JOIN/GROUP BY), простые агрегаты.
|
||||
* **Узнаете:** слои (STG/ODS/DDS/DM), модели (3NF/Star/DV/Anchor), SCD и суррогатные ключи.
|
||||
* **Не рассматриваем здесь:** физический дизайн, производительность, партиционирование, распределённые кластеры — это отдельная тема.
|
||||
|
||||
### 2) Мини-глоссарий RU/EN (по 1–2 строки)
|
||||
|
||||
* **Слой (Layer)** — логический уровень в DWH: STG/ODS/DDS/DM.
|
||||
* **Витрина (Data Mart)** — предметно-ориентированный набор таблиц/представлений для конкретной аналитики.
|
||||
* **Факт (Fact)** — таблица событий/измерений величин (кол-во, сумма).
|
||||
* **Измерение (Dimension)** — справочник контекста фактов (клиенты, товары, даты).
|
||||
* **Суррогатный ключ (Surrogate Key, SK)** — искусственный технический ключ (int/bigint).
|
||||
* **Бизнес-ключ (Business Key, BK)** — естественный ключ из источника (например, `customer_id`).
|
||||
* **SCD (Slowly Changing Dimension)** — подход к хранению истории атрибутов измерения.
|
||||
* **CDC (Change Data Capture)** — техника инкрементальной загрузки изменений.
|
||||
* **Conformed Dimension** — «конформное» измерение, общее для нескольких витрин.
|
||||
|
||||
### 3) Отображение синонимов слоёв
|
||||
|
||||
* **STG (Bronze)** → **ODS (Silver)** → **DDS (Conformed/Core)** → **DM (Gold)**.
|
||||
|
||||
### 4) «Правила слоя» — короткие чек-листы
|
||||
|
||||
* **STG (Bronze):** как пришло; неизменяемо/переигрузка; идемпотентность; дедуп по `(BK, load_ts)`; только тех. обогащения (например, `_ingest_id`).
|
||||
* **ODS (Silver):** типизация; базовая очистка и стандартные коды; **без** тяжёлой бизнес-логики; стабильные схемы/имена.
|
||||
* **DDS (Conformed/Core):** интеграция источников; единая терминология; SK/BK; SCD; здесь живут модели (3NF/Star/DV/Anchor).
|
||||
* **DM (Gold):** под конкретные вопросы BI/продукта; агрегаты/материализации; доступные метрики и измерения.
|
||||
|
||||
### 5) Антипаттерны (короткий бокс)
|
||||
|
||||
* «Одна огромная историческая таблица» → медленные запросы, нет истории атрибутов.
|
||||
* «Смешали STG и ODS» → потеря трассировки ошибок и инцидентов качества.
|
||||
* «Факт с текстовыми атрибутами без причин» → раздутая таблица и неявные бизнес-правила.
|
||||
* «SCD без BK» → история «плывёт», не привязана к бизнес-идентификатору.
|
||||
|
||||
### 6) SCD2: рецепт джойна «по интервалу» (шпаргалка)
|
||||
|
||||
```sql
|
||||
-- Привязка факта к актуальной версии измерения (Type 2)
|
||||
SELECT f.*, d.customer_sk
|
||||
FROM fact_sales f
|
||||
JOIN dim_customer d
|
||||
ON d.customer_bk = f.customer_bk -- совпадение BK
|
||||
AND f.event_date >= d.valid_from
|
||||
AND f.event_date < COALESCE(d.valid_to, DATE '2999-12-31');
|
||||
```
|
||||
|
||||
### 7) Мини-практикум (сквозной, короткий)
|
||||
|
||||
1. Показать джойн факта к SCD2-измерению (по примеру выше).
|
||||
2. Собрать `mart_daily_sales` (у вас уже есть SQL).
|
||||
3. Пара проверок качества (см. ниже).
|
||||
|
||||
### 8) Качество данных: минимальные метрики + однострочники
|
||||
|
||||
* **Freshness (свежесть):** `MAX(event_time)` против «сейчас».
|
||||
* **Completeness (полнота):** доля NULL по важным столбцам.
|
||||
* **Consistency (согласованность):** факт без соответствия в измерении (нарушение покрытии ссылок).
|
||||
* **Uniqueness (уникальность):** уникальность BK в справочнике.
|
||||
|
||||
```sql
|
||||
-- Примеры «однострочников»
|
||||
-- Freshness
|
||||
SELECT NOW() - MAX(event_time) AS lag FROM ods.order;
|
||||
|
||||
-- Uniqueness BK
|
||||
SELECT customer_bk, COUNT(*)
|
||||
FROM dds.dim_customer
|
||||
GROUP BY customer_bk
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Orphan facts (без покрытия измерения)
|
||||
SELECT COUNT(*)
|
||||
FROM dds.fact_sales f
|
||||
LEFT JOIN dds.dim_customer d ON f.customer_sk = d.customer_sk
|
||||
WHERE d.customer_sk IS NULL;
|
||||
```
|
||||
|
||||
### 9) Нейминг-гайд (пример)
|
||||
|
||||
* `stg.*` — как в источнике, добавочные тех. поля: `_ingest_id`, `_load_ts`.
|
||||
* `ods.*` — очищенные «плоские» таблицы, стаб. схемы.
|
||||
* `dds.dim_*`, `dds.fact_*` — интегрированная модель.
|
||||
* `dm.mart_*` — витрины/представления.
|
||||
|
||||
### 10) «Зачем разные модели» (без выбора)
|
||||
|
||||
* **Star (звезда):** быстро писать отчёты, учим новичков на ней.
|
||||
* **3NF:** лучшая консистентность/интеграция понятий, тяжелее для BI.
|
||||
* **Data Vault:** масштабируемая интеграция множества источников + «история по умолчанию».
|
||||
* **Anchor:** гибкая эволюция схемы, максимальная атомарность, цена — сложность.
|
||||
|
||||
### 11) Что почитать дальше
|
||||
|
||||
* **Kimball, The Data Warehouse Toolkit** — модели «звезды», SCD.
|
||||
* **Inmon, Building the Data Warehouse** — корпоративный DWH и нормализация.
|
||||
* **Linstedt & Olschimke, The Data Vault 2.0** — практический DV.
|
||||
* **Anchor Modeling** — официальный сайт/документация по Anchor.
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ C. Мини-датасет (для примеров в статье)
|
||||
|
||||
> Опционально приложить к репозиторию/приложению статьи.
|
||||
|
||||
**customers.csv**
|
||||
|
||||
```
|
||||
customer_id,email,phone,city
|
||||
101,a@ex.com,700,Москва
|
||||
101,b@ex.com,700,Москва
|
||||
102,c@ex.com,701,СПб
|
||||
```
|
||||
|
||||
**orders.csv**
|
||||
|
||||
```
|
||||
order_id,order_date,customer_id
|
||||
5001,2024-01-10,101
|
||||
5002,2024-02-05,102
|
||||
```
|
||||
|
||||
**order_items.csv**
|
||||
|
||||
```
|
||||
order_item_id,order_id,product_id,qty,price_at_sale
|
||||
1,5001,9001,2,100.00
|
||||
2,5001,9002,1,50.00
|
||||
3,5002,9001,1,100.00
|
||||
```
|
||||
|
||||
**products.csv**
|
||||
|
||||
```
|
||||
product_id,name
|
||||
9001,Phone
|
||||
9002,Case
|
||||
```
|
||||
|
||||
**prices.csv**
|
||||
|
||||
```
|
||||
product_id,valid_from,valid_to,price
|
||||
9001,2023-12-01,2024-01-31,100
|
||||
9001,2024-02-01,2999-12-31,110
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ D. DDL-скелеты (минимально)
|
||||
|
||||
```sql
|
||||
-- DDS: измерение клиента (Type 2)
|
||||
CREATE TABLE dds.dim_customer (
|
||||
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_bk VARCHAR NOT NULL,
|
||||
email VARCHAR,
|
||||
phone VARCHAR,
|
||||
city VARCHAR,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE,
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- DDS: факт продаж (гранулярность: строка заказа)
|
||||
CREATE TABLE dds.fact_sales (
|
||||
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_sk BIGINT NOT NULL,
|
||||
product_sk BIGINT NOT NULL,
|
||||
date_sk INT NOT NULL,
|
||||
quantity INT NOT NULL,
|
||||
amount DECIMAL(18,2) NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ЧАСТЬ E. Текстовые альтернативы для «капризных» диаграмм
|
||||
|
||||
Если `mindmap`/`quadrantChart` не поддерживаются местом публикации, используйте пункты:
|
||||
|
||||
* **Сводка:**
|
||||
|
||||
* Архитектура: слои STG→ODS→DDS→DM, разделение ответственности, управляемость изменений.
|
||||
* Модели: 3NF (целостность), Data Vault (масштабируемость), Звезда (производительность), Anchor (гибкая эволюция).
|
||||
* Понятия: факты, измерения, SCD2, SK/BK.
|
||||
* Практика: понимать бизнес-задачу, выбирать подход под контекст, строить итеративно.
|
||||
|
||||
* **Интуитивное сравнение моделей (простыми словами):**
|
||||
Звезда — «быстро стартануть BI»; 3NF — «собрать единый словарь сущностей»; DV/Anchor — «надёжно интегрировать десятки источников и хранить всю историю», но читать сложнее.
|
||||
|
||||
---
|
||||
|
||||
## Итог
|
||||
|
||||
Статья даёт общий словарь и картинку мира: что за слои в DWH, какие модели бывают и зачем они нужны. Задача менти — почувствовать интуицию различий, научиться видеть место фактов/измерений/SCD и уверенно собрать простую витрину из DDS. Дальше — углубляться по нужной модели в отдельном материале (звезда/DV/Anchor/3NF).
|
||||
@@ -0,0 +1,125 @@
|
||||
-- ===============================================
|
||||
-- DDL-скрипт: определение структуры хранилища
|
||||
-- Запускается ОДИН РАЗ при инициализации БД
|
||||
-- или при изменении схемы (миграции)
|
||||
-- ===============================================
|
||||
|
||||
-- 1. Схемы
|
||||
DROP SCHEMA IF EXISTS stg CASCADE;
|
||||
DROP SCHEMA IF EXISTS ods CASCADE;
|
||||
DROP SCHEMA IF EXISTS dds CASCADE;
|
||||
|
||||
CREATE SCHEMA stg;
|
||||
CREATE SCHEMA ods;
|
||||
CREATE SCHEMA dds;
|
||||
|
||||
-- 2. STG: сырые данные (как пришли)
|
||||
CREATE TABLE stg.customers_raw (
|
||||
customer_id TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
city TEXT,
|
||||
_load_ts TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE stg.orders_raw (
|
||||
order_id TEXT,
|
||||
order_date TEXT,
|
||||
customer_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE stg.order_items_raw (
|
||||
order_item_id TEXT,
|
||||
order_id TEXT,
|
||||
product_id TEXT,
|
||||
qty TEXT,
|
||||
price_at_sale TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE stg.products_raw (
|
||||
product_id TEXT,
|
||||
name TEXT
|
||||
);
|
||||
|
||||
-- 3. ODS: очищенные данные
|
||||
CREATE TABLE ods.customers (
|
||||
customer_id INT,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
city VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE TABLE ods.orders (
|
||||
order_id INT,
|
||||
order_date DATE,
|
||||
customer_id INT
|
||||
);
|
||||
|
||||
CREATE TABLE ods.order_items (
|
||||
order_item_id INT,
|
||||
order_id INT,
|
||||
product_id INT,
|
||||
qty INT,
|
||||
price_at_sale NUMERIC(10,2)
|
||||
);
|
||||
|
||||
CREATE TABLE ods.products (
|
||||
product_id INT,
|
||||
name VARCHAR(100)
|
||||
);
|
||||
|
||||
-- Первичные ключи в ODS (для ускорения и валидации)
|
||||
ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id);
|
||||
ALTER TABLE ods.orders ADD PRIMARY KEY (order_id);
|
||||
ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id);
|
||||
ALTER TABLE ods.products ADD PRIMARY KEY (product_id);
|
||||
|
||||
-- 4. DDS: интегрированная модель
|
||||
|
||||
-- dim_date: справочник дат (без первичного ключа — генерируется)
|
||||
CREATE TABLE dds.dim_date (
|
||||
date_key INT PRIMARY KEY,
|
||||
date_actual DATE NOT NULL,
|
||||
year SMALLINT,
|
||||
quarter SMALLINT,
|
||||
month SMALLINT,
|
||||
day SMALLINT,
|
||||
weekday_name VARCHAR(10),
|
||||
weekday_num SMALLINT,
|
||||
is_first_week BOOLEAN
|
||||
);
|
||||
|
||||
-- dim_product: измерение "Товар"
|
||||
CREATE TABLE dds.dim_product (
|
||||
product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_bk INT NOT NULL,
|
||||
product_name VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- dim_customer: измерение "Клиент" с SCD Type 2
|
||||
CREATE TABLE dds.dim_customer (
|
||||
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_bk INT NOT NULL,
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
city VARCHAR(50),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE NOT NULL DEFAULT '9999-12-31',
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- fact_sales: факт "Продажи"
|
||||
CREATE TABLE dds.fact_sales (
|
||||
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_sk BIGINT NOT NULL,
|
||||
product_sk BIGINT NOT NULL,
|
||||
date_key INT NOT NULL,
|
||||
quantity INT NOT NULL CHECK (quantity > 0),
|
||||
amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0)
|
||||
);
|
||||
|
||||
-- Внешние ключи (опционально — в продакшене часто отключают ради скорости)
|
||||
ALTER TABLE dds.fact_sales
|
||||
ADD CONSTRAINT fk_fact_customer FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk),
|
||||
ADD CONSTRAINT fk_fact_product FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk),
|
||||
ADD CONSTRAINT fk_fact_date FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key);
|
||||
@@ -0,0 +1,182 @@
|
||||
-- ===============================================
|
||||
-- DML-скрипт: загрузка и трансформация данных
|
||||
-- Запускается ПОВТОРНО при каждой загрузке (идемпотентно!)
|
||||
-- ===============================================
|
||||
|
||||
-- 1. STG: имитация загрузки из источников (в реальности — COPY или INSERT из Kafka/NiFi)
|
||||
-- ⚠️ В продакшене STG часто очищается перед загрузкой (TRUNCATE), либо используется партицирование по дате
|
||||
|
||||
DELETE FROM stg.customers_raw;
|
||||
DELETE FROM stg.orders_raw;
|
||||
DELETE FROM stg.order_items_raw;
|
||||
DELETE FROM stg.products_raw;
|
||||
|
||||
INSERT INTO stg.customers_raw (customer_id, email, phone, city) VALUES
|
||||
('101', 'a@ex.com', '700', 'Москва'),
|
||||
('101', 'b@ex.com', '700', 'Москва'),
|
||||
('102', 'c@ex.com', '701', 'СПб');
|
||||
|
||||
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
|
||||
('5001', '2024-01-10', '101'),
|
||||
('5002', '2024-02-05', '102');
|
||||
|
||||
INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES
|
||||
('1', '5001', '9001', '2', '100.00'),
|
||||
('2', '5001', '9002', '1', '50.00'),
|
||||
('3', '5002', '9001', '1', '100.00');
|
||||
|
||||
INSERT INTO stg.products_raw (product_id, name) VALUES
|
||||
('9001', 'Phone'),
|
||||
('9002', 'Case');
|
||||
|
||||
-- 2. ODS: очистка и типизация
|
||||
-- ⚠️ В продакшене используем UPSERT или incremental load, не TRUNCATE+INSERT
|
||||
|
||||
TRUNCATE ods.customers, ods.orders, ods.order_items, ods.products;
|
||||
|
||||
INSERT INTO ods.customers (customer_id, email, phone, city)
|
||||
SELECT
|
||||
customer_id::INT,
|
||||
NULLIF(TRIM(email), ''),
|
||||
NULLIF(TRIM(phone), ''),
|
||||
NULLIF(TRIM(city), '')
|
||||
FROM stg.customers_raw
|
||||
WHERE customer_id ~ '^\d+$';
|
||||
|
||||
INSERT INTO ods.orders (order_id, order_date, customer_id)
|
||||
SELECT
|
||||
order_id::INT,
|
||||
TO_DATE(order_date, 'YYYY-MM-DD'),
|
||||
customer_id::INT
|
||||
FROM stg.orders_raw
|
||||
WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$';
|
||||
|
||||
INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale)
|
||||
SELECT
|
||||
order_item_id::INT,
|
||||
order_id::INT,
|
||||
product_id::INT,
|
||||
NULLIF(qty, '')::INT,
|
||||
NULLIF(price_at_sale, '')::NUMERIC(10,2)
|
||||
FROM stg.order_items_raw
|
||||
WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$';
|
||||
|
||||
INSERT INTO ods.products (product_id, name)
|
||||
SELECT
|
||||
product_id::INT,
|
||||
TRIM(name)
|
||||
FROM stg.products_raw
|
||||
WHERE product_id ~ '^\d+$';
|
||||
|
||||
-- 3. DDS: dim_date — генерация календаря (идемпотентно: можно пересоздавать)
|
||||
-- В реальности — делается ОДИН РАЗ, либо дополняется по мере необходимости
|
||||
|
||||
DELETE FROM dds.dim_date;
|
||||
|
||||
WITH RECURSIVE dates AS (
|
||||
SELECT DATE '2023-01-01' AS d
|
||||
UNION ALL
|
||||
SELECT d + INTERVAL '1 day'
|
||||
FROM dates
|
||||
WHERE d + INTERVAL '1 day' <= DATE '2027-12-31'
|
||||
)
|
||||
INSERT INTO dds.dim_date (
|
||||
date_key, date_actual, year, quarter, month, day,
|
||||
weekday_name, weekday_num, is_first_week
|
||||
)
|
||||
SELECT
|
||||
CAST(TO_CHAR(d, 'YYYYMMDD') AS INT),
|
||||
d,
|
||||
EXTRACT(YEAR FROM d)::SMALLINT,
|
||||
EXTRACT(QUARTER FROM d)::SMALLINT,
|
||||
EXTRACT(MONTH FROM d)::SMALLINT,
|
||||
EXTRACT(DAY FROM d)::SMALLINT,
|
||||
TO_CHAR(d, 'Day'),
|
||||
EXTRACT(DOW FROM d)::SMALLINT,
|
||||
d BETWEEN DATE_TRUNC('month', d)
|
||||
AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day'
|
||||
AND EXTRACT(DAY FROM d) <= 7
|
||||
FROM dates;
|
||||
|
||||
-- 4. DDS: dim_product — полная перезагрузка (если товары редко меняются)
|
||||
-- В реальности — инкрементальная загрузка по BK
|
||||
|
||||
DELETE FROM dds.dim_product;
|
||||
|
||||
INSERT INTO dds.dim_product (product_bk, product_name)
|
||||
SELECT product_id, name
|
||||
FROM ods.products;
|
||||
|
||||
-- 5. DDS: dim_customer — SCD Type 2 (упрощённая версия для обучения)
|
||||
-- В продакшене — используем алгоритм «детектирования изменений + UPSERT»
|
||||
-- Здесь: перестраиваем всю историю на основе STG (для детерминированности)
|
||||
|
||||
DELETE FROM dds.dim_customer;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
customer_id::INT AS customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY customer_id
|
||||
ORDER BY _load_ts, email
|
||||
) AS rn
|
||||
FROM stg.customers_raw
|
||||
WHERE customer_id ~ '^\d+$'
|
||||
),
|
||||
changes AS (
|
||||
SELECT
|
||||
customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
-- Имитируем хронологию: +1 день на каждое изменение
|
||||
'2023-01-01'::DATE + (rn - 1) * INTERVAL '1 day' AS eff_from
|
||||
FROM ranked
|
||||
),
|
||||
final AS (
|
||||
SELECT
|
||||
customer_bk,
|
||||
email,
|
||||
phone,
|
||||
city,
|
||||
eff_from::DATE AS valid_from,
|
||||
COALESCE(
|
||||
LEAD(eff_from) OVER (PARTITION BY customer_bk ORDER BY eff_from) - INTERVAL '1 day',
|
||||
'9999-12-31'::DATE
|
||||
) AS valid_to,
|
||||
CASE WHEN LEAD(eff_from) OVER (PARTITION BY customer_b_k ORDER BY eff_from) IS NULL
|
||||
THEN TRUE ELSE FALSE END AS is_current
|
||||
FROM changes
|
||||
)
|
||||
INSERT INTO dds.dim_customer (customer_bk, email, phone, city, valid_from, valid_to, is_current)
|
||||
SELECT customer_bk, email, phone, city, valid_from, valid_to, is_current
|
||||
FROM final;
|
||||
|
||||
-- 6. DDS: fact_sales — загрузка фактов с учётом SCD
|
||||
-- В продакшене — фильтруем по диапазону дат (инкрементально)
|
||||
|
||||
DELETE FROM dds.fact_sales;
|
||||
|
||||
INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount)
|
||||
SELECT
|
||||
dc.customer_sk,
|
||||
dp.product_sk,
|
||||
CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT),
|
||||
oi.qty,
|
||||
oi.price_at_sale * oi.qty
|
||||
FROM ods.orders o
|
||||
JOIN ods.order_items oi ON o.order_id = oi.order_id
|
||||
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_customer dc
|
||||
ON o.customer_id = dc.customer_bk
|
||||
AND o.order_date BETWEEN dc.valid_from AND dc.valid_to;
|
||||
|
||||
-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки)
|
||||
-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды
|
||||
|
||||
-- SELECT 'dim_customer count = ' || COUNT(*) FROM dds.dim_customer;
|
||||
-- SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales;
|
||||
@@ -0,0 +1,98 @@
|
||||
-- ===============================================
|
||||
-- Проверки качества данных после загрузки STG→ODS→DDS
|
||||
-- Запускается после 02_dml.sql
|
||||
-- ===============================================
|
||||
|
||||
-- 1. Проверка: dim_customer не пуста
|
||||
DO $$
|
||||
BEGIN
|
||||
ASSERT (SELECT COUNT(*) FROM dds.dim_customer) > 0,
|
||||
'ОШИБКА: таблица dds.dim_customer пуста — загрузка не прошла';
|
||||
RAISE NOTICE '✅ dim_customer: НЕ ПУСТА (всего строк: %)',
|
||||
(SELECT COUNT(*) FROM dds.dim_customer);
|
||||
END $$;
|
||||
|
||||
-- 2. Проверка: fact_sales содержит все строки из order_items
|
||||
DO $$
|
||||
DECLARE
|
||||
expected_count INT := (SELECT COUNT(*) FROM ods.order_items);
|
||||
actual_count INT := (SELECT COUNT(*) FROM dds.fact_sales);
|
||||
BEGIN
|
||||
ASSERT actual_count = expected_count,
|
||||
FORMAT('ОШИБКА: в fact_sales %s строк, а в ods.order_items — %s. Разница: %s',
|
||||
actual_count, expected_count, expected_count - actual_count);
|
||||
RAISE NOTICE '✅ fact_sales: количество строк совпадает с ods.order_items (%)', actual_count;
|
||||
END $$;
|
||||
|
||||
-- 3. Проверка: у каждого факта есть валидная дата (date_key существует)
|
||||
DO $$
|
||||
DECLARE missing_dates INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO missing_dates
|
||||
FROM dds.fact_sales f
|
||||
LEFT JOIN dds.dim_date d ON f.date_key = d.date_key
|
||||
WHERE d.date_key IS NULL;
|
||||
|
||||
ASSERT missing_dates = 0,
|
||||
FORMAT('ОШИБКА: %s фактов ссылаются на несуществующие даты (неверный date_key)', missing_dates);
|
||||
RAISE NOTICE '✅ Все факты имеют валидные date_key';
|
||||
END $$;
|
||||
|
||||
-- 4. Проверка SCD Type 2: у клиента 101 должно быть ≥2 версий (из-за смены email)
|
||||
DO $$
|
||||
DECLARE version_count INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO version_count
|
||||
FROM dds.dim_customer
|
||||
WHERE customer_bk = 101;
|
||||
|
||||
ASSERT version_count >= 2,
|
||||
FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count);
|
||||
RAISE NOTICE '✅ SCD Type 2: клиент 101 имеет %s версий — история сохранена', version_count;
|
||||
END $$;
|
||||
|
||||
-- 5. Проверка: сумма amount = qty * price_at_sale (без округления)
|
||||
DO $$
|
||||
DECLARE bad_rows INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO bad_rows
|
||||
FROM (
|
||||
SELECT
|
||||
f.sale_id,
|
||||
f.amount,
|
||||
oi.qty * oi.price_at_sale AS expected_amount
|
||||
FROM dds.fact_sales f
|
||||
JOIN ods.orders o ON f.date_key = CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT)
|
||||
JOIN ods.order_items oi ON o.order_id = oi.order_id
|
||||
WHERE ROUND(f.amount, 2) <> ROUND(oi.qty * oi.price_at_sale, 2)
|
||||
LIMIT 10
|
||||
) mismatches;
|
||||
|
||||
ASSERT bad_rows = 0,
|
||||
'ОШИБКА: обнаружены расхождения между amount и qty * price_at_sale';
|
||||
RAISE NOTICE '✅ Все суммы рассчитаны верно (amount = qty × price_at_sale)';
|
||||
END $$;
|
||||
|
||||
-- ===============================================
|
||||
-- Финальный отчёт для аналитика
|
||||
-- ===============================================
|
||||
|
||||
RAISE NOTICE '──────────────────────────────';
|
||||
RAISE NOTICE '📊 ОТЧЁТ: Выручка по клиентам (актуальные версии)';
|
||||
RAISE NOTICE '──────────────────────────────';
|
||||
|
||||
SELECT
|
||||
dc.customer_bk AS "ID клиента",
|
||||
dc.email AS "Email",
|
||||
dc.city AS "Город",
|
||||
SUM(f.quantity) AS "Всего товаров",
|
||||
SUM(f.amount) AS "Выручка, ₽"
|
||||
FROM dds.fact_sales f
|
||||
JOIN dds.dim_customer dc
|
||||
ON f.customer_sk = dc.customer_sk
|
||||
AND dc.is_current -- только актуальная версия
|
||||
GROUP BY dc.customer_bk, dc.email, dc.city
|
||||
ORDER BY SUM(f.amount) DESC;
|
||||
|
||||
RAISE NOTICE '──────────────────────────────';
|
||||
RAISE NOTICE '✅ Все проверки пройдены. DWH готов к построению витрин.';
|
||||
Reference in new issue
Block a user