- Зачем: - приёмка #40 нашла в торговых данных два точных равенства, каких в живом магазине не бывает: событие корзины случалось ровно у визитов со страницей /cart, а внутри такого визита в корзину уходили все открытые карточки. Привлекательность товара было нечем измерить, а аналитик читал бы эти равенства как склейку в разметке. - Что: - класть в корзину может любой визит, открывший карточку; страница /cart осталась шагом воронки, а у визита, дошедшего до неё, корзина непуста. - у товара появился уровень спроса — колонка каталога с тремя значениями и два ряда вероятностей в числах мира: намерение визита берётся из шага воронки, а не из метки покупателя, которая уже действует через неё. - уровни рассыпаны по каталогу одной колодой: одинакового расклада по категориям нет, связи с ценой нет, и то и другое сторожится тестами. - числа мира перемерены: конверсия карточки в корзину 8,62% (по уровням 13,1 / 9,2 / 5,8), кладут без открытия корзины 41,4% таких визитов, средний день 49 834 события, конверсия визита в покупку 2,43%. - спека генератора и мастер-спека приведены к новой форме каталога. - Проверка: - make lint && make typecheck && make test — 394 passed (было 383). - трафиковая половина побайтово та же: sha256 по (URL, времени, WatchID) всех просмотров за 14 канонических дней совпадает со снимком до правки.
617 lines
53 KiB
Markdown
617 lines
53 KiB
Markdown
# Боевой реализм стенда (v2): широкое событие, заказы, кластер, анонимность
|
||
|
||
Статус: Accepted (2026-07-30). Три помеченных отступления подтверждены
|
||
владельцем на приёмке: порядок страховочных срезов (раздел 9), `Sign` как
|
||
колонка без механики (раздел 1.1), `VisitID` как эталон самопроверки (1.2).
|
||
Дата: 2026-07-30. Тикет: #17 (сборка карты #10).
|
||
Переехала из v1
|
||
(https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/specs/2026-07-30-stand-v2-realism.md);
|
||
номера тикетов #NN в тексте — из трекера v1.
|
||
Источники: резолюции #18 (модель данных), #15 (`purchase` и заказы),
|
||
#14 (кластер), #13 (цена кластера), #16 (анонимность и склейка); исследование
|
||
[формата кликстрима Яндекса](../research/2026-07-26-yandex-clickstream-format.md);
|
||
[docs/generator-realism.md](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/generator-realism.md).
|
||
|
||
## Зачем
|
||
|
||
Менти должен узнавать в стенде тот кликстрим и тот дата-контур, с которыми
|
||
столкнётся на работе. Сейчас стенд упрощён в четырёх местах: событие разрезано
|
||
на четыре топика, есть только просмотры страниц (нет денег), весь трафик
|
||
идентифицирован по email, ClickHouse — одна нода. Карта #10 приняла четыре
|
||
решения, которые эти упрощения снимают. Эта спека собирает их в одну целевую
|
||
картину и оценивает объём исполнения.
|
||
|
||
Исполнение — **новый репозиторий**, не переработка этого (решение карты #10
|
||
от 2026-07-23): v1 замораживается как стабильный стенд для менти, v2 стартует
|
||
пустым с осознанным первым коммитом — переносим только нужное, генератор
|
||
переписывается, переиспользуются идеи. Рабочее имя — `clickstream-data-platform`
|
||
(финальное закрепление — при решении тикета о нише). Карта и открытые тикеты
|
||
переедут туда после создания. Предусловие — задача «Редизайн пути менти»
|
||
(#9) — выполнено, задача закрыта.
|
||
|
||
## Целевая картина одним взглядом
|
||
|
||
- **Одно широкое событие** по образцу Яндекс Метрики: плоское ядро,
|
||
параллельные массивы, сырое поле `ecommerce`. Таксономия `EventType`:
|
||
`pageview`, `add_to_cart`, `purchase`. Четыре топика уходят.
|
||
- **Второй источник — заказы бэкенда**: та же Kafka, но ежедневный полный
|
||
слепок окна изменяемости, со статусами и JSON-позициями. Одна труба,
|
||
два режима.
|
||
- **Каталог товаров** — словарь ClickHouse из CSV в репозитории.
|
||
- **Сверка** клиентского `purchase` против заказа: четыре конструируемых
|
||
расхождения плюс опоздание. Деньги в витринах — только по бэкенду.
|
||
- **Кликстрим анонимный**: у события только `ClientID` (кука). Склейка
|
||
идентичностей — через мост `purchase`↔заказ; часть покупателей — с двух кук.
|
||
- **Кластер единственным режимом**: 2 шарда × 1 реплика + clickhouse-keeper,
|
||
`make up` поднимает сразу кластер. Superset — на ноду 2.
|
||
|
||
## 1. Широкое событие кликстрима
|
||
|
||
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
|
||
многозначное — в параллельных массивах, плюс одно сырое JSON-поле
|
||
`ecommerce`. Одна длина у массивов общая внутри группы: `purchase*` — по
|
||
элементу на заказ, `product*` — по элементу на товар; между собой группы
|
||
разной длины. Сессий в потоке нет — их менти собирает сам в DDS.
|
||
|
||
### 1.1 Решения по именам и типам
|
||
|
||
- **Имена колонок — как в облачной выгрузке Метрики** (`ClientID`,
|
||
`UTCEventTime`, `purchaseID`…). Сырой слой хранит имена источника; свои
|
||
snake_case-имена появляются в DDS/DM. Это учебный пункт: у каждого
|
||
источника — свой стиль, нормализует его хранилище, а не трекер.
|
||
- **Идентификаторы — числовые UInt64** (`WatchID`, `VisitID`, `ClientID`),
|
||
UUID уходят. Исследование советовало UUID не трогать, но тот совет исходил
|
||
из цены переделки текущего генератора; v2 пишет генератор заново, цена
|
||
нулевая, а числовые id — самая узнаваемая черта формата Метрики. Генератор
|
||
держит значения id ниже 2^53: выше этой границы double-числа в JSON (jq,
|
||
консоль браузера) искажают id при округлении. Настоящая Метрика так не
|
||
делает — её id длиннее.
|
||
- **Убираем наши выдумки**: `geo_latitude`, `geo_longitude` — координат в
|
||
выгрузке Метрики нет (гео — регион и его числовой id). `browser_user_agent`
|
||
тоже не берём, но это наш выбор, а не запрет источника: исследование
|
||
запрещало только выдавать это поле за формат Яндекса, оставить разрешало.
|
||
Разбор строки user agent — не урок этого стенда.
|
||
- **`Sign` берём как колонку формата, без механики** (решение владельца на
|
||
приёмке спеки): генератор всегда пишет `Sign = 1`, исправлений записей не
|
||
шлёт — движки и запросы не меняются. Сама механика версий
|
||
(CollapsingMergeTree, пара `HitVersion`) — кандидат на потом, по #15.
|
||
Честность: комментарий в DDL и абзац в документе о реализме («в бою здесь
|
||
бывают −1/+1, считают через `sum(Sign)`»); в лекции — крючок про
|
||
CollapsingMergeTree (частый вопрос на собеседованиях).
|
||
- **Не берём** `ClientEventTime` (в выгрузке Метрики нет клиентской метки;
|
||
расхождение часов — тема тумана «грязь»), `Params` (второй сырой JSON не
|
||
нужен: этот навык уже несут заказы), `LastSearchEngineRoot`, `IsPageView`,
|
||
`NotBounce`, `HTTPError`, `pageViewID`, `CounterUserIDHash`, Openstat
|
||
и соцдем-поля (см. «чего не воспроизводить» в исследовании).
|
||
- **Отступление по типу**: `DeviceCategory` берём как UInt8, у Метрики это
|
||
String; коды те же (1–4).
|
||
- **Наша честная добавка** — `EventType`: у Метрики такого поля нет
|
||
(там `isPageView` + `productEventType`), стенду таксономия нужна явно.
|
||
|
||
### 1.2 Состав полей (47 колонок)
|
||
|
||
Идентификаторы и время:
|
||
|
||
| Колонка | Тип | Комментарий |
|
||
|---|---|---|
|
||
| `WatchID` | UInt64 | id события (хита) |
|
||
| `VisitID` | UInt64 | id визита от генератора — эталон самопроверки лабы сессий («собери сам, потом сравни») |
|
||
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
|
||
| `CounterID` | UInt32 | константа стенда (один сайт) |
|
||
| `EventDate` | Date | дата события |
|
||
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики |
|
||
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
|
||
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
|
||
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
|
||
|
||
Правила резки визитов в генераторе документируются и совпадают с лабной
|
||
логикой (30-минутный таймаут).
|
||
|
||
Страница и атрибуция: `URL`, `Referer`, `Title`, `UTMSource`, `UTMMedium`,
|
||
`UTMCampaign`, `UTMContent`, `UTMTerm`, `LastTrafficSource`, `HasGCLID`
|
||
(UInt8), `YCLID` (UInt64) — 11 колонок: все String, кроме `HasGCLID`
|
||
(UInt8) и `YCLID` (UInt64).
|
||
|
||
Браузер, устройство, гео: `Browser`, `BrowserMajorVersion` (UInt16),
|
||
`BrowserLanguage`, `OperatingSystem`, `OperatingSystemRoot`, `DeviceCategory`
|
||
(UInt8, коды 1–4 как у Метрики), `MobilePhoneModel`, `ScreenWidth`,
|
||
`ScreenHeight` (UInt16), `IPAddress`, `RegionCountry`, `RegionCity`,
|
||
`RegionCountryID`, `RegionCityID` (UInt32) — 14 колонок.
|
||
|
||
Массивы и параметры: `GoalsReached` Array(UInt32) (две цели: корзина и
|
||
покупка — цели в бою дублируют события, это нормально), `ParsedParamsKey1`
|
||
Array(String) (свои параметры сайта, один уровень, например вариант
|
||
A/B-теста; Key2..10 не берём).
|
||
|
||
Ecommerce (заполнены только у торговых событий):
|
||
|
||
| Колонка | Тип |
|
||
|---|---|
|
||
| `purchaseID` | Array(String) |
|
||
| `purchaseRevenue` | Array(Float64) |
|
||
| `purchaseCurrency` | Array(String) |
|
||
| `purchaseCoupon` | Array(String) |
|
||
| `productID`, `productName`, `productCategory` | Array(String) |
|
||
| `productPrice` | Array(Int64) |
|
||
| `productQuantity` | Array(UInt64) |
|
||
| `productEventType` | Array(String) |
|
||
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
|
||
|
||
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
|
||
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики:
|
||
это не недосмотр, а часть урока о расхождениях (см. раздел 4).
|
||
|
||
### 1.3 Ключи и движки
|
||
|
||
- Партиции — **по дням** (`PARTITION BY EventDate`): дневная партиция —
|
||
единица переобработки (решение #14). Отступление от Метрики (там месяц) —
|
||
зафиксировать комментарием в DDL.
|
||
- `ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)` — ключ под
|
||
запросы «по сайту за период по посетителю», хвост `WatchID` даёт
|
||
дедупликацию в ReplacingMergeTree. Точную форму проверить при исполнении.
|
||
`SAMPLE BY intHash32(ClientID)` — семплирование по тому же выражению;
|
||
учебный вопрос к лабе: почему `SAMPLE 0.1` не портит uniq-метрики.
|
||
- В DDL комментарием зафиксировать вырождение ключа как учебный факт:
|
||
`CounterID` — константа стенда (один сайт), `EventDate` — константа внутри
|
||
дневной партиции; реальная сортировка идёт по посетителю и событию
|
||
(`intHash32(ClientID)`, `WatchID`).
|
||
- Шардирование — **по `cityHash64(ClientID)`, не по сырому `ClientID`**:
|
||
структурированный числовой id перекашивает остаток по модулю числа шардов,
|
||
хеш — нет. Сессионизация, склейка идентичностей и uniq-метрики остаются
|
||
локальными на шарде. У анонимов кука есть — перекоса в NULL нет.
|
||
- Предупреждение-урок из v1: колонка версии не должна попадать ни в
|
||
партицию, ни в ключ сортировки ReplacingMergeTree — иначе версии одной
|
||
строки никогда не окажутся рядом и не склеятся при мерже.
|
||
|
||
### 1.4 Схема как контракт
|
||
|
||
Машинное описание схемы события — python-модуль с чистыми данными,
|
||
собственность генератора (data contract; решение развилки «Архитектура»
|
||
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||
раздел 3). Из контракта выводятся сам генератор, его валидация и
|
||
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
|
||
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
|
||
этой документации на своих этапах, как в бою хранилище адаптируется к
|
||
источнику; границу сторожат строгий приём (раздел 6) и contract-тест в
|
||
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
|
||
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
|
||
молча. Заодно это учебный артефакт: менти видит на живом примере, что
|
||
такое data contract.
|
||
|
||
## 2. Заказы бэкенда
|
||
|
||
Второй источник и вторая версия правды о покупке. Транспорт — та же Kafka
|
||
(топик `orders`), но **пачками**: раз в модельный день бэкенд выгружает
|
||
**полный слепок заказов окна изменяемости K дней**.
|
||
|
||
- **K = 7 модельных дней, константа мира** (страховочный срез 3 из #15
|
||
применён — см. раздел 9). За окном заказ неизменяем, возить его незачем;
|
||
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
|
||
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
|
||
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
|
||
бэкенда в snake_case:
|
||
|
||
| Поле | Тип | Комментарий |
|
||
|---|---|---|
|
||
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
|
||
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
|
||
| `status` | String | `created` → `paid` → `cancelled` |
|
||
| `created_at`, `updated_at` | DateTime | |
|
||
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
|
||
| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` |
|
||
| `snapshot_date` | Date | дата слепка (день выгрузки) |
|
||
|
||
- Приём идемпотентный, но дедуп расщеплён на два слоя:
|
||
- `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**,
|
||
хранит «как приехало»; идемпотентность повторного прогона — заменой
|
||
партиции дня слепка, а не ReplacingMergeTree.
|
||
- Дедуп до последней версии — **argMax** в трансформации при сборке
|
||
`dds.order`. `dds.order` — единственная дедуплицированная таблица:
|
||
партиция по дню заказа (`toDate(created_at)`),
|
||
ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда
|
||
лежит в одной партиции, дедуп работает.
|
||
|
||
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
||
- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше
|
||
витрины работают с плоскими массивами `dds.order`: `item_sku`
|
||
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
|
||
— одной длины, порядок как в JSON. Это единственный носитель навыка
|
||
«вложенный JSON в ClickHouse» на стенде.
|
||
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
||
выручки внутри окна; сужение до двух — резервный срез 1.
|
||
|
||
## 3. Каталог товаров
|
||
|
||
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
|
||
`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл
|
||
использует генератор — расхождений нет по построению. Даёт `dictGet` в
|
||
витринах и разговор о политике обновления словаря. На кластере файл
|
||
монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка —
|
||
уровень спроса товара, заведена при исполнении #50 (спека генератора,
|
||
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
|
||
|
||
## 4. Сверка `purchase` против заказов
|
||
|
||
Ключ: клиентский `purchaseID` = `order_id` бэкенда (магазин знает номер
|
||
заказа на `/confirmation`). У события `purchase` массив `purchaseID` несёт
|
||
ровно один элемент (одно подтверждение — один заказ), сверка соединяет по
|
||
`purchaseID[1]`; правило зафиксировать комментарием в SQL сверки.
|
||
Расхождения — перечислимый список,
|
||
детерминированный от seed, не хаос:
|
||
|
||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||
|---|---|---|---|
|
||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
|
||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
||
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% |
|
||
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
||
|
||
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
||
`duplicate_event` > `amount_delta` > `match`.
|
||
|
||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
||
манифест хранит точные счётчики по каждому классу расхождений (отмены,
|
||
потери, дубли).
|
||
|
||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||
механически дублирует B.
|
||
|
||
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
|
||
бэкенду**. Единственное разрешённое исключение — клиентская оценка выручки
|
||
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
||
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
||
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
||
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
|
||
|
||
## 5. Анонимность и склейка идентичностей
|
||
|
||
- Email из кликстрима исчезает полностью: у события только `ClientID`.
|
||
Это честно к Logs API Метрики (UserID не выгружается). Отдельная «доля
|
||
анонимов» не нужна: мы знаем ровно тех, кто купил, — это сам урок.
|
||
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
|
||
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
|
||
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
|
||
- Форма и место карты: таблица `dds.identity_map`
|
||
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime),
|
||
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по
|
||
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации
|
||
нет); ко-локация делает обогащение витрин локальным;
|
||
заказы при сборке карты подтягиваются через GLOBAL JOIN.
|
||
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
||
параметр мира; значение фиксирует эта спека: 15%, детерминировано
|
||
от seed. Ядро лабы:
|
||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||
(она строится только из покупок) и лаба не воспроизводится. Манифест
|
||
хранит число именно таких пар.
|
||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||
|
||
## 6. Кластер
|
||
|
||
Соседний `clickhouse-learning-cluster` остаётся разминкой при курсе: там
|
||
концепции, здесь жизнь — забыть ON CLUSTER, получить ошибку, починить.
|
||
|
||
Топология и режим — по резолюции #14:
|
||
|
||
- **2 шарда × 1 реплика + отдельный clickhouse-keeper**, единственный режим:
|
||
`make up` поднимает сразу кластер, выключателя нет. Страховка от «слишком
|
||
сложно» — отсутствие реплик и runbook, а не профиль без кластера.
|
||
- Движки локальных таблиц — `Replicated*` (макросы `{shard}`/`{replica}`,
|
||
пути keeper, готовность к будущей реплике). Без HAProxy — балансировать
|
||
нечего; в доках абзац «в бою здесь LB».
|
||
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
|
||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
|
||
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
|
||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
||
`cityHash64(order_id)`, сырьё STG —
|
||
`cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между
|
||
прогонами, куда легли данные — нет».
|
||
- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные
|
||
поля — без значений по умолчанию. Контракт присутствия: генератор выдаёт
|
||
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
|
||
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
|
||
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
|
||
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
||
опечатка иначе не падает.
|
||
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
||
комментарием, почему локальный результат корректен; по любому другому ключу
|
||
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
||
`purchase`↔заказ —
|
||
легитимная GLOBAL-витрина (заказы малы).
|
||
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
|
||
через argMax); батчевая переобработка — по дневным партициям
|
||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` остаётся
|
||
только в `make reset`. `DROP/REPLACE PARTITION` работает только по
|
||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
||
осознанная цена, не баг).
|
||
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
|
||
партиция дня слепка, без переделки старого); материализованное ниже —
|
||
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
|
||
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
|
||
Сессии перестраиваются только за текущий день: правило мира — сессия
|
||
режется по границе модельных суток, дневная партиция самодостаточна.
|
||
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
||
`insert_distributed_sync`), иначе проверки видят неполные данные.
|
||
- Все контрольные суммы и dq-проверки считают через `argMax`/`GROUP BY`/
|
||
`FINAL` — голый `count()` по ReplacingMergeTree зависит от того, сколько
|
||
мержей уже прошло.
|
||
- **Проверки и контрольные суммы — только по Distributed-таблицам**:
|
||
агрегаты от раскладки не зависят; раскладка по шардам нигде не фиксируется,
|
||
пошардовые наблюдения — исследовательские, в лабах.
|
||
|
||
### Ресурсный бюджет (#10)
|
||
|
||
Предела расхода у стенда нет. Есть требование к машине: около 8 ГБ памяти,
|
||
доступной Docker. Решение и его основания —
|
||
[ADR 0004](../adr/0004-resource-limits.md); для менти то же самое объясняет
|
||
README.
|
||
|
||
Прежняя оценка «полный стенд в покое ≈3,4 ГБ» была расчётом по
|
||
стенду-предшественнику, сделанным до первой сборки v2, и предела не задавала.
|
||
Проверка `make smoke`, сторожившая это число, убрана: она мерила потребление
|
||
вместе со страничным кэшем и с появлением настоящих данных начала бы краснеть
|
||
на здоровом стенде.
|
||
|
||
Топология 2×2 остаётся отвергнутой по главному доводу — репликационная
|
||
эксплуатация есть отдельный операционный домен. Второй довод, от бюджета, снят
|
||
вместе с бюджетом. Выбор clickhouse-keeper вместо ZooKeeper держится на
|
||
резолюции #14 и ссылки на бюджет больше не требует.
|
||
|
||
## 7. Слои: карта таблиц v2
|
||
|
||
| Слой | Объект | Что это |
|
||
|---|---|---|
|
||
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
|
||
| STG | `stg.kafka_hits`, `stg.hits_raw` + MV; то же для orders | сырые строки, Kafka Engine на обеих нодах |
|
||
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
|
||
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
|
||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
||
| DDS | `dds.v_event` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
|
||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
||
| DDS | словарь `products` | каталог из CSV |
|
||
| DM | витрины `dm.v_*`, `dm.dq_summary` | см. ниже |
|
||
|
||
Служебные колонки: `ods.event` и `ods.order_snapshot` получают метку приёма
|
||
`_ingested_at`; у `ods.event` та же колонка — колонка версии
|
||
ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальные колонки Kafka
|
||
(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода
|
||
читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый
|
||
`event_date` — им кормится переобработка дня X (при исчерпании retention
|
||
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
||
см. [спеку генератора](2026-08-01-generator.md)).
|
||
|
||
`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не
|
||
обязательно копия данных».
|
||
|
||
Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже
|
||
широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ,
|
||
идентичность). «Грязные» записи по-прежнему уходят в `*_errors`, не валят
|
||
пайплайн.
|
||
|
||
### Витрины DM
|
||
|
||
- **`v_revenue_daily`** (выручка, только от заказов): `report_date`,
|
||
`product_category` (через `dictGet` каталога + ARRAY JOIN позиций),
|
||
`orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе
|
||
`paid`; внутри окна K число дня «дышит».
|
||
- **`v_purchase_vs_orders`** (сверка): FULL OUTER GLOBAL JOIN по
|
||
`purchaseID = order_id`; колонки: `order_day`, `order_id`,
|
||
`declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не
|
||
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
||
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
||
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
||
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
|
||
после приведения к сравнимой базе; его создаёт генератор намеренно
|
||
(~1–2% заказов, см. раздел 4).
|
||
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
||
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
||
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
|
||
закрытия окна K таких строк не остаётся — сироты исключены построением
|
||
(раздел 4).
|
||
- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру);
|
||
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
||
`declared_revenue` по UTM.
|
||
- **`v_daily_traffic`** — расширяется парой «посетители» / «известные
|
||
пользователи» (обогащение через `dds.identity_map`, локальное соединение
|
||
по ключу ко-локации).
|
||
- `v_events_enriched`, `v_top_pages_daily`, `v_session_overview`,
|
||
`v_dq_errors_daily` — переезжают на новую модель без смены роли: источник —
|
||
`dds.v_event`, не `ods.event`.
|
||
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
|
||
(политика «без TRUNCATE»).
|
||
|
||
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
|
||
таблица сверки с классами расхождений, пара посетители/известные.
|
||
|
||
Ландшафт итогом: Kafka — единственная труба (кликстрим потоком, заказы
|
||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||
ландшафта выражена режимами и частотами, а не второй трубой.
|
||
|
||
## 8. Эталонный мир и манифест
|
||
|
||
В git хранится только манифест эталонного мира; сам снимок (14 модельных
|
||
дней) генерируется на месте — при `make up` и при проверках (решение
|
||
развилки «Производительность» карты #26, подробности —
|
||
[спека генератора](2026-08-01-generator.md), раздел 5). Манифест несёт
|
||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман
|
||
карты #10) закрыта этим же ходом: версионируется манифест.
|
||
Контрольные числа манифеста:
|
||
|
||
- заказная сторона: заказы и выручка по дням; манифест хранит точные
|
||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
||
самопроверка лабы сверки;
|
||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
||
покупателей — лаба склейки получает самопроверку.
|
||
|
||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||
при пересборке.
|
||
|
||
## 9. Оценка объёма исполнения
|
||
|
||
v2 стартует пустым, поэтому объём ниже — это новый код, а не правка на
|
||
месте; v1 служит источником идей и образцов (масштаб оценён по нему).
|
||
|
||
| Направление | Что строим | Объём |
|
||
|---|---|---|
|
||
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
|
||
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
|
||
| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||
| Эталонный мир | пересборка снимка на месте, манифест-счётчики, чек-скрипты | M–L |
|
||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||
|
||
Итого ~80–110 файлов нового репозитория (посчитаны конфиги по нодам,
|
||
документация, экспорт Superset — дерево YAML, CI и артефакты данных);
|
||
тяжёлое — генератор и SQL. Это крупный релиз, но он режется на этапы с
|
||
работающим стендом после каждого. Согласуется с оценкой исследования #13
|
||
(~15–20 файлов только на кластерную часть).
|
||
|
||
### Решение по страховочным срезам (#15)
|
||
|
||
- **Срез 3 применён**: окно K — константа мира (7 дней), не параметр.
|
||
- **Срез 2 применён как порядок, не как отказ**: расхождения A+C входят в
|
||
этап сверки, B+D — отдельным следующим этапом.
|
||
- **Срез 1 в резерве**: статусы держим все три (`created`/`paid`/
|
||
`cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары
|
||
`created`/`cancelled` — запасной ход, если генератор заказов окажется дороже
|
||
ожиданий. Связка: если срез 1 сработает, определение выручки в
|
||
`v_revenue_daily` придётся сменить с «заказы в статусе `paid`» на «все
|
||
неотменённые заказы».
|
||
- **Отступление от порядка #15**: резолюция предписывала резать в порядке
|
||
1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2
|
||
ничего не отнимают у уроков (окно и так одно, расхождения и так вводятся
|
||
этапами), а срез 1 убирает статус `paid` — вместе с ним ушло бы «дыхание»
|
||
выручки.
|
||
|
||
### Этапы для /to-tickets (черновик)
|
||
|
||
0. Рождение v2: создать репозиторий (рабочее имя
|
||
`clickstream-data-platform`), осознанный первый коммит (скелет доков,
|
||
AGENTS.md, лицензия), переезд карты #10 и открытых тикетов.
|
||
1. Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow,
|
||
Superset, мониторинг), конфиги, `make up`, smoke-проверка ON CLUSTER.
|
||
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
|
||
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
|
||
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
|
||
целиком). В конце этапа фиксируется маленький «зерновой» мир для
|
||
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
||
отдельный этап 7).
|
||
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
|
||
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
||
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
||
батча заказов — сенсор/Datasets).
|
||
6. Расхождения B+D и опоздания; счётчики манифеста.
|
||
7. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
|
||
на amd64 и arm64.
|
||
8. Superset-дашборд v2.
|
||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||
и границы — ADR 0002.
|
||
|
||
Критерий приёмки этапа — честный: `make up` работает и проходят
|
||
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
|
||
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
|
||
(правило AGENTS.md).
|
||
|
||
## 10. Границы: чего не делаем
|
||
|
||
- «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи,
|
||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
||
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
|
||
стенд работает, но учебного пути на нём ещё нет.
|
||
- Инкрементальный ETL (#8) — свой issue.
|
||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||
- Полный словарь торговых событий Метрики (detail, remove, impressions),
|
||
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
|
||
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
|
||
визитов Метрики Про); сама колонка `Sign` уже в схеме, статикой (см. 1.1).
|
||
- Событийный лог заказов, CDC/Debezium, HTTP-сервис заказов, шапка+строки,
|
||
отдельный поток возвратов — отклонены в #15/#18.
|
||
- Файловые дропы как источник — зона следующего стенда (Lakehouse), у нас
|
||
остаются теорией; каталог отдельным топиком Kafka — в бою так не делают
|
||
(оба отклонения — из #18).
|
||
- Вероятностная склейка, identity graph, кука 1:N («семейный планшет») —
|
||
тема лекции, не лабы.
|
||
- Эмуляция `setUserID` и сюжет «логин посреди сессии» через свои параметры —
|
||
отклонены в #16: нечестно к формату выгрузки Метрики.
|
||
|
||
## 11. Проверить при исполнении
|
||
|
||
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` —
|
||
эмпирически на стенде (хвост #14).
|
||
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
|
||
прогонами, отсутствие дублей при штатной работе.
|
||
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
|
||
ReplacingMergeTree).
|
||
- Размер артефакта эталонного мира после пересборки.
|
||
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
||
сверять через MCP Context7 (правило AGENTS.md).
|
||
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся
|
||
под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры
|
||
проверить через Context7.
|
||
- Генератор: рабочее решение — Python с производительной архитектурой
|
||
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
||
распараллеливание по модельным дням). Читаемость генератора для менти —
|
||
не довод при выборе языка: он в любом случае сложнее уровня DE-джуна.
|
||
Числовые требования производительности и способ замера зафиксированы
|
||
[спекой генератора](2026-08-01-generator.md), раздел 5 (порядки величин —
|
||
[исследование](../research/2026-08-01-python-batch-generation-speed.md));
|
||
переход на компилируемый язык (Rust/Go) — только если живые замеры
|
||
этапа 2 выйдут за её порог.
|
||
|
||
## 12. Влияние на документацию
|
||
|
||
Состав и структура доков v2 проектируются заново: набор документов v1
|
||
сложился исторически и не копируется. Какие документы нужны v2 — решение
|
||
этапа 0. Обязательный минимум по содержанию (не по списку файлов):
|
||
быстрый старт, архитектура слоёв, операционка с runbook keeper/DDL,
|
||
словарь терминов (широкое событие, слепок, окно изменяемости,
|
||
посетители/известные пользователи, ко-локация).
|
||
|
||
Документ о реализме (`generator-realism.md`) переезжает в v2 и получает
|
||
крупное обновление: Kafka — учебная замена батчевого Logs API («настоящий
|
||
Logs API — это скачанный TSV»); trade-off «инкремент экономнее, слепок
|
||
надёжнее» + сноска про compacted topic; identity stitching перестаёт быть
|
||
чистой теорией; «прямое чтение прод-базы — анти-приём, в бою — реплика или
|
||
выгрузка»; колонка `Sign` без механики — честное ограничение стенда
|
||
(в бою −1/+1 и `sum(Sign)`); полный словарь торговых событий — теория.
|
||
|
||
В v1 при заморозке — указатель на v2 в README (форму решить при рождении
|
||
v2, этап 0).
|
||
|
||
### Опорные точки для будущих лекций и лаб
|
||
|
||
Лабы и курс — вне скоупа спеки (см. раздел 10). Список нужен только затем,
|
||
чтобы хвосты резолюций не потерялись:
|
||
|
||
- анти-паттерны ключа шардирования (`rand()`, `toDate`) — как отрицательные
|
||
примеры;
|
||
- «как выбирают топологии в бою»: часто 1 шард × N реплик, шардирование — про
|
||
рост;
|
||
- сцена «разные consumer groups → дубли»;
|
||
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
|
||
`RegionCityID`);
|
||
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
|
||
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
|
||
синтетическая постановка — осознанный приём;
|
||
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
|
||
`count()`, а в бою — нет; частый вопрос на собеседованиях;
|
||
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
||
детерминированная против вероятностной склейки, identity graph,
|
||
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
||
user id не отдаёт».
|