From 4f1e58752c42738f8fecb1c1ba792b1cb81a1a3b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 18:33:36 +0300 Subject: [PATCH] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D1=83=D1=80=D0=BE=D0=BA=203=20(ODS?= =?UTF-8?q?=E2=86=92DDS,=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - собрать разрозненные кусочки ODS в цельные сущности DDS и ввести понятие целостности связей (сироты), пока без жёсткого гейта — он в уроке 4 - Что: - добавлен docs/course/lessons/03_ods_to_dds.md: сущности dds.click/dds.event, UNION-универсум кликов, дедуп через argMax, LEFT JOIN, понятие сироты, управляемая правка (вставка сироты), recap STG→ODS→DDS, заметка про DM - §3.1: «поток данных» в шапку sql/dds/30_ods_to_dds.sql - демоут DM: убран закомментированный пример материализации в sql/dm/40_dds_to_dm.sql, добавлены «поток данных» и заметка «VIEW сейчас, материализуем если затормозит» со ссылкой на docs/ARCHITECTURE.md - sql/ddl/dm/40_dm.sql: пояснён seed 1919 в groupArraySample, поправлен неверный комментарий «последние» (groupArraySample берёт случайную выборку) - README курса: индекс обновлён до «уроки 0–3» - Проверка: - LIMIT=50 make transform: dds.click=26, dds.event=50, orphan_events=0 - §4 на стенде: вставка события-сироты → orphan 0→1; LEFT JOIN в dm.v_events_enriched даёт NULL по полям клика; make transform откатывает к 0 - /ai-text-lint (article): house style сохранён, AI-маркеры не найдены --- docs/course/README.md | 2 +- docs/course/lessons/03_ods_to_dds.md | 379 +++++++++++++++++++++++++++ sql/ddl/dm/40_dm.sql | 4 +- sql/dds/30_ods_to_dds.sql | 4 + sql/dm/40_dds_to_dm.sql | 33 +-- 5 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 docs/course/lessons/03_ods_to_dds.md diff --git a/docs/course/README.md b/docs/course/README.md index e416c77..8560972 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -18,7 +18,7 @@ | [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ | | [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» | | [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока | -| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0 и 1) | Прохождение курса менти | +| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: уроки 0–3) | Прохождение курса менти | ## Порядок чтения diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md new file mode 100644 index 0000000..d06d13e --- /dev/null +++ b/docs/course/lessons/03_ods_to_dds.md @@ -0,0 +1,379 @@ +# Урок 3. ODS → DDS: сборка сущностей + +> Формат: **практика** — будешь сам запускать команды и менять код, не только читать. +> Пререквизит: пройден урок 2 (слой ODS — данные типизированы и разложены по качеству: +> чистые строки в `ods.*`, битые — в `ods.*_errors`). +> Эталонный путь: [`sql/dds/30_ods_to_dds.sql`](../../../sql/dds/30_ods_to_dds.sql) +> и DDL целевых таблиц [`sql/ddl/dds/30_dds.sql`](../../../sql/ddl/dds/30_dds.sql). +> +> Поток данных одной строкой: +> `ods.device_by_click + ods.geo_by_click → dds.click`, `ods.browser_event + ods.location_event → dds.event` +> +> О чём урок простыми словами: в ODS один клик размазан по двум таблицам (отдельно устройство, +> отдельно гео), а событие — по двум другим. Здесь мы склеиваем эти кусочки в цельные карточки: +> карточку клика и карточку события. И встречаем первую проблему стыковки — событие, у которого +> потерялся свой клик. + +--- + +## 1. Зачем и где в проде + +В ODS у нас аккуратные, типизированные данные — но они всё ещё лежат **по кусочкам**. Про один +и тот же клик стенд знает две отдельные вещи и хранит их в двух разных таблицах: с какого +устройства был клик (`ods.device_by_click`) и из какой точки на карте (`ods.geo_by_click`). Про +событие — то же самое: что за событие и в каком браузере (`ods.browser_event`) и на какой +странице с какими UTM-метками (`ods.location_event`). + +Для аналитики так работать неудобно. Стоит задать простой вопрос — «сколько кликов с мобильных +устройств пришло из России» — и аналитику приходится каждый раз вручную сшивать две-четыре +таблицы. Это долго писать и легко ошибиться. + +Поэтому появляется следующий слой — **DDS**. Это сокращение от Detailed Data Store («подробное +хранилище»): слой, где разрозненные кусочки собраны в цельные карточки, готовые к анализу. Такую +цельную карточку мы дальше называем **сущностью** — это просто запись, которая описывает один +объект целиком, со всеми его признаками сразу. В нашем стенде две сущности: + +- **`dds.click`** — карточка клика: один клик и сразу всё про него — устройство, операционная + система, страна, координаты, IP. Один клик — одна строка; +- **`dds.event`** — карточка события: одно событие и всё про него — тип, время, браузер, + страница, UTM-метки. Одно событие — одна строка. Внутри карточки лежит `click_id` — ссылка на + тот клик, в рамках которого событие произошло. + +Идея слоя: собрать один раз — пользоваться много раз. После сборки аналитику не нужно ничего +сшивать вручную: он берёт готовую `dds.click` или `dds.event` и сразу считает. + +### Откуда берётся проблема целостности + +Как только мы начинаем **склеивать** таблицы, появляется вопрос, которого на прошлых слоях не +было: а что, если стыкуемые кусочки не сходятся? Событие говорит «я случилось в рамках клика +`X`», мы идём искать клик `X` в `dds.click` — а его там нет. Ни устройства, ни гео по этому +клику стенд не получил. + +Такое событие — **без своего клика** — называют **сиротой** (по-английски orphan, «осиротевшее»). +В этом уроке мы вводим само понятие и учимся сирот **считать**. А ловить их жёстко — останавливать +пайплайн, если сироты появились, — будем в уроке 4. Сейчас задача проще: понять, откуда они +берутся и как их увидеть. + +> **Откуда сироты берутся в проде.** Чаще всего — из-за того, что данные приходят **не +> одновременно**. Событие в браузере произошло и улетело в Kafka сразу, а контекст клика +> (устройство, гео) досчитался и доехал на секунды позже. Если собрать `dds.click` именно в этот +> момент — клика ещё нет, и событие на мгновение осиротело. Бывает и совсем потеря: контекст +> клика не доехал вообще. Поэтому целостность между событием и кликом — то, за чем на этом слое +> следят отдельно. + +--- + +## 2. Руки: смотрим базовый прогон + +Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем +трансформацию: + +```bash +make up # поднять инфраструктуру +make ddl # создать базы и таблицы (в т.ч. слой DDS) +LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG +make transform # батч STG → ODS → DDS → DM +``` + +`make transform` прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика +DDS»** — счётчики строк по двум нашим сущностям: + +``` +Статистика DDS: + ┌─table─────┬─rows─┐ + │ dds.click │ 26 │ + │ dds.event │ 50 │ + └───────────┴──────┘ +``` + +Прочитаем эти две строки. + +**`dds.event` — 50.** Сколько событий пришло, столько карточек и собралось: одно событие — одна +строка. Ровно как `ods.browser_event` из прошлого урока. + +**`dds.click` — 26, а не 50.** И это та же история, что мы уже разбирали в уроке 2. Карточка +клика — одна на клик, а в срезе на 50 событий разных кликов всего 26 (на один клик приходится +несколько событий). Поэтому 50 событий ссылаются на 26 кликов — это нормально, так и должно быть. + +Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»** +(это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку +про сирот: + +``` + ┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐ + │ 2026-06-05 │ dds │ event_without_click │ orphan_events │ 0 │ + └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ +``` + +`orphan_events = 0` — ни одной сироты. Каждое из 50 событий нашло свой клик в `dds.click`. На +чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы +сироту устроим сами — и эта строка оживёт. + +Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play` +(пользователь `default`, пароль `123456`) и посчитай сирот напрямую: + +```sql +-- Сирота = событие, у которого click_id есть, но в dds.click такого клика нет +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +`NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех +click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет. +Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число. + +--- + +## 3. Загляни внутрь + +Слой, как и ODS, описан **двумя файлами** — держи оба открытыми, они про разное: + +| Файл | Что задаёт | +|------|------------| +| `sql/ddl/dds/30_dds.sql` | **форму** сущностей: какие колонки, какие типы, какой движок | +| `sql/dds/30_ods_to_dds.sql` | **сборку**: как из таблиц ODS склеить эти сущности | + +Дальше — четыре места, ради которых урок и затевался. Пойдём по сборке `dds.click` сверху вниз: +сначала собираем список всех кликов, потом приклеиваем к каждому устройство и гео. + +### Универсум кликов: собрать все `click_id` + +Сборка начинается с вопроса «а какие клики у нас вообще есть?». Источников два — `device` и +`geo`, и клик может быть в любом из них (а то и в обоих). Нам нужен полный список без повторов: + +```sql +SELECT click_id FROM ods.device_by_click ... +UNION DISTINCT +SELECT click_id FROM ods.geo_by_click ... +``` + +`UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор +уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный +список всех клиентов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы +строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал +в `device`. + +> На нашем срезе `device` и `geo` содержат один и тот же набор из 26 кликов, так что универсум +> тоже 26. Но код написан так, чтобы пережить случай, когда наборы **разойдутся**, — и это +> правильно: в проде они расходятся постоянно. + +### `argMax`: одна строка на клик, самая свежая + +Собрав список кликов, к каждому надо приклеить данные об устройстве. Тут есть тонкость из урока 2: +`ods.device_by_click` — таблица на движке `ReplacingMergeTree`, и повторы по `click_id` она +схлопывает **в фоне**, не мгновенно. Значит, прямо сейчас в ней может лежать несколько строк про +один клик. Какую брать? + +Берём самую свежую — и делаем это явно, через `argMax`: + +```sql +argMax(device_type, src_ingest_ts) AS device_type, +argMax(os_name, src_ingest_ts) AS os_name +... +GROUP BY click_id +``` + +`argMax(A, B)` читается так: «верни значение `A` из той строки, где `B` максимально». Здесь +`B` — это `src_ingest_ts`, время загрузки в ODS. То есть для каждого `click_id` берём `device_type` +из самой поздней загрузки. А `GROUP BY click_id` гарантирует, что на выходе **ровно одна строка +на клик** — прямо сейчас, не дожидаясь, пока `ReplacingMergeTree` схлопнет повторы у себя в фоне. + +> **Зачем так строго.** `ReplacingMergeTree` обещает оставить одну строку на ключ, но не обещает, +> *когда* (фоновое схлопывание может ещё не случиться). Если бы мы просто прочитали таблицу, то +> могли бы поймать дубль. `argMax` + `GROUP BY` убирают эту неопределённость на чтении: одна +> свежая строка на клик, всегда. + +### `LEFT JOIN`: приклеиваем устройство и гео + +Теперь главная операция сборки — соединить список кликов с данными об устройстве и гео. Это +делает **`JOIN`** — операция «состыкуй строки двух таблиц по общему ключу». Ключ у нас `click_id`: +для каждого клика из универсума ищем строку с тем же `click_id` среди устройств и среди гео. + +```sql +FROM ( ...универсум кликов... ) AS c +LEFT JOIN ( ...снапшот device... ) AS d ON d.click_id = c.click_id +LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id +``` + +Важно, что это именно `LEFT JOIN`, а не обычный `JOIN`. Разница — в том, что делать, когда пары +**не нашлось**: + +- обычный (`INNER`) `JOIN` выкинул бы клик, у которого нет, скажем, гео, — нет пары, нет строки; +- **`LEFT JOIN`** оставляет **все** строки левой таблицы (нашего универсума кликов) в любом + случае. Если для клика не нашлось гео — клик всё равно в результате, просто гео-поля у него + останутся пустыми (`NULL`). + +Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять +нельзя**. Не доехало гео — ладно, сохраним клик с пустым гео и пометкой, что гео нет. Эта пометка +тут же и ставится: рядом со сборкой стоит `if(g.click_id IS NULL, ['geo_not_found'], [])` — если +гео не подтянулось, в список ошибок карточки добавится метка `geo_not_found`. Тот же принцип +«не теряем и помечаем», что и `parse_errors` в ODS, только теперь про пропавшие связи. + +> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` +> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу +> и UTM. Если `location` не доехал — событие остаётся, а поля страницы пустые с меткой +> `location_not_found`. Разбирать этот блок построчно не будем — он повторяет ту же логику. + +### Сироты: событие без клика + +Мы собрали `dds.click` (26 карточек кликов) и `dds.event` (50 карточек событий). Внутри каждого +события лежит `click_id` — ссылка на клик. И вот тут возникает вопрос целостности из секции 1: +**а на каждую ли ссылку есть карточка клика?** + +Событие, чей `click_id` не находит себе клика в `dds.click`, — это и есть **сирота**. Считается +он ровно тем запросом, что ты уже видел в секции 2: + +```sql +SELECT count() FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Заметь разницу с предыдущим пунктом. `geo_not_found` — это когда у **клика** нет гео (внутренний +пропуск в карточке). А сирота — это когда у **события** нет вообще никакого клика (порвана связь +между сущностями). Это разные дырки, и следят за ними по отдельности. На чистом срезе сирот ноль — +сейчас мы это изменим. + +--- + +## 4. Управляемая правка: заведём сироту + +Сирота на чистом срезе не появится сама — данные слишком аккуратные. Поэтому **создадим её +руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого в `dds.click` +нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`. + +Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие: + +```sql +-- Событие со ссылкой на несуществующий клик dddd...-dddd (такого в dds.click нет) +INSERT INTO dds.event (event_id, event_ts, event_type, click_id, browser_name, dds_update_ts, ods_parse_errors) +VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- event_id нашего «события-сироты» + now64(6), 'pageview', + 'dddddddd-dddd-dddd-dddd-dddddddddddd', -- click_id, которого нет ни в одной карточке клика + 'DemoBrowser', now64(3), [] +); +``` + +Теперь посчитай сирот тем же запросом, что в секции 2: + +```sql +SELECT count() AS orphans +FROM dds.event +WHERE click_id IS NOT NULL + AND click_id NOT IN (SELECT click_id FROM dds.click); +``` + +Было `0` — стало `1`. Появилась первая сирота: событие `aaaa…` ссылается на клик `dddd…`, +которого в `dds.click` нет. + +Теперь посмотри, что с этим событием делает `LEFT JOIN`. В стенде есть витрина `dm.v_events_enriched` — +это `VIEW` (готовый запрос под именем), который как раз приклеивает к каждому событию его клик +через `LEFT JOIN`. Посмотрим на нашу сироту через неё: + +```sql +SELECT event_id, click_id, device_type, geo_country +FROM dm.v_events_enriched +WHERE event_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +``` + +``` +┌─event_id─────┬─click_id─────┬─device_type─┬─geo_country─┐ +│ aaaaaaaa-... │ dddddddd-... │ ᴺᵁᴸᴸ │ ᴺᵁᴸᴸ │ +└──────────────┴──────────────┴─────────────┴─────────────┘ +``` + +Вот он, `LEFT JOIN` вживую. Событие осталось в результате (его не выкинуло), но клика-то нет — +и все поля из клика (`device_type`, `geo_country` и остальные) пришли пустыми. Будь это `INNER +JOIN`, событие просто исчезло бы из витрины, и мы бы даже не заметили, что потеряли его. `LEFT +JOIN` его сохранил — поэтому сироту вообще можно увидеть и посчитать. + +> **Почему мы её только считаем, а не блокируем.** Логично было бы сказать: раз сирота — это +> разрыв целостности, давай не пустим её дальше, уроним прогон. Так и сделаем — но в уроке 4. +> Там запрос-счётчик из этого урока станет **жёстким гейтом** в Airflow: DAG покраснеет, если +> `orphan_events > 0`. Сейчас мы только научились сирот видеть; превратить взгляд в стоп-кран — +> следующий шаг. + +**Верни как было.** Наша сирота лежит прямо в `dds.event`, мимо ODS. Достаточно пересобрать слой +DDS — `make transform` чистит `dds.event` (`TRUNCATE`) и наполняет заново из ODS, где никакой +сироты нет: + +```bash +make transform +``` + +После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд совсем «поплыл» — +полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`. + +--- + +## 5. Проверь себя + +| Действие | Где смотреть | Что ожидать | +|----------|--------------|-------------| +| `make transform` (базовый прогон) | блок «Статистика DDS» | `dds.click` = 26, `dds.event` = 50 | +| `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` | +| почему `click` = 26, а `event` = 50 | запрос `count()` по `dds.click` и `dds.event` | 50 событий ссылаются на 26 кликов — норма | +| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` | +| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` | + +--- + +## 6. Что должно получиться + +После урока у тебя на руках — видимый результат (одно на выбор): + +- скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`; +- либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country` + пусты, — `LEFT JOIN` сохранил событие без клика. + +И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: + +- что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS; +- почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео; +- что такое сирота и чем разрыв «событие без клика» отличается от пропуска `geo_not_found` внутри + карточки клика. + +Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый +`click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку. + +--- + +## Вся цепочка разом: STG → ODS → DDS + +Мы прошли три слоя по отдельности — стоит собрать их в одну картину, чтобы они не остались тремя +не связанными кусками. Один и тот же клик прошёл весь путь: + +- **STG** (урок 1) — приняли поток как есть: сырой JSON строкой плюс метаданные доставки из + Kafka. Ничего не разбираем, ничего не теряем; +- **ODS** (урок 2) — разобрали JSON на поля и типизировали; чистое поехало в `ods.*`, битое — в + `ods.*_errors` (это и есть DQ-split). Здесь же один клик честно лёг в две таблицы: устройство + отдельно, гео отдельно; +- **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые + спросили про целостность связей между ними (сироты). + +Заметь общий принцип всех трёх слоёв — **«не теряем, а помечаем»**. На STG не роняем приём из-за +кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в +`*_errors`. На DDS не выкидываем клик без гео и событие без клика, а помечаем (`geo_not_found`) +и считаем (`orphan_events`). Один и тот же подход к качеству, проведённый через весь пайплайн. + +> **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые +> `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они +> сделаны как **`VIEW`** — это просто сохранённый под именем запрос поверх DDS, без копии данных: +> логику меняешь — данные не перегружаешь. Если тяжёлая агрегация однажды начнёт тормозить, её +> можно **материализовать** — превратить `VIEW` в обычную таблицу (как это делается — в +> `docs/ARCHITECTURE.md`, раздел «Материализация витрин»). Сами витрины в деле разберём в уроках +> 5–6, где их потребляют мониторинг и Superset. + +--- + +## Мост к уроку 4 + +Сущности собраны, целостность мы умеем **видеть** — но пока только глазами: запустили запрос, +посмотрели на число сирот. В проде так не следят: проверка должна срабатывать сама на каждом +прогоне и **останавливать** пайплайн, если целостность нарушена. В уроке 4 (оркестрация в Airflow) +мы соберём всю цепочку STG → ODS → DDS → DM в один DAG с зависимостями между шагами — и превратим +наш запрос-счётчик сирот в честный **гейт**: если `orphan_events > 0`, DAG падает и дальше данные +не идут. Там же снова заведём сироту — и увидим, как на неё краснеет конкретная задача в Airflow. diff --git a/sql/ddl/dm/40_dm.sql b/sql/ddl/dm/40_dm.sql index 0bd89a5..98db334 100644 --- a/sql/ddl/dm/40_dm.sql +++ b/sql/ddl/dm/40_dm.sql @@ -139,7 +139,9 @@ SELECT arrayDistinct(groupArray(page_url_path)) AS pages_visited, -- Уникальные страницы arrayDistinct(groupArray(geo_country)) AS countries, -- Страны (если менялась) arrayDistinct(groupArray(device_type)) AS devices, -- Устройства (если менялось) - -- Берём последние UTM-метки сессии (для атрибуции) + -- Берём одну UTM-метку сессии (для атрибуции). + -- groupArraySample(1, 1919) выбирает 1 случайный элемент; 1919 — фиксированный seed, + -- чтобы выборка была воспроизводимой между прогонами (одинаковый seed → один результат). groupArraySample(1, 1919)(utm_source)[1] AS utm_source_last, groupArraySample(1, 1919)(utm_medium)[1] AS utm_medium_last FROM dm.v_events_enriched diff --git a/sql/dds/30_ods_to_dds.sql b/sql/dds/30_ods_to_dds.sql index 20921d4..851939d 100644 --- a/sql/dds/30_ods_to_dds.sql +++ b/sql/dds/30_ods_to_dds.sql @@ -1,6 +1,10 @@ -- ============================================================================ -- Batch-трансформация: ODS → DDS -- ============================================================================ +-- Поток данных: +-- ods.device_by_click + ods.geo_by_click → dds.click (контекст клика) +-- ods.browser_event + ods.location_event → dds.event (контекст события) +-- -- Что делает: -- Собирает "чистые" сущности из типизированных данных ODS для аналитики. -- Использует argMax() для получения последней версии строк по ключу. diff --git a/sql/dm/40_dds_to_dm.sql b/sql/dm/40_dds_to_dm.sql index 58d20d7..f8f22a2 100644 --- a/sql/dm/40_dds_to_dm.sql +++ b/sql/dm/40_dds_to_dm.sql @@ -1,6 +1,10 @@ -- ============================================================================ -- Batch-трансформация: DDS → DM (Data Quality summary) -- ============================================================================ +-- Поток данных: +-- dds.click + dds.event + ods.* + stg.* → dm.dq_summary (сводка по слоям) +-- Сами витрины (dm.v_*) — это VIEW поверх DDS, создаются в sql/ddl/dm/40_dm.sql. +-- -- Что делает: -- Собирает статистику по всем слоям (stg/ods/dds) для мониторинга качества данных. -- Позволяет быстро проверить, сколько данных прошло через каждый слой @@ -9,33 +13,12 @@ -- Важно: -- Таблица dq_summary пересоздаётся при каждом запуске (TRUNCATE + INSERT), -- чтобы не накапливать дубликаты при повторных прогонах. +-- +-- Витрины DM сейчас — это VIEW (логика без копии данных). Если тяжёлая агрегация +-- начнёт тормозить, её материализуют в таблицу; пример — в docs/ARCHITECTURE.md, +-- раздел «Материализация витрин». -- ============================================================================ --- ---------------------------------------------------------------------------- --- Пример материализации тяжёлой витрины (закомментировано) --- ---------------------------------------------------------------------------- --- Если VIEW dm.v_daily_traffic работает медленно, можно создать таблицу: --- --- CREATE TABLE IF NOT EXISTS dm.daily_traffic_mart --- ( --- event_date Date, --- geo_country LowCardinality(Nullable(String)), --- device_type LowCardinality(Nullable(String)), --- browser_name LowCardinality(Nullable(String)), --- utm_source LowCardinality(Nullable(String)), --- utm_medium LowCardinality(Nullable(String)), --- events UInt64, --- uniq_clicks UInt64, --- uniq_users UInt64 --- ) --- ENGINE = ReplacingMergeTree(event_date) --- PARTITION BY toYYYYMM(event_date) --- ORDER BY (event_date, geo_country, device_type, browser_name, utm_source, utm_medium); --- --- TRUNCATE TABLE dm.daily_traffic_mart; --- INSERT INTO dm.daily_traffic_mart SELECT * FROM dm.v_daily_traffic; --- ---------------------------------------------------------------------------- - -- ---------------------------------------------------------------------------- -- Таблица для сводки по качеству данных (DQ summary) -- ----------------------------------------------------------------------------