Author SHA1 Message Date
ddadmin 4b24e8847f Данные для инкремента статусов 2025-12-21 21:41:01 +03:00
ddadmin cfc79894b8 Упростить комментарии в решении 2025-12-21 21:32:40 +03:00
ddadmin 9d837f5f5b Решение домашки: customer_status SCD2 2025-12-21 21:26:01 +03:00
ddadmin 20ea175e3a Улучшения домашки 2025-12-21 21:09:52 +03:00
ddadmin a347988af2 возможные планы 2025-12-21 20:23:20 +03:00
ddadmin 855530bee5 Более понятная реализация scd2 2025-12-21 20:23:07 +03:00
ddadmin d342519bb5 одна из целей - анализ плана запросов 2025-12-21 19:56:26 +03:00
ddadmin ae00475df5 переписана реализация scd2 2025-12-21 18:50:50 +03:00
ddadmin 87301921a9 Merge branch 'roadmap' 2025-11-30 12:06:23 +03:00
ddadmin 4bec6217cf Правки по ходу перечитывания 2025-11-30 12:02:30 +03:00
ddadmin 5e1c80f870 Merge branch 'main' into roadmap 2025-11-30 11:18:36 +03:00
ddadmin ba9cdff421 Причесывание комментариев 2025-11-29 21:37:03 +03:00
ddadmin eb9215c4f1 Уулчшения витрин 2025-11-29 21:24:08 +03:00
ddadmin e659fbb731 Исправление неточностей 2025-11-29 20:46:03 +03:00
ddadmin 218c6b9fe7 Исправлиение примера с scd2 2025-11-29 20:16:16 +03:00
ddadmin 2d78fb4459 Домашка по моделированию 2025-11-23 18:31:43 +03:00
ddadmin 04573efda1 Раздел про курсовую работу 2025-11-23 18:16:18 +03:00
ddadmin e53fa0b9d4 Очередная чистка мелких шероховатостей 2025-11-23 17:58:17 +03:00
ddadmin 26a35b0560 DoD по разделам 2025-11-23 17:47:05 +03:00
ddadmin e37c1ed1cc Критерии пройденности разделов 2025-11-23 17:36:06 +03:00
ddadmin 805141a0b9 Смена тональности обращений 2025-11-23 13:30:15 +03:00
ddadmin e5615c9419 Расширен раздел с GreenPlum 2025-11-23 13:18:34 +03:00
ddadmin 64fe665cdc Ссылки на ментора 2025-11-23 12:46:11 +03:00
ddadmin d46d9848f4 Полировка формулировок 2025-11-23 12:20:35 +03:00
ddadmin d5cee7ae6d Про аудит 2025-11-23 12:15:35 +03:00
ddadmin d8c98c73c5 Добавлена шпаргалка 2025-11-23 12:13:53 +03:00
ddadmin add67223b8 Мелкие правки 2025-11-23 12:07:59 +03:00
ddadmin 0521b6cc9a Правка реализации истории 2025-11-23 11:56:51 +03:00
ddadmin ee4b6ae5a4 AI guideline 2025-11-23 11:37:04 +03:00
ddadmin 58fe605116 Добавлено оглавление 2025-11-15 21:38:59 +03:00
ddadmin 71d885a9db Чистка заголовков 2025-11-15 21:20:52 +03:00
ddadmin b2ae216b07 Merge branch 'main' into dwh-modeling-basics 2025-11-15 21:01:04 +03:00
ddadmin f1338ccfc5 Детальнее про pit и bridge таблицы 2025-11-15 20:59:53 +03:00
ddadmin aa3ebbfd7c Переработка структуры 2025-11-15 20:47:08 +03:00
ddadmin 32ffcfb4c5 Фикс диаграммы в практикуме 2025-11-15 20:44:59 +03:00
ddadmin 5f16476c81 Первый вариант статьи про DV 2025-11-15 20:00:58 +03:00
ddadmin bb9902a448 Упрощение раздела про DV 2025-11-15 19:08:20 +03:00
ddadmin c66ba776c2 Пересобран раздел 8 2025-11-15 18:43:43 +03:00
ddadmin 51c33a543d Маленькая склейка 2025-11-15 18:36:25 +03:00
ddadmin e48fa424e2 Еще битые ссылки 2025-11-15 18:33:46 +03:00
ddadmin 9175f84cf9 Битые ссылки 2025-11-15 18:29:23 +03:00
ddadmin ecb41941fc Улучшения диаграммы в "базовые понятия" 2025-11-15 18:13:06 +03:00
ddadmin a8a122bdfa Переделка раздела советов для сомневающихся 2025-11-15 17:57:35 +03:00
ddadmin e7ba8cebd0 Еще материал по DV 2025-11-15 13:03:52 +03:00
ddadmin 0d9bb047a4 Раздел про Data Vault 2025-11-15 12:53:02 +03:00
ddadmin ff6086ff21 Стилистическая правка 2025-11-15 12:29:31 +03:00
ddadmin dabcadfc19 Правка ошибок 2025-11-10 11:22:47 +03:00
ddadmin fde77d33ce Интеграция структуры хранилища данных в общий план обучения 2025-11-09 13:30:44 +03:00
ddadmin d5220adcf0 В статью добавлены ссылки на скрипты с учебным примером 2025-11-08 22:16:11 +03:00
ddadmin 3cbaeb5fc2 Переименования скриптов 2025-11-08 20:42:35 +03:00
ddadmin a234c2954d Вычитка статьи 2025-11-08 20:40:04 +03:00
ddadmin c52d0d8b31 Стилистические правки 2025-11-08 19:21:43 +03:00
ddadmin be1557716f Забыли заполнить dds.orders 2025-11-07 16:31:23 +03:00
ddadmin cefe9971b8 Проверки 2025-11-07 16:31:09 +03:00
ddadmin 3cb77d9cf6 SCD2 теперь строится 2025-11-07 16:15:25 +03:00
ddadmin 57d1dd3186 Уточнения по SCD 2 - бех юэкфилл 2025-11-07 15:46:52 +03:00
ddadmin b29de24c31 SCD2 для dim_customers 2025-11-07 15:10:30 +03:00
ddadmin 42c8515b62 Доработки идей 2025-11-07 12:25:41 +03:00
ddadmin dfc5748358 Чистовик статьи + материалы 2025-11-07 11:05:44 +03:00
ddadmin fa42a4508f Дальнейшая доработка 2025-11-07 10:22:11 +03:00
ddadmin ee55515b54 Развитие идей 2025-11-07 09:58:37 +03:00
ddadmin acfbc4d037 Правка плани 2025-11-04 23:15:14 +03:00
ddadmin a17c775c1d Вариант от deepseek 2025-11-04 21:26:13 +03:00
ddadmin 1879eb341f Чистка 2025-11-04 21:22:03 +03:00
ddadmin fc6282a4d4 Чистка заголовков 2025-11-04 21:12:15 +03:00
ddadmin 960bfa3ce0 Фикс графиков 2025-11-04 21:00:31 +03:00
ddadmin e16ea6e0cf Первоначальный план статьи 2025-11-04 20:54:14 +03:00
ddadmin e9b50406e3 Пререработка структуры заголовков 2025-11-04 18:00:40 +03:00
ddadmin 197ef1b0d2 Упрощение 2025-11-04 17:45:03 +03:00
ddadmin f18bd7ea39 Раскрыт детальнее update для trino 2025-11-04 17:39:17 +03:00
ddadmin f789839643 Расширен раздел про SCD2 для append-only систем 2025-11-04 17:20:35 +03:00
ddadmin 1730f6801b Документация по dbc 2025-11-03 17:36:49 +03:00
ddadmin 20acb883a9 Merge branch 'main' of github.com-dementev:dementev-dev/de-roadmap 2025-10-26 22:24:18 +03:00
ddadmin 4979a1dc57 Больше материалов по Python 2025-10-26 22:24:14 +03:00
ddadmin 63b8811776 Ссылка на статью про Parquet и Iceberg 2025-10-24 10:35:10 +03:00
ddadmin 43fb42cfb0 Ссылка на учебник 2025-10-19 22:27:23 +03:00
ddadmin 9793ab209e Ссылка на Jupyter Lab 2025-10-19 16:13:00 +03:00
ddadmin d25db5b780 Развитие тематики Python 2025-10-19 13:17:29 +03:00
ddadmin 15f4140ef8 Фикс оформления 2025-10-19 13:13:29 +03:00
ddadmin 69faae7f25 Материал про Jupyter Lab 2025-10-19 13:12:59 +03:00
ddadmin 42754739cf Раздел про Python - ООП, Pandas 2025-10-19 13:07:23 +03:00
ddadmin 6fd8b69d1c Добавлена ссылка на SCD 2025-10-19 12:35:24 +03:00
ddadmin ed5a18d655 Статья про SCD 2025-10-19 12:33:27 +03:00
ddadmin 4693147750 Фикс опечатки 2025-10-18 20:23:50 +03:00
ddadmin f636d74e02 Раздел про моделирование данных 2025-10-18 19:09:44 +03:00
ddadmin f374047387 Улучшение структуры 2025-10-18 18:44:18 +03:00
ddadmin b43630cf49 Материалы по Docker 2025-10-16 23:28:06 +03:00
ddadmin d699696869 Фикс оформления 2025-10-16 23:19:32 +03:00
ddadmin 459cb5ab2b Приоретизация материалов 2025-10-16 23:18:51 +03:00
ddadmin 253268bd60 Главы книги, которые стоит прочесть 2025-10-16 23:17:44 +03:00
ddadmin 07f9e0c5d2 Доп видео по git 2025-10-16 23:10:45 +03:00
37 changed files with 3654 additions and 792 deletions
+2
View File
@@ -1 +1,3 @@
.env
.idea
.internal/
+35
View File
@@ -0,0 +1,35 @@
# Repository Guidelines
## Project Structure & Module Organization
- Root `README.md` describes the learning roadmap (RU).
- `dwh-modeling/` contains the article and demo DWH model; SQL lives in `dwh-modeling/sql` as ordered scripts `01_...sql``06_...sql`.
- `postgres-bookings/` is a Dockerized PostgreSQL + demo “bookings” DB; start it first, then apply DWH scripts against the `demo` database.
## Build, Test, and Development Commands
- Start demo Postgres:
`cd postgres-bookings && bash download_db.sh && docker compose up -d`
- Open `psql` inside the container:
`cd postgres-bookings && ./psql_sh`
- Apply DWH schema from the repo root (after Postgres is up):
`psql -h 127.0.0.1 -p 5432 -U postgres -d demo -f dwh-modeling/sql/01_ddl_stg-dds.sql`
- Stop and reset the cluster when needed:
`cd postgres-bookings && docker compose down -v`
## Coding Style & Naming Conventions
- SQL: PostgreSQL dialect, uppercase keywords, `snake_case` identifiers, 4-space indentation, and concise comments (`-- ...`).
- SQL files: keep numeric prefixes (`01_`, `02_`, …) to reflect execution order and use descriptive suffixes like `ddl_*` / `dml_*`.
- Shell: target `bash`, prefer simple, POSIX-friendly constructs; mirror the style of existing scripts in `postgres-bookings/`.
## Testing Guidelines
- There is no dedicated test framework; treat SQL scripts as executable documentation.
- For `dwh-modeling/sql`, run scripts sequentially and rerun `04_validation.sql` after changes to ensure the demo model still loads and basic checks pass.
- For `postgres-bookings`, after modifications run `docker compose up -d && ./psql_sh` and verify simple queries such as `SELECT COUNT(*) FROM bookings.flights;`.
## Commit & Pull Request Guidelines
- Commit messages are short, imperative or descriptive phrases (often in Russian), e.g. `Добавлено оглавление`, `Переработка структуры`; group related edits into a single commit.
- Pull requests should focus on one topic, include a brief context, list of changes, and manual steps to reproduce or validate (commands you ran, expected results).
## Security & Configuration Tips
- Do not commit personal `.env` files or credentials; use local overrides only.
- Demo credentials and ports in `postgres-bookings` are for local training only—never reuse them in shared or production environments.
+262 -60
View File
@@ -1,17 +1,51 @@
# О роадмапе
Этот роадмап — конспект моих подходов к обучению Data Engineering.
Он подойдёт тем, кто хочет:
- системно войти в профессию с нуля или близкого к нулю уровня;
- закрыть пробелы в базе (SQL, Git, Python, DWH, Airflow, GreenPlum);
- подготовиться к собеседованиям и первым рабочим задачам.
Роадмап можно проходить самостоятельно или вместе со мной в формате менторства.
Если хотите идти с поддержкой ментора — напишите в Telegram: [@dementev_dev](https://t.me/dementev_dev).
## Оглавление
- [Основные знания](#основные-знания)
- [Практика и инструменты](#практика-и-инструменты)
- [Карьера и менторство](#карьера-и-менторство)
- [Расширенные навыки](#расширенные-навыки)
- [Софт скиллы](#софт-скиллы)
- [Дополнительные материалы](#дополнительные-материалы)
Рекомендуемый способ использования:
- двигаться по разделам последовательно, не перепрыгивая через базу;
- выполнять практику и домашки, а не только смотреть материалы;
- возвращаться к разделам по мере появления реальных задач.
# Основные знания
## База по Git
## Git и базовые инструменты
### База по Git
Что такое контроль версий, когда используется, ПОЧЕМУ и как мы в обучении будем использовать.
Как создать репозиторий на GitHub, сохранять в нем изменения.
- [Что такое Git для Начинающих / GitHub за 30 минут / Git Уроки - Youtube](https://www.youtube.com/watch?v=VJm_AjiTEEc)
- [Git: Конфликты для Начинающих // Git Cherry Pick, Git Revert, Git Reset - Youtube](https://www.youtube.com/watch?v=F7FnnfnB9YY)
- Книга [Pro Git](https://git-scm.com/book/ru/v2) - читать главу 1
Основы Markdown
### Основы Markdown
- [Язык Markdown и файл README | Git и GitHub для начинающих - Youtube](https://www.youtube.com/watch?v=8lEDTrr-G4U)
- [Markdown и его возможности: простой способ оформления текста](https://kurshub.ru/journal/blog/markdown-chto-eto/)
- [Синтаксис Markdown: подробная шпаргалка для веб-разработчиков / Skillbox Media](https://skillbox.ru/media/code/yazyk-razmetki-markdown-shpargalka-po-sintaksisu-s-primerami/)
Домашки по остальным темам тренируемся делать в Git, там же пишем документацию.
**Когда блок Git и базовые инструменты считаем пройденным:**
- вы уверенно создаёте репозиторий, коммитите изменения и отправляете их на GitHub;
- имеете представление о работе с ветками: создание, переключение, что такое merge/PR и разруливание простых конфликтов;
- оформляете базовую документацию в Markdown (README, заголовки, списки, ссылки, кодовые блоки).
## SQL
### База по SQL
Книга: [PostgreSQL. Основы языка SQL](https://postgrespro.ru/education/books/sqlprimer) - Глава 1 "Введение в базы данных и SQL" + ДЗ
@@ -23,7 +57,9 @@
- Подробнее про синтаксис: [PostgreSQL : Документация: 17: 7.8. Запросы WITH (Общие табличные выражения) : Компания Postgres Professional](https://postgrespro.ru/docs/postgresql/17/queries-with)
Для дальнейшей тренировки и поддержания уровня можно использовать [Database - LeetCode](https://leetcode.com/problem-list/database/). Хорошая подборка задачек: [SQL 50 - Study Plan - LeetCode](https://leetcode.com/studyplan/top-sql-50/)
### Повышение знаний SQL
Смотрим курс от Postgres Pro [DEV1](https://postgrespro.ru/education/courses/DEV1)
Темы - от "Введение" до "SQL" включительно, "Управление доступом", "Резервное копирование". Для лучшего усваивания материала проделываем все примеры и домашние задания из конспектов лекция.
С темой "PL/pgSQL" можно ознакомиться обзорно.
@@ -38,120 +74,286 @@
Вопросы оптимизации запросов хорошо описаны в курсе [QPT](https://postgrespro.ru/education/courses/QPT) от Postgres Pro. Полученные навыки применимы для работы в том числе с GreenPlum, и частично, другими БД.
На момент написания, видеолекции были доступны только для старой версии Postgres 13, но ее вполне достаточно.
### Моделирование данных
Понимание того, **как устроены данные и зачем они нужны**, — ключ к качественным ETL-процессам.
Мы кратко разбираем:
- Основные подходы: нормализованные (3NF) vs денормализованные (звезда, снежинка)
- Что такое staging, marts, слои raw / clean / business
- Как проектировать таблицы под конкретные сценарии использования
Цель — не стать архитектором, а **уметь читать и объяснять структуру данных**, чтобы писать осмысленные запросы и трансформации.
Материалы (включая демо DWH-модель из этого репозитория):
- [Яндекс Практикум: что такое нормализация, простыми словами (для самых начинающих)](https://practicum.yandex.ru/blog/chto-takoe-normalizaciya-dannyh/)
- [Базы данных. 1,2,3 нормальные формы. - Youtube](https://www.youtube.com/watch?v=zwQzL80U51c)
- [Введение в структуру хранилища данных](dwh-modeling/README.md)
- Теория про Slowly Changing Dimensions: [SCD](dwh-modeling/SCD.md)
- Практика по моделированию статусов клиента: [домашка STG → ODS → DDS → DM](dwh-modeling/Homework_Customer_Status_DDS_DM.md)
- Еще про Data Vault:
- Конспект и примеры из этого репозитория: [DataVault.md](dwh-modeling/DataVault.md)
- [DataVault за 10 минут - Youtube](https://www.youtube.com/watch?v=9oQs_wJ045I)
- Статья [«Что такое Data Vault: моделирование КХД для архитектора Big Data»](https://bigdataschool.ru/blog/what-is-data-vault/) — обзор, плюсы/минусы, контекст применения.
- [Гибкие методологии проектирования Data Vault и Anchor Modeling | Евгений Ермаков | karpov.courses](https://www.youtube.com/watch?v=fNGIOb8SJvU)
- Лекция в рамках курса по DWH: именно [«Основы Data Vault, создаем первую модель»](https://www.youtube.com/watch?v=65b99XCuiR4) — хороший формат: теория + пример.
- Доклад - практический пример: [Денис Лукьянов — Data Vault 2.0. Когда внедрять, проблемы применения при построении DWH на Greenplum](https://www.youtube.com/watch?v=oGwQbeP5iss)
- Краткая теория про [DWH](https://halltape.github.io/HalltapeRoadmapDE/DWH/) - повторим еще раз, в другом изложении
- Хорошее общее введение в модели данных дано в статье и докладе от Yandex: [Как мы внедрили свою модель хранения данных — highly Normalized hybrid Model. Доклад Яндекса](https://habr.com/ru/companies/yandex/articles/557140/)
**Когда блок SQL считаем пройденным:**
- вы уверенно пишете запросы с JOIN, агрегатами, подзапросами и CTE;
- можете подробно объяснить план запроса в Postgres, понимаете где планировщик отработал корректно, а где - есть возможность улучшить;
- можете объяснить простую модель данных (3NF/звезда) и прочитать схему DWH;
- решаете типовые задачи уровня SQL live-coding без долгих пауз.
## Python
2 курса по Python - простой и расширенный
- ["Поколение Python": курс для начинающих – Stepik](https://stepik.org/course/58852/info)
- ["Поколение Python": курс для продвинутых – Stepik](https://stepik.org/course/68343/info)
- Кратко(???) про ООП, менеджеры контекста
- Если совсем не знакомы с Python, начинаем с курса ["Поколение Python": курс для начинающих – Stepik](https://stepik.org/course/58852/info)
- Изучаем глубже и "оттачиваем" live coding: ["Поколение Python": курс для продвинутых – Stepik](https://stepik.org/course/68343/info)
- Продолжение "базы", спрашиваемой на собеседованиях, по Python: ["Поколение Python": курс для профессионалов](https://stepik.org/course/82541/promo). Курс очень полезный, но платный. Вместо него можно почитать "продвинутые" темы дальше.
- "Продвинутые" темы:
- [Полезные функции](https://pyneng.readthedocs.io/ru/latest/book/10_useful_functions/index.html)
- [Работа с файлами в формате CSV, JSON, YAML](https://pyneng.readthedocs.io/ru/latest/book/17_serialization/index.html)
- [Итераторы, итерируемые объекты и генераторы](https://pyneng.readthedocs.io/ru/latest/book/13_iterator_generator/index.html)
- [Декораторы Python: пошаговое руководство](https://habr.com/ru/companies/otus/articles/727590/)
- Работа с датой/временем: https://django.fun/docs/python/3.10/library/datetime/
- ООП
- [Tproger: «ООП простыми словами»](https://tproger.ru/experts/oop-in-simple-words)
- Введение в [ООП](https://metanit.com/python/tutorial/7.1.php)
- [Яндекс Учебник: «Объектная модель Python: классы, поля и методы»](https://education.yandex.ru/handbook/python/article/obuektnaya-model-python-klassy-polya-i-metody)
- [Real Python: OOP in Python (tutorial)](https://realpython.com/python3-object-oriented-programming/)
- Jupyter Lab
- [Блог Практикума: «Что такое Jupyter Notebook: как установить и открыть»](https://practicum.yandex.ru/blog/chto-takoe-jupyter-notebook/)
- Готовая реализация Jupyter Lab, включающая в себя Spark, в Docker: https://github.com/dementev-dev/jupyter-spark-docker
- Pandas
- Jupyter Lab - кратко
- [GeeksforGeeks: “Why Pandas is Used in Python”](https://www.geeksforgeeks.org/pandas/why-pandas-is-used-in-python/)
- [Skillbox: «Для чего нужна библиотека Pandas»](https://skillbox.ru/media/code/rabotaem-s-pandas-osnovnye-ponyatiya-i-realnye-dannye/)
- [Official: “10 minutes to pandas”](https://pandas.pydata.org/docs/user_guide/10min.html)
- [Хабр (RUVDS): «Моя шпаргалка по pandas»](https://habr.com/ru/companies/ruvds/articles/494720/)
- [Tproger: «Наглядная шпаргалка по операциям с DataFrame»](https://tproger.ru/articles/pandas-data-wrangling-cheatsheet)
Полезно, но дороговато и не обязательно: хорошее комбо SQL + Python: ["Поколение Python": профи + ООП + SQL Stepik](https://stepik.org/course/233341/promo?search=7181036958)
Цель - LiveCoding простых задач Python, далее нужно будет для создания DAG Airflow
Полезно, но дороговато и не обязательно: хорошее комбо SQL + Python — ["Поколение Python": профи + ООП + SQL Stepik](https://stepik.org/course/233341/promo?search=7181036958)
Цель — уверенно решать простые задачи на Python в формате live-coding; дальше эти навыки пригодятся для создания DAG Airflow.
**Когда блок Python считаем пройденным:**
- вы без подсказок пишете небольшие скрипты с циклами, функциями, обработкой ошибок и работой с коллекциями;
- умеете читать и модифицировать чужой код, в том числе с использованием pandas и DataFrame;
- уверенно проходите простой live-coding по Python для DE: прочитать CSV/JSON, отфильтровать, сгруппировать данные и посчитать агрегаты.
- можете отвечать как на простые вопросы собеседований (циклы, списки, словари), так и продвинутые (итераторы, декораторы, управление памятью, базовые понятия ООП)
## Технические навыки
### Запись встреч
OBS Studio
[Настройка записи экрана](https://docs.google.com/document/d/1qd8uRYlAaZp9c5zpvCVBOvYQCEukGHI9PEPjnjahI1k/)
### Git
Книга: [Pro Git](https://git-scm.com/book/ru/v2) - указать главы для чтения
[Курс работы с Git и GitLab - ЭФКО ЦПР | YouTube плейлист](https://www.youtube.com/playlist?list=PLbf8m52BvqlFlblJqQKPuEU26pwgqe7zK) - указать номера лекций для просмотра и повторения за лектором.
### Продвинутый Git
- Сжатый, но емкий видеогайд: [GIT, GitHub, GitLab. Полный АКТУАЛЬНЫЙ гайд ЗА ПОЛТОРА ЧАСА. Без этого выгонят с работы - Youtube](https://www.youtube.com/watch?v=0Y-fneoUIO8)
- Книга: [Pro Git](https://git-scm.com/book/ru/v2) - главы
- 2 Основы Git
- 3 Ветвление в Git
- 5 Распределённый Git
- 6 GitHub
- [Курс работы с Git и GitLab - ЭФКО ЦПР | YouTube плейлист](https://www.youtube.com/playlist?list=PLbf8m52BvqlFlblJqQKPuEU26pwgqe7zK). Настоятельно рекомендую проделать за лектором все те действия что он показывает.
Целевой уровень знания - понимание процесса GitFlow. Как создать ветку, влить изменения в другие ветки. Понимание, зачем.
На собесах обычно не спрашивают, но нужно в работе.
### Docker
- Postgres (развертывание, допиливание, запекание в него учебной БД, выгрузка на docker hub)
- Основа для будущих домашних работ - сборка стендов. Хранение в Git и проверка ментором.
- Курс https://karpov.courses/docker
### Методы разработки (водопад, scrum, kanban)
Найти краткие обзоры методов разработки. Потом проговорить на занятии, когда что используется
Основное предназначение для нас - учебные стенды, где мы разбираем и тренируемся с разными технологиями. На работе - иногда пригождается. На собесах спрашивают редко.
### Методы разработки
Кратко знакомимся с основными подходами к организации работы в IT:
- **Водопад** — последовательная разработка,
- **Scrum / Kanban** — гибкие методологии, популярные в data-командах.
Понимание этих концепций помогает быстрее адаптироваться в новых проектах и правильно интерпретировать требования.
### Запись встреч
OBS Studio
- Руководство по OBS: [OBS Studio - Настройка ОБС для Записи Игр и Стрима | Настройка Микрофона в Обс и т.д - Youtube](https://www.youtube.com/watch?v=bj8VEphZ65U)
- [Как записывать собеседования](https://docs.google.com/document/d/1qd8uRYlAaZp9c5zpvCVBOvYQCEukGHI9PEPjnjahI1k/)
**Когда блок технических навыков считаем пройденным:**
- вы понимаете базовый GitFlow: как организована работа с ветками в команде и как ваши коммиты попадают в прод;
- используете Docker для учебных стендов: запускаете контейнеры, смотрите логи и при необходимости перезапускаете сервисы;
- ориентируетесь в основных методологиях разработки (Scrum/Kanban/водопад) и понимаете, как в них живут задачи и отчётность;
- при необходимости умеете настроить запись экрана/созвонов, чтобы сохранять материалы обучения.
# Практика и инструменты
## Airflow
Один из основных инструментов.
Упрощенный docker compose: https://github.com/LexxaRRioo/rzv_de_shared_folder/tree/main/docker_compose
Найти объяснение для менти...
Apache Airflow — инструмент для оркестрации ETL-процессов.
Мы используем его для:
- планирования задач,
- отслеживания зависимостей между шагами,
- визуализации статуса выполнения.
Материалы:
- [Учебник по Airflow](https://github.com/dementev-dev/airflow-manual)
**Когда блок Airflow считаем пройденным:**
- вы можете объяснить, что такое DAG, задачи, операторы и сенсоры, и как между ними задаются зависимости;
- на базе учебного стенда подготавливаете, отлаживаете и запускаете свои DAG'и с расписанием и несколькими шагами (например, загрузка данных и последующие трансформации);
- уверенно смотрите логи, находите место падения и понимаете, как перезапустить задачу.
## Greenplum
Разбираем, чем Greenplum отличается от PostgreSQL и зачем нужны MPP-хранилища.
Предварительно:
- Простое введение: [Greenplum | Что это такое и как оно работает? - Youtube](https://www.youtube.com/watch?v=rLG9Z_HcKPY)
- Оно же, но текстом: https://halltape.github.io/HalltapeRoadmapDE/GREENPLUM/
- [Визуализатор распределения Greenplum](https://gpskew.rzvde.pro/)
- Бесплатный, но большой учебный курс от Yandex: https://yandex.cloud/ru/training/greenplum
- [Учебный курс по Greenplum от datafinder](https://datafinder.ru/products/uchebnyy-kurs-po-greenplum) — взять только отдельные главы.
Практика:
- [DE Starter Kit — Airflow + Greenplum + CSV](https://github.com/dementev-dev/airflow-greenplum)
Сложные варианты с виртуалками — только если менти сильно захочет, в базовый путь не включаем.
**Когда блок Greenplum считаем пройденным:**
- вы понимаете, как данные распределяются по сегментам, что такое skew и как его увидеть;
- на базе учебного стенда (например, DE Starter Kit) можете загружать и выгружать данные в Greenplum, выполнять запросы и разбирать планы выполнения (`EXPLAIN`);
- можете объяснить, в чём практическая разница между MPP-хранилищем и одиночным Postgres на уровне типичных задач DE и собеседований.
## Курсовая работа
К финалу роадмапа мы собираем небольшую end-to-end курсовую работу — свой первый «боеподобный» data-проект.
### Стенд в Docker Compose
- Apache Airflow
- Источник данных - TelecomX
- Postgres
- ETL
- Исходные коды всего - в Git
- Apache Airflow — оркестратор;
- источник данных TelecomX (или аналогичный открытый датасет);
- Greenplum — основное хранилище;
- вспомогательный Postgres (по желанию);
- ETL-скрипты и DAG'и;
- исходные коды всего — в отдельном Git-репозитории.
В качестве альтернативы файловому источнику можно использовать генератор данных для демо-базы `bookings` от Postgres Pro: https://github.com/postgrespro/demodb.
Его удобнее всего встроить в стенд DE Starter Kit (Airflow + Greenplum) как отдельный сервис Postgres с регулярно генерируемыми данными и уже оттуда забирать их в Greenplum (в том числе через PXF, если хочется усложнить архитектуру).
**Когда блок курсовой работы считаем пройденным:**
- у вас есть отдельный репозиторий с docker-compose, DAG'ами Airflow, SQL-скриптами и README по проекту;
- стенд поднимается локально, DAG'и успешно прогоняются на тестовых данных от загрузки сырья до витрин;
- вы можете на собеседовании за 5–10 минут рассказать архитектуру курсового проекта, его цели и показать ключевые части кода.
## Понятие сложности алгоритмов
### SQL
### Python
В Data Engineering редко требуется писать сложные алгоритмы, но важно понимать, как оценивать эффективность кода:
- в SQL — через объём сканируемых данных, типы JOIN’ов, использование индексов;
- в Python — через асимптотику операций с pandas/списками (например, O(n) vs O(n²)).
Это помогает избегать «тормозящих» решений на собеседованиях и в реальных пайплайнах.
# Карьера и менторство
## Менторство по этому роадмапу
Если вы нашли этот роадмап в интернете и хотите пройти его не в одиночку, а с поддержкой ментора, можно присоединиться ко мне.
**Что даёт менторство:**
- структурный план прохождения роадмапа под вашу ситуацию;
- разбор вопросов по SQL / DWH / Airflow и другим темам из этого документа;
- разбор домашних заданий и код-ревью;
- помощь с подготовкой к собеседованиям (резюме, мок-интервью).
**Как записаться**
Просто напишите мне в Telegram: [@dementev_dev](https://t.me/dementev_dev)
со словами «Хочу пройти роадмап с ментором» — дальше всё обсудим.
## Подготовка к собеседованиям
Думаем, как "сделать" опыт, от 2 лет
Цель блока — сформировать «опыт от 2 лет» и уметь корректно его показать в резюме и на собеседовании.
### Помощь в подготовке резюме
- Видео от ОМ по составлению резюме
- [Как накрутить опыт в резюме | «Ультимативный гайд» ‪@digital_ninja](https://www.youtube.com/watch?v=EPuogJuYsvY)
- [Как писать резюме, чтобы его читали - доклад - Boosty](https://boosty.to/m0rtymerr/posts/71b02a6b-8116-466a-b945-b2ed793abd8f)
- [Как писать резюме, чтобы его читали - доклад - Boosty](https://boosty.to/m0rtymerr/posts/71b02a6b-8116-466a-b945-b2ed793abd8f)
- [Как грамотно продать себя на собеседовании / Созвон сообщества - Boosty](https://boosty.to/m0rtymerr/posts/7289cd23-60c6-4010-bb1c-a5b28dac399a)
- Попытки менти написать, моя обратная связь - итеративно
- Попытки менти написать резюме, моя обратная связь итеративно.
### Навыки поиска работы с HH и Habr карьера
- [Как подтвердить опыт без трудовой / Хабр против работяг](https://www.youtube.com/watch?v=GHqABzA1zi8)
- [Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
- Видео по прохождению собесов от ОМ.
- Мои комментарии к нему, мой опыт
- Первые тренировки мок собесы, обратная связь
### Помощь с прохождением испытательного срока
- [Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
- [Испытательный срок - доклад - Boosty](https://boosty.to/m0rtymerr/posts/40e7f17e-022b-495c-8d03-dabbe4383b8e)
Видео по прохождению собесов от ОМ. Мои комментарии к нему, мой опыт
Первые тренировки мок собесы, обратная связь
Навыки поиска работы с HH и Habr карьера
[Как накрутить опыт в резюме | «Ультимативный гайд» ‪@digital_ninja](https://www.youtube.com/watch?v=EPuogJuYsvY)
[Как подтвердить опыт без трудовой / Хабр против работяг](https://www.youtube.com/watch?v=GHqABzA1zi8)
[Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
**Когда блок подготовки к собеседованиям считаем пройденным:**
- у вас есть актуальное резюме под DE с понятными примерами проектов вместо «пустого» опыта;
- вы умеете искать и отбирать вакансии на HH и Habr Карьера, адаптируя отклики под конкретную позицию;
- вы прошли хотя бы пару мок-собеседований, получили обратную связь и по результатам доработали резюме и стратегию поиска.
# Расширенные навыки
Поясняю, что главное - уметь пользоваться и отвечать на вопросы собесов. Уметь самому разворачивать сложные конфигурации - излишне, для этого в компаниях обычно есть DevOps и DBA. Достаточно прочувствовать на простом docker стенде.
## Greenplum
Дать теоретический материал - разница с Postgres.
Предварительно: [Учебный курс по Greenplum](https://datafinder.ru/products/uchebnyy-kurs-po-greenplum) - дать только отдельные главы
Контейнер с GreenPlum, несколько домашек по нему, чтобы прочувствовать работу распределенных запросов.
- [sergeyosechkin/greenplum Tags | Docker Hub](https://hub.docker.com/r/sergeyosechkin/greenplum/tags)
- [Как собрать Docker-образ Greengage DB | Greengage DB Docs](https://greengagedb.org/ru/docs-gg/current/use_docker.html)
В сложности с виртуалками - только если менти сильно захочет. Не буду рекомендовать.
Эти темы выходят за рамки базового минимума для старта в Data Engineering, но дают более полное представление об экосистеме.
Их цель — понимать, зачем и когда используется тот или иной инструмент, а не осваивать его на уровне администратора или DevOps-инженера.
Мы кратко знакомимся с:
- **Apache NiFi** и **Kafka** — инструментами для построения потоковых и интеграционных пайплайнов;
- **ClickHouse** — колоночной СУБД для высоконагруженной аналитики;
- **dbt** — подходом к трансформации данных как кода.
Практика ограничивается минимальным рабочим примером (например, запуск в Docker, простой пайплайн или SQL-модель).
Этого достаточно, чтобы уверенно говорить об инструменте на собеседовании и понимать его место в архитектуре — а всё остальное при необходимости осваивается уже на проекте.
## ClickHouse
Бесплатный курс https://yandex.cloud/ru/training/clickhouse
Платный курс [ClickHouse для аналитика – Stepik](https://stepik.org/course/100210/promo?search=6551441002)
## NiFi
Плейлист [Apache NiFi с нуля за 3 часа. Конструктор вместо кода - Youtube](https://youtube.com/playlist?list=PL4MpKy3QjNp_rOEEibc4Ro8UK4g8vLX6_&si=W_hidjHmBOZ_aUfS) - первые 4 видео. Дальше - по желанию.
Делаем отдельный docker compose Postgres + Nifi
В NiFi собираем генератор данных
Плейлист [Apache NiFi с нуля за 3 часа. Конструктор вместо кода - Youtube](https://youtube.com/playlist?list=PL4MpKy3QjNp_rOEEibc4Ro8UK4g8vLX6_&si=W_hidjHmBOZ_aUfS) первые 4 видео, дальше по желанию.
Практика:
- собираем отдельный стенд в Docker Compose с Postgres и NiFi;
- в NiFi настраиваем простой генератор данных.
## Kafka
[Лучший Гайд по Kafka для Начинающих За 1 Час - Youtube](https://www.youtube.com/watch?v=hbseyn-CfXY)
Добавляем к предыдущему docker compose Kafka.
Строим поток данных NiFi->Kafka
Kafka->NiFi->Postgres
Практика:
- расширяем предыдущий стенд, добавляя Kafka;
- строим поток данных: NiFi → Kafka;
- добавляем обратный поток: Kafka → NiFi → Postgres.
## dbt
Обзорная лекция, для понимания смысла
dbt (data build tool) — инструмент для трансформации данных в хранилище.
Мы рассматриваем его как альтернативу «ручному» написанию сложных CTE и для понимания современного подхода к моделированию данных как кода.
**Когда блок расширенных навыков считаем пройденным:**
- вы можете на собеседовании кратко объяснить, когда уместны NiFi/Kafka, ClickHouse и dbt, и чем они дополняют базовый стек (Postgres, Airflow, Greenplum);
- понимаете типичные сценарии: потоковые интеграции и очереди (Kafka/NiFi), аналитические витрины и отчёты на ClickHouse, трансформации данных в dbt;
- не боитесь увидеть эти инструменты в описании вакансии и можете поддержать содержательный разговор об их месте в архитектуре.
# Софт скиллы
- [Все ветви дохода в IT / Полный гайд по деньгам](https://youtube.com/live/JHClTWwK1EM)
- [Гайд как писать отзывы](https://boosty.to/m0rtymerr/posts/b04040ec-0f46-4524-9c75-188a513140ad?share=post_link)
- [Гайд по Антистрессу](https://youtu.be/bu0YiXOKaoU)
# Тех. материалы несортировано
# Дополнительные материалы
- [ananevsyu/SandBox_DB_public: Песочница для изучения различных технологий связанных с инженерией данных](https://gitflic.ru/project/ananevsyu/sandbox_db_public)
- Клон проекта [dementev_dev/sandbox_db_public-форк](https://gitflic.ru/project/dementev_dev/sandbox_db_public-fork)
- [Индексы в БД - Youtube](https://www.youtube.com/watch?v=DyqtBiDrz3g)
- [Spark + Iceberg in 1 Hour - Memory Tuning, Joins, Partition - Youtube](https://www.youtube.com/watch?v=3R-SLYK-P_0)
- [Введение в устройство Parquet и Iceberg - habr](https://habr.com/ru/companies/vktech/articles/959398/)
- [Введение в Apache Iceberg. Основы, архитектура, как работает?](https://ivan-shamaev.ru/apache-iceberg-tutorial-architecture-how-to-work/#__Apache_Iceberg-2)
- [Алгоритмы: теория и практика. Методы – Stepik](https://stepik.org/course/217/info)
- [Алгоритмы: теория и практика. Структуры данных – Stepik](https://stepik.org/course/1547/promo)
- [Apache Hadoop для самых маленьких: HDFS, RACK-AWARENESS, репликация и Data Locality - Youtube](https://youtu.be/0fsY5bW2l84)
- [Книга. Введение в Apache Kafka для системных аналитиков и проектировщиков интеграций](https://systems.education/kafka)
-
- [Книга. Введение в Apache Kafka для системных аналитиков и проектировщиков интеграций](https://systems.education/kafka)
- [Перевод документации dbt на русский язык](https://docs.getdbt.tech/)
## Записи ОМ
- [Как пройти собеседование на программиста | Ультимативный гайд с ‪@om_nazarov - Youtube](https://www.youtube.com/watch?v=tzSdiYZ52kI)
- [Как стать программистом в 2025 | «Ультимативный гайд» с ‪@om_nazarov](https://www.youtube.com/watch?v=6151ekTOl38)
-
-25
View File
@@ -1,25 +0,0 @@
# Airflow Configuration
AIRFLOW_USER=admin
AIRFLOW_PASSWORD=admin
# PostgreSQL (Airflow metadata)
PG_USER=airflow
PG_PASSWORD=airflow
PG_DB=airflow
# Greenplum Configuration
GP_USER=gpadmin
GP_PASSWORD=gpadmin
GP_DB=gpadmin
GP_HOST=greenplum
GP_PORT=5432
GP_CONN_ID=greenplum_conn
GP_USE_AIRFLOW_CONN=true
# Kafka Configuration
KAFKA_BOOTSTRAP=kafka:29092
KAFKA_TOPIC=orders
KAFKA_BATCH_SIZE=500
KAFKA_POLL_TIMEOUT=10
KAFKA_MAX_EMPTY_POLLS=3
KAFKA_UI_CLUSTER_NAME=KafkaCluster
-6
View File
@@ -1,6 +0,0 @@
.vscode/settings.json
# Do not commit secrets
.env
.env.*
/airflow/dags/__pycache__
-44
View File
@@ -1,44 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- `airflow/dags/` — Airflow DAGs (e.g., `airflow/dags/kafka_to_greenplum.py`).
- `airflow/requirements.txt` — Python deps installed inside Airflow containers.
- `sql/` — database DDL and helpers (e.g., `sql/ddl_gp.sql`).
- `docker-compose.yml` — Greenplum, Kafka, Airflow, Postgres (metadata DB).
- `Makefile` — local DX commands; see targets below.
- `.env(.example)` — runtime configuration; never commit real secrets.
## Build, Test, and Development Commands
- `make up` — start the full stack.
- `make airflow-init` — migrate metadata DB and create admin user.
- `make logs` — follow webserver and scheduler logs.
- `make ddl-gp` — apply DDL to Greenplum.
- `make gp-psql` — open `psql` in the GP container.
- `make down` — stop stack and remove volumes.
Example: `make up && make airflow-init` then open `http://localhost:8080`.
## Coding Style & Naming Conventions
- Python: PEP 8, 4-space indents, `snake_case` for functions/vars, DAG IDs lower_snake_case.
- Imports: stdlib → third-party → local; prefer one module per line.
- SQL: uppercase keywords, `snake_case` identifiers, end statements with `;`.
- Filenames: DAGs as `<source>_to_<target>.py` (e.g., `kafka_to_greenplum.py`).
- Formatting: if available, use `black` (88 cols) and `isort`; otherwise keep existing style.
- Language: комментарии, docstrings и документацию (README, описания PR/Issues) пишем на русском; имена идентификаторов и код — на английском.
## Testing Guidelines
- No test suite yet. If adding tests, use `pytest` under `tests/` with `test_*.py`.
- Prefer unit tests for Python callables used by tasks; mock env vars and external systems.
- Run locally with `pytest -q`.
## Commit & Pull Request Guidelines
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:` etc. Example: `feat(dags): load orders to Greenplum`.
- Keep PRs focused; include a description, run steps, and relevant screenshots (e.g., DAG graph or task logs).
- Link issues; update `README.md` and DDL when behavior or schema changes.
## Security & Configuration Tips
- Configure via `.env`; do not hardcode credentials. Common vars: `GP_USER`, `GP_PASSWORD`, `GP_DB`, `GP_PORT`, `PG_*`, `AIRFLOW_*`.
- Be cautious with `make down` (removes volumes). Pin images/deps; prefer digests for critical images.
## Agent-Specific Notes
- Keep changes minimal and localized; do not rename Make targets without updating docs.
- Validate by running `make up`, `make airflow-init`, and inspecting the DAG in Airflow.
-19
View File
@@ -1,19 +0,0 @@
SHELL := /bin/bash
up:
docker compose -f docker-compose.yml up -d
down:
docker compose -f docker-compose.yml down -v
airflow-init:
docker compose -f docker-compose.yml run --rm airflow-init
logs:
docker compose -f docker-compose.yml logs -f airflow-webserver airflow-scheduler
gp-psql:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gpadmin'"
ddl-gp:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gpadmin -f /sql/ddl_gp.sql'"
-165
View File
@@ -1,165 +0,0 @@
# DE Starter Kit — Greenplum + Kafka + Airflow
Обновлено: 2025-09-15 11:12
Этот вариант повторяет логику Postgres-стенда, но в роли DWH — **Greenplum (single node в Docker)**.
Airflow по‑прежнему использует **Postgres** только как metadata DB (это стандартная и простая схема).
## Требования
- Docker Desktop (Windows/Mac) или Docker Engine 24+ (Linux) с `docker compose v2`.
- Make (опционально). Если `make` нет — используйте приведённые ниже команды `docker compose` напрямую.
- Windows: запускайте команды в Git Bash или WSL; PowerShell тоже подойдёт, но для `make` удобнее Git Bash/WSL.
## Что внутри
- **Greenplum** (single node) — `woblerr/greenplum:6.27.1` (локальный стенд для разработки).
- **Kafka (KRaft, без Zookeeper)** — генерация событий.
- **Airflow (2.9)** — оркестрация пайплайна, metadata в Postgres.
- **DAG** `kafka_to_greenplum.py` — генерирует данные → пишет в Kafka → читает и грузит в Greenplum.
- **DAG** `greenplum_data_quality.py` — выполняет проверки качества данных (наличие таблицы, схема, заполненность, дубли).
- **Kafka UI** — автоматически подключается к стендовому брокеру (bootstrap берётся из `KAFKA_BOOTSTRAP` или значения по умолчанию).
- **DDL** `sql/ddl_gp.sql` — создаёт колонночную таблицу `orders` без PRIMARY KEY (AO-таблицы GP6 не поддерживают его), распределённую по `order_id`; контроль дублей реализован в DAG.
> Примечание по версиям и надёжности: используется образ `woblerr/greenplum:6.27.1` с поддержкой переменных окружения и fallback значениями. Для продакшен‑подобных тестов зафиксируй digest (SHA256) конкретного тега на Docker Hub.
## Быстрый старт
Установим make (Linux/Mac)
```bash
sudo apt install -y make
```
Настроим переменные окружения
```bash
cp .env.example .env
# При необходимости отредактируйте .env для ваших настроек
```
Запустим приложение (вариант с Make)
```bash
make up && make airflow-init
make logs # ждем "Listening at: http://0.0.0.0:8080"
```
Открой Airflow: http://localhost:8080 (логин/пароль см. `.env`, по умолчанию admin/admin).
Включи DAG **kafka_to_greenplum** и нажми **Trigger** — он создаст таблицу и загрузит ~1000 записей в `gpadmin.public.orders`.
Альтернатива без Make (на всех ОС):
```bash
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml run --rm airflow-init
docker compose -f docker-compose.yml logs -f airflow-webserver airflow-scheduler
```
### Создаём Airflow Connection для Greenplum
1. Открой Airflow UI → **Admin → Connections****Add a new record**.
2. Заполни поля:
- `Conn Id`: `greenplum_conn` (или своё значение, тогда пропиши его в переменной `GP_CONN_ID`).
- `Conn Type`: `Postgres`.
- `Host`: `greenplum`.
- `Schema`: значение `GP_DB` (по умолчанию `gpadmin`).
- `Login`: `GP_USER` (по умолчанию `gpadmin`).
- `Password`: `GP_PASSWORD`.
- `Port`: `5432`.
3. Сохрани соединение и перезапусти DAG (если он уже был активирован).
CLI-альтернатива (выполняется внутри контейнера Airflow):
```bash
docker compose -f docker-compose.yml exec airflow-webserver bash -lc "
airflow connections add 'greenplum_conn' \
--conn-type postgres \
--conn-host greenplum \
--conn-login ${GP_USER:-gpadmin} \
--conn-password ${GP_PASSWORD:-gpadmin} \
--conn-schema ${GP_DB:-gpadmin} \
--conn-port 5432"
```
### Интерфейсы и порты
- Airflow UI: http://localhost:8080 (admin/admin по умолчанию)
- Kafka UI: http://localhost:8082 (просмотр топиков/сообщений; подключение к кластеру создаётся автоматически)
- Greenplum: `localhost:${GP_PORT:-5432}` (внешний порт проброшен из контейнера)
- Postgres (Airflow metadata): `localhost:5433`
- Kafka (для клиентов на хосте): `localhost:9092`
- Kafka (из контейнеров Docker): `kafka:29092`
### Параметры чтения/загрузки
- `KAFKA_BATCH_SIZE` — размер батча при вставке в Greenplum (по умолчанию 500).
- `KAFKA_POLL_TIMEOUT` — таймаут ожидания сообщения в секундах (по умолчанию 10).
- `KAFKA_MAX_EMPTY_POLLS` — сколько подряд пустых `poll` допускается перед выходом из цикла (по умолчанию 3).
### Проверка загрузки
```bash
# Подключение к Greenplum через внешний psql клиент
psql -h localhost -p 5432 -U gpadmin -d gpadmin
# Или через Docker (используя make команду)
make gp-psql
# Или напрямую через Docker
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gpadmin'"
# Внутри psql:
\dt
SELECT count(*) FROM public.orders;
```
### Проверка Kafka
- Открой Kafka UI: http://localhost:8082 — проверь, что существует топик `orders` и в нём появляются сообщения после запуска DAG.
- Если автосоздание топиков в брокере отключено, создай топик вручную через UI перед запуском DAG.
## Файлы
- `docker-compose.yml` — сервисы Greenplum + Kafka + Airflow + Postgres (metadata).
- `.env.example` — шаблон переменных окружения.
- `.env` — переменные окружения (создается из .env.example).
- `airflow/dags/kafka_to_greenplum.py` — сам DAG.
- `sql/ddl_gp.sql` — DDL таблицы в Greenplum.
- `Makefile` — обёртки команд (`up`, `down`, `airflow-init`, `gp-psql`, `ddl-gp`).
## Конфигурация через переменные окружения
Все настройки Greenplum передаются через переменные окружения в файле `.env` с fallback значениями:
- `GP_USER` — пользователь Greenplum (по умолчанию: gpadmin)
- `GP_PASSWORD` — пароль пользователя (по умолчанию: gpadmin)
- `GP_DB` — база данных (по умолчанию: gpadmin)
- `GP_PORT` — порт для подключения (по умолчанию: 5432)
- `GP_CONN_ID` — ID Airflow Connection (по умолчанию: `greenplum_conn`)
- `GP_USE_AIRFLOW_CONN` — использовать ли Airflow Connection (`true`/`false`). Если `false`, DAG подключается к БД напрямую по ENV.
Настройки Kafka берутся из тех же переменных окружения:
- `KAFKA_BOOTSTRAP` — bootstrap-адрес брокера для Airflow и Kafka UI (по умолчанию `kafka:29092`).
- `KAFKA_TOPIC` — имя демо-топика (по умолчанию `orders`).
- `KAFKA_BATCH_SIZE`, `KAFKA_POLL_TIMEOUT`, `KAFKA_MAX_EMPTY_POLLS` — параметры чтения/загрузки (см. раздел выше).
- `KAFKA_UI_CLUSTER_NAME` — отображаемое имя кластера в Kafka UI (по умолчанию `KafkaCluster`).
Образ `woblerr/greenplum:6.27.1` использует переменные:
- `GREENPLUM_USER` (маппится на `GP_USER`)
- `GREENPLUM_PASSWORD` (маппится на `GP_PASSWORD`)
- `GREENPLUM_DATABASE_NAME` (маппится на `GP_DB`)
Внутри контейнеров Airflow хост для подключения к БД — `greenplum` (см. `GP_HOST`), а с вашей машины — `localhost:${GP_PORT}`. Созданный в Airflow Connection реиспользует те же значения, что и `.env`.
## Пинning и альтернативы
- Зафиксируй digest образа `woblerr/greenplum:6.27.1` (Docker Hub → Tag → «Copy digest») и замени тег на `@sha256:...` в `docker-compose.yml`.
- Альтернативы: можно использовать другие образы Greenplum или собрать собственный образ для GPDB 6/7.
- Особенность GPDB 6: в нём **нет** `INSERT ... ON CONFLICT`. В DAG используется безопасная для GP6 конструкция `INSERT ... WHERE NOT EXISTS` внутри транзакции.
## Ограничения и заметки
- Этот стенд — учебный. Для высокой надёжности и производительности Greenplum обычно разворачивают кластерами на нескольких узлах, на裸‑железе/VM с отдельными дисками под сегменты.
- Для GP7 (основан на новее PostgreSQL) можно упростить загрузку, включая `ON CONFLICT`. В учебных целях мы остались на широко доступном GP6 образе.
## Поток данных (DAG)
Последовательность задач в `kafka_to_greenplum`:
- `create_table` — создаёт таблицу `public.orders` в Greenplum (колоночная, AO/CO, распределение по `order_id`).
- `produce_messages` — генерирует ~1000 сообщений и пишет их в Kafka-топик `orders`.
- `consume_and_load` — читает сообщения из Kafka и вставляет в `public.orders` батчами (по `KAFKA_BATCH_SIZE`) с защитой от дублей для GP6 через anti-join. По умолчанию использует Airflow Connection `greenplum_conn`, но при `GP_USE_AIRFLOW_CONN=false` подключается по ENV (`GP_HOST`, `GP_PORT`, `GP_DB`, `GP_USER`, `GP_PASSWORD`).
Повторный запуск DAG безопасен: при вставке используется проверка на существование `order_id`.
## Типичные проблемы и решения
- Airflow UI не открывается: проверь `make logs` и дождись строки `Listening at: http://0.0.0.0:8080`.
- Ошибка подключения к Greenplum: дождись, пока контейнер `greenplum` станет `healthy`; проверь, что порт `GP_PORT` не занят локальными сервисами.
- Нет топика `orders`: создай его через Kafka UI (или перезапусти DAG после включения авто‑создания топиков).
- `make` отсутствует на Windows: используй команды `docker compose` из раздела «Альтернатива без Make» или установи Git Bash/WSL.
## Проверка данных
- Подними стенд (`make up && make airflow-init`) и запусти DAG `kafka_to_greenplum`, чтобы заполнить таблицу `orders`.
- Активируй и запусти DAG `greenplum_data_quality` — он последовательно проверит наличие таблицы, схему, объём данных и отсутствие дублей. Все проверки выполняются внутри Airflow и используют те же настройки подключений.
@@ -1,54 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from helpers.greenplum import (
assert_orders_have_rows,
assert_orders_no_duplicates,
assert_orders_schema,
assert_orders_table_exists,
get_gp_conn,
)
def _run_check(check_callable):
"""Оборачиваем проверку в контекст подключения."""
with get_gp_conn() as conn:
check_callable(conn)
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="greenplum_data_quality",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "greenplum", "quality"],
) as dag:
check_exists = PythonOperator(
task_id="check_orders_table_exists",
python_callable=_run_check,
op_args=[assert_orders_table_exists],
)
check_schema = PythonOperator(
task_id="check_orders_schema",
python_callable=_run_check,
op_args=[assert_orders_schema],
)
check_has_rows = PythonOperator(
task_id="check_orders_has_rows",
python_callable=_run_check,
op_args=[assert_orders_have_rows],
)
check_no_duplicates = PythonOperator(
task_id="check_order_duplicates",
python_callable=_run_check,
op_args=[assert_orders_no_duplicates],
)
check_exists >> check_schema >> check_has_rows >> check_no_duplicates
@@ -1,107 +0,0 @@
from __future__ import annotations
import os
from typing import List, Sequence, Tuple
import psycopg2
# Настройки для подключения к Greenplum. По умолчанию используем Airflow Connection,
# но при проблемах можно переключиться на ENV-подключение, установив GP_USE_AIRFLOW_CONN=false.
GP_CONN_ID = os.getenv("GP_CONN_ID", "greenplum_conn")
GP_USE_AIRFLOW_CONN = os.getenv("GP_USE_AIRFLOW_CONN", "true").lower() in ("1", "true", "yes")
EXPECTED_ORDERS_SCHEMA: List[Tuple[str, str]] = [
("order_id", "bigint"),
("order_ts", "timestamp without time zone"),
("customer_id", "bigint"),
("amount", "numeric"),
]
def get_gp_conn():
"""Возвращает psycopg2 connection к Greenplum (через Airflow Connection или напрямую по ENV)."""
if GP_USE_AIRFLOW_CONN:
try:
from airflow.providers.postgres.hooks.postgres import PostgresHook
hook = PostgresHook(postgres_conn_id=GP_CONN_ID)
return hook.get_conn()
except Exception:
# Фоллбек на прямое подключение по переменным окружения.
pass
return psycopg2.connect(
dbname=os.getenv("GP_DB", "gpadmin"),
user=os.getenv("GP_USER", "gpadmin"),
password=os.getenv("GP_PASSWORD", ""),
host=os.getenv("GP_HOST", "greenplum"),
port=int(os.getenv("GP_PORT", "5432")),
)
def assert_orders_table_exists(conn) -> None:
"""Проверяет наличие таблицы orders в схеме public."""
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1
FROM pg_catalog.pg_tables
WHERE schemaname = 'public' AND tablename = 'orders'
"""
)
if cur.fetchone() is None:
raise ValueError("Таблица public.orders не найдена; запусти DAG kafka_to_greenplum.")
def fetch_orders_schema(conn) -> Sequence[Tuple[str, str]]:
with conn.cursor() as cur:
cur.execute(
"""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'orders'
ORDER BY ordinal_position
"""
)
return cur.fetchall()
def assert_orders_schema(conn) -> None:
"""Проверяет, что схема таблицы orders соответствует ожидаемой."""
schema = fetch_orders_schema(conn)
if list(schema) != EXPECTED_ORDERS_SCHEMA:
raise ValueError(f"Неожиданная схема orders: {schema}. Ожидали {EXPECTED_ORDERS_SCHEMA}.")
def fetch_orders_count(conn) -> int:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM public.orders")
return cur.fetchone()[0]
def assert_orders_have_rows(conn) -> None:
"""Проверяет, что таблица orders не пустая."""
if fetch_orders_count(conn) <= 0:
raise ValueError("Таблица public.orders пустая — запусти DAG kafka_to_greenplum перед проверкой.")
def fetch_orders_duplicates(conn) -> int:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM (
SELECT order_id
FROM public.orders
GROUP BY order_id
HAVING COUNT(*) > 1
) d
"""
)
return cur.fetchone()[0]
def assert_orders_no_duplicates(conn) -> None:
"""Проверяет, что в таблице нет дублей по order_id."""
duplicates = fetch_orders_duplicates(conn)
if duplicates:
raise ValueError(f"Обнаружены дубли по order_id ({duplicates} шт.) — проверь загрузку данных.")
@@ -1,146 +0,0 @@
from __future__ import annotations
import json
import os
import random
from datetime import datetime, timedelta
from typing import List, Tuple, Optional
from airflow import DAG
from airflow.operators.python import PythonOperator
from confluent_kafka import Consumer, KafkaException, Producer
from psycopg2.extras import execute_values
from helpers.greenplum import get_gp_conn
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "kafka:29092")
TOPIC = os.getenv("KAFKA_TOPIC", "orders")
BATCH_SIZE = int(os.getenv("KAFKA_BATCH_SIZE", "500"))
POLL_TIMEOUT_S = int(os.getenv("KAFKA_POLL_TIMEOUT", "10"))
MAX_EMPTY_POLLS = int(os.getenv("KAFKA_MAX_EMPTY_POLLS", "3"))
def _create_table():
ddl = """
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
)
WITH (appendonly=true, orientation=column, compresstype=zlib)
DISTRIBUTED BY (order_id);
"""
with get_gp_conn() as conn, conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
def _produce(n=1000):
producer = Producer({"bootstrap.servers": KAFKA_BOOTSTRAP})
for idx in range(n):
payload = {
"order_id": idx + 1,
"order_ts": datetime.utcnow().isoformat(),
"customer_id": random.randint(1, 100),
"amount": round(random.uniform(10, 500), 2),
}
producer.produce(TOPIC, json.dumps(payload).encode("utf-8"))
producer.flush()
def _flush_batch(cur, rows: List[Tuple]):
"""Insert deduplicated batch of rows into public.orders for GP6 (no PK support)."""
if not rows:
return
# Дедупликация внутри батча по первичному ключу (order_id)
by_id = {int(r[0]): r for r in rows}
unique_rows = list(by_id.values())
# Вставка через VALUES + anti-join для GP6 (без ON CONFLICT)
execute_values(
cur,
"""
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
SELECT v.order_id, v.order_ts, v.customer_id, v.amount
FROM (VALUES %s) AS v(order_id, order_ts, customer_id, amount)
LEFT JOIN public.orders o ON o.order_id = v.order_id
WHERE o.order_id IS NULL
""",
unique_rows,
template="(%s,%s,%s,%s)",
)
def _consume_and_load(max_messages=1000, timeout_s: Optional[int] = None):
if timeout_s is None:
timeout_s = POLL_TIMEOUT_S
consumer = Consumer(
{
"bootstrap.servers": KAFKA_BOOTSTRAP,
"group.id": "airflow-loader-gp",
"auto.offset.reset": "earliest",
"enable.auto.commit": False,
}
)
consumer.subscribe([TOPIC])
with get_gp_conn() as conn, conn.cursor() as cur:
batch: List[Tuple] = []
consumed = 0
empty_polls = 0
while consumed < max_messages and empty_polls < MAX_EMPTY_POLLS:
msg = consumer.poll(timeout_s)
if msg is None:
empty_polls += 1
continue
empty_polls = 0
if msg.error():
raise KafkaException(msg.error())
data = json.loads(msg.value().decode("utf-8"))
batch.append(
(
int(data["order_id"]),
datetime.fromisoformat(data["order_ts"]),
int(data["customer_id"]),
float(data["amount"]),
)
)
consumed += 1
if len(batch) >= BATCH_SIZE:
_flush_batch(cur, batch)
conn.commit()
batch.clear()
# Финальный сброс, если вышли по лимиту сообщений
if batch:
_flush_batch(cur, batch)
conn.commit()
# Фиксируем оффсеты после успешной загрузки
consumer.commit()
consumer.close()
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="kafka_to_greenplum",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "kafka", "greenplum"],
) as dag:
create_table = PythonOperator(task_id="create_table", python_callable=_create_table)
produce = PythonOperator(
task_id="produce_messages", python_callable=_produce, op_kwargs={"n": 1000}
)
consume_and_load = PythonOperator(
task_id="consume_and_load",
python_callable=_consume_and_load,
op_kwargs={"max_messages": 1000},
)
create_table >> produce >> consume_and_load
@@ -1,2 +0,0 @@
confluent-kafka==2.3.0
psycopg2-binary==2.9.9
-151
View File
@@ -1,151 +0,0 @@
services:
# Postgres только для Airflow метаданных
pgmeta:
image: postgres:16
# container_name: gp_pgmeta
env_file: .env
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB}
ports:
- "5433:5432"
volumes:
- pgmeta:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
interval: 5s
timeout: 5s
retries: 20
greenplum:
image: woblerr/greenplum:6.27.1
# container_name: gp_single
# hostname: gpdbsne
environment:
GREENPLUM_USER: ${GP_USER:-gpadmin}
GREENPLUM_PASSWORD: ${GP_PASSWORD:-gpadmin}
GREENPLUM_DATABASE_NAME: ${GP_DB:-gpadmin}
# GP_PORT: ${GP_PORT:-5432}
# Порты: внешний 5432
ports:
- "${GP_PORT}:5432"
volumes:
- ./sql:/sql:ro
- greenplum_data:/data
# Простая проверка доступности: psql откликается
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U ${GP_USER:-gpadmin} -d ${GP_DB:-gpadmin} || echo 1"]
interval: 10s
timeout: 5s
retries: 30
kafka:
image: apache/kafka:3.8.0
environment:
KAFKA_BROKER_ID: 1
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_NODE_ID: 1
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
# 22-символьный base64 идентификатор для KRaft-кластера
CLUSTER_ID: aGVsbG93b3JsZGtpdGNoZW4
ports:
- "9092:9092"
kafka-ui:
image: provectuslabs/kafka-ui:v0.7.2
env_file: .env
ports:
- 8082:8080
environment:
DYNAMIC_CONFIG_ENABLED: "true"
KAFKA_CLUSTERS_0_NAME: ${KAFKA_UI_CLUSTER_NAME:-KafkaCluster}
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: ${KAFKA_BOOTSTRAP:-kafka:29092}
volumes:
- kafka-ui-data:/app/data
depends_on:
- kafka
airflow-webserver:
image: apache/airflow:2.9.2
container_name: gp_airflow_web
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
command: >
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
airflow webserver"
ports:
- "8080:8080"
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
depends_on:
pgmeta:
condition: service_healthy
kafka:
condition: service_started
greenplum:
condition: service_healthy
airflow-scheduler:
image: apache/airflow:2.9.2
container_name: gp_airflow_sch
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
command: >
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
airflow scheduler"
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
depends_on:
pgmeta:
condition: service_healthy
kafka:
condition: service_started
greenplum:
condition: service_healthy
airflow-init:
image: apache/airflow:2.9.2
# container_name: gp_airflow_init
env_file: .env
environment:
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
volumes:
- ./airflow/dags:/opt/airflow/dags
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
command: >
bash -lc "
set -e;
pip install --no-cache-dir -r /opt/airflow/requirements.txt;
# Дожидаемся готовности БД ретрая миграции
for i in {1..30}; do
airflow db migrate && break || echo 'waiting for pgmeta' && sleep 3;
done;
# Создаём админа; при повторном запуске не падаем
airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org || true
"
depends_on:
pgmeta:
condition: service_healthy
volumes:
pgmeta:
kafka-ui-data:
greenplum_data:
-12
View File
@@ -1,12 +0,0 @@
-- Greenplum DDL (GPDB 6 совместимо)
-- Колонночная таблица (append-optimized) и распределение по ключу.
-- Внимание: append-optimized таблицы не поддерживают UNIQUE/PRIMARY KEY,
-- поэтому контроль дублей выполняем в DAG при загрузке.
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
)
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
DISTRIBUTED BY (order_id);
+650
View File
@@ -0,0 +1,650 @@
# **Data Vault 2.0: как собрать хранилище из конструктора**
## 📚 Оглавление
1. [Зачем вообще нужен Data Vault?](#1-зачем-вообще-нужен-data-vault)
2. [Интуиция: DV как конструктор Lego](#2-интуиция-dv-как-конструктор-lego)
3. [Три типа таблиц в Data Vault 2.0](#3-три-типа-таблиц-в-data-vault-20)
4. [Типы сателлитов в DV 2.0](#4-типы-сателлитов-в-dv-20)
5. [Raw Vault и Business Vault](#5-raw-vault-и-business-vault)
6. [Пример: клиент и заказы в DV-стиле](#6-пример-клиент-и-заказы-в-dv-стиле)
7. [Как это живёт в пайплайне загрузки](#7-как-это-живёт-в-пайплайне-загрузки)
8. [Плюсы и минусы Data Vault](#8-плюсы-и-минусы-data-vault)
9. [Когда DV стоит использовать, а когда нет](#9-когда-dv-стоит-использовать-а-когда-нет)
10. [Шпаргалка для собеседования](#10-шпаргалка-для-собеседования)
---
## 1. Зачем вообще нужен Data Vault?
Большинство знакомятся с хранилищами через две модели:
* **3NF** (Инмон) — нормализованное ядро: много таблиц, строгие связи, минимум дублирования.
* **Звезда (Star Schema)** (Кимбалл) — витрины под отчёты: факт + несколько «плоских» измерений.
Этого хватает для:
* 25 источников,
* относительно стабильных схем,
* задач типа «сделать отчёт для маркетинга/финансов».
Проблемы начинаются, когда:
* источников **становится десяток и больше** (CRM, биллинг, ERP, сайт, мобильное приложение, партнёры, скоринги…);
* схемы **постоянно меняются**: добавляются поля, сущности, новые связи;
* появляются требования по **аудиту и трассировке** (то есть умению по шагам показать, откуда взялись данные и как они менялись): «покажите, откуда взялся вот этот показатель, по шагам».
Вот тут обычная 3NF/Звезда начинает скрипеть:
* любое изменение источника → больно по ядру и витринам;
* история размазана по разным местам (где-то SCD, где-то лог-таблицы, где-то вообще нет истории);
* добавление нового источника превращается в мини-проект на месяц.
**Data Vault 2.0** отвечает именно на эту боль:
> Как сделать так, чтобы **новый источник** → это не «ремонт всего дома», а просто «докрутить ещё один модуль»?
---
## 2. Интуиция: DV как конструктор Lego
Классическая метафора DV — это **конструктор из трёх типов деталей**:
* 🔴 **Hub (Хаб)***«кто/что это»*
Сущности: клиент, заказ, договор, счёт.
Внутри: бизнес‑ключ (например, customer_id или contract_number) и техполя.
***Link (Линк)***«как они связаны»*
«Клиент сделал заказ», «договор относится к счёту».
Внутри: ссылки на хабы и техполя.
* 🟡 **Satellite (Сателлит)***«какие у них свойства и как они менялись»*
Атрибуты сущности (имя, email, статус, тариф) плюс история изменений.
Главная идея:
> **Идентичность, связи и атрибуты живут отдельно.**
> Тогда изменения в одном не ломают другое.
На картинке это можно показать так:
```mermaid
erDiagram
HUB_CUSTOMER ||--o{ SAT_CUSTOMER_INFO : имеет_атрибуты
HUB_CUSTOMER ||--o{ LINK_ORDER_CUSTOMER : участвует_в
HUB_ORDER ||--o{ LINK_ORDER_CUSTOMER : создан
LINK_ORDER_CUSTOMER ||--o{ SAT_ORDER_STATUS : имеет_историю_статусов
HUB_CUSTOMER {
varchar customer_bk PK
varchar record_source
timestamp load_dttm
}
HUB_ORDER {
varchar order_id PK
varchar record_source
timestamp load_dttm
}
LINK_ORDER_CUSTOMER {
varchar link_key PK
varchar order_id FK
varchar customer_bk FK
varchar record_source
timestamp load_dttm
}
SAT_CUSTOMER_INFO {
varchar customer_bk FK
varchar hashdiff
varchar name
varchar email
varchar city
varchar record_source
timestamp load_dttm
}
SAT_ORDER_STATUS {
varchar link_key FK
varchar hashdiff
varchar status
varchar record_source
timestamp load_dttm
}
```
Как это читать:
* 🔴 HUB_CUSTOMER / HUB_ORDER — «этот клиент существует», «этот заказ существует»;
* ⚪ LINK_ORDER_CUSTOMER — «именно этот заказ сделал именно этот клиент»;
* 🟡 SAT_… — как менялись атрибуты (email, статус и т.п.) во времени.
На схеме выше для наглядности показаны бизнес-ключи (`customer_bk`, `order_id`, `link_key`), а в DDL-примерах дальше используются уже хэш-ключи (`hk_*`) — это два уровня детализации одной и той же модели.
## 3. Три типа таблиц в Data Vault 2.0
Чуть менее «сказочно», чуть более технично.
### 3.1. Hub — сущность и её бизнес-ключ
**Hub** содержит:
* бизнес‑ключ (customer_bk, order_id, contract_number);
* техническую информацию:
* record_source — из какой системы пришла первая запись;
* load_dttm — когда запись попала в DV;
* иногда — хэш бизнес‑ключа (hk_customer).
Главные правила:
* один бизнес‑ключ — один хаб (одна строка на сущность, без истории);
* хаб не знает про атрибуты (имя, email) — только идентичность.
Простейший DDL‑скелет:
```sql
CREATE TABLE hub_customer (
hk_customer BYTEA PRIMARY KEY, -- хэш от BK
customer_bk VARCHAR(50) NOT NULL, -- business key
record_source VARCHAR(50) NOT NULL,
load_dttm TIMESTAMP NOT NULL
);
```
Конкретные типы данных (`BYTEA`, длины `VARCHAR`, детали `hashdiff`) и реализации хэш‑ключей можно не запоминать: на старте важнее понять саму идею — у сущностей есть стабильные ключи, а все изменения атрибутов мы записываем отдельными версиями в сателлитах.
### 3.2. Link — связи между сущностями
**Link** описывает факт связи, например:
* заказ ↔ клиент;
* договор ↔ счёт;
* карта ↔ клиент.
Примеры бизнес‑смыслов:
* link_order_customer — «этот заказ принадлежит этому клиенту»;
* link_contract_account — «этот договор привязан к этому счёту».
DDL‑эскиз:
```sql
CREATE TABLE link_order_customer (
hk_order_customer BYTEA PRIMARY KEY,
hk_order BYTEA NOT NULL,
hk_customer BYTEA NOT NULL,
record_source VARCHAR(50) NOT NULL,
load_dttm TIMESTAMP NOT NULL
);
```
### 3.3. Satellite — атрибуты и история
**Satellite** хранит:
* атрибуты хаба или линка;
* историю изменений этих атрибутов.
Примеры:
* sat_customer_info — имя, email, город клиента;
* sat_customer_segment — сегмент, категория, риск‑профиль;
* sat_order_status — статус заказа.
Типичные поля:
* ссылка на HUB или LINK (hk_customer, hk_order_customer);
* атрибуты (email, city, status и т.п.);
* hashdiff — хэш от всех атрибутов, чтобы понять, изменилась ли строка;
* record_source, load_dttm — источник и момент загрузки версии.
```sql
CREATE TABLE sat_customer_info (
hk_customer BYTEA NOT NULL,
hashdiff BYTEA NOT NULL,
name VARCHAR(100),
email VARCHAR(100),
city VARCHAR(50),
record_source VARCHAR(50) NOT NULL,
load_dttm TIMESTAMP NOT NULL
);
```
Главная мысль: DV заставляет явно разделять идентичность, связи и атрибуты с историей. Это делает модель сложнее на вид, но гораздо устойчивее к изменениям источников.
Когда нужны именно бизнес-периоды действия («с/по»), их удобнее моделировать не в базовом сателлите, а отдельными effectivity-сателлитами или через PIT-таблицы в Business Vault.
## 4. Типы сателлитов в DV 2.0
> Если вы только знакомитесь с DV, этот раздел можно прочитать по диагонали: для собеседования важно скорее знать, что такие роли бывают, чем разбираться во всех нюансах.
В DV 2.0 появилось разделение по «ролям» сателлитов. Главное, что стоит знать:
* **Descriptive Satellites** — обычные атрибуты (имя, адрес, тариф) с историей.
* **Effectivity Satellites** — фокус на периодах действия (`valid_from` / `valid_to`), очень похоже на SCD2.
* **Multi-Active Satellites** — когда у сущности несколько одновременных значений (например, три активных телефона клиента).
* **Transactional Satellites** — события, привязанные к одному хабу/линку (например, журнал изменений статуса).
На практике это разные DDL-«шаблоны» поверх одной и той же идеи:
**атрибуты + время → отдельная табличка.**
---
## 5. Raw Vault и Business Vault
Обычно под «Data Vault» люди смешивают два слоя:
```mermaid
flowchart LR
SRC[Источники] --> STG[STG / ODS]
STG --> RAW[Raw Vault
Hubs, Links, Sats]
RAW --> BV[Business Vault
PIT, Bridge, Derived]
BV --> DM[Data Marts
Star Schema]
DM --> BI[BI / ML]
```
* **Raw Vault** — это про приём и хранение данных «как есть», но уже в форме Hub / Link / Satellite.
* **Business Vault** — это про приведение этих данных в более «деловой» вид: с бизнес-правилами, PIT/Bridge и подготовленными представлениями.
### 5.1. Raw Vault — «всё прилетевшее, аккуратно разложенное по ящичкам»
Raw DV — первый слой поверх STG / ODS:
* выравниваем ключи;
* разбираем сущности по Hub / Link / Sat;
* сохраняем всю историю изменений, не решая ещё, что такое «активный клиент» или «успешный заказ».
Характерные черты Raw Vault:
* минимум бизнес-логики:
* никаких правил вроде «клиент активен, если была хотя бы одна покупка за 90 дней»;
* все источники показываются «как есть», только приведены к общим ключам;
* структура стабильна: добавился новый источник → появился новый Satellite к тому же Hub.
### 5.2. Business Vault — «там, где из Lego собирают модули»
Business Vault (BV) — следующий слой над Raw DV:
* здесь применяются бизнес-правила (что считать активным клиентом, как трактовать статусы);
* здесь строятся вспомогательные структуры:
* PIT-таблицы,
* Bridge-таблицы,
* агрегаты и derived-таблицы.
Именно из BV чаще всего строятся витрины в формате Звезды, к которым подключаются BI и отчётность.
Если сильно упростить:
* Raw DV → «мы всё собрали»;
* Business Vault → «мы это привели в вид, с которым удобно жить»;
* DM → «мы вынесли это на витрину в понятной форме».
#### 5.2.1. PIT-таблицы (Point-in-Time)
> Раздел для любопытных: PIT-таблицы — уже продвинутая тема, на первом знакомстве с DV её можно смело пропустить и вернуться позже.
PIT (Point-in-Time) решает очень конкретную боль:
> «Покажи, как объект выглядел **на дату X**, но так, чтобы запрос был простым».
Если у нас есть несколько сателлитов с историей (например, `sat_customer_info`, `sat_customer_segment`, `sat_customer_risk`), то без PIT любой запрос превращается в пачку условий
`as_of_date >= valid_from AND (valid_to IS NULL OR as_of_date < valid_to)` (в effectivity-сателлитах или любых таблицах с периодами действия) или оконных функций.
**Идея PIT:**
Мы заранее считаем «словарь» вида:
> для каждой пары (объект, дата) — какие версии сателлитов на эту дату актуальны.
Упрощённый пример структуры:
```sql
CREATE TABLE pit_customer_daily (
hk_customer BYTEA,
as_of_date DATE,
hk_sat_info BYTEA, -- sat_customer_info на эту дату
hk_sat_segment BYTEA, -- sat_customer_segment на эту дату
load_dttm TIMESTAMP
);
```
Внутри логически это выглядит так:
| hk_customer | as_of_date | hk_sat_info | hk_sat_segment |
| ----------- | ---------- | ------------ | -------------- |
| 101 | 2023-06-10 | hash_info_v1 | hash_seg_v1 |
| 101 | 2023-06-11 | hash_info_v2 | hash_seg_v1 |
| 101 | 2023-06-12 | hash_info_v2 | hash_seg_v2 |
Дальше витрина продаж вместо сложных `BETWEEN` делает простой JOIN:
```sql
SELECT
s.sale_date,
s.amount,
seg.segment,
info.city
FROM fact_sales s
JOIN pit_customer_daily pit
ON pit.hk_customer = s.hk_customer
AND pit.as_of_date = s.sale_date
JOIN sat_customer_segment seg
ON seg.hk_customer = pit.hk_customer
AND seg.hashdiff = pit.hk_sat_segment
JOIN sat_customer_info info
ON info.hk_customer = pit.hk_customer
AND info.hashdiff = pit.hk_sat_info;
```
Мы **один раз** дорого посчитали PIT (по расписанию),
и дальше все витрины просто используют эту «справочную таблицу».
Коротко: **PIT — это таблица, где заранее записано, какая версия данных была актуальна на дату X.**
#### 5.2.2. Bridge-таблицы
> Тоже продвинутый приём: Bridge-таблицы чаще нужны в боевых хранилищах, чем на первых собеседованиях.
Bridge-таблицы отвечают на другой вопрос:
> «Какие объекты **в итоге** связаны между собой через длинную цепочку Links?»
В Data Vault связь между двумя сущностями редко бывает «одним JOIN’ом». Чаще это цепочка:
* клиент → договор → счёт → карта → транзакция;
* подразделение → филиал → торговая точка → чек;
* компания → дочерняя компания → проект → контракт → платёж.
Если каждый раз в витринах писать все эти JOIN’ы, запросы становятся:
* длинными и хрупкими;
* плохо читаемыми;
* дублируются во множестве отчётов.
**Идея Bridge:**
Сделать отдельную таблицу, где заранее развернуть «финальные» пары:
```sql
CREATE TABLE bridge_customer_trx (
hk_customer BYTEA,
hk_trx BYTEA
-- опционально: даты действия связи, тип связи и т.п.
);
```
Такую таблицу мы считаем **в ETL**, один раз по расписанию, а не в каждом запросе.
Условный псевдокод построения:
```sql
INSERT INTO bridge_customer_trx (hk_customer, hk_trx)
SELECT DISTINCT
c.hk_customer,
t.hk_trx
FROM hub_customer c
JOIN link_customer_contract lcc ON lcc.hk_customer = c.hk_customer
JOIN hub_contract ct ON ct.hk_contract = lcc.hk_contract
JOIN link_contract_account lca ON lca.hk_contract = ct.hk_contract
JOIN hub_account acc ON acc.hk_account = lca.hk_account
JOIN link_account_card lac ON lac.hk_account = acc.hk_account
JOIN hub_card card ON card.hk_card = lac.hk_card
JOIN link_card_trx lct ON lct.hk_card = card.hk_card
JOIN hub_trx t ON t.hk_trx = lct.hk_trx;
```
После этого витрина может использовать уже готовый Bridge:
```sql
SELECT
c.customer_bk,
SUM(t.amount) AS total_amount
FROM hub_customer c
JOIN bridge_customer_trx bct
ON bct.hk_customer = c.hk_customer
JOIN hub_trx t
ON t.hk_trx = bct.hk_trx
WHERE t.trx_date >= CURRENT_DATE - INTERVAL '30 day'
GROUP BY c.customer_bk;
```
Сложный путь по Links мы спрятали внутрь bridge‑таблицы,
а витрины работают с простой связкой «клиент ↔ транзакция».
Коротко:
* **PIT** — про «какая версия атрибутов была на дату X»;
* **Bridge** — про «какие объекты в итоге связаны друг с другом по длинному маршруту».
Bridge — это **не обязательный элемент DV**, а инструмент оптимизации.
Он нужен тогда, когда цепочки Links становятся длинными и повторяются во многих отчётах.
#### 5.2.3. Business-правила и derived-таблицы
> Этот раздел полезен, чтобы увидеть, как DV помогает «прятать» повторяющуюся бизнес-логику, но для базового понимания Data Vault его можно оставить «на потом».
В BV логично размещать бизнес-логику, которая:
* повторяется во многих отчётах;
* достаточно стабильна.
Примеры:
* флаг «активный клиент», который вычисляется на основе истории покупок и логинов;
* «основной тариф», выбранный по набору правил из нескольких источников;
* «чистый статус заказа», свёрнутый из цепочки статусов (created → paid → shipped → delivered / cancelled).
Это могут быть как отдельные Satellites / Links, так и логические таблицы BV с уже посчитанными флагами и агрегатами.
### 5.3. Граница между Business Vault и витринами (DM)
Важно проговорить границу:
* Business Vault — ещё про данные и историю;
* DM (Data Marts) — уже про конкретные бизнес-сценарии и удобство BI.
Витрины можно переделывать, не трогая BV, пока вы не меняете фундаментальные бизнес-правила.
### 5.4. Как выглядит связка Raw DV → BV → DM на примере
Мини-пример интернет-магазина:
1. Raw Vault:
* `hub_customer`, `hub_order`;
* `link_order_customer`;
* `sat_customer_info`, `sat_order_status`, `sat_customer_segment`.
2. Business Vault:
* `pit_customer_daily` — срез клиента по дням;
* `bv_customer_flags` — активность, VIP-статусы, сегменты;
* `bridge_customer_order` — связи клиент ↔ заказ с удобными ключами.
3. DM / Star Schema:
* `dm.fact_sales` — факт продаж;
* `dm.dim_customer` — уже плоское измерение с нужными полями (email_current, segment, is_active_90d и т.п.);
* `dm.dim_date`, `dm.dim_product` и другие измерения.
В результате:
* Raw DV — технически корректный, историчный и некрасивый слой;
* BV — рабочий слой для инженеров и продвинутых аналитиков;
* DM — привычная Звезда для всех остальных.
## 6. Пример: клиент и заказы в DV-стиле
Возьмём мини‑пример всё того же интернет‑магазина.
У нас есть:
* клиент с `customer_id = 101`, у которого иногда меняется email и город;
* два заказа: `order_id = 5001` и `order_id = 5002`;
* статусы заказов: `created → paid → shipped → delivered`.
### Что появляется в Raw Vault
В Data Vault это раскладывается на несколько таблиц:
* `hub_customer` — по одной строке на каждого клиента (BK = `customer_id`).
* `hub_order` — по одной строке на каждый заказ (BK = `order_id`).
* `link_order_customer` — связь «какой заказ сделал какой клиент».
* `sat_customer_info` — история атрибутов клиента (имя, email, город).
* `sat_order_status` — история статусов заказа.
Примерно так это выглядит логически:
```text
HUB_CUSTOMER
--------------------------------------
customer_bk | record_source | load_dttm
--------------------------------------
101 | CRM | 2023-01-10 10:00
SAT_CUSTOMER_INFO
customer_bk | load_dttm | hashdiff | email | city
---------------------------------------------------------------------------
101 | 2023-01-10 10:00 | ... | a@example.com | Moscow
101 | 2023-06-01 09:00 | ... | alice@newmail.com | Moscow
HUB_ORDER
--------------------------------------
order_id | record_source | load_dttm
--------------------------------------
5001 | SHOP | 2023-06-10 12:00
5002 | SHOP | 2023-06-11 09:30
SAT_ORDER_STATUS
order_id | load_dttm | hashdiff | status
--------------------------------------------------------------
5001 | 2023-06-10 12:00 | ... | created
5001 | 2023-06-10 12:05 | ... | paid
5001 | 2023-06-11 09:00 | ... | shipped
... | ... | ...
```
Ключевая идея: **любое изменение** (email, статус) — это **новая строка** в соответствующем Satellite.
### Как из этого получить витрину продаж
Дальше нам нужно привычное измерение `dim_customer` и факт `fact_sales` в формате Звезды.
Обычно цепочка выглядит так:
1. В Business Vault строим `PIT`‑таблицу по клиентам:
* для каждой даты (или дня, или часа) знаем, какая строка из `sat_customer_info` была актуальна.
2. Строим витрину `dm.dim_customer`:
* на выбранную дату берём нужную версию из `sat_customer_info`;
* добавляем флаги/сегменты из других Satellites/BV‑таблиц.
3. Строим факт `dm.fact_sales`, где каждая строка — заказ или позиция заказа.
Итог: на витрине мы видим «плоского» клиента (одна строка → текущее имя/город/email на момент заказа),
хотя внутри DV лежит полная, аккуратно разложенная история.
## 7. Как это живёт в пайплайне загрузки
Посмотрим теперь, как DV вписывается в обычный ETL/ELT‑пайплайн.
Типичный поток выглядит так:
1. **STG / ODS — приём и первичная обработка**
* Подключаемся к источникам (CRM, биллинг, сайт, партнёрские выгрузки).
* Забираем инкременты (CDC, выгрузки по расписанию, API).
* Приводим типы данных, чистим очевидный мусор, нормализуем форматы дат и т.п.
2. **Raw Vault — приземление в Hub / Link / Satellite**
* Из STG/ODS считаем хэш‑ключи для бизнес‑ключей (HK для Hubs).
* Создаём/обновляем **Hubs** — если бизнес‑ключ новый, заводим запись.
* Создаём/обновляем **Links** — фиксируем связи между сущностями.
* Для **Satellites** считаем `hashdiff` по атрибутам и добавляем новые строки
только если что‑то реально изменилось.
3. **Business Vault — бизнес‑правила и ускорители**
* Строим PIT‑таблицы, чтобы быстро получать срез «на дату X».
* Строим Bridge‑таблицы для сложных цепочек связей.
* Вычисляем стабильные бизнес‑флаги и derived‑атрибуты (активность, сегменты, статусы).
4. **DM / Star Schema — витрины для отчётов и аналитики**
* На основе BV собираем факты и измерения в формате Звезды.
* Под это уже настраиваются BI‑инструменты, отчётность, дашборды.
Чем это отличается от классического «STG → ODS → DDS → DM»:
* слой DDS в DV‑подходе часто фактически превращается в **Raw+Business Vault**;
* вместо одной «большой» нормализованной схемы ядра у нас набор Lego‑модулей (Hubs/Links/Sats), которые за счёт хэш‑ключей и независимой загрузки доменов проще масштабировать и развивать/грузить параллельно.
DV не отменяет STG/ODS/DM — он скорее **раскладывает слой DDS на более мелкие и управляемые детали**.
## 8. Плюсы и минусы Data Vault
### 8.1. Плюсы
* 📜 **История «из коробки»**
Каждое изменение — отдельная запись в сателлите. Ничего не перезатирается.
* 🧩 **Лёгкое добавление источников**
Новый источник с теми же сущностями = новые сателлиты к тем же хабам.
* 🔎 **Трассировка и аудит**
Видно, из какого источника, когда и с какими атрибутами прилетела каждая строка.
* 👥 **Параллельная работа команд**
Разные домены в DV практически не блокируют друг друга.
### 8.2. Минусы
* 🧠 **Высокий порог входа**
Нужно понимать SCD, хэш‑ключи, нагрузку на JOIN, паттерны загрузки.
* 📈 **Больше таблиц и JOIN’ов**
Даже простой запрос превращается в «HUB + LINK + 23 SAT + PIT».
* 🛠 **Нужна дисциплина**
Забыли заполнить `record_source`/`load_dttm` — потеряли часть аудита.
***Плохо подходит для MVP**
Для 2–3 источников DV обычно дороже, чем классическая 3NF/Звезда.
## 9. Когда DV стоит использовать, а когда нет
### Подходит, если:
* у вас **зоопарк источников** (5+ систем, которые ещё и меняются);
* важна **полная история и аудит** (финтех, гос, телеком, крупный банк);
* команда нацелена на долгую жизнь DWH, а не одноразовый отчёт;
* есть люди, готовые жить в этой модели (архитектор, data engineer’ы).
### Лучше не начинать с DV, если:
* это **первый DWH в компании**;
* 1–3 источника и нет жёстких требований по аудиту;
* команда малая (1–2 инженера + аналитик) и сроки жмут;
* задача звучит как «дайте отчёты к кварталу», а не «построим платформу на 5 лет».
В таких случаях честнее (и дешевле) начать с:
> `stg → ods → dds (3NF/простая Звезда с SCD2) → dm (Звезда)`
А DV оставить как следующий шаг, когда появятся реальные боли, которые он решает.
## 10. Шпаргалка для собеседования
Если нужно быстро объяснить, что такое Data Vault:
- Data Vault — это модель хранилища, которая разделяет идентичность (Hubs), связи (Links) и атрибуты с историей (Satellites), чтобы проще переживать изменения источников.
- В DV есть три типа таблиц: `Hub` (бизнес-ключи сущностей), `Link` (связи между сущностями) и `Satellite` (атрибуты и их история).
- Raw Vault — слой, где данные складываются «как есть» в виде Hub/Link/Sat, Business Vault — слой с бизнес-правилами, PIT/Bridge и подготовленными представлениями для витрин.
- История в DV хранится «из коробки»: каждое изменение атрибутов — новая строка в сателлите, прошлые значения не затираются.
- DV хорошо подходит, когда много источников, они часто меняются и важен аудит; для маленького, простого DWH обычно хватает 3NF/Звезды.
- Для собеседования важно уметь связать всё вместе: объяснить Hub/Link/Satellite, отличия Raw и Business Vault и то, что хэш-ключи и независимые сателлиты позволяют параллельно загружать разные сущности, не ломая историю и аудит.
@@ -0,0 +1,294 @@
# Домашка: статусы клиента от STG до DDS (и немного DM)
Небольшое практическое задание на 1–2 вечера: по данным о смене статусов клиента (CRM) построить цепочку слоёв `STG → ODS → DDS (SCD2)` и, по желанию, небольшую витрину в `dm`.
Цель — потренировать **руками**:
- работу со слоями DWH (stg / ods / dds / dm);
- проектирование и загрузку **измерения с историей (SCD Type 2)**;
- аккуратную работу со временем (`event_ts`, `valid_from`, `valid_to`).
Исходим из того, что вы уже прошли основную статью `dwh-modeling/README.md` и познакомились с примером интернет‑магазина.
---
## 1. Данные: события смены статуса клиента
Представьте, что в CRM для каждого клиента хранится история статусов:
- `new` — только что зарегистрировался;
- `active` — делал покупки недавно;
- `vip` — часто покупает и много тратит;
- `churned` — давно ничего не делал, считаем «отвалившимся».
Эта информация приходит в DWH в виде **событий** (events): «у клиента X в момент времени Y статус стал Z».
В репозитории в каталоге `dwh-modeling/data` лежит файл:
- `customer_status_events.csv`
Структура файла:
```text
customer_id,status,event_ts,_load_id,load_ts
101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
...
```
Колонки:
- `customer_id` — бизнес-ключ клиента (тот же, что и в основном примере — 101, 102, 103);
- `status` — статус клиента в CRM (`new`, `active`, `vip`, `churned`);
- `event_ts` — момент, когда статус сменился в CRM;
- `_load_id` — идентификатор батча загрузки;
- `load_ts` — момент, когда данные попали в DWH (в таблицах STG/ODS эта колонка будет называться `_load_ts`, но по смыслу это то же самое время загрузки).
Файл содержит несколько клиентов и несколько смен статуса по каждому — этого достаточно, чтобы отработать SCD2.
---
## 2. Целевая схема: какие таблицы уже есть
Чтобы не тратить время на DDL, структуры таблиц для домашки уже подготовлены в `dwh-modeling/sql`:
- `07_ddl_hw_customer_status.sql` — создаёт дополнительные таблицы:
- `stg.customer_status_raw` — сырые события о статусе клиента;
- `ods.customer_status` — очищенные и типизированные события;
- `dds.dim_customer_status` — измерение статусов клиента в формате **SCD Type 2**.
- `08_dml_hw_customer_status_template.sql` — шаблон DML-скрипта с подсказками и заготовками блоков.
Перед началом работы:
1. Поднимите demo‑Postgres по инструкции из корневого `README.md`.
2. Выполните базовые скрипты DWH:
- `01_ddl_stg-dds.sql`
- `02_dml_stg-dds.sql`
3. Выполните DDL для домашки:
- `07_ddl_hw_customer_status.sql`
После этого схемы `stg`, `ods`, `dds` уже существуют, а дополнительные таблицы для статусов созданы.
---
## 3. Часть 1 — STG → ODS (обязательно)
**Задача:** загрузить CSV в STG и переложить данные в ODS с приведением типов.
### 3.1. STG: загрузка CSV
Есть два варианта — выберите любой. Для первого прохождения рекомендуем **вариант A** (самый простой).
#### Вариант A (рекомендуемый): вставить данные в STG через `INSERT`
Откройте SQL‑клиент (DBeaver или `psql`) и вставьте данные текстом:
> 💡 Если вы уже загружали данные в `stg.customer_status_raw` и делаете повторный запуск — начните с `TRUNCATE stg.customer_status_raw;`.
>
> 💡 В примере ниже показан минимальный набор для клиента 101. Чтобы получить несколько клиентов и больше событий — используйте вариант B (CSV) или добавьте строки из файла `dwh-modeling/data/customer_status_events.csv`.
```sql
INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _load_ts) VALUES
('101','new','2024-01-01 09:00:00','batch_20240101_1000','2024-01-01 10:00:00'),
('101','active','2024-02-15 10:30:00','batch_20240215_1100','2024-02-15 11:00:00'),
('101','vip','2024-05-10 11:00:00','batch_20240510_1200','2024-05-10 12:00:00'),
('101','churned','2024-09-01 12:15:00','batch_20240901_1300','2024-09-01 13:00:00');
```
> 💡 Здесь `_load_ts` — это время загрузки (в CSV оно называется `load_ts`).
#### Вариант B: загрузить CSV
Можно загрузить файл `dwh-modeling/data/customer_status_events.csv` в таблицу `stg.customer_status_raw`:
- **Через DBeaver**: Import Data → CSV → `stg.customer_status_raw` (колонку `load_ts` маппить в `_load_ts`).
- **Через `psql` в контейнере (`./psql_sh`)**: без установки `psql` на хост.
Способ: передайте CSV в `psql` через STDIN и выполните `\copy ... FROM STDIN`:
```bash
./postgres-bookings/psql_sh -c "TRUNCATE stg.customer_status_raw;"
cat dwh-modeling/data/customer_status_events.csv | ./postgres-bookings/psql_sh -c \
"\\copy stg.customer_status_raw(customer_id,status,event_ts,_load_id,_load_ts) FROM STDIN WITH (FORMAT csv, HEADER true)"
```
> 💡 Для части 5 (инкрементальная загрузка) `TRUNCATE stg.customer_status_raw` делать не нужно — загружайте только новые строки.
После загрузки убедитесь, что данные на месте:
```sql
SELECT * FROM stg.customer_status_raw LIMIT 10;
```
### 3.2. ODS: очистка и типизация
В файле `08_dml_hw_customer_status_template.sql` найдите заготовку блока ODS и допишите SQL:
- привести:
- `customer_id``INT`,
- `status``VARCHAR(20)` (можно оставить как есть),
- `event_ts` и `load_ts``TIMESTAMP` (в DWH-таблицах эта колонка будет лежать как `_load_ts`);
- аккуратно обработать возможные пустые значения (если бы они были);
- заполнить `_load_id` и `_load_ts` в `ods.customer_status`.
> 💡 Для простоты сделайте загрузку идемпотентной через `TRUNCATE ods.customer_status;` перед `INSERT` (таблица в ODS имеет первичный ключ `(customer_id, event_ts)`).
Проверьте, что в `ods.customer_status` данные выглядят аккуратно:
```sql
SELECT *
FROM ods.customer_status
ORDER BY customer_id, event_ts;
```
---
## 4. Часть 2 — ODS → DDS (SCD Type 2, обязательно)
**Задача:** по событиям в `ods.customer_status` построить измерение `dds.dim_customer_status`, где каждая строка — период действия статуса.
Целевая таблица уже создана (см. `07_ddl_hw_customer_status.sql`):
- `customer_bk` — бизнес-ключ клиента (тот же, что `customer_id` в ODS);
- `status` — статус клиента;
- `hashdiff` — хэш от атрибутов (здесь достаточно самого `status`);
- `valid_from` / `valid_to` — период, когда статус был актуален;
- `created_at` / `updated_at` — технические поля.
### 4.1. Начальная загрузка SCD2
В шаблоне `08_dml_hw_customer_status_template.sql` допишите блок начальной загрузки:
1. Сформируйте промежуточный набор:
- `customer_bk`,
- `status`,
- `event_ts` (как «время начала действия статуса»),
- `hashdiff` (например, `md5(status)`; можно вынести расчёт в отдельную функцию по аналогии с `dds.customer_hash` для клиентов).
2. Для каждого клиента отсортируйте события по `event_ts` и с помощью `LEAD()` посчитайте (в учебном варианте считаем, что обновление DWH идёт раз в день, поэтому используем `DATE`):
- `valid_from``event_ts::date`,
- `valid_to` — следующий `event_ts::date` (а у последней версии `valid_to = NULL`).
> 💡 Упрощение для домашки: считаем, что у клиента не бывает двух разных смен статуса в один и тот же день.
> Если такое бывает — удобнее строить периоды в `TIMESTAMP` (или вводить дополнительное правило сортировки), но это уже усложнение.
> 💡 Если в событиях встречаются повторы одного и того же статуса подряд, можно отфильтровать “не-изменения” через `LAG(status)` (или `LAG(hashdiff)`) перед расчётом `LEAD()`.
3. Вставьте получившиеся строки в `dds.dim_customer_status`. Актуальная строка для клиента — та, где `valid_to IS NULL`.
> 💡 Для первой версии решения можно сделать full refresh: перед вставкой очистить таблицу (`TRUNCATE dds.dim_customer_status;`), как в основном примере с `dds.dim_customer`.
```sql
INSERT INTO dds.dim_customer_status (
customer_bk, status, hashdiff,
valid_from, valid_to,
created_at, updated_at
)
SELECT
...
```
Проверьте результат:
```sql
SELECT *
FROM dds.dim_customer_status
ORDER BY customer_bk, valid_from;
```
Ожидаемое поведение:
- у клиента 101 несколько строк с разными статусами и непересекающимися периодами;
- `valid_to IS NULL` только у самой свежей строки для каждого клиента.
### 4.2. Проверка себя
Примеры проверочных запросов (можно придумать свои):
- «Какой статус был у клиента 101 на дату `2024-06-01`
→ одна строка с нужным статусом.
- «Сколько клиентов были в статусе `active` на `2024-04-10`
→ несколько строк, если статус *активен* для диапазона дат.
---
## 5. Часть 3 — инкрементальная загрузка (по желанию)
Если хочется потренироваться глубже:
1. Добавьте ещё несколько событий смены статуса (например, переход части клиентов из `churned` обратно в `active`).
- Можно дописать в исходный CSV самостоятельно.
- Либо взять готовую порцию “для инкремента” из файла `dwh-modeling/data/customer_status_events_increment.csv`.
2. Загрузите **только новые строки** в `stg.customer_status_raw` (не делайте `TRUNCATE`):
```bash
cat dwh-modeling/data/customer_status_events_increment.csv | ./postgres-bookings/psql_sh -c \
"\\copy stg.customer_status_raw(customer_id,status,event_ts,_load_id,_load_ts) FROM STDIN WITH (FORMAT csv, HEADER true)"
```
3. Напишите логику инкрементального обновления `dds.dim_customer_status`:
- ориентируйтесь на пример из `03_demo_increment.sql` для `dds.dim_customer`;
- важно:
- корректно «закрыть» старую актуальную строку (заполнить `valid_to` датой начала новой версии);
- вставить новую строку с `valid_to = NULL`.
Эта часть особенно полезна, если вы хотите почувствовать, как SCD2 живёт в реальном DWH.
---
## 6. Часть 4 — витрина в DM (по желанию)
Опциональное задание для закрепления: собрать небольшую витрину с количеством клиентов по статусам на каждую дату.
Перед началом убедитесь, что слой DM создан (схема `dm` и таблицы):
- выполните `dwh-modeling/sql/05_ddl_dm.sql` (один раз);
- затем можно собирать витрину.
Пример целевой таблицы:
```sql
CREATE TABLE dm.mart_customer_status_daily (
date_actual DATE NOT NULL,
status VARCHAR(20) NOT NULL,
customers_cnt INT NOT NULL
);
```
Идея:
- использовать `dds.dim_date` как календарь;
- для каждой `date_actual` найти, какой статус был у клиента в этот день
(через `JOIN` на `dds.dim_customer_status` по диапазону `valid_from/valid_to`);
- агрегировать по `status`.
Пример запроса к витрине:
```sql
SELECT
date_actual,
status,
customers_cnt
FROM dm.mart_customer_status_daily
WHERE date_actual BETWEEN '2024-04-01' AND '2024-04-30'
ORDER BY date_actual, status;
```
---
## 7. Как вписать эту домашку в обучение
Рекомендуемое место в дорожке:
1. Пройти основную теорию по DWH и SCD:
- `dwh-modeling/README.md`
- `dwh-modeling/SCD.md`
2. Разобрать базовый пример интернет‑магазина (скрипты `01_``06_`).
3. Выполнить **эту домашку** как первую попытку «самостоятельного» моделирования и ETL:
- познакомиться с ещё одним измерением с историей (`dim_customer_status`);
- потренироваться аккуратно работать с датами и периодами;
- при желании — собрать простую витрину в `dm`.
Если что‑то не получается — можно разбирать решения по шагам вместе с ментором: от простого `SELECT` из STG до полноценного SCD2 в DDS.
+848
View File
@@ -0,0 +1,848 @@
# Хранилище данных: как устроена аналитика «под капотом»
*Для тех, кто знает SQL, но хочет понять, как хранить данные не в Excel, а по-взрослому*
## Оглавление
- [Что вы уже умеете — и что узнаете здесь](#что-вы-уже-умеете-и-что-узнаете-здесь)
- [1. Введение: почему нельзя просто SELECT из базы заказов?](#1-введение-почему-нельзя-просто-select-из-базы-заказов)
- [2. Учебный пример: интернет-магазин](#2-учебный-пример-интернет-магазин)
- [3. Зачем делить DWH на слои?](#3-зачем-делить-dwh-на-слои)
- [4. Путешествие данных: от STG до DM](#4-путешествие-данных-от-stg-до-dm)
- [5. Базовые понятия: факты, измерения, SCD](#5-базовые-понятия-факты-измерения-scd)
- [6. Модели данных для слоя DDS: 4 подхода — и когда какой выбрать](#6-модели-данных-для-слоя-dds-4-подхода-и-когда-какой-выбрать)
- [7. Практикум: как собрать первую витрину](#7-практикум-как-собрать-первую-витрину)
- [8. Как выбрать модель данных? Советы от практиков](#8-как-выбрать-модель-данных-советы-от-практиков)
- [9. Эксплуатация: качество данных — это не «опция»](#9-эксплуатация-качество-данных-это-не-опция)
- [10. Заключение: главное — понимать «почему»](#10-заключение-главное-понимать-почему)
- [Приложения](#приложения)
---
## Что вы уже умеете — и что узнаете здесь
✅ Уже знаете:
- `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 / Bronze)** — «как пришло»
- Таблицы: `stg.orders_raw`, `stg.customers_raw`;
- Структура — *точно как в источнике* (может быть `VARCHAR` даже у дат);
- Добавлены технические поля:
- `_load_id` — идентификатор загрузки;
- `_load_ts` — время получения данных;
- Главное правило: **неизменяемость**. Если пришла новая порция — либо добавляем новые строки, либо *полностью перезагружаем* слой (идемпотентность).
> 💡 *Пример:* `stg.orders_raw` содержит `"2024-01-10"` как строку — это нормально. Главное — не потерять оригинал.
---
### **ODS (Operational Data Store / Silver)** — «почистили, но не трогали смысл»
- Таблицы: `ods.orders`, `ods.customers`;
- Здесь:
- привели `order_date` к типу `DATE`;
- убрали заказы без клиента (`customer_id IS NULL` → ошибка или флаг);
- привели телефоны к формату `79991112233`;
- проверили email на валидность (регуляркой или простой проверкой).
- **Но!** Не объединяем клиента из CRM и клиента из заказов — это будет позже.
- Пока — никакой бизнес-логики. Только *техническая* очистка.
- Дедупликация: если два раза пришёл один и тот же заказ — оставляем один (по `order_id + _load_ts`).
> 🎯 Цель ODS — дать «надёжную платформу» для следующего слоя. Как сухое, чистое бревно перед сборкой дома.
---
### **DDS (Data Delivery Store / Core / Conformed)** — «интеграция + история»
Здесь рождается *единая бизнес-модель*.
Появляются понятия: **измерения**, **факты**, **суррогатные ключи**, **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 / Gold/ «Витрины»)** — «готово к употреблению»
Здесь — таблицы и представления для конкретных задач:
- `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_date {
int date_key PK "YYYYMMDD"
date calendar_date "сама дата"
int year
int month
int day
varchar dow "день недели"
}
dim_customer {
bigint customer_sk PK "суррогатный ключ"
int customer_bk "бизнес-ключ, напр. 101"
varchar customer_name
varchar email
varchar city
date valid_from "SCD2: с какой даты запись актуальна"
date valid_to "SCD2: по какую дату актуальна (NULL = сейчас)"
}
dim_product {
bigint product_sk PK
varchar product_bk "код товара / артикул"
varchar product_name
varchar category
}
fact_sales {
bigint sale_id PK
int date_key FK "ссылка на dim_date.date_key"
bigint customer_sk FK
bigint product_sk FK
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 |
|-------------|-------------|-------|------|------------|----------|
| 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-16 |
| 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-10-01 |
| 1003 | 101 | b@ex.com | СПб | 2023-10-01 | NULL |
Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где:
```sql
fact_sales.order_date >= dim_customer.valid_from
AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid_to)
```
и получаем актуальный на тот день email и город.
> 🔍 Подробнее про SCD — в отдельной статье [Slow Changing Dimensions](SCD.md) (сравнение Type 1/2/3, паттерны обновления).
Теперь, когда мы разобрались, что такое факты, измерения и SCD, давайте посмотрим, как именно можно устроить слой DDS внутри — есть несколько вариантов.
---
## 6. Модели данных для слоя DDS: 4 подхода — и когда какой выбрать
В DDS мы можем хранить данные по-разному. Это не «правильно/неправильно», а **выбор под задачу**.
### 1. 3NF (третья нормальная форма)
*Источник: Билл Инмон (Bill Inmon)*
**Плюсы**:
- Минимум избыточности при строгих ключах и правилах дедупликации.
- Проще поддерживать единую терминологию и НСИ (reference data).
- Атрибуты и справочники легко расширять.
**Минусы**:
- Много JOIN даже для простых отчётов.
- Историчность (SCD2) усложняет таблицы.
- Новые источники дороже гармонизировать (привести к канону).
📌 **Когда выбирать**:
→ Корпоративные DWH, где важна *единая терминология* и *долгосрочная поддержка*.
→ Стабильные домены (финансы, НСИ, договоры) и умеренная динамика изменений.
---
### 2. Звезда (Star Schema)
*Источник: Ральф Кимболл (Ralph Kimball)*
**Плюсы**:
- **Простота**: факт + несколько «плоских» измерений;
- **Скорость**: BI-системы любят звезду — запросы пишутся за 5 минут;
- **Понятно бизнесу**: «продажи по товарам и клиентам» — это ровно то, что в таблицах.
**Минусы**:
- Дублирование: город будет повторяться в каждой строке клиента;
- Изменение структуры измерения — дорого (перестроить всю витрину).
📌 **Когда выбирать**:
→ Витрины (DM), а не ядро (DDS);
→ Начинающим командам и MVP;
→ Когда отчёты — главная цель.
---
### 3. Data Vault 2.0 — «конструктор Lego» для больших DWH
*Идея: Дэн Линстедт (Dan Linstedt). Цель — так организовать хранилище, чтобы можно было спокойно добавлять новые источники и хранить историю, не ломая старую модель.*
#### В чём идея, по-человечески
В Data Vault (DV) все сущности разлетаются по трём типам таблиц:
- **Hub (Хаб)** — *«кто/что это»*.
Только бизнес-ключ и технические поля: клиент, заказ, договор.
- **Link (Линк)** — *«как они связаны»*.
«Клиент сделал заказ», «договор относится к счёту».
- **Satellite (Сателлит)** — *«какие у них свойства и как они менялись»*.
Имя клиента, email, статус заказа, цены — всё с историей изменений.
💡 **Главная мысль:**
идентичность, связи и атрибуты живут **в разных таблицах**, поэтому:
- историю проще хранить;
- новые источники проще прикручивать;
- меньше шансов «сломать» старые отчёты.
---
#### Чем DV отличается от 3NF и Звезды
Если сильно упростить:
- В **3NF/Звезде** мы часто смешиваем:
- бизнес-ключ,
- текущие атрибуты,
- историю (SCD2)
— всё это в одной таблице измерения.
- В **Data Vault** это *разнесено*:
- Hub — только бизнес-ключ;
- Satellite — только атрибуты + история;
- Link — только связи между сущностями.
За это приходится платить сложностью модели и количеством таблиц. Зато DV хорошо выдерживает:
- много разнородных источников;
- «грязные» данные;
- жёсткие требования по аудиту и трассировке.
---
#### Raw Vault и Business Vault — два слоя
Часто говорят «Raw Vault» и «Business Vault». Грубо:
- **Raw Vault** — «как прилетело из источников».
Хабы, линкы и сателлиты, максимально близкие к исходным данным.
Задача: надёжно собрать и сохранить **полную историю**.
- **Business Vault** — «как удобно считать дальше».
На основе Raw Vault появляются:
- служебные таблицы (PIT, Bridge и т.п.),
- подготовленные представления под витрины и отчёты,
- бизнес-правила (например, что считать «активным клиентом»).
Дальше поверх этого уже строятся **обычные витрины в формате Звезды**, с которыми работают аналитики.
Если примерить это к классическим слоям `stg → ods → dds → dm`, то **очень грубо** можно думать так:
- `stg` всё равно остаётся как «приземление» (landing) из источников;
- **Raw Vault** по духу ближе к **ODS**: мало бизнес-логики, зато полная история и интеграция из разных систем;
- **Business Vault** ближе к **DDS**: здесь уже живут бизнес-правила и подготовка данных к витринам;
- `dm` по-прежнему остаётся витринами в формате Звезды, с которыми работают аналитики и BI.
Важно: это именно *аналогия для понимания*, а не жёсткое правило проектирования.
---
#### Когда DV вам, скорее всего, рано
Если у вас:
- 25 источников,
- небольшая команда (1–2 инженера + аналитик),
- задачи уровня «сделать первые отчёты»,
то **Data Vault почти наверняка избыточен**.
Чаще всего хватает связки:
> `stg → ods → dds (3NF или простая Звезда с SCD2) → dm (Звезда)`
---
#### Что важно запомнить из этой статьи
Для этой статьи достаточно:
- знать, что **Data Vault** — это способ строить хранилище как **конструктор из Hub/Link/Satellite**,
- понимать, что он нужен в первую очередь там, где:
- много систем-источников,
- нужна *полная* история и прозрачный аудит.
Детали (Raw vs Business Vault, PIT/Bridge, DV 1.0 vs 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` — таблицу, которую можно сразу подключить к 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 TD
%% STG
STG_PROD[stg.products_raw]
STG_CUST[stg.customers_raw]
STG_ORD[stg.orders_raw]
STG_ITEMS[stg.order_items_raw]
%% ODS
ODS_PROD[ods.products]
ODS_CUST[ods.customers]
ODS_ORD[ods.orders]
ODS_ITEMS[ods.order_items]
%% DDS
DIM_PROD[dds.dim_product]
DIM_CUST[dds.dim_customer]
DIM_DATE[dds.dim_date]
FACT_SALES[dds.fact_sales]
%% DM / BI
DM_SALES[dm.mart_daily_sales]
BI[BI / Power BI]
%% Потоки данных
STG_PROD --> ODS_PROD --> DIM_PROD --> DM_SALES
STG_CUST --> ODS_CUST --> DIM_CUST --> DM_SALES
STG_ORD --> ODS_ORD --> FACT_SALES --> DM_SALES
STG_ITEMS --> ODS_ITEMS --> FACT_SALES
DIM_DATE --> FACT_SALES
DM_SALES --> BI
```
### Готовые SQL-скрипты
Все необходимые скрипты для построения хранилища находятся в папке [`sql/`](sql/):
- [`01_ddl_stg-dds.sql`](sql/01_ddl_stg-dds.sql) — создание схем и таблиц (STG, ODS, DDS);
- [`02_dml_stg-dds.sql`](sql/02_dml_stg-dds.sql) — первичная загрузка данных и демонстрация SCD2 через полный пересчёт (`full backfill`) из STG;
- [`03_demo_increment.sql`](sql/03_demo_increment.sql) — пример инкрементальной загрузки и SCD2 по последнему снимку в ODS;
- [`04_validation.sql`](sql/04_validation.sql) — проверки качества данных;
- [`05_ddl_dm.sql`](sql/05_ddl_dm.sql) — создание витрин (Data Marts);
- [`06_dml_dm.sql`](sql/06_dml_dm.sql) — наполнение витрин данными.
### Пример 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 >= c.valid_from
AND (c.valid_to IS NULL OR f.order_date < c.valid_to) -- SCD!
GROUP BY d.date_actual, p.product_name, c.customer_segment;
```
> 💡 **Материализованное представление (MATERIALIZED VIEW)** — это «кэш» результата. Обновляется по расписанию (например, ночью).
---
## 8. Как выбрать модель данных? Советы от практиков
Выбор модели — **не техническая задача, а стратегическая**.
Это как решать: строить дом из кирпича, дерева или SIP-панелей. У каждой технологии — свои плюсы, но **главное — подходит ли она *вам* сегодня**.
### 🔹 Главное, что нужно понять новичку:
> **Не существует «самой правильной» модели.**
> Есть **самая подходящая под ваш контекст** — и он у всех разный.
---
### 🛑 Что делать **не стоит** (если опыта ещё мало):
| Что делать не стоит | Почему |
|---------------------|--------|
| **Брать Data Vault «потому что модно»** | DV требует глубокого понимания интеграции, CDC, идемпотентности. Без этого легко получить «историю», в которой невозможно найти актуальные данные. |
| **Строить сложную 3NF «как в книжках» под 10 таблиц** | Если у вас 2–3 источника — вы потратите недели на нормализацию, чтобы потом делать 5 JOIN’ов ради простого отчёта. |
| **Пытаться «сделать сразу гибко на 5 лет вперёд»** | Гибкость = сложность. А сложность = баги, задержки, выгорание команды. |
---
### ✅ Базовые советы — с чего начать, если вы учитесь или делаете первый DWH
1. **Начните с витрины в формате Звезды (Star Schema).**
— Это просто: одна таблица фактов + несколько «плоских» измерений.
— Это быстро: отчёт в BI — за 10 минут.
— Это понятно: даже менеджер поймёт структуру.
2. **Стройте DDS только когда это *действительно нужно*.**
— Если источников ≤ 3 и они стабильны — можно идти `ods → dm` напрямую.
— Если появляются расхождения («email в CRM и в заказах — разные») — тогда заводите `dds.dim_customer` и другие общие сущности.
3. **Историю (SCD) включайте *постепенно*.**
— Сначала — без истории (Type 1: просто обновляете строку).
— Потом — только для ключевых сущностей (клиент, товар, договор).
— Только потом — думайте про DV или полную историзацию всего.
4. **Если сомневаетесь — спросите: «А кто будет этим пользоваться?»**
Выбор модели начинается не с технологий, а с вопроса: **кто будет работать с результатом?**
Это как выбрать инструмент в мастерской: для гвоздей — молоток, для саморезов — отвёртка.
Вот как это выглядит на практике:
**Аналитик в Metabase / Looker Studio****Звезда (Star Schema)**
Почему: ему нужны готовые метрики без сложных JOIN’ов. Звезда даёт понятные таблицы: «продажи по дням и товарам» — без углубления в атомарные сущности.
**BI-разработчик в Power BI / Tableau****Звезда**
Почему: все инструменты визуализации оптимизированы под star schema. Один факт + несколько измерений = быстрые отчёты и простою модель.
**Инженер ML (Data Scientist / ML-инженер)****3NF или сырые ODS-таблицы**
Почему: для фичей нужны атомарные события и детальные атрибуты. Машинное обучение ценит полноту и детализацию данных больше, чем удобство отчётов.
**Юрист, финансовый контролёр, аудитор / регулятор****3NF с SCD Type 2, иногда + DV в ядре**
Почему: им нужна доказуемая история изменений, но не инфраструктурная сложность DV на каждый чих.
Обычно достаточно хранить аудит-историю по ключевым сущностям (клиенты, договоры, счета) в формате 3NF + SCD Type 2.
**Data Vault имеет смысл только если** у вас 10+ разнородных источников и жёсткие требования по аудиту и трассировке.
💡 **Золотое правило:**
«**Собирай данные как DV (максимально детально), показывай как Звезду (максимально просто).**»
На начальных этапах вам почти всегда хватит **SCD Type 2 в рамках 3NF/Звезды**.
**Data Vault** нужен тогда, когда основная боль — интеграция множества систем и аудит, а не «первый отчёт для маркетинга».
---
### 💡 Ещё один совет от практиков
> **Лучше сделать простую модель — и вовремя переделать,**
> чем сделать «идеальную» — и застрять на этапе проектирования.
Переделать Звезду → Звезду с SCD Type 2 — относительно легко.
Переделать «недоделанный DV» → что-то рабочее — в разы сложнее.
---
### 📌 Кратко — что выбрать *сегодня*, если вы только учитесь
| У вас… | Делайте… |
|--------|----------|
| Учебный проект, 1–2 CSV | `ods → dm` по модели **Звезда** (без DDS, без истории) |
| Первый рабочий DWH, 3–5 источников | `stg → ods → dds (3NF или простая Звезда) → dm (Звезда)` |
| Команда из 1 инженера + 1 аналитика | **Не трогайте DV и Anchor** — они «съедят» ваше время без отдачи |
А когда наберётесь опыта — приходите в DV. Он того стоит. Но *не раньше времени*.
---
## 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 → доработка → новые метрики.
---
## Приложения
### Дополнительные материалы и практика
- [Домашка: статусы клиента от STG до DDS (и немного DM)](Homework_Customer_Status_DDS_DM.md)
- [SCD: как хранить историю изменений](SCD.md)
- [DataVault: как пережить бурную жизнь источников](DataVault.md)
### 📚 Мини-глоссарий (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** | История «отвязана» от бизнеса: удалили клиента — и вся его история исчезла |
---
### Мини-датасет (для практики)
Все данные для практики находятся в папке [`data/`](data/) — тренируйтесь:
[`customers.csv`](data/customers.csv):
```csv
customer_id,email,phone,city,event_ts,_load_id,load_ts
101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
```
[`orders.csv`](data/orders.csv):
```csv
order_id,order_date,customer_id
5001,2024-01-10,101
5002,2024-02-05,102
```
[`order_items.csv`](data/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`](data/products.csv):
```csv
product_id,name
9001,Phone
9002,Case
```
[`prices.csv`](data/prices.csv):
```csv
product_id,valid_from,valid_to,price
9001,2023-12-01,2024-01-31,100
9001,2024-02-01,,110
9002,2023-01-01,,50
```
> 📂 Все SQL-скрипты для построения хранилища находятся в папке [`sql/`](sql/).
---
### DDL-скелеты (PostgreSQL)
Полные DDL-скрипты для всех слоёв хранилища находятся в файле [`01_ddl_stg-dds.sql`](sql/01_ddl_stg-dds.sql).
Пример структуры основных таблиц DDS:
```sql
-- DDS: измерение клиента (SCD Type 2)
CREATE TABLE dds.dim_customer (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_bk INT NOT NULL, -- напр. 101
email VARCHAR(100),
phone VARCHAR(20),
city VARCHAR(50),
valid_from DATE NOT NULL,
valid_to DATE
);
-- 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`.
+425
View File
@@ -0,0 +1,425 @@
# Медленно меняющиеся измерения (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` — значит, версия **актуальна сейчас**.
Пример структуры:
```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 = сейчас)
);
```
**Шаг 1.** Добавляем начальную запись (клиент зарегистрировался 1 января 2024):
```sql
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'Regular', DATE '2024-01-01', NULL);
```
**Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**:
1. **Закрываем старую запись**: указываем дату начала новой версии (интервал `[valid_from, valid_to)`).
2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна.
```sql
-- 1. Завершаем предыдущую версию
UPDATE customers_scd2
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL;
-- 2. Вставляем новую версию
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
VALUES (1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL);
```
Теперь в таблице две строки для одного клиента. И мы можем спросить:
> Какой была категория клиента **на 10 апреля 2025**?
```sql
SELECT category
FROM customers_scd2
WHERE customer_id = 1
AND DATE '2025-04-10' >= valid_from
AND (valid_to IS NULL OR DATE '2025-04-10' < valid_to);
```
Результат: `'Regular'` — правильно!
> 💡 Почему не `BETWEEN valid_from AND valid_to`?
> Потому что у актуальной записи `valid_to IS NULL`. В SQL сравнения с `NULL` не дают `TRUE`, поэтому для “текущей” версии обычно пишут `valid_to IS NULL OR ...`.
### 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 valid_to IS NULL
)
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
SELECT 1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL
WHERE EXISTS (
SELECT 1 FROM last_version WHERE category != 'VIP'
);
-- Шаг 2: если вставка произошла — закрываем старую запись
UPDATE customers_scd2
SET valid_to = DATE '2025-04-15'
WHERE customer_id = 1 AND valid_to IS NULL
AND EXISTS (
SELECT 1 FROM customers_scd2
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = DATE '2025-04-15'
);
```
На практике такие логики чаще выносят в **ETL-инструменты** (например, dbt с пакетом `dbt-scd`), потому что чистый SQL быстро становится громоздким.
> 💡 Главное: **SCD Type 2 — это не одна операция, а процесс**: сравнить → закрыть старое → добавить новое.
---
В учебном проекте из папки `dwh-modeling/sql/` эти идеи можно увидеть «вживую»:
- в [`02_dml_stg-dds.sql`](sql/02_dml_stg-dds.sql) собирается полная история клиентов (SCD2) из всех событий в `stg.customers_raw` — это пример **первичной загрузки** / `full backfill`;
- в [`03_demo_increment.sql`](sql/03_demo_increment.sql) реализован **инкрементальный SCD2**: в одной транзакции добавляются новые версии клиентов из снимка `ods.customers` и закрываются предыдущие актуальные строки в `dds.dim_customer`.
---
#### А что, если СУБД не позволяет 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) в системах без встроенной поддержки этой функции.
> **Почему не использовать флаг «текущая версия»?**
> В append-only системах любой флаг актуальности становится устаревшим сразу после новой вставки. Без `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` для актуальной записи, если это допустимо в вашей СУБД — это упрощает модель.
- Для условий “актуально на дату” используйте паттерн: `d >= valid_from AND (valid_to IS NULL OR d < valid_to)`.
- Type 2 увеличивает объём данных — но для аналитики это нормально.
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
---
## 7. Заключение
Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени.
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своей БД. Задайте себе вопрос:
> «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
Если нет — вам нужен SCD Type 2.
И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.
+28
View File
@@ -0,0 +1,28 @@
# TODO: dwh-modeling
## SCD2 (dim_customer / dim_customer_status)
- Добавить в `02_dml_stg-dds.sql` (блок SCD2 backfill) и `03_demo_increment.sql` (incremental) явные допущения:
- гранулярность `DATE` (daily-grain), интервалы `[valid_from, valid_to)`, current = `valid_to IS NULL`;
- предполагаем **не более одного изменения в день** на BK (иначе нужен `TIMESTAMP`/sequence);
- `valid_from` берём как **effective date**: `COALESCE(event_ts, _load_ts)::date` (и почему так);
- late-arriving/backdated события в демо **не обрабатываются** (что будет “в проде”).
- Коротко документировать “effective time vs load time”:
- `event_ts` = когда изменение произошло в источнике;
- `_load_ts` = когда событие попало в DWH;
- `valid_from/valid_to` строим по effective time, а `_load_id/_load_ts` используем для трассировки/аудита.
- (Опционально) Добавить микросекцию “как читать CTE” в SCD2-блоках: что делает `src → ordered → changes → framed`.
## Вариант с TIMESTAMP (advanced, под вопросом)
- Подумать над отдельным примером SCD2 с `valid_from_ts/valid_to_ts TIMESTAMP`:
- кейс “несколько изменений в один день”;
- корректная обработка одинаковых `event_ts` (tie-breaker: `_load_ts`/`_load_id`);
- влияние на join фактов (условие по `[from,to)`).
- Зафиксировать: показываем как “опционально/advanced”, чтобы не пугать на базовом треке.
## Greenplum (после Postgres-трека)
- Отдельно проговорить практику для больших объёмов:
- обновления SCD2 в GP могут быть дорогими; обсудить паттерны (partitioning/append-only/минимизация UPDATE);
- какие поля выбирать для распределения и сортировки таблиц измерений/фактов (на уровне рекомендаций).
@@ -0,0 +1,9 @@
customer_id,status,event_ts,_load_id,load_ts
101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
101,active,2024-02-15 10:30:00,batch_20240215_1100,2024-02-15 11:00:00
101,vip,2024-05-10 11:00:00,batch_20240510_1200,2024-05-10 12:00:00
101,churned,2024-09-01 12:15:00,batch_20240901_1300,2024-09-01 13:00:00
102,new,2024-03-05 14:00:00,batch_20240305_1500,2024-03-05 15:00:00
102,active,2024-04-01 09:45:00,batch_20240401_1000,2024-04-01 10:00:00
102,churned,2024-04-20 16:20:00,batch_20240420_1700,2024-04-20 17:00:00
103,new,2024-03-10 10:10:00,batch_20240310_1100,2024-03-10 11:00:00
1 customer_id status event_ts _load_id load_ts
2 101 new 2024-01-01 09:00:00 batch_20240101_1000 2024-01-01 10:00:00
3 101 active 2024-02-15 10:30:00 batch_20240215_1100 2024-02-15 11:00:00
4 101 vip 2024-05-10 11:00:00 batch_20240510_1200 2024-05-10 12:00:00
5 101 churned 2024-09-01 12:15:00 batch_20240901_1300 2024-09-01 13:00:00
6 102 new 2024-03-05 14:00:00 batch_20240305_1500 2024-03-05 15:00:00
7 102 active 2024-04-01 09:45:00 batch_20240401_1000 2024-04-01 10:00:00
8 102 churned 2024-04-20 16:20:00 batch_20240420_1700 2024-04-20 17:00:00
9 103 new 2024-03-10 10:10:00 batch_20240310_1100 2024-03-10 11:00:00
@@ -0,0 +1,5 @@
customer_id,status,event_ts,_load_id,load_ts
101,active,2024-11-15 09:00:00,batch_20241115_1000,2024-11-15 10:00:00
102,active,2024-05-05 09:30:00,batch_20240505_1000,2024-05-05 10:00:00
103,active,2024-03-20 12:00:00,batch_20240320_1300,2024-03-20 13:00:00
104,new,2024-06-01 08:00:00,batch_20240601_0900,2024-06-01 09:00:00
1 customer_id status event_ts _load_id load_ts
2 101 active 2024-11-15 09:00:00 batch_20241115_1000 2024-11-15 10:00:00
3 102 active 2024-05-05 09:30:00 batch_20240505_1000 2024-05-05 10:00:00
4 103 active 2024-03-20 12:00:00 batch_20240320_1300 2024-03-20 13:00:00
5 104 new 2024-06-01 08:00:00 batch_20240601_0900 2024-06-01 09:00:00
+5
View File
@@ -0,0 +1,5 @@
customer_id,email,phone,city,event_ts,_load_id,load_ts
101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
1 customer_id email phone city event_ts _load_id load_ts
2 101 a@ex.com 700 Москва 2024-01-01 batch_20240101_0800 2024-01-01 08:00
3 102 c@ex.com 701 СПб 2024-01-01 batch_20240101_0800 2024-01-01 08:00
4 101 b@ex.com 700 Москва 2024-05-16 batch_20240516_0800 2024-05-16 08:00
5 101 b@ex.com 700 Санкт-Петербург 2024-10-01 batch_20241001_0800 2024-10-01 08:00
+4
View File
@@ -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
1 order_item_id order_id product_id qty price_at_sale
2 1 5001 9001 2 100.00
3 2 5001 9002 1 50.00
4 3 5002 9001 1 100.00
+3
View File
@@ -0,0 +1,3 @@
order_id,order_date,customer_id
5001,2024-01-10,101
5002,2024-02-05,102
1 order_id order_date customer_id
2 5001 2024-01-10 101
3 5002 2024-02-05 102
+4
View File
@@ -0,0 +1,4 @@
product_id,valid_from,valid_to,price
9001,2023-12-01,2024-01-31,100.00
9001,2024-02-01,,110.00
9002,2023-01-01,,50.00
1 product_id valid_from valid_to price
2 9001 2023-12-01 2024-01-31 100.00
3 9001 2024-02-01 110.00
4 9002 2023-01-01 50.00
+3
View File
@@ -0,0 +1,3 @@
product_id,name
9001,Phone
9002,Case
1 product_id name
2 9001 Phone
3 9002 Case
+155
View File
@@ -0,0 +1,155 @@
-- ===============================================
-- 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: сырые данные (как пришли)
DROP TABLE IF EXISTS stg.customers_raw;
CREATE TABLE stg.customers_raw (
customer_id TEXT, -- может быть строкой или числом
email TEXT,
phone TEXT,
city TEXT,
event_ts TEXT,
_load_id TEXT, -- идентификатор загрузки (обязательно!)
_load_ts TIMESTAMP DEFAULT NOW() -- время получения в DWH
);
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: очищенные данные
DROP TABLE IF EXISTS ods.customers;
CREATE TABLE ods.customers (
customer_id INT NOT NULL, -- привели к INT
email VARCHAR(100),
phone VARCHAR(20),
city VARCHAR(50),
event_ts TIMESTAMP,
_load_id TEXT NOT NULL, -- сохраняем для отладки и SCD
_load_ts TIMESTAMP NOT NULL -- время загрузки (копия из STG)
);
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: справочник дат (ключ — суррогатный date_key)
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 BIGSERIAL PRIMARY KEY,
customer_bk INT NOT NULL, -- бизнес-ключ
email TEXT,
phone TEXT,
city TEXT,
hashdiff TEXT NOT NULL, -- md5 по нормализованным атрибутам
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- одна версия на момент времени (на одну пару BK+valid_from)
ALTER TABLE dds.dim_customer
ADD CONSTRAINT uq_dim_customer_bk_from UNIQUE (customer_bk, valid_from);
-- ускорители
CREATE INDEX ix_dim_customer_bk_current ON dds.dim_customer (customer_bk) WHERE valid_to IS NULL;
CREATE INDEX ix_dim_customer_bk_from_to ON dds.dim_customer (customer_bk, valid_from, valid_to);
-- 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);
-- Через md5 по нормализованным атрибутам
CREATE OR REPLACE FUNCTION dds.customer_hash(email TEXT, phone TEXT, city TEXT)
RETURNS TEXT LANGUAGE sql IMMUTABLE AS $$
SELECT md5(
concat_ws('||',
lower(coalesce(trim(email), '')),
lower(coalesce(trim(phone), '')),
lower(coalesce(trim(city), ''))
)
);
$$;
+216
View File
@@ -0,0 +1,216 @@
-- ===============================================
-- 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;
-- STG (пример вставки с метками времени)
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '101','a@ex.com','700','Москва'),
('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '102','c@ex.com','701','СПб'),
('batch_20240516_0800', '2024-05-16 08:00', '2024-05-16', '101','b@ex.com','700','Москва'),
('batch_20241001_0800', '2024-10-01 08:00', '2024-10-01', '101','b@ex.com','700','Санкт-Петербург');
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
('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 (INSERT ... ON CONFLICT DO UPDATE) или incremental load, не TRUNCATE+INSERT
TRUNCATE ods.customers, ods.orders, ods.order_items, ods.products;
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+$';
-- берём по BK самую позднюю запись (по дате события, иначе по дате загрузки)
WITH src AS (
SELECT
s.customer_id::INT AS customer_id,
NULLIF(trim(s.email), '') AS email,
NULLIF(trim(s.phone), '') AS phone,
NULLIF(trim(s.city), '') AS city,
NULLIF(s.event_ts, '')::timestamp AS event_ts,
s._load_id,
s._load_ts,
COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date
FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$'
),
ranked AS (
SELECT
customer_id, email, phone, city, event_ts, _load_id, _load_ts,
row_number() OVER (PARTITION BY customer_id ORDER BY eff_date DESC, _load_ts DESC) AS rn
FROM src
)
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
FROM ranked
WHERE rn = 1;
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')::DATE -- ← приведение к DATE
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,
TRIM(TO_CHAR(d, 'Day')), -- ← TRIM — убрать trailing space
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 — первичная загрузка SCD2 (full backfill из STG)
-- В ЭТОМ ДЕМО: dim_customer строится напрямую из stg.customers_raw, который играет роль
-- устойчивого event-лога (все события по клиенту в одном месте).
-- Это удобно для учебной первичной загрузки (full backfill), когда мы один раз
-- восстанавливаем всю историю клиента.
-- В РЕАЛЬНОМ DWH: так делают редко. Исторические измерения обычно строят
-- поверх очищенных и нормализованных слоёв (ODS / PSA / Data Vault).
-- Для примера инкрементальной заливки SCD2 по снимку из ODS см. 03_demo_increment.sql и SCD.md.
--
-- Идея SCD2 простыми словами:
-- - одна строка = один период, когда атрибуты клиента (email/phone/city) были одинаковыми;
-- - valid_from = дата, когда "стало так";
-- - valid_to = дата следующего изменения (NULL = текущая версия).
--
-- Откуда берём дату изменения:
-- - если в событии есть event_ts — считаем, что изменение произошло тогда;
-- - если event_ts пустой — берём дату загрузки (_load_ts), чтобы не терять историю.
--
-- Важно для демо: считаем, что у клиента не бывает двух разных изменений в один и тот же день.
TRUNCATE dds.dim_customer, dds.fact_sales;
WITH src AS ( -- 1) Приводим типы, готовим дату изменения (eff_date) и считаем hashdiff атрибутов
SELECT
s.customer_id::INT AS customer_bk,
NULLIF(trim(s.email), '') AS email,
NULLIF(trim(s.phone), '') AS phone,
NULLIF(trim(s.city), '') AS city,
COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date,
dds.customer_hash(s.email, s.phone, s.city) AS hashdiff
FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$'
),
ordered AS ( -- 2) Сортируем по датам и смотрим "какой hashdiff был до этого" (LAG)
SELECT *,
lag(hashdiff) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS prev_hash
FROM src
),
changes AS ( -- 3) Оставляем только первое состояние и реальные изменения (где hashdiff поменялся)
SELECT *
FROM ordered
WHERE prev_hash IS DISTINCT FROM hashdiff OR prev_hash IS NULL
),
framed AS ( -- 4) Превращаем изменения в периоды: valid_to = дата следующего изменения (LEAD)
SELECT
customer_bk, email, phone, city, hashdiff,
eff_date AS valid_from,
lead(eff_date) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS valid_to
FROM changes
)
INSERT INTO dds.dim_customer (
customer_bk, email, phone, city, hashdiff,
valid_from, valid_to,
created_at, updated_at
)
SELECT
customer_bk, email, phone, city, hashdiff,
valid_from,
valid_to,
now(), now()
FROM framed
ORDER BY customer_bk, valid_from;
-- 6. DDS: fact_sales — загрузка фактов с учётом SCD
-- В продакшене — фильтруем по диапазону дат (инкрементально)
--TRUNCATE 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 >= dc.valid_from
AND (dc.valid_to IS NULL OR o.order_date < dc.valid_to);
-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки)
-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды
SELECT 'dim_customer current = ' || COUNT(*) FROM dds.dim_customer WHERE valid_to IS NULL;
SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales;
+149
View File
@@ -0,0 +1,149 @@
-- ===============================================
-- 03_demo_increment.sql
-- Имитация новых событий + инкрементальный SCD2
-- ===============================================
-- 0. Новые события в STG (пример)
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
('batch_20241101_0800', '2024-11-01 08:00', '2024-11-01', '101','b@ex.com','700','Москва'), -- город вернулся
('batch_20240310_0800', '2024-03-10 08:00', '2024-03-10', '103','d@ex.com','702','Казань'); -- новый клиент
-- 1) UPSERT в ODS (вставка с обновлением по конфликту, INSERT ... ON CONFLICT DO UPDATE):
-- сохраняем в ods.customers последнюю версию клиента по BK (бизнес-ключ = customer_id)
WITH src AS (
SELECT
s.customer_id::INT AS customer_id,
NULLIF(trim(s.email), '') AS email,
NULLIF(trim(s.phone), '') AS phone,
NULLIF(trim(s.city), '') AS city,
NULLIF(s.event_ts,'')::timestamp AS event_ts,
s._load_id,
s._load_ts,
-- eff_ts нужен, чтобы выбрать "самое свежее" событие по клиенту:
-- если event_ts нет, используем время загрузки (_load_ts) как приближение.
COALESCE(NULLIF(s.event_ts,'')::timestamp, s._load_ts) AS eff_ts
FROM stg.customers_raw s
WHERE s.customer_id ~ '^\d+$'
),
ranked AS (
SELECT
customer_id, email, phone, city, event_ts, _load_id, _load_ts,
-- берём одну строку на клиента: с максимальным eff_ts (при равенстве — с максимальным _load_ts)
row_number() OVER (PARTITION BY customer_id ORDER BY eff_ts DESC, _load_ts DESC) AS rn
FROM src
)
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
FROM ranked
WHERE rn = 1
ON CONFLICT (customer_id) DO UPDATE
SET email = EXCLUDED.email,
phone = EXCLUDED.phone,
city = EXCLUDED.city,
event_ts= EXCLUDED.event_ts,
_load_id= EXCLUDED._load_id,
_load_ts= EXCLUDED._load_ts
-- апдейтим только если пришло более «свежее» событие
WHERE COALESCE(EXCLUDED.event_ts, EXCLUDED._load_ts) >
COALESCE(ods.customers.event_ts, ods.customers._load_ts);
-- 2) Инкрементальное SCD2 из ODS (в одной транзакции, по последнему снимку в ODS)
-- Канон для курса: считаем по дням (valid_from/valid_to — DATE), интервалы [valid_from, valid_to),
-- текущая версия = valid_to IS NULL
-- Предполагаем, что для клиента нет нескольких изменений в один день.
--
-- Идея (по шагам):
-- 1) Берём текущий "снимок" клиента из ODS (одна строка на BK = customer_id).
-- 2) Сравниваем его с текущей версией в dds.dim_customer (valid_to IS NULL) по hashdiff.
-- 3) Если изменилось — закрываем текущую версию (ставим valid_to) и вставляем новую (valid_to = NULL).
--
-- Про даты:
-- eff_date берём из event_ts, а если его нет — из _load_ts (как приближение).
BEGIN;
-- 2.1) Закрываем предыдущую актуальную версию (только если реально изменились атрибуты)
-- delta/current считаем прямо в запросе (без временных таблиц) специально для читабельности.
WITH delta AS (
SELECT
c.customer_id AS customer_bk,
c.email, c.phone, c.city,
COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
FROM ods.customers c
),
current AS (
SELECT d.*
FROM dds.dim_customer d
WHERE d.valid_to IS NULL
)
UPDATE dds.dim_customer d
SET valid_to = x.eff_date,
updated_at = now()
FROM (
-- x = кандидаты на "закрытие" текущей версии:
-- клиент есть в DDS (current) и атрибуты изменились (hashdiff стал другим).
SELECT
t.customer_bk,
t.eff_date,
c.customer_sk
FROM delta t
JOIN current c
ON c.customer_bk = t.customer_bk
WHERE c.hashdiff <> t.hashdiff
AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины
) x
WHERE d.customer_sk = x.customer_sk
AND d.valid_to IS NULL;
-- 2.2) Вставляем новую версию (только если новая или изменившаяся)
-- delta/current повторяем ещё раз отдельно, чтобы блок вставки читался независимо от блока UPDATE.
WITH delta AS (
SELECT
c.customer_id AS customer_bk,
c.email, c.phone, c.city,
COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
FROM ods.customers c
),
current AS (
SELECT d.*
FROM dds.dim_customer d
WHERE d.valid_to IS NULL
),
to_insert AS (
-- to_insert = кандидаты на вставку:
-- 1) новый клиент (в current нет строки);
-- 2) изменившийся клиент (hashdiff поменялся).
-- если атрибуты не менялись — клиент сюда не попадёт, и ничего делать не нужно.
SELECT
t.customer_bk,
t.email,
t.phone,
t.city,
t.hashdiff,
t.eff_date
FROM delta t
LEFT JOIN current c
ON c.customer_bk = t.customer_bk
WHERE c.customer_sk IS NULL
OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from)
)
INSERT INTO dds.dim_customer (
customer_bk, email, phone, city, hashdiff,
valid_from, valid_to,
created_at, updated_at
)
SELECT
t.customer_bk, t.email, t.phone, t.city, t.hashdiff,
t.eff_date, NULL,
now(), now()
FROM to_insert t
-- защита от повторного запуска: не вставляем одну и ту же версию (BK + valid_from) второй раз
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_customer d
WHERE d.customer_bk = t.customer_bk
AND d.valid_from = t.eff_date
);
COMMIT;
-- (факты можно не перезаливать — даты заказов не поменялись)
+73
View File
@@ -0,0 +1,73 @@
-- ===============================================
-- Проверки качества данных после загрузки STG→ODS→DDS
-- Запускается после 02_dml_stg-dds.sql (и, при необходимости, 03_demo_increment.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. Проверка: каждая строка из ODS попала в DDS-факт
-- Сравниваем количество строк в ods.order_items и dds.fact_sales
DO $$
DECLARE
expected_count bigint;
actual_count bigint;
BEGIN
SELECT COUNT(*) INTO expected_count FROM ods.order_items;
SELECT COUNT(*) INTO actual_count FROM dds.fact_sales;
--
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. Проверка SCD2 (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 '✅ SCD2: клиент 101 имеет % версий — история сохранена', version_count;
END $$;
-- 4. Проверка SCD2 (Type 2): у каждого клиента ровно одна актуальная версия (valid_to IS NULL)
DO $$
DECLARE
customers_cnt BIGINT;
current_cnt BIGINT;
BEGIN
SELECT COUNT(DISTINCT customer_bk) INTO customers_cnt FROM dds.dim_customer;
SELECT COUNT(*) INTO current_cnt
FROM dds.dim_customer
WHERE valid_to IS NULL;
--
ASSERT current_cnt = customers_cnt,
FORMAT('ОШИБКА: актуальных строк %s, а уникальных клиентов %s (ожидается 1 current на клиента)',
current_cnt, customers_cnt);
RAISE NOTICE '✅ SCD2: current-строки = количеству клиентов (%)', current_cnt;
END $$;
-- 5. Проверка SCD2 (Type 2): периоды корректны (valid_to > valid_from или valid_to IS NULL)
DO $$
BEGIN
ASSERT NOT EXISTS (
SELECT 1
FROM dds.dim_customer
WHERE valid_to IS NOT NULL
AND valid_to <= valid_from
),
'ОШИБКА: найдены строки dim_customer с некорректным периодом (valid_to <= valid_from)';
RAISE NOTICE '✅ SCD2: периоды valid_from/valid_to корректны';
END $$;
+34
View File
@@ -0,0 +1,34 @@
-- ===============================================
-- DDL: Data Marts (DM)
-- Слой "готовых решений" — для BI, отчётов, API
-- ===============================================
DROP SCHEMA IF EXISTS dm CASCADE;
CREATE SCHEMA dm;
-- Витрина: ежедневные продажи по товарам и клиентам
-- Гранулярность: 1 строка = 1 день × 1 товар × 1 сегмент клиента
CREATE TABLE dm.mart_daily_sales (
date_actual DATE NOT NULL,
product_name VARCHAR(100) NOT NULL,
customer_segment VARCHAR(20) NOT NULL, -- напр. 'Premium', 'Basic'
total_qty INT NOT NULL,
total_revenue NUMERIC(18,2) NOT NULL
);
-- Витрина: 360°-портрет клиента (lifetime value)
CREATE TABLE dm.mart_customer_360 (
customer_bk INT NOT NULL,
first_order_date DATE,
last_order_date DATE,
total_orders INT NOT NULL,
total_items INT NOT NULL,
lifetime_value NUMERIC(18,2) NOT NULL,
last_email VARCHAR(100),
last_city VARCHAR(50)
);
-- Индексы для ускорения BI (опционально, но рекомендовано)
CREATE INDEX ON dm.mart_daily_sales (date_actual);
CREATE INDEX ON dm.mart_daily_sales (product_name);
CREATE INDEX ON dm.mart_customer_360 (customer_bk);
+59
View File
@@ -0,0 +1,59 @@
-- ===============================================
-- DML: построение Data Marts из DDS
-- Идемпотентно: можно пересобирать в любое время
-- ===============================================
-- 1. Очистка (full refresh — для простоты; в продакшене — incremental)
TRUNCATE dm.mart_daily_sales, dm.mart_customer_360;
-- 2. mart_daily_sales: продажи по датам и товарам
-- Одна строка = дата × товар × простой сегмент заказа
INSERT INTO dm.mart_daily_sales (
date_actual, product_name, customer_segment, total_qty, total_revenue
)
SELECT
d.date_actual,
p.product_name,
-- Делим заказы на Premium / Basic по сумме
CASE
WHEN f.amount >= 200 THEN 'Premium'
ELSE 'Basic'
END AS customer_segment,
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 -- факт уже ссылается на нужную версию клиента
GROUP BY d.date_actual, p.product_name,
CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END;
-- 3. mart_customer_360: 360‑портрет клиента
-- Одна строка = один клиент
-- Считаем суммы по всей истории его покупок
INSERT INTO dm.mart_customer_360 (
customer_bk, first_order_date, last_order_date,
total_orders, total_items, lifetime_value,
last_email, last_city
)
SELECT
c.customer_bk,
MIN(d.date_actual) AS first_order_date,
MAX(d.date_actual) AS last_order_date,
COUNT(DISTINCT f.sale_id) AS total_orders, -- считаем строки факта (продажи), не бизнес-заказы
SUM(f.quantity) AS total_items,
SUM(f.amount) AS lifetime_value,
-- Берём самый свежий email и город клиента
(SELECT email FROM dds.dim_customer c2
WHERE c2.customer_bk = c.customer_bk
ORDER BY c2.valid_from DESC
LIMIT 1) AS last_email,
(SELECT city FROM dds.dim_customer c2
WHERE c2.customer_bk = c.customer_bk
ORDER BY c2.valid_from DESC
LIMIT 1) AS last_city
FROM dds.fact_sales f
JOIN dds.dim_date d ON f.date_key = d.date_key
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk
-- Здесь не фильтруем по valid_to: нужна вся история фактов
GROUP BY c.customer_bk;
@@ -0,0 +1,48 @@
-- ===============================================
-- DDL: дополнительные таблицы для домашки
-- Тема: статусы клиента (SCD2 поверх статуса)
-- Скрипт можно запускать после 01_ddl_stg-dds.sql
-- ===============================================
-- 1. STG: сырые события о статусе клиента из CRM
DROP TABLE IF EXISTS stg.customer_status_raw;
CREATE TABLE stg.customer_status_raw (
customer_id TEXT,
status TEXT,
event_ts TEXT,
_load_id TEXT,
_load_ts TIMESTAMP DEFAULT NOW()
);
-- 2. ODS: очищенные и типизированные статусы
DROP TABLE IF EXISTS ods.customer_status;
CREATE TABLE ods.customer_status (
customer_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
event_ts TIMESTAMP NOT NULL,
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL
);
ALTER TABLE ods.customer_status
ADD PRIMARY KEY (customer_id, event_ts);
-- 3. DDS: измерение статусов клиента с историей (SCD Type 2)
DROP TABLE IF EXISTS dds.dim_customer_status;
CREATE TABLE dds.dim_customer_status (
customer_status_sk BIGSERIAL PRIMARY KEY,
customer_bk INT NOT NULL,
status VARCHAR(20) NOT NULL,
hashdiff TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
ALTER TABLE dds.dim_customer_status
ADD CONSTRAINT uq_dim_customer_status_bk_from UNIQUE (customer_bk, valid_from);
CREATE INDEX ix_dim_customer_status_bk_current
ON dds.dim_customer_status (customer_bk)
WHERE valid_to IS NULL;
@@ -0,0 +1,48 @@
-- ===============================================
-- DML-шаблон для домашки
-- Тема: статусы клиента (STG → ODS → DDS SCD2)
-- Цель: по customer_status_events.csv построить историю статусов
-- ===============================================
-- Подсказка:
-- 1) Загрузите CSV в stg.customer_status_raw (через COPY или \copy в psql).
-- См. пример структуры файла в dwh-modeling/data/customer_status_events.csv
-- 2) Переложите данные в ods.customer_status с приведением типов.
-- customer_id → INT, status → VARCHAR(20), event_ts / load_ts → TIMESTAMP
-- (в STG/ODS эта колонка будет жить как _load_ts).
-- 3) Постройте из ods.customer_status измерение dds.dim_customer_status в стиле SCD2:
-- - одна строка на период действия статуса (valid_from / valid_to);
-- - актуальная строка для клиента — та, где valid_to IS NULL;
-- - hashdiff можно считать, например, от одного поля status.
-- 4) При желании добавьте инкрементальную логику (как в 03_demo_increment.sql).
-- 5) Опционально: соберите витрину dm.mart_customer_status_daily
-- с количеством клиентов по статусам на каждую дату.
-- Ниже — ЗАГОТОВКИ блоков, которые можно дописать.
-- Они намеренно оставлены пустыми, чтобы вы написали SQL сами.
-- 1. ODS: очистка и типизация
-- TRUNCATE ods.customer_status;
-- INSERT INTO ods.customer_status (...)
-- SELECT ...
-- FROM stg.customer_status_raw;
-- 2. DDS: начальная загрузка SCD2
-- Примерный план:
-- - рассчитать hashdiff по (status);
-- - по каждому клиенту отсортировать события по времени;
-- - построить для каждой строки valid_from и valid_to (LEAD() OVER ...), последняя valid_to = NULL;
-- - вставить в dds.dim_customer_status.
-- 3. DDS: инкрементальная загрузка (по желанию)
-- Можно ориентироваться на примеры в 03_demo_increment.sql.
-- 4. DM: витрина статусов клиентов по датам (по желанию)
-- Пример целевой структуры:
-- CREATE TABLE dm.mart_customer_status_daily (
-- date_actual DATE NOT NULL,
-- status VARCHAR(20) NOT NULL,
-- customers_cnt INT NOT NULL
-- );
-- Идея: на каждую дату взять актуальный статус клиента
-- через JOIN dds.dim_customer_status + dds.dim_date.
@@ -0,0 +1,276 @@
-- ===============================================
-- 09_dml_hw_customer_status_solution.sql
-- Решение домашки: статусы клиента (STG -> ODS -> DDS SCD2 -> DM)
--
-- Что делает этот файл:
-- 1) Перекладывает события статусов в ODS (приводит типы, чистит пустое).
-- 2) Строит DDS-измерение со "встроенной историей" (SCD2): периоды valid_from/valid_to.
-- 3) Показывает пример обновления DDS маленькой порцией (инкремент): закрыть старое + вставить новое.
-- 4) Собирает простую витрину в DM: сколько клиентов в каком статусе по дням.
--
-- Как запускать:
-- - для первого знакомства можно запускать файл целиком;
-- - если хотите потренировать инкремент (п.3): добавьте новые события -> обновите ODS -> запустите блок 3 ещё раз.
--
-- Важно:
-- - здесь часто используется TRUNCATE (полная очистка), чтобы было легко повторять домашку;
-- - в реальном DWH так делают не всегда, но для обучения это удобнее.
--
-- Предусловия (DDL + данные в STG):
-- 1) dwh-modeling/sql/01_ddl_stg-dds.sql
-- 2) dwh-modeling/sql/05_ddl_dm.sql
-- 3) dwh-modeling/sql/07_ddl_hw_customer_status.sql
-- 4) stg.customer_status_raw заполнена (см. dwh-modeling/Homework_Customer_Status_DDS_DM.md)
-- ===============================================
-- ==========================================================
-- 1) ODS: очистка и типизация (full refresh)
-- ==========================================================
-- Идея:
-- - STG хранит "как пришло" (обычно TEXT);
-- - ODS хранит "аккуратно": правильные типы + простая чистка.
-- Для простоты пересобираем ODS с нуля.
TRUNCATE ods.customer_status;
INSERT INTO ods.customer_status (
customer_id, status, event_ts, _load_id, _load_ts
)
SELECT
s.customer_id::INT AS customer_id,
NULLIF(trim(s.status), '') AS status,
NULLIF(trim(s.event_ts), '')::TIMESTAMP AS event_ts,
s._load_id,
COALESCE(s._load_ts, now()) AS _load_ts
FROM stg.customer_status_raw s
WHERE s.customer_id ~ '^\d+$'
AND NULLIF(trim(s.event_ts), '') IS NOT NULL
AND NULLIF(trim(s.status), '') IS NOT NULL;
-- ==========================================================
-- 2) DDS: начальная загрузка SCD2 (full refresh)
-- ==========================================================
-- Идея SCD2 простыми словами:
-- - одна строка = один период, когда статус был одним и тем же;
-- - valid_from = с какого дня статус "начался";
-- - valid_to = с какого дня статус "закончился" (NULL = текущий статус);
-- - интервалы считаем так: [valid_from, valid_to) (valid_to не включаем).
-- - чтобы найти статус "на дату D":
-- D >= valid_from AND (valid_to IS NULL OR D < valid_to)
--
-- Упрощение для домашки:
-- - считаем, что у клиента нет двух разных смен статуса в один день.
TRUNCATE dds.dim_customer_status;
WITH src AS (
-- src: события из ODS + "контрольная сумма" статуса.
-- Так проще проверять, поменялся статус или остался тем же.
SELECT
customer_id AS customer_bk,
status,
event_ts,
md5(lower(coalesce(status, ''))) AS hashdiff
FROM ods.customer_status
),
ordered AS (
-- ordered: для каждого клиента смотрим "какая версия была до этого" (LAG)
SELECT
*,
lag(hashdiff) OVER (
PARTITION BY customer_bk
ORDER BY event_ts
) AS prev_hash
FROM src
),
changes AS (
-- changes: оставляем только первое состояние и реальные изменения статуса
SELECT *
FROM ordered
WHERE prev_hash IS DISTINCT FROM hashdiff OR prev_hash IS NULL
),
framed AS (
-- framed: превращаем изменения в периоды (valid_to = дата следующего события через LEAD)
SELECT
customer_bk,
status,
hashdiff,
event_ts::DATE AS valid_from,
lead(event_ts::DATE) OVER (
PARTITION BY customer_bk
ORDER BY event_ts
) AS valid_to
FROM changes
)
INSERT INTO dds.dim_customer_status (
customer_bk, status, hashdiff,
valid_from, valid_to,
created_at, updated_at
)
SELECT
customer_bk, status, hashdiff,
valid_from, valid_to,
now(), now()
FROM framed
ORDER BY customer_bk, valid_from;
-- ==========================================================
-- 3) DDS: инкрементальная загрузка SCD2 (по последним событиям)
-- ==========================================================
-- Этот блок нужен, чтобы показать "как это обычно обновляют":
-- после новой порции событий мы:
-- 1) берём по каждому клиенту самое позднее событие из ODS;
-- 2) сравниваем его с текущей версией в DDS (valid_to IS NULL);
-- 3) если статус изменился — закрываем старую версию и вставляем новую.
--
-- Ограничение учебного варианта (в домашке можно игнорировать):
-- - если вы добавили событие "задним числом" со старой датой, этот блок не пересоберёт всю историю.
-- Для такого кейса обычно делают отдельную логику или full refresh.
--
-- Примечание:
-- - в этом файле блок 2 (full refresh) запускается раньше, поэтому сразу после него
-- блок 3, скорее всего, ничего не изменит. Зато его можно повторять после новых событий.
BEGIN;
-- 3.1) Закрываем предыдущую актуальную версию
WITH ranked AS (
-- ranked: выбираем "самое свежее" событие на клиента.
-- Если event_ts одинаковый, берём то, что загрузилось позже (_load_ts).
SELECT
customer_id AS customer_bk,
status,
event_ts::DATE AS eff_date,
md5(lower(coalesce(status, ''))) AS hashdiff,
row_number() OVER (
PARTITION BY customer_id
ORDER BY event_ts DESC, _load_ts DESC
) AS rn
FROM ods.customer_status
WHERE event_ts IS NOT NULL
),
delta AS (
-- delta: ровно одна строка на клиента (самое свежее событие)
SELECT * FROM ranked WHERE rn = 1
),
current AS (
-- current: текущие версии в DDS (valid_to IS NULL)
SELECT d.*
FROM dds.dim_customer_status d
WHERE d.valid_to IS NULL
)
UPDATE dds.dim_customer_status d
SET valid_to = x.eff_date,
updated_at = now()
FROM (
-- x: кого "закрываем":
-- клиент уже есть в DDS, и статус действительно изменился.
SELECT
t.customer_bk,
t.eff_date,
c.customer_status_sk
FROM delta t
JOIN current c
ON c.customer_bk = t.customer_bk
WHERE c.hashdiff <> t.hashdiff
AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины
) x
WHERE d.customer_status_sk = x.customer_status_sk
AND d.valid_to IS NULL;
-- 3.2) Вставляем новую версию
WITH ranked AS (
-- ranked/delta/current повторяем отдельно, чтобы блок INSERT читался отдельно от UPDATE
SELECT
customer_id AS customer_bk,
status,
event_ts::DATE AS eff_date,
md5(lower(coalesce(status, ''))) AS hashdiff,
row_number() OVER (
PARTITION BY customer_id
ORDER BY event_ts DESC, _load_ts DESC
) AS rn
FROM ods.customer_status
WHERE event_ts IS NOT NULL
),
delta AS (
SELECT * FROM ranked WHERE rn = 1
),
current AS (
SELECT d.*
FROM dds.dim_customer_status d
WHERE d.valid_to IS NULL
),
to_insert AS (
-- to_insert: кого "вставляем":
-- 1) новый клиент (в current нет строки);
-- 2) изменившийся клиент (статус поменялся).
SELECT
t.customer_bk,
t.status,
t.hashdiff,
t.eff_date
FROM delta t
LEFT JOIN current c
ON c.customer_bk = t.customer_bk
WHERE c.customer_status_sk IS NULL
OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from)
)
INSERT INTO dds.dim_customer_status (
customer_bk, status, hashdiff,
valid_from, valid_to,
created_at, updated_at
)
SELECT
t.customer_bk, t.status, t.hashdiff,
t.eff_date, NULL,
now(), now()
FROM to_insert t
-- защита от повторного запуска: не вставляем одну и ту же версию (BK + valid_from) второй раз
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_customer_status d
WHERE d.customer_bk = t.customer_bk
AND d.valid_from = t.eff_date
);
COMMIT;
-- ==========================================================
-- 4) DM: витрина статусов клиентов по датам (full refresh)
-- ==========================================================
-- Витрина "снимок на дату":
-- для каждого дня считаем, сколько клиентов было в каждом статусе.
-- Берём календарь dds.dim_date и подбираем статус по периоду valid_from/valid_to.
CREATE TABLE IF NOT EXISTS dm.mart_customer_status_daily (
date_actual DATE NOT NULL,
status VARCHAR(20) NOT NULL,
customers_cnt INT NOT NULL
);
TRUNCATE dm.mart_customer_status_daily;
WITH bounds AS (
SELECT
min(valid_from) AS date_from,
max(coalesce(valid_to, valid_from)) AS date_to
FROM dds.dim_customer_status
)
INSERT INTO dm.mart_customer_status_daily (
date_actual, status, customers_cnt
)
SELECT
d.date_actual,
s.status,
COUNT(DISTINCT s.customer_bk) AS customers_cnt
FROM dds.dim_date d
JOIN bounds b
ON d.date_actual BETWEEN b.date_from AND b.date_to
JOIN dds.dim_customer_status s
ON d.date_actual >= s.valid_from
AND (s.valid_to IS NULL OR d.date_actual < s.valid_to)
GROUP BY d.date_actual, s.status
ORDER BY d.date_actual, s.status;
+19 -1
View File
@@ -1,2 +1,20 @@
#!/bin/bash
docker compose exec -it db psql -U ${POSTGRES_USER:-postgres} -d demo
set -euo pipefail
# Usage:
# ./psql_sh # interactive psql inside container
# ./psql_sh -c "SELECT 1;" # run a command
# cat file.csv | ./psql_sh -c "\\copy ... FROM STDIN WITH (FORMAT csv, HEADER true)"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
compose_file="${script_dir}/docker-compose.yml"
compose() {
docker compose -f "${compose_file}" --project-directory "${script_dir}" "$@"
}
if [ -t 0 ]; then
compose exec -it db psql -U "${POSTGRES_USER:-postgres}" -d demo "$@"
else
compose exec -T db psql -U "${POSTGRES_USER:-postgres}" -d demo "$@"
fi