# Архитектура ClickHouse Mini DWH Подробное описание слоёв хранилища, потоков данных и принятых решений. --- ## Содержание 1. [Обзор архитектуры](#обзор-архитектуры) 2. [Слои хранилища](#слои-хранилища) - [STG (Staging)](#stg-staging) - [ODS (Operational Data Store)](#ods-operational-data-store) - [DDS (Detailed Data Store)](#dds-detailed-data-store) - [DM (Data Marts)](#dm-data-marts) 3. [Поток данных](#поток-данных) 4. [Связи ключей](#связи-ключей) 5. [Принятые решения](#принятые-решения) 6. [Масштабирование](#масштабирование) --- ## Обзор архитектуры ### Общая схема потока данных ```mermaid flowchart LR subgraph Sources["📁 Источники (JSONL)"] BE[browser_events] LE[location_events] DE[device_events] GE[geo_events] end subgraph Kafka["🚀 Kafka"] K1[browser_events] K2[location_events] K3[device_events] K4[geo_events] end subgraph STG["📦 STG"] S1[browser_raw] S2[location_raw] S3[device_raw] S4[geo_raw] MV1[mv_*_to_ods] MV2[mv_*_to_errors] end subgraph ODS["🔧 ODS"] O1[browser_event] O2[location_event] O3[device_by_click] O4[geo_by_click] OE[error_tables] end subgraph DDS["🎯 DDS"] DE1[event] DC1[click] end subgraph DM["📊 DM"] DM1[v_events_enriched] DM2[v_daily_traffic] DM3[v_utm_effectiveness] DM4[v_top_pages] end BE --> K1 --> S1 --> MV1 --> O1 --> DE1 --> DM1 LE --> K2 --> S2 --> MV1 --> O2 --> DE1 DE --> K3 --> S3 --> MV1 --> O3 --> DC1 --> DM1 GE --> K4 --> S4 --> MV1 --> O4 --> DC1 S1 & S2 & S3 & S4 --> MV2 -.-> OE DE1 --> DM2 & DM3 & DM4 DC1 --> DM2 & DM3 & DM4 ``` ### Слои и их назначение ```mermaid flowchart TB subgraph L0["📝 Источники"] RAW["JSON файлы (1000 строк)"] end subgraph L1["📦 STG - Staging"] direction LR KAFKA["Kafka Engine"] STG_T["*_raw таблицы
(MergeTree)"] end subgraph L2["🔧 ODS - Операционный слой"] direction LR ODS_T["Типизированные таблицы
(ReplacingMergeTree)"] DQ["parse_errors
DQ-метрики"] end subgraph L3["🎯 DDS - Детальный слой"] DDS_T["event + click
(Batch SQL)"] end subgraph L4["📊 DM - Витрины"] DM_T["VIEW для BI
(Superset/Grafana)"] end RAW -->|kafka-console-producer| KAFKA -->|MV| STG_T -->|MV| ODS_T ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T ODS_T -.->|ошибки| DQ ``` --- ## Слои хранилища ### STG (Staging) **Назначение:** Сохранение сырых данных "как есть" для воспроизводимости и отладки. | Таблица | Движок | Описание | |---------|--------|----------| | `browser_raw` | MergeTree | Сырые события браузера | | `location_raw` | MergeTree | Сырые данные страниц/UTM | | `device_raw` | MergeTree | Сырые данные устройств | | `geo_raw` | MergeTree | Сырые гео-данные | | `kafka_*_raw` | Kafka | Таблицы-источники Kafka | | `mv_kafka_*_to_stg` | MV | Поток из Kafka в STG | **Структура таблицы:** ```sql CREATE TABLE stg.browser_raw ( ingest_ts DateTime64(3), kafka_topic LowCardinality(String), kafka_partition Int32, kafka_offset Int64, kafka_ts DateTime64(3), raw String -- ← JSON как есть ) ``` **Почему так:** - **Повторяемость**: если в ODS ошибка — можно перестроить без перезагрузки из Kafka - **Отладка**: видеть "что реально пришло" vs "что распарсилось" - **DQ**: невалидные JSON не ломают pipeline --- ### ODS (Operational Data Store) **Назначение:** Типизированные данные с дедупликацией и DQ-метриками. | Таблица | Ключ | Движок | Описание | |---------|------|--------|----------| | `browser_event` | event_id | ReplacingMergeTree(src_ingest_ts) | События браузера | | `location_event` | event_id | ReplacingMergeTree(src_ingest_ts) | Данные страниц | | `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства | | `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные | | `*_errors` | — | MergeTree | Строки с битыми ключами | **Materialized Views для обработки ошибок:** | MV | Назначение | |----|-----------| | `mv_browser_raw_to_ods_errors` | Переносит строки с ошибками в `browser_event_errors` | | `mv_location_raw_to_ods_errors` | Переносит строки с ошибками в `location_event_errors` | | `mv_device_raw_to_ods_errors` | Переносит строки с ошибками в `device_by_click_errors` | | `mv_geo_raw_to_ods_errors` | Переносит строки с ошибками в `geo_by_click_errors` | **Логика разделения:** - **Основная таблица**: строки с валидными ключами (`WHERE key IS NOT NULL`) - **Таблица ошибок**: строки с невалидными ключами (`WHERE key IS NULL`) **Пример структуры:** ```sql CREATE TABLE ods.browser_event ( event_id Nullable(UUID), event_ts Nullable(DateTime64(6)), event_date Date MATERIALIZED ifNull(toDate(event_ts), toDate(src_ingest_ts)), event_type LowCardinality(Nullable(String)), click_id Nullable(UUID), browser_name LowCardinality(Nullable(String)), src_ingest_ts DateTime64(3), src_raw String, parse_errors Array(LowCardinality(String)) ) ENGINE = ReplacingMergeTree(src_ingest_ts) ORDER BY (event_id) SETTINGS allow_nullable_key = 1; ``` **DQ-контроль:** ```sql -- Проверка ошибок парсинга SELECT arrayJoin(parse_errors) AS error, count() AS cnt FROM ods.browser_event GROUP BY error; ``` **Почему так:** - **Изоляция источников**: изменения в одном не ломают другие - **Версионирование**: `ReplacingMergeTree` хранит последнюю версию по `src_ingest_ts` - **Nullable ключи**: `allow_nullable_key = 1` позволяет хранить "битые" строки --- ### DDS (Detailed Data Store) **Назначение:** Собранные сущности для аналитики. | Таблица | PK | Источники | JOIN-ключ | |---------|-----|-----------|-----------| | `event` | event_id | browser_event + location_event | click_id → click | | `click` | click_id | device_by_click + geo_by_click | — | **Структура:** ```sql CREATE TABLE dds.event ( event_id UUID, event_ts Nullable(DateTime64(6)), event_type LowCardinality(Nullable(String)), click_id Nullable(UUID), page_url Nullable(String), page_url_path LowCardinality(Nullable(String)), utm_source LowCardinality(Nullable(String)), browser_name LowCardinality(Nullable(String)), -- ... все поля из browser + location dds_update_ts DateTime64(3), ods_parse_errors Array(LowCardinality(String)) ); CREATE TABLE dds.click ( click_id UUID, user_domain_id Nullable(UUID), device_type LowCardinality(Nullable(String)), geo_country LowCardinality(Nullable(String)), -- ... все поля из device + geo dds_update_ts DateTime64(3), ods_parse_errors Array(LowCardinality(String)) ); ``` **Загрузка (Batch SQL):** Загрузка `dds.click` с поддержкой partial data (когда device и geo приходят независимо): ```sql -- UNION всех click_id из device и geo INSERT INTO dds.click SELECT c.click_id, d.user_domain_id, d.device_type, g.geo_country, g.geo_latitude, -- ... остальные поля now64(3) AS dds_update_ts, arrayFilter(x -> x != '', arrayConcat( ifNull(d.parse_errors, []), if(d.click_id IS NULL, ['device_not_found'], []), if(g.click_id IS NULL, ['geo_not_found'], []) )) AS ods_parse_errors FROM ( -- Union всех click_id для обработки geo-only и device-only SELECT click_id FROM ( SELECT assumeNotNull(click_id) AS click_id FROM ods.device_by_click WHERE click_id IS NOT NULL GROUP BY click_id ) UNION DISTINCT SELECT click_id FROM ( SELECT assumeNotNull(click_id) AS click_id FROM ods.geo_by_click WHERE click_id IS NOT NULL GROUP BY click_id ) ) AS c LEFT JOIN ( -- Снапшот device SELECT assumeNotNull(click_id) AS click_id, ... FROM ods.device_by_click GROUP BY click_id ) AS d ON d.click_id = c.click_id LEFT JOIN ( -- Снапшот geo SELECT assumeNotNull(click_id) AS click_id, ... FROM ods.geo_by_click GROUP BY click_id ) AS g ON g.click_id = c.click_id; ``` **Ключевые особенности:** - **UNION click_id**: собираем все уникальные click_id из обоих источников - **LEFT JOIN**: обрабатываем случаи когда есть только device или только geo - **`assumeNotNull`**: типобезопасное преобразование после фильтрации NULL - **DQ-метрики**: маркируем отсутствующие данные (`device_not_found`, `geo_not_found`) **Почему batch, а не MV:** - **Согласованность**: MV с JOIN даёт eventual consistency (данные приходят в разное время) - **Контроль**: Batch SQL можно проверить, откатить, перезапустить - **Масштабируемость**: легко сделать инкрементальный batch --- ### DM (Data Marts) **Назначение:** Витрины для BI-инструментов. | Витрина | Назначение | Гранулярность | |---------|-----------|---------------| | `v_events_enriched` | Полное обогащение | 1 строка = 1 событие | | `v_daily_traffic` | Агрегация трафика | День × страна × устройство × браузер × UTM | | `v_top_pages_daily` | Популярность страниц | День × URL path | | `v_utm_effectiveness` | Маркетинговая аналитика | День × UTM source/medium/campaign | | `v_session_overview` | Сессионная аналитика | День × пользователь × сессия | | `v_dq_errors_daily` | Мониторинг качества | День × тип ошибки | **Пример:** ```sql CREATE VIEW dm.v_events_enriched AS SELECT e.*, c.user_domain_id, c.device_type, c.geo_country, arrayConcat(e.ods_parse_errors, c.ods_parse_errors) AS parse_errors FROM dds.event AS e LEFT JOIN dds.click AS c ON c.click_id = e.click_id; ``` **Материализованная таблица DQ:** ```sql -- Таблица для мониторинга качества (пересоздаётся при каждом batch) TRUNCATE TABLE dm.dq_summary; INSERT INTO dm.dq_summary SELECT today() AS check_date, 'stg' AS layer, ... FROM ... ``` - `TRUNCATE` предотвращает накопление дубликатов при повторных запусках - Хранит статистику по всем слоям (stg/ods/dds) для быстрой проверки **Почему VIEW:** - Для демо: достаточно производительности - Гибкость: изменения логики не требуют пересоздания таблиц - Для продакшена: можно материализовать тяжёлые агрегации --- ## Поток данных ### Sequence диаграмма процесса ```mermaid sequenceDiagram participant User as Пользователь participant Make as Makefile participant K as Kafka participant CH as ClickHouse participant STG as stg.*_raw participant ODS as ods.* participant DDS as dds.* participant DM as dm.* User->>Make: make up Make->>K: docker compose up kafka Make->>CH: docker compose up clickhouse K-->>User: ✅ Инфраструктура готова User->>Make: make ddl Make->>CH: ddl/00_databases.sql Make->>CH: ddl/10_stg.sql (Kafka Engine) Make->>CH: ddl/20_ods.sql (MV) Make->>CH: ddl/30_dds.sql Make->>CH: ddl/40_dm.sql CH-->>User: ✅ Структура БД создана User->>Make: make data Make->>K: load_kafka_data.sh K->>K: Создание топиков loop 4 файла Make->>K: kafka-console-producer end K->>CH: Потребление сообщений CH->>STG: INSERT через MV STG->>ODS: INSERT через MV (типизация) K-->>User: ✅ Данные в Kafka CH-->>User: ✅ Данные в STG/ODS User->>Make: make transform Make->>CH: jobs/30_dds_refresh.sql CH->>ODS: argMax() — снапшот CH->>DDS: JOIN + INSERT Make->>CH: jobs/40_dm_refresh.sql CH->>DM: DQ summary CH-->>User: ✅ DDS/DM обновлены ``` --- ## Связи ключей ### ER-диаграмма ```mermaid erDiagram BROWSER_EVENT ||--|| LOCATION_EVENT : "event_id" BROWSER_EVENT ||--o| DEVICE_BY_CLICK : "click_id" BROWSER_EVENT ||--o| GEO_BY_CLICK : "click_id" BROWSER_EVENT { UUID event_id PK DateTime event_ts String event_type UUID click_id FK String browser_name String browser_user_agent String browser_language } LOCATION_EVENT { UUID event_id PK String page_url String page_url_path String referer_url String referer_medium String utm_source String utm_medium String utm_campaign } DEVICE_BY_CLICK { UUID click_id PK String os String os_name String device_type UInt8 device_is_mobile String user_custom_id UUID user_domain_id } GEO_BY_CLICK { UUID click_id PK Float64 geo_latitude Float64 geo_longitude String geo_country String geo_timezone String geo_region_name String ip_address } ``` ### Сборка DDS-сущностей **event** (browser + location): ```mermaid flowchart LR subgraph ODS["ODS"] B["browser_event"] L["location_event"] end subgraph DDS["DDS"] EV["event"] end B -->|JOIN по event_id| EV L -->|JOIN по event_id| EV ``` **click** (device + geo) с поддержкой partial data: ```mermaid flowchart LR subgraph ODS["ODS"] D["device_by_click"] G["geo_by_click"] end subgraph BUILD["Batch SQL"] U["UNION DISTINCT
click_id"] J["LEFT JOIN"] end subgraph DDS["DDS"] CL["click"] end D -->|все click_id| U G -->|все click_id| U U --> J D -->|данные| J G -->|данные| J J --> CL ``` **Важно:** Не все `click_id` из events есть в device/geo. Используем `LEFT JOIN`. --- ## Принятые решения ### Почему `allow_nullable_key = 1`? В ClickHouse ключ сортировки не может быть NULL по умолчанию. Но в "грязных" данных ключи могут отсутствовать. **Решение:** 1. Включаем `allow_nullable_key = 1` в `ReplacingMergeTree` 2. Фильтруем NULL в MV (`WHERE key IS NOT NULL` → основная таблица) 3. Отдельные `*_errors` таблицы для NULL-ключей ### Почему `ReplacingMergeTree`? - Дедупликация по бизнес-ключу - Версионирование по timestamp (последняя версия wins) - Фоновый merge не блокирует чтение ### Почему batch ODS→DDS? | Подход | Плюсы | Минусы | |--------|-------|--------| | **MV + JOIN** | Реалтайм | Eventual consistency, дубли при late arrival | | **Batch (выбрано)** | Согласованность, контроль | Задержка до следующего запуска | ### Обработка ошибок в ODS **Проблема:** Грязные данные с невалидными ключами (NULL event_id/click_id). **Решение:** 1. **Основная таблица**: только валидные строки (`WHERE key IS NOT NULL`) 2. **Таблица ошибок**: строки с невалидными ключами через отдельные MV 3. **DQ-метрики**: массив `parse_errors` для аудита ```sql -- Основная таблица CREATE MV mv_browser_raw_to_ods_browser_event TO ods.browser_event SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL; -- Таблица ошибок CREATE MV mv_browser_raw_to_ods_errors TO ods.browser_event_errors SELECT ... FROM stg.browser_raw WHERE event_id IS NULL; ``` ### Partial data в DDS **Проблема:** Device и geo события приходят независимо (не все click_id есть в обоих источниках). **Решение:** 1. **UNION DISTINCT** всех click_id из обоих источников 2. **LEFT JOIN** для получения данных (обрабатываем device-only и geo-only) 3. **DQ-маркеры**: `device_not_found`, `geo_not_found` в `parse_errors` --- ## Масштабирование ### Инкрементальный batch Вместо полного `TRUNCATE + INSERT`: ```sql -- Добавить watermark INSERT INTO dds.click SELECT ... FROM ods.device_by_click WHERE src_ingest_ts > ( SELECT max(dds_update_ts) FROM dds.click ); ``` ### Материализация витрин Для тяжёлых агрегаций: ```sql -- Создать таблицу вместо VIEW CREATE TABLE dm.daily_traffic AS SELECT * FROM dm.v_daily_traffic; -- Пересчёт по расписанию TRUNCATE TABLE dm.daily_traffic; INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic; ``` ### Airflow-оркестрация ```python # dag.py with DAG('clickhouse_etl'): ddl = BashOperator(task_id='ddl', bash_command='make ddl') load = BashOperator(task_id='load', bash_command='make data') transform = BashOperator(task_id='transform', bash_command='make transform') ddl >> load >> transform ``` --- ## Полезные запросы ### Проверка слоёв ```sql -- Статистика по слоям SELECT database, countDistinct(table) AS tables, formatReadableQuantity(sum(rows)) AS rows, formatReadableSize(sum(bytes)) AS size FROM system.parts WHERE database IN ('stg', 'ods', 'dds', 'dm') GROUP BY database ORDER BY database; ``` ### DQ-анализ ```sql -- Ошибки парсинга по слоям SELECT 'ods.browser_event' AS table, countIf(length(parse_errors) > 0) AS errors, count() AS total FROM ods.browser_event UNION ALL SELECT 'dds.event', countIf(length(ods_parse_errors) > 0), count() FROM dds.event; ``` ### Воронка конверсии ```sql SELECT page_url_path, pageviews, uniq_clicks, round(uniq_clicks * 100.0 / lag(uniq_clicks) OVER (ORDER BY pageviews DESC), 2) AS conversion_pct FROM dm.v_top_pages_daily ORDER BY pageviews DESC; ```