Files
clickstream-ch-kafka-supers…/docs/course/lessons/03_ods_to_dds.md
T
ddadminandClaude Opus 4.8 cc1cffe2f3 docs(course): каркас курса и переобвязка уроков 0–6 на путь import (#21)
Зачем: после редизайна пути менти курс ссылался на старый путь
generated-history-analytics/backfill и не проходился по новому стенду.

Что: в README курса — единый блок подготовки и канонического сброса
(make clean -> make up + ddl_init/world_init -> make superset-init),
таблица уроков дополнена лабами 07–08 («в работе»); LESSON_STANDARD и
уроки 0–6 ссылаются на канонический блок; урок 1 переведён на дозаливку
через world_next_day (кнопкой-анонсом, цена в минутах названа); урок 4 —
лесенка DAG-ов; урок 5 — словарь «база import / живой поток»; урок 6
обязателен; цифры старого мира помечены маркером «сверить-на-стенде».

Проверка: grep по generated-history-analytics/backfill в docs/course/
пуст; правки только в docs/course/; git diff --check чистый.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:22:41 +03:00

386 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Урок 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. Руки: смотрим базовый прогон
Подготовь стенд по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
Ниже — форма блока **«Статистика DDS»**, который печатает `make transform`: счётчики
строк по двум нашим сущностям.
```
Статистика DDS:
┌─table─────┬─rows─┐
│ dds.click │ ... │
│ dds.event │ ... │
└───────────┴──────┘
```
Прочитаем эти две строки.
**`dds.event` — события.** Сколько событий пришло с валидным ключом, столько карточек и
собралось: одно событие — одна строка. Ровно как `ods.browser_event` из прошлого урока.
**`dds.click` обычно меньше, чем `dds.event`.** И это та же история, что мы уже разбирали
в уроке 2. Карточка клика — одна на клик, а событий на один клик может быть несколько.
Поэтому много событий ссылаются на меньшее число кликов — это нормально, так и должно быть.
Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»**
(это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку
про сирот:
```
┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐
│ 2026-06-05 │ dds │ event_without_click │ orphan_events │ 0 │
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
```
<!-- сверить-на-стенде -->
В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя
дата — не пугайся, если она не совпадёт с примером.
`orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `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` должны содержать один и тот же набор кликов,
> так что универсум совпадает с обоими источниками. Но код написан так, чтобы пережить случай,
> когда наборы **разойдутся**, — и это правильно: в проде они расходятся постоянно.
### `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`: левая таблица здесь — это полный список кликов, и **ни один клик терять
нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся
пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику
не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только
теперь не про кривое поле, а про пропавшую связь между таблицами.
> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location`
> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу
> и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать
> этот блок построчно не будем — он повторяет ту же логику.
### Сироты: событие без клика
Мы собрали `dds.click` (карточки кликов) и `dds.event` (карточки событий). Внутри каждого
события лежит `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);
```
Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой
контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события**
нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по
пустым полям внутри карточки, вторую — отдельным счётчиком. На чистой стартовой истории сирот
ноль — сейчас мы это изменим.
---
## 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`, придуманное событие исчезло. А если стенд
совсем «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
---
## 5. Проверь себя
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| базовый прогон | блок «Статистика DDS» | `dds.click` и `dds.event` не пустые |
| `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` |
| почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма |
| правка из секции 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`, — что было бы с кликами без гео;
- что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки
клика.
Если запнёшься на `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 не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика
(считаем такие сироты через `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.