# Урок 1. Заземление Kafka → ClickHouse (слой STG) > Формат: **практика** — будешь сам запускать команды и менять код, не только читать. > Пререквизит: пройден урок 0 (словарь Kafka — топик, партиция, offset, consumer-группа — > уже знаком и виден в Kafka UI). > Эталонный путь: [`sql/ddl/stg/10_stg.sql`](../../../sql/ddl/stg/10_stg.sql). > > Поток данных одной строкой: > `Kafka → kafka_*_raw (ENGINE=Kafka) → MV → *_raw (MergeTree)` > > О чём урок простыми словами: смотрим, как сообщение из Kafka-топика само, без нашего > участия, превращается в строку таблицы ClickHouse — и почему на этом первом слое мы кладём > JSON целиком, ничего в нём не разбирая. --- ## 1. Зачем и где в проде Первое, что делаем с потоком событий — складываем его в таблицу **как есть**, ничего в нём не меняя. А рядом, в соседних колонках, пишем метаданные доставки: из какого топика и партиции пришло сообщение, с каким offset'ом и в какое время. Это слой **STG** — тот самый staging, знакомый тебе по курсовой: первая «посадочная площадка», куда поток приземляется в сыром виде, до любой обработки. Зачем хранить сырой JSON строкой и не парсить его сразу: - чтобы отладке было на что опереться: когда дальше по пайплайну что-то «не сходится», всегда есть исходное сообщение для сверки; - чтобы любой следующий слой можно было пересобрать из STG, не вычитывая Kafka заново; - и, главное, чтобы приём не падал из-за одного кривого поля. Разбор JSON и проверки качества — это уже следующий слой (урок 2), а STG принимает всё подряд. Отдельно про `kafka_offset` — это не просто справочная метка. Помнишь из урока 0: пара «партиция + offset» однозначно указывает на конкретное сообщение в топике. По ней всегда видно, та же это запись или другая, — пригодится, когда дальше начнём сверять данные между слоями. При этом **повторы STG не отсеивает**. Движок этих таблиц — `MergeTree`, и он не дедуплицирует, то есть не убирает строки-дубли: что пришло, то и легло, даже если две записи окажутся одинаковыми. Если дубли потом помешают — их убирают уже на следующих слоях, а STG держит всё подряд. > **В проде иначе.** На потоке в десятки тысяч сообщений в секунду читателей будет > несколько, и Kafka сама делит работу между ними. В этом уроке — один читатель > (`kafka_num_consumers = 1`) и маленький срез данных: нам важно понять, как поток вообще > попадает в базу, а не выжимать скорость. --- ## 2. Руки: убедись, что данные текут Подготовь стенд по [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс). После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM. Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль: `http://localhost:9123/play` (или Kafka UI на `http://localhost:8082`, чтобы тем же взглядом, что в уроке 0, увидеть те же сообщения «с другого конца»). ```sql -- Сколько сырых событий браузера приземлилось SELECT count() FROM stg.browser_raw; -- Как выглядит приземлённая строка: метаданные доставки + сырой JSON SELECT kafka_topic, kafka_partition, kafka_offset, kafka_ts, raw FROM stg.browser_raw ORDER BY kafka_offset LIMIT 5; ``` Что важно заметить: - `raw` — это **целый JSON строкой**, никто его пока не разбирал; - поля `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` заполнены: видно, откуда именно приехала запись; - offset'ы идут по возрастанию и не повторяются. --- ## 3. Загляни внутрь (`sql/ddl/stg/10_stg.sql`) ### Три кирпича слоя Весь слой STG собран из **трёх кирпичей**, и каждый топик повторяет одну и ту же тройку: | Кирпич | Объект | Движок | Что делает | |--------|--------|--------|------------| | 1 | `stg.browser_raw` | `MergeTree` | **хранит** сырьё + метаданные, постоянно | | 2 | `stg.kafka_browser_raw` | `ENGINE = Kafka` | **читает** топик, ничего не хранит | | 3 | `stg.mv_kafka_browser_to_stg` | `MATERIALIZED VIEW` | **перекладывает** из (2) в (1) на лету | Связка работает так. Таблица с `ENGINE = Kafka` (кирпич 2) — это не хранилище, а «кран» к топику: через неё ClickHouse читает сообщения, но **сами данные она не копит**. Забирает их третий кирпич — **Materialized View** (MV). И тут стоит остановиться на самом слове. Обычное представление (view) — это сохранённый запрос: данные оно считает только тогда, когда его спросишь. «Materialized» (материализованное) значит другое: оно срабатывает **само** на каждую новую порцию из источника и сразу складывает результат в постоянную таблицу. Получается цепочка: сообщение появилось в топике → MV тут же подхватило его и положило в `MergeTree`-таблицу `*_raw` (кирпич 1), где оно и лежит. Дальше задержимся на двух местах файла. ### Таблица-источник Kafka (`kafka_*_raw`) Здесь живёт вся настройка чтения топика: ```sql ENGINE = Kafka SETTINGS kafka_broker_list = 'kafka:29092', -- адрес брокера внутри Docker-сети kafka_topic_list = 'browser_events', -- какой топик читаем kafka_group_name = 'ch_stg_browser', -- consumer-группа: по ней Kafka помнит offset'ы kafka_format = 'JSONAsString', -- берём сообщение целиком, как строку kafka_num_consumers = 1, kafka_handle_error_mode = 'stream'; -- кривое сообщение не рвёт чтение топика ``` Две настройки тут — самые важные для всего урока: - `kafka_format = 'JSONAsString'` — вот почему мы и можем класть `raw` одной строкой: ClickHouse берёт тело сообщения как текст и **не пытается разобрать** JSON на этом этапе; - `kafka_handle_error_mode = 'stream'` — это ровно то «STG принимает всё» из секции 1: одно битое сообщение не уронит консьюмера, чтение топика продолжится. ### Materialized View: как сообщение становится строкой Здесь сообщение из топика превращается в строку таблицы. Откуда MV берёт метаданные доставки? Из **виртуальных колонок** Kafka-движка — это служебные поля (`_topic`, `_partition`, `_offset`, `_timestamp_ms`), которые движок подставляет к каждому сообщению сам, хотя в теле JSON их нет: ```sql SELECT now64(3) AS ingest_ts, _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, _timestamp_ms AS kafka_ts, raw FROM stg.kafka_browser_raw; ``` Тут без фокусов: каждая виртуальная колонка ложится в свою. Одно место стоит запомнить — `_timestamp_ms`: это уже готовый `DateTime64(3)` (время сообщения с точностью до миллисекунд), поэтому оно идёт в `kafka_ts` как есть, без всякого преобразования. В секции 4 ты положишь рядом ещё одно время из Kafka и увидишь, чем они отличаются. --- ## 4. Управляемая правка: протащи ещё одно поле метаданных Задача: добавить в `stg.browser_raw` ещё одну колонку — то же время сообщения, но из другой виртуальной колонки Kafka: `_timestamp` (тип `DateTime`, точность до секунды) — и увидеть её заполненной. Соль урока — в **порядке из двух шагов**: MV не «дотянет» новое поле само, его сначала нужно добавить в таблицу-приёмник. Схема приёмника и MV меняются вместе. **Шаг 1 — добавляем колонку в таблицу-приёмник:** ```sql ALTER TABLE stg.browser_raw ADD COLUMN kafka_msg_ts DateTime; ``` **Шаг 2 — пересоздаём MV, чтобы оно заполняло новую колонку** (поменять SELECT можно и «на месте» через `ALTER TABLE ... MODIFY QUERY`, но для наглядности пересоздадим целиком): ```sql DROP VIEW stg.mv_kafka_browser_to_stg; CREATE MATERIALIZED VIEW stg.mv_kafka_browser_to_stg TO stg.browser_raw AS SELECT now64(3) AS ingest_ts, _topic AS kafka_topic, _partition AS kafka_partition, _offset AS kafka_offset, _timestamp_ms AS kafka_ts, _timestamp AS kafka_msg_ts, -- ещё одно время из Kafka, но с точностью только до секунды raw FROM stg.kafka_browser_raw; ``` **Дозаливаем следующий день, чтобы новая колонка заполнилась.** Не очищай `stg.browser_raw`: `world_next_day` растит уже импортированный мир, а его финальная проверка сверяет весь накопленный результат. В старых строках, вставленных до `ADD COLUMN`, `kafka_msg_ts` останется пустым (`1970-01-01`) — ниже мы их отфильтруем. Открой Airflow и кнопкой **Trigger DAG** запусти `world_next_day` с пустой формой. Он дозальёт в Kafka следующий день, а ClickHouse прочитает новые сообщения уже с изменённой схемой. Такой прогон занимает несколько минут — дождись состояния `success`. Здесь мы только нажимаем готовую кнопку; подробно `world_next_day` разберём в лабе 07. **Смотрим результат:** ```sql SELECT kafka_ts, kafka_msg_ts FROM stg.browser_raw WHERE kafka_msg_ts > toDateTime(0) ORDER BY kafka_offset LIMIT 5; ``` Видно два времени одного и того же сообщения: `kafka_ts` (из `_timestamp_ms`) — с миллисекундами, `kafka_msg_ts` (из `_timestamp`) — округлённое до секунды. Момент тот же, точность разная: Kafka отдаёт эту метку через две виртуальные колонки сразу. > **Порядок важен, а ClickHouse молчит.** Пересоздай MV с `_timestamp`, **не добавив** > колонку `kafka_msg_ts` в таблицу, — ошибки не будет. ClickHouse сопоставляет колонки MV > с таблицей по имени и лишнюю просто отбрасывает: данные текут, а твоё новое поле молча > теряется. Обратный случай — колонку добавил, а в MV не указал — тоже не упадёт: поле > заполнится дефолтом. Вывод: за синхронность схемы и MV отвечаешь ты, а не движок. **Верни как было.** После дозаливки мир уже вырос, поэтому верни схему и данные [каноническим сбросом](../README.md#подготовка-и-канонический-сброс). --- ## 5. Проверь себя | Действие | Где смотреть | Что ожидать | |----------|--------------|-------------| | каноническая подготовка | `SELECT count() FROM stg.browser_raw` | счётчик > 0 | | глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный | | глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей | | `world_next_day` после правки из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw WHERE kafka_msg_ts > toDateTime(0) LIMIT 5` | новые строки заполнены временем сообщения | --- ## 6. Что должно получиться После урока у тебя на руках — видимый результат (одно на выбор): - строка `stg.browser_raw` с заполненными `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` и `raw`; - либо результат твоей правки — две колонки времени, `kafka_ts` и `kafka_msg_ts`, рядом. И проверь себя на словах: сможешь объяснить, чем таблица с `ENGINE = Kafka` отличается от `MergeTree` и зачем между ними нужен Materialized View? Короткая зацепка для ответа — что случится с данными, если MV убрать? Примерно такие вопросы по теме урока всплывут на еженедельном созвоне. --- ## Мост к уроку 2 Мы приземлили поток через Materialized View — почти «в реальном времени». Возникает честный вопрос: **раз MV так удобно перекладывает данные, почему дальше, в ODS, мы не продолжаем на MV, а уходим в батч?** Ответ — в уроке 2: когда доходит до типизации и проверок качества, важнее видеть и держать под контролем каждый пересчёт, чем экономить доли секунды на задержке.