Files
clickstream-ch-kafka-supers…/docs/course/lessons/02_stg_to_ods.md
T
ddadmin 589c556b17 docs(course): сквозной ревью — правки консистентности уроков 2/3/5 и OPERATIONS
- Зачем:
  - сквозной ревью курса нашёл расхождения учебного текста с реальным выводом
    стенда и один баг в операторских доках — менти увидел бы не то, что в уроке.
- Что:
  - урок 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.
2026-06-06 18:33:14 +03:00

26 KiB
Raw Blame History

Урок 2. STG → ODS: типизация и DQ-split

Формат: практика — будешь сам запускать команды и менять код, не только читать. Пререквизит: пройден урок 1 (слой STG — сырой JSON строкой уже лежит в stg.*_raw, рядом метаданные доставки из Kafka). Эталонный путь: sql/ods/20_stg_to_ods.sql и DDL целевых таблиц sql/ddl/ods/20_ods.sql.

Поток данных одной строкой: stg.*_raw → ods.* (валидный ключ) + ods.*_errors (любая ошибка)

О чём урок простыми словами: берём сырой JSON из STG, разбираем его на поля и приводим к типам, а заодно отделяем чистые записи от битых. И смотрим, что бывает, когда тип выбран неверно.


1. Зачем и где в проде

В прошлом уроке мы сложили сообщение в STG как есть — целым JSON-строкой. Никто его там не разбирал: задача STG была просто принять поток и ничего не уронить.

Теперь этот JSON пора разобрать. Каждое поле достаём из строки и приводим к нормальному типу: event_id делаем UUID, время события — DateTime, координаты — числом. Зачем это нужно? Пока значение лежит строкой, с ним почти ничего нельзя сделать: по строке не отфильтруешь события за вчера, не сложишь координаты, не сравнишь числа. Как только поле стало настоящим типом — с ним уже работают запросы. Слой, где данные впервые типизированы, и называется ODS.

И ещё одно: именно здесь мы впервые начинаем отделять чистое от грязного. Поток никогда не бывает идеальным — где-то поле пустое, где-то вместо UUID мусор, где-то число записано как текст. Бросать такие записи нельзя (вдруг пригодятся для разбора), но и держать их вперемешку с чистыми — мешать себе же. Поэтому на входе в ODS поток раздваивается.

Правило, по которому всё раскладывается

Запомни его на весь урок — дальше всё держится на нём:

  • у каждой таблицы есть ключ — поле, которое однозначно опознаёт запись. Для событий это event_id, для контекста клика (устройство, гео) — click_id;
  • если ключ разобрался (получился валидным) — строка едет в основную таблицу ods.*. Это «рабочие» данные, с которыми дальше живёт пайплайн;
  • а копия любой строки, где при разборе случилась хоть одна ошибка, едет в отдельную таблицу ошибок ods.*_errors. Туда складываем битое, чтобы потом разобрать, — и не теряем его, и не мешаем им чистым данным.

Вот это раздвоение по качеству и называется DQ-split (DQ — data quality, качество данных; split — разделение).

Осталась одна тонкость, к которой мы вернёмся в секции 3. Ошибка бывает не только в ключе. Бывает, что ключ-то валидный, а испортилось какое-то другое поле. Тогда строка остаётся в основной таблице (ключ на месте, она рабочая), но рядом, прямо в самой строке, ставится пометка: вот это поле не разобралось. Пометки складываются в специальный столбец-список parse_errors. И да — из-за этого одна запись может оказаться сразу в двух местах. Это не ошибка, так задумано; почему — разберём ниже.

Почему батч, а не Materialized View

В уроке 1 остался открытый вопрос: поток в STG перекладывало Materialized View, почти в реальном времени, — почему дальше так не продолжить?

Разобрать STG → ODS через MV технически можно: оно бы типизировало каждое сообщение на лету, по одному. Но мы сознательно идём другим путём — батчем. Батч значит вот что: всю таблицу ODS мы пересобираем целиком, одной задачей Airflow. Сначала очищаем (TRUNCATE), потом заново наполняем (INSERT из STG).

Зачем так, если MV быстрее? Ради двух вещей.

  • Видно каждый прогон. Батч — это отдельная задача в Airflow: у неё есть запуск, статус, лог. Если что-то пошло не так, ты видишь, какой прогон сломался. MV же работает молча, фоном, и поймать момент сложнее.
  • Пересчёт повторяем. Раз мы каждый раз чистим и наполняем заново, повторный запуск даёт ровно тот же результат. Захотел пересобрать слой — просто запусти задачу ещё раз.

На разборе типов и проверках качества это важнее, чем выиграть доли секунды на задержке. Это и есть «наблюдаемость и управляемость пересчёта» — одна из целей нашего стенда.

В проде иначе. Чистить и наполнять таблицу целиком каждый раз — это нормально для демо и маленького среза. На реальных объёмах так не делают: данные грузят инкрементально — добирают только новые, по «водяному знаку» (watermark — отметка, до какого момента уже всё загружено). Сама идея слоёв и DQ-split при этом не меняется.


2. Руки: смотрим базовый прогон

Поднимаем стенд, создаём схему, заливаем малый срез (50 строк на топик) и запускаем трансформацию:

make up                  # поднять инфраструктуру
make ddl                 # создать базы и таблицы (в т.ч. слой ODS)
LIMIT=50 make data       # залить по 50 строк каждого файла в Kafka → STG
make transform           # батч STG → ODS → DDS → DM (нас интересует первый шаг)

make transform прогоняет всю цепочку слоёв сразу, но прямо в консоли печатает то, что нам нужно сейчас, — блок «Статистика ODS». Это просто счётчики строк по всем восьми таблицам слоя (четыре основных и четыре с ошибками):

Статистика ODS:
   ┌─table──────────────────────┬─rows─┐
   │ ods.browser_event          │   50 │
   │ ods.location_event         │   50 │
   │ ods.device_by_click        │   26 │
   │ ods.geo_by_click           │   26 │
   │ ods.browser_event_errors   │    0 │
   │ ods.location_event_errors  │    0 │
   │ ods.device_by_click_errors │    0 │
   │ ods.geo_by_click_errors    │    0 │
   └────────────────────────────┴──────┘

Прочитаем эту табличку — в ней три вещи, которые стоит заметить.

Все четыре *_errors — по нулям. Значит, наш срез чистый: ни одна запись не дала ошибки разбора, столбец parse_errors у всех пустой. Это нормально — данные в демо аккуратные. Ошибки мы увидим в секции 4, когда сами их устроим.

browser и location дали 50 из 50. Сколько событий пришло — столько и легло, один к одному.

А device и geo — только 26 из 50. Вот это уже интересно. Половина куда-то делась? Нет. И это важно понять, иначе дальше будет казаться, что данные текут.

Дело в том, что эти две таблицы хранят не события, а контекст клика: с какого устройства был клик и из какой точки на карте. Ключ у них — click_id. А в срезе на 50 событий разных кликов всего 26: на один клик приходится несколько событий, и click_id у них повторяется. Движок таблицы (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке на клик. Отсюда и 26.

Проверь это сам, а не верь на слово. Открой SQL-консоль http://localhost:9123/play (пользователь default, пароль 123456) и посчитай, сколько в срезе различных click_id:

-- Всего строк в STG — 50, но различных click_id среди них — ровно 26
SELECT count() AS stg_rows,
       uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks
FROM stg.geo_raw;

Получишь stg_rows = 50, distinct_clicks = 26 — ровно столько, сколько строк в ods.geo_by_click. Значит, 26 — это схлопнутые повторы, а не пропавшие данные. Ничего не потерялось молча.


3. Загляни внутрь

Слой описан двумя файлами. Их полезно держать открытыми рядом — они про разное:

Файл Что задаёт
sql/ddl/ods/20_ods.sql форму целевых таблиц: какие колонки, какие типы, какой движок
sql/ods/20_stg_to_ods.sql наполнение: как из сырого JSON получить эти колонки

Дальше — три места, ради которых урок и затевался. Пойдём по ним по порядку.

Типизация через *OrNull

Поле достаём из JSON и тут же приводим к нужному типу. Но не «жёстко», а через функции, у которых на конце стоит OrNull:

toUUIDOrNull(JSONExtractString(raw, 'event_id'))                              AS event_id,
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude'))                       AS geo_latitude

Читается так: JSONExtractString(raw, 'event_id') достаёт поле из JSON как строку, а toUUIDOrNull(...) пытается превратить эту строку в UUID.

Весь смысл — в суффиксе OrNull. Если значение не приводится к нужному типу (вместо UUID пришёл мусор), функция не падает с ошибкой, а просто возвращает NULL. Это ровно то правило стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов. Один кривой event_id станет NULL и будет помечен, а остальные 49 строк спокойно доедут.

Кстати, про AS: эти строки живут в блоке WITH в начале запроса. WITH — это просто способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него коротко, по имени, а не повторять всю формулу. Имя задаётся через AS.

Сборка parse_errors

Теперь — как собирается тот самый список пометок. Какие именно поля не разобрались, видно вот здесь:

arrayFilter(x -> x != '', [
    if(event_id IS NULL, 'bad_event_id', ''),
    if(event_ts IS NULL, 'bad_event_timestamp', ''),
    if(click_id IS NULL, 'bad_click_id', '')
]) AS parse_errors

Разберём изнутри. Сначала строится список меток: на каждое поле — своя строка. Если поле вышло NULL (не разобралось) — кладём метку вроде 'bad_event_id', иначе — пустую строку ''. Потом arrayFilter выкидывает из списка все пустые строки. Что осталось — и есть список «что сломалось в этой записи», прямо в самой строке данных. У чистой записи он пустой.

Сам split — и почему запись бывает в двух местах

Теперь главное. Одни и те же строки STG раскладываются по двум INSERT — в основную таблицу и в таблицу ошибок. Отличаются они условием WHERE:

-- в основную таблицу: берём строки с валидным ключом
... WHERE event_id IS NOT NULL;

-- в таблицу ошибок: берём строки, где есть хоть одна ошибка разбора
... WHERE length(parse_errors) > 0
      AND (event_id IS NULL OR event_ts IS NULL OR click_id IS NULL);

Обрати внимание: эти два условия пересекаются, и это сделано нарочно. Представь строку, у которой event_id валидный, а вот event_timestamp пришёл битый. Что с ней происходит:

  • в основную таблицу она попадёт — ключ (event_id) на месте, строка рабочая. Рядом в parse_errors будет стоять метка bad_event_timestamp;
  • и в таблицу ошибок она тоже попадёт — ошибка-то в ней есть.

Одна запись — в двух местах. Это и есть «двойной учёт», и у каждой таблицы тут своя роль. Основная отвечает на вопрос «что у нас есть для работы» (и честно помечает, где в строке изъян). Таблица ошибок отвечает на другой вопрос — «что пришло битым и требует разбора». В самом файле это записано комментарием в шапке, в блоке «DQ-split».

Заметь на будущее. Логика разбора в файле продублирована: каждое поле типизируется дважды — один раз в INSERT основной таблицы, другой раз в INSERT таблицы ошибок (у каждого свой WITH с теми же формулами). Для учебного файла так нагляднее, но есть цена: если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим воспользуемся — и увидим, чем грозит такой рассинхрон.

Движок: откуда взялись 26 строк

И последнее место — строчка про движок основных таблиц:

ENGINE = ReplacingMergeTree(src_ingest_ts)
ORDER BY (click_id)

ReplacingMergeTree — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся из ORDER BY), оставляя самую свежую по src_ingest_ts — времени загрузки в ODS. Вот она, причина «26 из 50» из секции 2: у device и geo много строк с одинаковым click_id, и движок оставляет по одной на клик.


4. Управляемая правка: сломай тип — поймай тихую потерю

Урок про типы — так давай намеренно ошибёмся типом и посмотрим, что будет. Это самый поучительный момент урока.

Возьмём координату geo_latitude — широту. Это дробное число, например 50.82709. Достаём мы её через toFloat64OrNull — «привести к дробному числу». Заменим тип на целочисленный — toInt64OrNull, «привести к целому». Для строки "50.82709" целого числа не получится (там точка, дробная часть), и функция вернёт NULL. То есть широта просто исчезнет.

Из секции 3 помним: разбор продублирован, поэтому правок будет две — в обоих INSERT блока GEO EVENTS. Открой sql/ods/20_stg_to_ods.sql, найди оба вхождения и в каждом замени функцию:

-- было:
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude
-- стало:
toInt64OrNull(JSONExtractString(raw, 'geo_latitude'))   AS geo_latitude

Пересобираем слой:

make transform

И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:

   │ ods.geo_by_click           │   26 │
   │ ods.geo_by_click_errors    │   50 │   ← было 0

А в самой основной таблице широта пропала — но не молча, рядом стоит метка:

SELECT click_id, geo_latitude, geo_longitude, parse_errors
FROM ods.geo_by_click
LIMIT 4;
┌─click_id─────┬─geo_latitude─┬─geo_longitude─┬─parse_errors─────────┐
│ 58cdfc1e-... │         ᴺᵁᴸᴸ │          -0.2 │ ['bad_geo_latitude'] │
│ 9ffd819b-... │         ᴺᵁᴸᴸ │      85.37752 │ ['bad_geo_latitude'] │
└──────────────┴──────────────┴───────────────┴──────────────────────┘

Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. 26 строк остались в основной таблице (ключ click_id цел) с пометкой bad_geo_latitude. И те же записи попали в число 50 строк geo_by_click_errors. Долгота на месте, а широты больше нет: один неверный тип — и целое поле потеряно по всему слою. Заметили это parse_errors и таблица ошибок — для того DQ-split и нужен.

Бывает и хуже — тихо, совсем без метки. Здесь нас спас суффикс OrNull: неверный тип дал NULL, а NULL мы умеем замечать (на него и сработал parse_errors). По-настоящему опасен другой случай — когда неверный тип успешно возвращает неправильное значение. Ни NULL, ни ошибки, ни метки — всё «зелёное», а данные испорчены. Ровно так в уроке 1 и нашёлся баг: время kafka_ts приводили через toInt64(...) от значения типа DateTime64, это молча срезало миллисекунды, и время по всему стенду уехало в 1970-01-21. Ничто на это не указывало — поймали только прогоном на стенде. Мораль урока: тип выбирают осознанно, даже когда функция «не падает».

Верни как было. Откати обе правки — верни toFloat64OrNull в оба места. Если запутался, проще одной командой откатить весь файл к версии из репозитория:

git checkout -- sql/ods/20_stg_to_ods.sql
make transform

После этого geo_by_click_errors снова 0, широта на месте. А если стенд совсем «поплыл» — всегда есть полный сброс: make clean && make up && make ddl && LIMIT=50 make data && make transform.


5. Проверь себя

Действие Где смотреть Что ожидать
make transform (базовый прогон) блок «Статистика ODS» browser/location = 50, device/geo = 26, все *_errors = 0
почему 26, а не 50 запрос uniqExact(click_id) по stg.geo_raw 26 различных click_id — это схлопывание повторов, а не потеря
правка из секции 4 блок «Статистика ODS» ods.geo_by_click_errors прыгнул 0 → 50
правка из секции 4 SELECT geo_latitude, parse_errors FROM ods.geo_by_click широта NULL, в parse_errorsbad_geo_latitude

6. Что должно получиться

После урока у тебя на руках — видимый результат (одно на выбор):

  • скрин блока «Статистика ODS», где после правки ods.geo_by_click_errors ушёл с 0 на 50;
  • либо выборка из ods.geo_by_click с пустой широтой и меткой bad_geo_latitude рядом.

И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:

  • чем функции с суффиксом OrNull удобнее «жёсткого» приведения типа;
  • почему слой ODS мы наполняем батчем, а не Materialized View, как STG;
  • почему одна и та же строка может оказаться и в основной таблице, и в *_errors.

Если на последнем вопросе запнёшься — вернись к секции 3 и посмотри на условия WHERE у двух INSERT. Ответ там.


Мост к уроку 3

Данные теперь типизированы и разложены по качеству. Но ods.device_by_click и ods.geo_by_click — это всё ещё отдельные кусочки про один клик: устройство в одной таблице, гео в другой. В уроке 3 (ODS → DDS) мы соберём из них цельную сущность — dds.click (клик сразу с устройством и гео) — и таблицу событий dds.event. И там же наткнёмся на первый вопрос целостности: а что делать с событием, у которого нет своего клика? Такие «сироты» (orphan) — тема следующего урока.