- Зачем:
- сквозной ревью курса нашёл расхождения учебного текста с реальным выводом
стенда и один баг в операторских доках — менти увидел бы не то, что в уроке.
- Что:
- урок 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.
363 lines
26 KiB
Markdown
363 lines
26 KiB
Markdown
# Урок 2. STG → ODS: типизация и DQ-split
|
||
|
||
> Формат: **практика** — будешь сам запускать команды и менять код, не только читать.
|
||
> Пререквизит: пройден урок 1 (слой STG — сырой JSON строкой уже лежит в `stg.*_raw`,
|
||
> рядом метаданные доставки из Kafka).
|
||
> Эталонный путь: [`sql/ods/20_stg_to_ods.sql`](../../../sql/ods/20_stg_to_ods.sql)
|
||
> и DDL целевых таблиц [`sql/ddl/ods/20_ods.sql`](../../../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 строк на топик) и запускаем
|
||
трансформацию:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```sql
|
||
-- Всего строк в 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`:
|
||
|
||
```sql
|
||
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`
|
||
|
||
Теперь — как собирается тот самый список пометок. Какие именно поля не разобрались, видно вот
|
||
здесь:
|
||
|
||
```sql
|
||
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`:
|
||
|
||
```sql
|
||
-- в основную таблицу: берём строки с валидным ключом
|
||
... 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 строк
|
||
|
||
И последнее место — строчка про движок основных таблиц:
|
||
|
||
```sql
|
||
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`, найди оба вхождения и в каждом замени
|
||
функцию:
|
||
|
||
```sql
|
||
-- было:
|
||
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude
|
||
-- стало:
|
||
toInt64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude
|
||
```
|
||
|
||
Пересобираем слой:
|
||
|
||
```bash
|
||
make transform
|
||
```
|
||
|
||
И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:
|
||
|
||
```
|
||
│ ods.geo_by_click │ 26 │
|
||
│ ods.geo_by_click_errors │ 50 │ ← было 0
|
||
```
|
||
|
||
А в самой основной таблице широта пропала — но не молча, рядом стоит метка:
|
||
|
||
```sql
|
||
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` в оба места. Если запутался,
|
||
проще одной командой откатить весь файл к версии из репозитория:
|
||
|
||
```bash
|
||
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_errors` — `bad_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) — тема следующего урока.
|