- Зачем:
- сквозной ревью курса нашёл расхождения учебного текста с реальным выводом
стенда и один баг в операторских доках — менти увидел бы не то, что в уроке.
- Что:
- урок 2: порядок строк «Статистики ODS» выровнен под фактический вывод
run_batch.sh (4 основных таблицы, затем 4 *_errors); снято «по строчкам».
- урок 3: добавлено пояснение, что check_date — это today() из витрины
(у менти будет своя дата, не как в примере).
- урок 5: «должно быть не в Alerting» → «в состоянии Normal (не Alerting)».
- OPERATIONS.md: несуществующий FULL=1 заменён на реальный knob LIMIT=50
(по умолчанию полный объём — подтверждено load_kafka_data.sh:27,128).
- Проверка:
- git diff показывает 4 файла, +9/-6; grep 'FULL=' по docs/ пуст.
- порядок таблицы сверен с run_batch.sh:111-118; today() — sql/dm/40_dds_to_dm.sql:105.
29 KiB
Урок 3. ODS → DDS: сборка сущностей
Формат: практика — будешь сам запускать команды и менять код, не только читать. Пререквизит: пройден урок 2 (слой ODS — данные типизированы и разложены по качеству: чистые строки в
ods.*, битые — вods.*_errors). Эталонный путь:sql/dds/30_ods_to_dds.sqlи DDL целевых таблиц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 строк на топик) и запускаем трансформацию:
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 │
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
В колонке check_date стоит today() из кода витрины, так что у тебя там будет сегодняшняя
дата — не пугайся, если она не совпадёт с примером.
orphan_events = 0 — ни одной сироты. Каждое из 50 событий нашло свой клик в dds.click. На
чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы
сироту устроим сами — и эта строка оживёт.
Проверь нолик сам, не верь на слово. Открой SQL-консоль http://localhost:9123/play
(пользователь default, пароль 123456) и посчитай сирот напрямую:
-- Сирота = событие, у которого 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, и клик может быть в любом из них (а то и в обоих). Нам нужен полный список без повторов:
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:
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 среди устройств и среди гео.
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 (26 карточек кликов) и dds.event (50 карточек событий). Внутри каждого
события лежит click_id — ссылка на клик. И вот тут возникает вопрос целостности из секции 1:
а на каждую ли ссылку есть карточка клика?
Событие, чей click_id не находит себе клика в dds.click, — это и есть сирота. Считается
он ровно тем запросом, что ты уже видел в секции 2:
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 и вставь придуманное событие:
-- Событие со ссылкой на несуществующий клик 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:
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. Посмотрим на нашу сироту через неё:
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, где никакой
сироты нет:
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, — что было бы с кликами без гео; - что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки клика.
Если запнёшься на 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.