docs(warehouse): упрощены объяснения витрин, сессий и пересчета
Зачем: читателю ступеней 1–4 нужно проследить выбор дней, расчет строки и публикацию результата без расшифровки метафор. Что: согласованы справочники DM, сессий, ETL и два раздела хранилища с комментариями шести SQL-примеров. Уточнены ограничения повторного запуска и выбора атрибутов при равном времени событий. Проверка: make lint, git diff --check, локальные Markdown-ссылки и якоря. SQL без комментариев совпадает; AST Python без строк документации совпадает. Исторические разделы и материалы вне границ задачи сохранены. Refs: #249
This commit is contained in:
1 parent
84dc718f62
commit
5f317ba8d2
11 files changed
+440
-490
No files matched your search
@@ -1,111 +1,113 @@
|
||||
# Модель сессии в DDS
|
||||
|
||||
Учебный результат: менти строит расчётную сущность, которой нет в потоке, —
|
||||
режет события на сессии по правилу визита, чеканит идентификатор и сверяет
|
||||
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её
|
||||
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый
|
||||
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
|
||||
генератора использует только как эталон самопроверки — иначе сборка сессий
|
||||
выродилась бы в `GROUP BY` по чужому полю и центральный урок
|
||||
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
|
||||
считать то, что уже дано колонкой».
|
||||
Сессия объединяет события одного визита. На стенде вы учитесь определять
|
||||
ее границы по событиям, вычислять устойчивый идентификатор и сверять
|
||||
результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
|
||||
`VisitID`. Этот идентификатор источника остается в ODS для проверки.
|
||||
|
||||
## Зерно
|
||||
|
||||
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие
|
||||
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток.
|
||||
В нарезку входят все три типа событий — торговые садятся на свои страницы
|
||||
и держат сессию наравне с просмотрами. События внутри сессии
|
||||
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка
|
||||
приёма).
|
||||
Зерно описывает, чему соответствует одна строка: здесь это один
|
||||
[визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
|
||||
события одного `client_id` без пауз длиннее 30 минут и без перехода через
|
||||
модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
|
||||
Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
|
||||
|
||||
Граница суток проходит по модельному дню счётчика, и он у события уже есть:
|
||||
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех
|
||||
её событий `EventDate` один; он и становится **днём сессии** — колонкой
|
||||
партиции, без пересчёта.
|
||||
День сессии `session_date` берется из `event_date` событий DDS — это
|
||||
`EventDate` источника, дата по часовому поясу счетчика. У всех событий
|
||||
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
|
||||
|
||||
Оба окна нарезки используют порядок `(event_time, event_id)`: при равном
|
||||
времени события идут по идентификатору. Одинаковый порядок нужен, чтобы
|
||||
признак начала визита и накопленный номер визита относились к одним
|
||||
и тем же событиям.
|
||||
|
||||
## Идентификатор
|
||||
|
||||
Идентификатор сессии в природе не существует: сессия — расчётная сущность,
|
||||
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат,
|
||||
отчеканенный генератором в роли Метрики; наш — `session_id`,
|
||||
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`).
|
||||
Детерминированность обязательна: партиция дня пересобирается заменой, и
|
||||
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
|
||||
ломают.
|
||||
`session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
|
||||
При повторной сборке тех же событий посетитель и начало визита остаются
|
||||
прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
|
||||
выдали бы другие значения.
|
||||
|
||||
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
|
||||
в соединениях он не участвует — события его не несут, мост к заказам живёт
|
||||
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
|
||||
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
|
||||
По `session_id` можно сопоставить сессии до и после пересборки и назвать
|
||||
конкретную строку при разборе расхождения. Он не используется для сортировки
|
||||
или шардирования. У событий этого поля нет; связь с заказами строится
|
||||
по событиям покупки.
|
||||
|
||||
## Состав строки
|
||||
|
||||
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по
|
||||
ней состав и собран:
|
||||
- `client_id`, `session_id`, `session_date` — посетитель, сессия и ее день.
|
||||
- `started_at`, `finished_at`, `duration_seconds` — время первого и последнего
|
||||
события и разница между ними в секундах. Времена хранятся в часовом поясе
|
||||
счетчика.
|
||||
- `events_total`, `pageviews`, `cart_adds`, `purchases` — число всех событий
|
||||
и счетчики трех типов.
|
||||
- `entry_url`, `exit_url` — страницы первого и последнего события.
|
||||
- `utm_*` — метки первого события: по ним визит относят к рекламной кампании.
|
||||
- `device_category`, `region_city` — устройство и город посетителя.
|
||||
На стенде они постоянны для одной куки.
|
||||
- `_load_id`, `_load_ts` — идентификатор запуска и время сборки строки.
|
||||
|
||||
- **ключи и времена**: кука, `session_id`, начало и конец сессии,
|
||||
длительность, день сессии. Времена — в поясе счётчика по правилу слоя;
|
||||
- **счётчики**: событий всего, просмотров, корзин, покупок;
|
||||
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного
|
||||
списка страниц нет и у Метрики, топы считаются по событиям;
|
||||
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
|
||||
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
|
||||
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
|
||||
и город — у куки они постоянны, берутся с первого события;
|
||||
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
|
||||
([модель заказа](order.md), «Служебные колонки»).
|
||||
В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
|
||||
устройство и город — через `argMin`, только по `event_time`. При одинаковом
|
||||
времени выбор между событиями не определен. Полный порядок окон нарезки
|
||||
сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
|
||||
в [задании ступени 4](../../labs/4-1-session-pipeline.md).
|
||||
|
||||
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики
|
||||
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел
|
||||
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
|
||||
счётчики торговых событий.
|
||||
В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
|
||||
сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
|
||||
|
||||
## Движок и наполнение
|
||||
|
||||
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу
|
||||
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии.
|
||||
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением,
|
||||
что события, — это ко-локация с `dds.identity_map_rep` ([дока
|
||||
хранилища](../storage.md), «Раскладка по шардам»).
|
||||
Совпадение ключа с событиями не случайно: все события куки лежат на одном
|
||||
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
|
||||
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
|
||||
`dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
|
||||
версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
|
||||
раздел таблицы за один `session_date`; ключ сортировки — посетитель
|
||||
и начало сессии. Читайте результат через `dds.session_v`: представление
|
||||
над `dds.session_dist` явно перечисляет деловые и служебные колонки.
|
||||
|
||||
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
|
||||
источник приходит раз в сутки. Собирается день, который мир уже прожил, и
|
||||
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
|
||||
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
|
||||
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
|
||||
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
|
||||
спеки, раздел 6). Второй раз он соберётся только
|
||||
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
|
||||
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
|
||||
сколько мир успел прожить без слоя. Внутри живого дня
|
||||
модель сессии про сегодня честно молчит — «сегодня» смотрят через
|
||||
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
|
||||
Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
|
||||
распределены по одному ключу `cityHash64(client_id)`: данные посетителя
|
||||
находятся на одном шарде. Роли объектов и правило записи через `_dist`
|
||||
описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
|
||||
|
||||
Публичная поверхность — `dds.session_v`: обычное представление с явным
|
||||
списком деловых и собственных служебных колонок над `dds.session_dist`.
|
||||
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
|
||||
объектов».
|
||||
Три SQL-файла выполняются последовательно в группе `session` дага
|
||||
`dds_transform`:
|
||||
|
||||
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии
|
||||
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки.
|
||||
Механика запуска — где собирается партиция и как подменяется на кластере —
|
||||
не входит: это устройство [дневного конвейера](../etl.md).
|
||||
1. `session_scope.sql` находит первый день событий, которого еще нет
|
||||
в таблице сессий. Последний день для сборки — первый день событий ODS
|
||||
плюс `world_position` минус один. Так в расчет попадают только полностью
|
||||
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
|
||||
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
|
||||
пропускается.
|
||||
2. `session_rebuild.sql` очищает `dds.session_stage_rep` и собирает весь
|
||||
выбранный диапазон в `dds.session_stage_dist`. Это донор — служебная
|
||||
таблица для подготовки результата до публикации. Вставка ждет доставки
|
||||
строк на шарды благодаря `distributed_foreground_insert = 1`.
|
||||
3. `session_replace.sql` заменяет каждый день в `dds.session_rep` партицией
|
||||
донора. Для каждого дня Airflow создает отдельную задачу `replace`.
|
||||
|
||||
В обычном ходе сессии прожитого дня больше не меняются. Если пропущен ранний
|
||||
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
|
||||
Поиск по наличию даты не обнаружит неполный день, у которого часть строк
|
||||
сохранилась. Условия повторного запуска и границы атомарности замены
|
||||
описаны в [справочнике конвейера](../etl.md#идемпотентность).
|
||||
|
||||
Сессий еще идущего дня в модели нет; его события доступны через
|
||||
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
|
||||
в ODS — см. [готовность источника](../etl.md#готовность-источника).
|
||||
|
||||
## Сверка с эталоном
|
||||
|
||||
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки
|
||||
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и
|
||||
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная
|
||||
компенсация ошибок внутри одной куки за день; эталон в модель не тащим —
|
||||
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
|
||||
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки —
|
||||
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт
|
||||
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v`
|
||||
([контракт события](event.md)).
|
||||
Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
|
||||
посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
|
||||
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
|
||||
скрывает `VisitID`.
|
||||
|
||||
Нулевое число расхождений подтверждает совпадение счета, но не всех
|
||||
границ и атрибутов сессий. Две ошибки внутри одного посетителя и дня
|
||||
могут сохранить число визитов. Поэтому в заданиях вы дополнительно
|
||||
сопоставляете границы сессий по исходным событиям и проверяете страницы,
|
||||
UTM, устройство и город.
|
||||
|
||||
## Читатели
|
||||
|
||||
|
||||
+117
-105
@@ -1,87 +1,83 @@
|
||||
# Слой DM: витрины
|
||||
|
||||
Как модель DDS превращается в витрины для потребителя BI: какую форму
|
||||
принимает витрина, кто какие витрины строит и как слой проверяется. Состав
|
||||
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
|
||||
наполнение и контроль.
|
||||
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
|
||||
хранения, правила обновления и проверки. Состав колонок задан в
|
||||
[спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
|
||||
и [учебных заданиях](../labs/README.md).
|
||||
|
||||
## Таблица или представление
|
||||
|
||||
Дневной агрегат и сверка по заказам меняют зерно источника. На этом стенде такие
|
||||
результаты хранятся в `dm.<имя>_rep` / `_dist` и читаются через публичное
|
||||
представление `dm.<имя>_v` с явным списком колонок. Имена и устройство объектов
|
||||
следуют общим [конвенциям хранилища](storage.md). Таблицы наполняет
|
||||
`dm_transform`; ритм и механика пересборки описаны ниже. В задании этот путь
|
||||
приходится пройти целиком: рассчитать агрегат, загрузить его и включить
|
||||
обновление в конвейер.
|
||||
Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
|
||||
а в дневной выручке — день и категория товара. На стенде результат
|
||||
с новым зерном хранится в таблице: отчет читает готовые итоги без
|
||||
повторного расчета. Таблицу наполняет `dm_transform`.
|
||||
|
||||
Переименование колонок и обогащение строки зерно не меняют. Такие витрины на
|
||||
стенде остаются обычными представлениями и не хранят вторую копию событий.
|
||||
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
|
||||
представление `_v` с явным списком колонок. Для замены дневных партиций
|
||||
служат еще две таблицы: `_stage_rep` и `_stage_dist`. Это донор — место
|
||||
сборки результата перед публикацией. Подробности — в
|
||||
[справочнике хранилища](storage.md#имена-объектов).
|
||||
|
||||
На объёмах стенда все витрины могли бы остаться представлениями: условия
|
||||
проходят сквозь их цепочку
|
||||
Если запрос только переименовывает или дополняет поля и сохраняет одну
|
||||
строку на событие, достаточно обычного представления. Оно выполняет
|
||||
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
|
||||
|
||||
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
|
||||
([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||||
Мы все равно оставили материализованные агрегаты ради практики: без них в
|
||||
программе не было бы загрузки и пересборки витрин. Представления дают другой
|
||||
случай: преобразование строк на прежнем зерне.
|
||||
Готовые таблицы нужны в программе для практики загрузки, обновления
|
||||
и включения витрин в конвейер.
|
||||
|
||||
## Что готово в стенде, а что остаётся заданием
|
||||
|
||||
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
|
||||
инфраструктуру контроля:
|
||||
Готовы три витрины дашборда и сводка качества:
|
||||
|
||||
- `revenue_daily` — выручка по дням и категориям; образец материализованной
|
||||
витрины, на него ссылаются учебные задания;
|
||||
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
|
||||
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
|
||||
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
|
||||
[картой идентичностей](dds/identity.md);
|
||||
- `dq_summary` — сводка проверок качества (ниже).
|
||||
- `revenue_daily` — одна строка на день заказа и категорию товара;
|
||||
образец дневной пересборки для учебных заданий.
|
||||
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
|
||||
из кликстрима с заказом магазина и класс расхождения.
|
||||
- `daily_traffic` — одна строка на день: число посетителей и известных
|
||||
пользователей с учетом [карты идентичностей](dds/identity.md).
|
||||
- `dq_summary` — одна строка на день данных и проверку качества.
|
||||
|
||||
Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и
|
||||
образцу. Разминка — представление `events_enriched_v` (зерно события), затем
|
||||
агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней —
|
||||
самая содержательная `utm_effectiveness` с уроком `declared_*`. В финале
|
||||
каждого задания с агрегатом его задачу нужно добавить в `dm_transform`. До
|
||||
этого конвейер о витрине не знает, после — пересобирает её вместе со
|
||||
стендовыми. Представление из разминки добавлять некуда: его не нужно
|
||||
пересобирать. Тексты заданий лежат в [`docs/labs/`](../labs/README.md).
|
||||
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
|
||||
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
|
||||
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
|
||||
Представление `events_enriched_v` отдельной задачи не требует.
|
||||
|
||||
Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не
|
||||
зависит от домашних заданий. Отдельный дашборд по учебным витринам можно
|
||||
собрать в [факультативном задании](../labs/optional/superset-dashboard.md),
|
||||
доступном после ступени 2. Устройство стендового дашборда описано в
|
||||
[справочнике Superset](superset.md).
|
||||
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
|
||||
Дашборд по своим витринам можно собрать в
|
||||
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
|
||||
Устройство стендового дашборда — в [справочнике Superset](superset.md).
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
День витрины пересобирается, пока может измениться его источник, —
|
||||
поэтому ритмов три, по числу источников:
|
||||
Дни для пересчета выбирают по тому, как меняется источник:
|
||||
|
||||
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
|
||||
хвост несобранных дней заменой дневных партиций;
|
||||
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
|
||||
заменяет партиции этого отрезка, дни за ним закрыты по построению;
|
||||
- **от карты идентичностей** — не замерзает никогда: склейка
|
||||
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
|
||||
днях, а карта пересчитывается целиком.
|
||||
- События и сессии прожитого дня на стенде больше не меняются. Витрины
|
||||
по ним собирают диапазон от первого пропущенного дня до последнего
|
||||
прожитого и заменяют дневные партиции.
|
||||
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
|
||||
задает диапазон дат для пересборки; пропущенные дни могут расширить его.
|
||||
- Новые связи карты идентичностей меняют трактовку прошлых визитов:
|
||||
сегодняшняя покупка позволяет узнать пользователя и в ранних днях.
|
||||
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
|
||||
|
||||
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
|
||||
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
|
||||
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
|
||||
а эта витрина заменяется только целиком. На стенде такая работа остается
|
||||
быстрой; витрина читает сессии и карту через их публичные договоры.
|
||||
Пересчитывать точечно только затронутые дни не из чего: для этого надо
|
||||
знать, какие связки новые, а полный пересчёт карты этот след затирает —
|
||||
восстановление следа оставлено лабе повышенной сложности (issue #149).
|
||||
`daily_traffic` каждый раз добавляет всю историю новой версией
|
||||
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
|
||||
строки выбирает представление через `FINAL`.
|
||||
|
||||
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
|
||||
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
|
||||
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
|
||||
самый ранний день с `awaiting_order`: предварительная строка уже есть в
|
||||
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
|
||||
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
|
||||
разгон длиннее K и не оставляет старое ожидание замороженным.
|
||||
У `purchase_vs_orders` два источника. Левая граница — самая ранняя
|
||||
из трех дат: начало окна последнего слепка, первый пропущенный день
|
||||
и первый день с `awaiting_order`. Последний нужен потому, что строка
|
||||
ожидания уже есть в витрине и поиском пропусков не обнаружится.
|
||||
Правая граница — более поздняя из конца окна заказов и последнего
|
||||
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
|
||||
даже если с предыдущего запуска прошло больше семи модельных дней.
|
||||
|
||||
Для учебной сводки брака действует отдельное правило: дни выбираются
|
||||
по времени загрузки, а прежние итоги сохраняются после удаления
|
||||
источника по TTL. Оно разобрано в
|
||||
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
|
||||
|
||||
## Витрины стенда
|
||||
|
||||
@@ -91,34 +87,49 @@
|
||||
|
||||
### `dm.revenue_daily_v`
|
||||
|
||||
Зерно: день заказа × категория товара. Витрина читает только оплаченные
|
||||
Одна строка — день заказа и категория товара. Витрина читает только оплаченные
|
||||
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
|
||||
категорию артикула через `dic.products`. Один заказ с несколькими позициями
|
||||
категории считается в `orders` один раз. `units` складывает количество единиц,
|
||||
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
|
||||
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
|
||||
`revenue` складывает цену позиции × количество. Скидка и доставка
|
||||
в выручку не входят; итог заказа между категориями не делится. `aov` равен `revenue / orders` в том же срезе и
|
||||
хранит два знака после запятой.
|
||||
|
||||
Первый запуск строит всю доступную историю. Следующие берут границы дней из
|
||||
последнего слепка ODS: только источник знает, какие даты еще может изменить.
|
||||
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
|
||||
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
|
||||
отвечает только на вопрос об объеме.
|
||||
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
|
||||
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
|
||||
только на вопрос об объеме.
|
||||
|
||||
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
|
||||
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
|
||||
В группе `revenue_daily` дага `dm_transform` три шага:
|
||||
|
||||
1. [`revenue_daily_scope.sql`](../../sql/dm/revenue_daily_scope.sql)
|
||||
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
|
||||
запрос не возвращает строк, и задача завершается ошибкой.
|
||||
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
|
||||
очищает донор и одной вставкой собирает весь диапазон в
|
||||
`dm.revenue_daily_stage_dist`. Вставка ждет доставки на шарды.
|
||||
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
|
||||
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
|
||||
готовой партицией `dm.revenue_daily_stage_rep`.
|
||||
|
||||
Повтор замены не добавляет копий строк. Ограничения при частичном отказе
|
||||
описаны в [конвейере](etl.md#идемпотентность).
|
||||
|
||||
Цель и донор шардируются
|
||||
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
|
||||
шесть категорий стартового мира близки по объему. Более сложный составной ключ
|
||||
распределил бы эту маленькую таблицу без нового урока.
|
||||
|
||||
### `dm.purchase_vs_orders_v`
|
||||
|
||||
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
||||
Одна строка — один заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
||||
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
|
||||
сумму, а отдельный счет событий оставляет класс `duplicate_event`
|
||||
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
||||
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
|
||||
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
|
||||
сумму. Отдельный счет событий позволяет обнаружить повтор покупки
|
||||
и присвоить класс `duplicate_event`. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
||||
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: левая
|
||||
Distributed-таблица без подзапроса вернула бы каждый непарный заказ
|
||||
по разу с каждого шарда
|
||||
([исследование](../research/2026-08-22-distributed-join.md)).
|
||||
|
||||
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
|
||||
@@ -129,10 +140,10 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
|
||||
события остается отмененным, а не потерянным.
|
||||
|
||||
Витрина нарезана по `order_day`, цель и донор шардируются по
|
||||
Партиции витрины разделены по `order_day`, цель и донор шардируются по
|
||||
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
|
||||
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
|
||||
следующие прогоны идут по двустороннему ритму, описанному выше.
|
||||
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
|
||||
|
||||
### `dm.daily_traffic_v`
|
||||
|
||||
@@ -140,28 +151,26 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
|
||||
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
|
||||
Несколько кук одного человека увеличивают `visitors`, но дают одного
|
||||
`known_user`, поэтому `known_users <= visitors`.
|
||||
известного пользователя, поэтому `known_users <= visitors`.
|
||||
|
||||
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
|
||||
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
|
||||
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
|
||||
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
|
||||
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
|
||||
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
|
||||
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
|
||||
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
|
||||
публичное представление выбирает текущие строки через `FINAL`.
|
||||
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
|
||||
чтение через публичные `_v` с `distributed_product_mode = 'local'`
|
||||
не дало локального соединения. Поэтому в запросе явно указан
|
||||
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
|
||||
в `known_users` учитываются только ненулевые `user_id`.
|
||||
Обновление всей истории новой версией описано в разделе
|
||||
[«Ритм пересборки»](#ритм-пересборки).
|
||||
|
||||
## `dm.dq_summary`
|
||||
|
||||
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
|
||||
Одна строка `dm.dq_summary_v` — день данных и одна проверка.
|
||||
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
|
||||
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
|
||||
|
||||
Деловые колонки сводки:
|
||||
|
||||
- `data_date` — проверяемый день;
|
||||
- `check_name` — устойчивое имя инварианта;
|
||||
- `check_name` — имя проверяемого правила;
|
||||
- `reference_rows` — число деловых ключей на эталонной стороне;
|
||||
- `actual_rows` — число деловых ключей в проверяемом объекте;
|
||||
- `failed_rows` — число отсутствующих или различающихся ключей;
|
||||
@@ -173,7 +182,7 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
## Звено DQ конвейера
|
||||
|
||||
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
|
||||
атомарных договоров:
|
||||
правил:
|
||||
|
||||
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
|
||||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||
@@ -197,9 +206,9 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
скомпенсировать друг друга общим итогом: `failed_rows` считается после
|
||||
сопоставления деловых ключей.
|
||||
|
||||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
|
||||
сам генератор: у настоящего магазина такого оракула нет.
|
||||
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
|
||||
проверка доставки и обработки эталонных данных стенда. Она не проверяет
|
||||
сам генератор; у настоящего магазина такого эталона нет.
|
||||
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||||
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
|
||||
@@ -218,17 +227,20 @@ business_key`.
|
||||
|
||||
## Даг самопроверки
|
||||
|
||||
Домашние задания проверяет `homework_check` — отдельный даг без расписания,
|
||||
который запускают вручную. В нём есть задача для каждого из
|
||||
[пяти заданий по витринам](../labs/README.md).
|
||||
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||||
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||||
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
||||
лабы, а не история качества конвейера.
|
||||
`homework_check` запускается вручную и содержит задачу для каждого из
|
||||
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
|
||||
расходятся с источником, задача завершается ошибкой. Журнал показывает
|
||||
причину и примеры несовпадений. Успех означает, что проверяемые значения
|
||||
совпали; остальные требования задания вы проверяете сами.
|
||||
|
||||
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
|
||||
валидационный даг с группами проверок по слоям давал обратную связь по каждому
|
||||
шагу задания, не трогая рабочий конвейер.
|
||||
Ожидаемые значения рассчитываются по исходным данным текущего стенда.
|
||||
Для четырех заданий это события или сессии DDS, для сводки брака —
|
||||
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
|
||||
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
|
||||
самопроверка учебных витрин отделена от качества готового конвейера.
|
||||
|
||||
Готовые SQL-проверки открывайте после собственного решения: они содержат
|
||||
расчет существенной части задания.
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
|
||||
+90
-82
@@ -1,28 +1,21 @@
|
||||
# Дневной конвейер трансформаций
|
||||
|
||||
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает
|
||||
трансформации, когда и в каком объёме. Правила самих сущностей — зерно,
|
||||
движки, что именно пересобирается — живут в доках слоя
|
||||
([карта модели DDS](dds/README.md)); здесь — только конвейер.
|
||||
|
||||
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
|
||||
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
|
||||
произвольного прошлого дня по команде.
|
||||
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
|
||||
результат. Здесь описаны запуск, выбор дней и публикация данных.
|
||||
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
|
||||
|
||||
## Форма
|
||||
|
||||
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
|
||||
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
|
||||
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
|
||||
[доке слоя DM](dm.md).
|
||||
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
|
||||
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
|
||||
Для этого `TriggerDagRunOperator` ждет завершения созданного им запуска;
|
||||
его ошибка завершает ошибкой и соответствующую задачу конвейера.
|
||||
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
|
||||
|
||||
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
|
||||
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
|
||||
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
|
||||
|
||||
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
|
||||
активный прогон, запускаются триггером конвейера или рукой при разборе
|
||||
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
|
||||
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
|
||||
можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
|
||||
два запуска одного дага не должны одновременно заполнять общий донор.
|
||||
Внутри дага модели собраны в отдельные задачи и группы задач.
|
||||
|
||||
## Три пути запуска
|
||||
|
||||
@@ -34,83 +27,98 @@ world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transfor
|
||||
world_next_day ─────> источники без ETL
|
||||
```
|
||||
|
||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
|
||||
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
|
||||
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
|
||||
прогона».
|
||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
|
||||
времени управляют генератором и только им. После разгона менти запускает
|
||||
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
|
||||
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
||||
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
||||
ручного запуска начинаются со следующих дней.
|
||||
- `world_live_day` запускает конвейер после успешной генерации, приема
|
||||
заказов и обновления `world_position`. Если трансформация упала, день
|
||||
уже прожит и позиция не откатывается. После исправления причины
|
||||
запустите `etl_pipeline` вручную.
|
||||
- `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
|
||||
не запускает. После него запустите `etl_pipeline` сами.
|
||||
- `world_initialize` запускает конвейер последним шагом: при успешном
|
||||
первом `make up` начальные данные уже собраны в DDS и DM.
|
||||
|
||||
## Объём прогона
|
||||
|
||||
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
||||
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
||||
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
|
||||
идентичностей пересчитывается целиком; правила витрин — у
|
||||
[слоя DM](dm.md).
|
||||
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
||||
после падения, а разгону на семь дней хватает одного прогона.
|
||||
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
|
||||
не нужно: даги определяют их по состоянию таблиц и `world_position`.
|
||||
Например, после генерации семи дней без ETL один запуск соберет все семь.
|
||||
|
||||
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
|
||||
`snapshot_date` равен последнему слепку, в котором источник его показал.
|
||||
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
|
||||
минимальный и максимальный `order_date`. Это границы дней, состояние которых
|
||||
источник ещё может изменить
|
||||
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
|
||||
трансформация закрывает пропуски: находит самый ранний день, представленный в
|
||||
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
|
||||
границу.
|
||||
Сессии собираются от первого отсутствующего дня до последнего прожитого.
|
||||
`world_position` хранит номер следующего дня для генерации, поэтому
|
||||
последний прожитый день равен `min(EventDate) + world_position - 1`.
|
||||
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
|
||||
Один `max(EventDate)` не годится: события за день есть и во время его
|
||||
генерации, когда последние визиты еще не закончились.
|
||||
Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
|
||||
|
||||
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
|
||||
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
|
||||
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
|
||||
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
|
||||
Для заказов диапазон задает последний принятый слепок ODS. Среди строк
|
||||
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
|
||||
даты оформления заказа. Эти дни еще могут измениться. Если в DDS
|
||||
отсутствует более ранний день, который есть в ODS, левая граница
|
||||
расширяется до него. Все дни отрезка пересобираются.
|
||||
|
||||
Для сессий границу задаёт `world_position`: она отличает полностью прожитый
|
||||
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
|
||||
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
|
||||
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
|
||||
повторно не собирает. Поэтому последний прожитый день равен первому дню
|
||||
событий плюс позиция минус один
|
||||
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
||||
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
||||
позиция, а «Готовность источника».
|
||||
Позиция мира для выбора дней заказов не подходит: с событиями дня D
|
||||
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
|
||||
их наличие еще не доказывает полноту слепка.
|
||||
|
||||
Карта идентичностей пересчитывается целиком. Витрины выбирают дни
|
||||
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
|
||||
Поиск отсутствующих дней проверяет только наличие даты: частично
|
||||
заполненный день требует отдельной проверки.
|
||||
|
||||
## Готовность источника
|
||||
|
||||
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
|
||||
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
|
||||
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
|
||||
раздела и все смещения должны существовать. Суммарное отставание должно стать
|
||||
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
|
||||
Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
|
||||
условий:
|
||||
|
||||
Только после этого шаг проверяет обе ноды ClickHouse через
|
||||
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
|
||||
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
|
||||
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
|
||||
один крайний срок. После Kafka срок не отсчитывается заново.
|
||||
1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
|
||||
Подтвержденные смещения сравниваются с верхними смещениями Kafka.
|
||||
Группа и смещения должны существовать, суммарное отставание — равняться нулю.
|
||||
2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
|
||||
доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
|
||||
|
||||
Зелёный приём заказов о событиях не говорит ничего — топики разные и
|
||||
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
|
||||
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
|
||||
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
|
||||
Опросы идут с паузой в три секунды; на обе стадии задан общий срок
|
||||
300 секунд. Журнал показывает смещения и отставание Kafka, затем число
|
||||
файлов и `last_exception` очереди на каждой ноде. Если готовность не
|
||||
подтверждена за срок ожидания, задача завершается ошибкой.
|
||||
|
||||
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
|
||||
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
|
||||
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
|
||||
|
||||
Эти условия проверяют доставку на момент опроса. Они не останавливают
|
||||
поступление новых событий и не сверяют содержимое таблиц. Поэтому
|
||||
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
|
||||
а качество результата проверяет `dq_check`.
|
||||
|
||||
## Идемпотентность
|
||||
|
||||
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник —
|
||||
вставка через Distributed раскладывает строки по шардам, — затем
|
||||
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта
|
||||
механика — часть самих трансформаций и строится этапом 4: без неё дневной
|
||||
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без
|
||||
замены: полная перезапись в `ReplacingMergeTree` — так живут карта
|
||||
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)).
|
||||
Идемпотентность означает, что повторная обработка тех же исходных данных
|
||||
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
|
||||
при новой сборке меняются.
|
||||
|
||||
Для дневных таблиц порядок такой: `scope` выбирает даты, `rebuild` очищает
|
||||
донор и собирает в него весь диапазон, затем отдельные задачи `replace`
|
||||
публикуют дни через `REPLACE PARTITION`. Донор — служебная таблица с той же
|
||||
структурой и раскладкой по шардам, что у цели. Запись в `_stage_dist`
|
||||
с `distributed_foreground_insert = 1` дожидается доставки строк перед заменой.
|
||||
|
||||
Замена выполняется на локальных `_rep` с `ON CLUSTER`. Она атомарна
|
||||
на каждом шарде: читатель видит старую или новую партицию. Единой
|
||||
транзакции для двух шардов и всех дней нет. Если замена прошла только
|
||||
на одном шарде, другой может сохранить прежние данные. Повтор задачи
|
||||
`replace` с тем же готовым донором повторит замену без добавления копий.
|
||||
|
||||
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
|
||||
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
|
||||
пропусков ее не выберет. В частности, изменение SQL сессий само по себе
|
||||
не заставляет пересчитать заполненные дни. Повторная сборка выбранного
|
||||
дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md).
|
||||
|
||||
Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск
|
||||
добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`.
|
||||
Публичные представления читают текущие строки через `FINAL`. Старые версии
|
||||
могут физически оставаться до фонового слияния; повторяемость относится
|
||||
к результату чтения, а не к числу строк на диске.
|
||||
|
||||
## Чего конвейер о себе не знает
|
||||
|
||||
|
||||
@@ -30,115 +30,92 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
|
||||
|
||||
## Имена объектов
|
||||
|
||||
Имя объекта заканчивается тем, что это за объект:
|
||||
Суффикс показывает назначение объекта:
|
||||
|
||||
| Суффикс | Что это |
|
||||
|---|---|
|
||||
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
|
||||
| `_dist` | `Distributed` поверх одноимённой локальной |
|
||||
| `_kafka` | таблица на движке `Kafka` |
|
||||
| `_file` | таблица на движке `File` — чтец файла |
|
||||
| `_mv` | материализованное представление |
|
||||
| `_v` | обычное представление, публичная поверхность чтения |
|
||||
| `_rep` | локальная таблица: хранит данные своего шарда, движок семейства `Replicated*` |
|
||||
| `_dist` | таблица `Distributed`: читает данные шардов и распределяет запись между ними |
|
||||
| `_kafka` | таблица на движке `Kafka`: читает сообщения топика |
|
||||
| `_file` | таблица на движке `File`: читает файл |
|
||||
| `_mv` | материализованное представление: обрабатывает вставляемые данные |
|
||||
| `_v` | обычное представление: выполняет сохраненный запрос при чтении |
|
||||
|
||||
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
|
||||
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
||||
именем в документах и разговоре понимается сущность, у которой этих объектов
|
||||
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
||||
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
|
||||
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
|
||||
словарь.
|
||||
Для запросов к моделям DDS и витринам DM используйте `_v`. Представления
|
||||
явно перечисляют колонки, поэтому добавление поля в физическую таблицу
|
||||
не меняет набор полей для читателя. У `dds.event_v` нет отдельной таблицы
|
||||
DDS: представление читает события ODS.
|
||||
|
||||
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
||||
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
||||
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
|
||||
только по `Distributed`» такую тишину переживает плохо.
|
||||
В документации имя без суффикса, например `stg.hits_raw`, обозначает
|
||||
сущность с несколькими объектами. Таблицы с таким именем нет: в запросе
|
||||
нужно указать суффикс. Исключение — словари ClickHouse: `dic.products`
|
||||
обращается к каталогу товаров, а `dic.products_file` читает его файл.
|
||||
|
||||
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
|
||||
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
|
||||
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
|
||||
порядок выдачи `SHOW TABLES` документация не оговаривает.
|
||||
В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных
|
||||
слоях оно называет сущность в единственном числе: `event`, `order`,
|
||||
`session`. Объекты одной сущности различаются суффиксом и стоят рядом
|
||||
при сортировке имен по алфавиту.
|
||||
|
||||
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
|
||||
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
|
||||
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
|
||||
имя приходит от предметной области и стоит в единственном числе: `event`,
|
||||
`order`, `session`. Граница между «как привезли» и «что это такое»
|
||||
проходит по STG, и имена её показывают.
|
||||
|
||||
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
|
||||
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
|
||||
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
|
||||
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
|
||||
кластера и случайно добавленные в физическую таблицу колонки. У события
|
||||
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
|
||||
договор.
|
||||
|
||||
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
|
||||
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
|
||||
публичной поверхностью. Конвенцию держат явный список колонок, документация и
|
||||
ревью, а не запрет на физические таблицы.
|
||||
|
||||
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
|
||||
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
|
||||
ClickHouse и отклонённые варианты записаны в
|
||||
`_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль
|
||||
`bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа
|
||||
принято использовать `_v`. Это соглашение о чтении, а не ограничение прав;
|
||||
причина широкого доступа описана в
|
||||
[ADR 0007](../adr/0007-clickhouse-access.md) и
|
||||
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
|
||||
|
||||
## Раскладка по шардам
|
||||
|
||||
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
|
||||
операций с партициями, ручной переобработки. Правило не про удобство: при записи
|
||||
через распределённую таблицу раскладку определяет ключ шардирования, то есть
|
||||
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
|
||||
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
|
||||
(видно в колонке `consumer_host`), а куда легли данные — нет.
|
||||
Шард хранит часть строк. При записи через `_dist` место строки выбирает
|
||||
ключ шардирования. При прямой записи в `_rep` она остается на той ноде,
|
||||
где выполнена вставка. Поэтому данные записывают через `_dist`,
|
||||
а операции с партициями выполняют на локальных `_rep`.
|
||||
|
||||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||||
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
|
||||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||||
шард.
|
||||
У событий ключ — `cityHash64(ClientID)`, у заказов —
|
||||
`cityHash64(order_id)`, у STG и таблиц ошибок — хеш сырой строки.
|
||||
Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые
|
||||
сообщения на один шард. У сырой строки и разобранного события разные
|
||||
ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают
|
||||
по всему кластеру через `_dist`, а не отдельно по каждой ноде.
|
||||
|
||||
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
|
||||
разобранное из неё событие почти всегда оказываются на разных шардах.
|
||||
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
|
||||
сверять слои можно только через `_dist`.
|
||||
При `INSERT INTO цель_dist SELECT … FROM источник_dist` важна настройка
|
||||
`parallel_distributed_insert_select`. При значении `2` и одном кластере
|
||||
вставка может выполняться локально на шардах, сохраняя раскладку источника
|
||||
вместо ключа цели. Если ключи различаются, укажите
|
||||
`parallel_distributed_insert_select = 0`: нода, принявшая запрос,
|
||||
соберет результат и распределит его по ключу цели. Так сделано в
|
||||
[`sql/ods/order_parse.sql`](../../sql/ods/order_parse.sql) и при сборке
|
||||
выручки. Неверную раскладку проверяют по `hostName()` и ожидаемому шарду
|
||||
строки; общий счет по `_dist` ее не показывает.
|
||||
|
||||
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT …
|
||||
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` —
|
||||
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом
|
||||
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и
|
||||
ошибки при этом нет — расхождение видно только пересчётом ключей по
|
||||
`hostName()`. Так половина заказов и легла на оба шарда, где
|
||||
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что
|
||||
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
|
||||
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
|
||||
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
|
||||
`_dist` и писать в `_dist` с другим ключом.
|
||||
События, `dds.session_dist` и `dds.identity_map_dist` распределены
|
||||
по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
|
||||
одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
|
||||
сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
|
||||
по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
|
||||
Само совпадение ключей еще не гарантирует локального выполнения запроса
|
||||
через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
|
||||
|
||||
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||||
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
||||
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist`
|
||||
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют
|
||||
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин
|
||||
появляются вместе с ними.
|
||||
|
||||
Замена партиций не используется для `ods.order`: версии прошлых дней
|
||||
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
|
||||
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
|
||||
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
|
||||
объектом, а не переносить на ODS заранее.
|
||||
|
||||
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
|
||||
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
|
||||
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
|
||||
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
|
||||
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
|
||||
и задвоилась на другом. Доводы целиком — в
|
||||
Для дневной пересборки DDS и DM используют донор — служебную таблицу,
|
||||
в которой готовят результат перед заменой партиций. Например,
|
||||
`dds.order_stage_rep` объявлена через `AS dds.order_rep`, а запись
|
||||
в нее идет через `dds.order_stage_dist` с тем же `cityHash64(order_id)`.
|
||||
У цели и донора должны совпадать структура, ключи партиции и сортировки,
|
||||
первичный ключ и политика хранения. Эти требования сверены через Context7
|
||||
с [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
|
||||
для используемой схемы на ClickHouse 26.3. Поэтому донор объявлен через
|
||||
`AS` цели с совпадающими ключами. DDL показан в
|
||||
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
|
||||
|
||||
`REPLACE PARTITION` копирует готовую партицию между локальными таблицами
|
||||
на каждом шарде; заново распределять строки по ключу цели эта операция
|
||||
не будет. Поэтому донор должен сразу иметь ту же раскладку, что и цель.
|
||||
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
|
||||
доставки данных на шарды.
|
||||
|
||||
В `ods.order` этот способ не применяется: туда добавляют версии заказов
|
||||
из слепков, а актуальные строки выбираются при чтении
|
||||
([ADR 0010](../adr/0010-order-versions-in-ods.md)).
|
||||
|
||||
## Служебные колонки
|
||||
|
||||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||||
|
||||
Reference in new issue
Block a user