Зачем: дать практику учета новых связей пользователей при обновлении прежних дней витрины. Что: добавлены две связанные лабораторные с наблюдениями через Airflow, собственными таблицами student и повтором частично записанной загрузки. Обновлены программа, индекс и ссылки из справочников DDS и DM. Проверка: DDL и SQL под student на ClickHouse 26.3.17.56, четыре модельных дня через Airflow, полные сверки и повтор незавершенной загрузки; config-test, lint, локальные ссылки и независимое ревью.
301 lines
24 KiB
Markdown
301 lines
24 KiB
Markdown
# Слой DM: витрины
|
||
|
||
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
|
||
хранения, правила обновления и проверки. Состав колонок задан в
|
||
[спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
|
||
и [учебных заданиях](../labs/README.md).
|
||
|
||
## Таблица или представление
|
||
|
||
Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
|
||
а в дневной выручке — день и категория товара. На стенде результат
|
||
с новым зерном хранится в таблице: отчет читает готовые итоги без
|
||
повторного расчета. Таблицу наполняет `dm_transform`.
|
||
|
||
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
|
||
представление `_v` с явным списком колонок. Для обновления по дням
|
||
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
|
||
В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
|
||
Затем дневные разделы основной таблицы заменяют готовыми разделами
|
||
промежуточной. Такие разделы называются партициями. Подробности — в
|
||
[справочнике хранилища](storage.md#имена-объектов).
|
||
|
||
Если запрос только переименовывает или дополняет поля и сохраняет одну
|
||
строку на событие, достаточно обычного представления. Оно выполняет
|
||
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
|
||
|
||
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
|
||
([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||
Готовые таблицы нужны в программе для практики загрузки, обновления
|
||
и включения витрин в конвейер.
|
||
|
||
## Что готово в стенде, а что остаётся заданием
|
||
|
||
Готовы три витрины дашборда и сводка качества:
|
||
|
||
- `revenue_daily` — одна строка на день заказа и категорию товара;
|
||
образец дневной пересборки для учебных заданий.
|
||
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
|
||
из кликстрима с заказом магазина и класс расхождения.
|
||
- `daily_traffic` — одна строка на день: число посетителей и известных
|
||
пользователей с учетом [карты идентичностей](dds/identity.md).
|
||
- `dq_summary` — одна строка на день данных и проверку качества.
|
||
|
||
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
|
||
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
|
||
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
|
||
Представление `events_enriched_v` отдельной задачи не требует.
|
||
|
||
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
|
||
Дашборд по своим витринам можно собрать в
|
||
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
|
||
Устройство стендового дашборда — в [справочнике Superset](superset.md).
|
||
|
||
## Ритм пересборки
|
||
|
||
Дни для пересчета выбирают по тому, как меняется источник:
|
||
|
||
- События и сессии прожитого дня на стенде больше не меняются. Витрины
|
||
по ним рассчитывают показатели от первого пропущенного дня до последнего
|
||
прожитого и заменяют дневные партиции.
|
||
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
|
||
задает диапазон дат для пересборки; пропущенные дни могут расширить его.
|
||
- Новая связь в карте идентичностей позволяет определить пользователя
|
||
по его прежним визитам: один `client_id` мог встречаться и до покупки,
|
||
которая связала его с `user_id`.
|
||
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
|
||
|
||
`daily_traffic` каждый раз добавляет всю историю новой версией
|
||
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
|
||
строки выбирает представление через `FINAL`.
|
||
|
||
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
|
||
из трех дат: первая дата оформления заказа в последнем слепке, первый
|
||
пропущенный день и первый день с `awaiting_order` — покупкой, для которой
|
||
заказ пока не поступил. День ожидания проверяется отдельно: строки за него
|
||
уже есть в витрине, поэтому он не считается пропущенным.
|
||
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
|
||
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
|
||
даже если с предыдущего запуска прошло больше семи модельных дней.
|
||
|
||
Для учебной сводки брака действует отдельное правило: дни выбираются
|
||
по времени загрузки, а прежние итоги сохраняются после удаления
|
||
источника по TTL. Оно разобрано в
|
||
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
|
||
|
||
## Витрины стенда
|
||
|
||
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
|
||
и `_load_ts` — идентификатор запуска и время записи. Новая колонка
|
||
физической таблицы появится в представлении только после изменения его SQL.
|
||
|
||
### `dm.revenue_daily_v`
|
||
|
||
Одна строка содержит итоги оплаченных заказов за день по категории товара.
|
||
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
|
||
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
|
||
становятся одной строкой. Категория артикула берется из `dic.products`.
|
||
|
||
`orders` считает разные заказы: несколько позиций одной категории
|
||
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
|
||
`revenue` — сумма произведений цены на количество. Скидка и доставка
|
||
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
|
||
с округлением до двух знаков после запятой.
|
||
|
||
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
|
||
берут диапазон дат оформления заказов из последнего слепка ODS.
|
||
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
|
||
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
|
||
только на вопрос об объеме.
|
||
|
||
В группе `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_rep`, рассчитывает показатели за выбранные
|
||
дни и записывает строки через `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` группируются по номеру заказа `purchase_id[1]`:
|
||
по формату источника у покупки один номер и одна заявленная сумма.
|
||
Отдельный счет событий позволяет обнаружить повтор покупки
|
||
и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
|
||
|
||
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
|
||
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
|
||
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
|
||
([исследование](../research/2026-08-22-distributed-join.md)).
|
||
|
||
Если для строки нет пары, поля отсутствующей стороны получают `NULL`
|
||
благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
|
||
нулями или пустыми строками, которые сложнее отличить от данных источника.
|
||
Клиентская `declared_revenue` и серверная `items_total` сравниваются
|
||
как `Decimal(18, 2)`. Классы проверяются по порядку:
|
||
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
|
||
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
|
||
события остается отмененным, а не потерянным.
|
||
|
||
Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
|
||
распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
|
||
выбранный диапазон и одной вставкой записывает строки в промежуточную
|
||
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
|
||
Первичная сборка охватывает всю историю обоих источников;
|
||
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
|
||
|
||
### `dm.daily_traffic_v`
|
||
|
||
В строке один день. `visitors` считает разные `client_id` из
|
||
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
|
||
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
|
||
Несколько кук, связанных с одним пользователем магазина, учитываются
|
||
в `visitors` по отдельности, а в `known_users` дают одного пользователя.
|
||
Поэтому `known_users <= visitors`.
|
||
|
||
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
|
||
чтение через публичные `_v` с `distributed_product_mode = 'local'`
|
||
не дало локального соединения. Поэтому в запросе явно указан
|
||
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
|
||
в `known_users` учитываются только ненулевые `user_id`.
|
||
Обновление всей истории новой версией описано в разделе
|
||
[«Ритм пересборки»](#ритм-пересборки).
|
||
|
||
## `dm.dq_summary`
|
||
|
||
Одна строка `dm.dq_summary_v` — день данных и одна проверка.
|
||
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
|
||
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
|
||
|
||
Деловые колонки сводки:
|
||
|
||
- `data_date` — проверяемый день;
|
||
- `check_name` — имя проверяемого правила;
|
||
- `reference_rows` — число деловых ключей на эталонной стороне;
|
||
- `actual_rows` — число деловых ключей в проверяемом объекте;
|
||
- `failed_rows` — число отсутствующих или различающихся ключей;
|
||
- `status` — `pass` при нуле нарушений, иначе `fail`.
|
||
|
||
В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
|
||
сводки. Даже одно расхождение дает `fail`.
|
||
|
||
## Звено DQ конвейера
|
||
|
||
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
|
||
правил:
|
||
|
||
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
|
||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||
`dds.session_v`;
|
||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||
`orders` в паспорте мира, объект — число заказов класса в
|
||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
|
||
дней;
|
||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
|
||
`dds.order_v`;
|
||
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
|
||
сравниваются с прямыми агрегатами `dds.session_v` и
|
||
`dds.identity_map_v`.
|
||
|
||
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
|
||
Поэтому потерянная строка остается видна на другой стороне сравнения.
|
||
Расхождения считаются по ключам: завышение одного показателя не скроет
|
||
занижение другого в общей сумме за день.
|
||
|
||
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
|
||
проверка доставки и обработки эталонных данных стенда. Она не проверяет
|
||
сам генератор; у настоящего магазина такого эталона нет.
|
||
|
||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
|
||
файл JSON в Python. Результаты всех пяти проверок записываются в общую
|
||
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
|
||
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
|
||
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
|
||
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
|
||
не выполнен, прежняя сводка остается на месте. Задача с расхождениями
|
||
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
|
||
business_key`.
|
||
|
||
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
|
||
для него строк. Даг отдельно удаляет прежний результат за этот день
|
||
из сводки.
|
||
|
||
## Даг самопроверки
|
||
|
||
`homework_check` запускается вручную и содержит задачу для каждого из
|
||
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
|
||
расходятся с источником, задача завершается ошибкой. Журнал показывает
|
||
причину и примеры несовпадений. Успех означает, что проверяемые значения
|
||
совпали; остальные требования задания вы проверяете сами.
|
||
|
||
Ожидаемые значения рассчитываются по исходным данным текущего стенда.
|
||
Для четырех заданий это события или сессии DDS, для сводки брака —
|
||
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
|
||
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
|
||
самопроверка учебных витрин отделена от качества готового конвейера.
|
||
|
||
Готовые SQL-проверки открывайте после собственного решения: они содержат
|
||
расчет существенной части задания.
|
||
|
||
## Отклонённые варианты
|
||
|
||
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
|
||
производственный паттерн слоя — пересборка агрегатов — остался бы
|
||
невидим, а `dm_transform` — без работы.
|
||
- **Материализовать всё.** Копия событий ради переименования колонок —
|
||
лишняя; такой пример приучал бы без нужды дублировать исходные данные.
|
||
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
|
||
стенда нет: сторож без настоящей опасности.
|
||
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
|
||
выполнения лаб, а проверка, которая всегда красная, приучает себя
|
||
игнорировать.
|
||
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
|
||
состояния; изображать его логами — семантика, которую пришлось бы
|
||
расшифровывать. Красное «ещё не построено» честнее.
|
||
- **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы
|
||
с историей качества конвейера.
|
||
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||
(#133).
|
||
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
|
||
Производственный ход, но на объёмах стенда его экономия неизмерима, а
|
||
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
|
||
повышенной сложности —
|
||
[«Как обновлять витрину без полной пересборки»](../labs/optional/incremental-daily-traffic.md).
|
||
В ней новые связи находятся сравнением наблюдений: время покупки
|
||
не заменяет момент обнаружения связи.
|
||
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
|
||
под потребителя», против раздела 7 спеки.
|
||
|
||
## Связанные решения
|
||
|
||
- [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, —
|
||
состав колонок витрин и классы сверки.
|
||
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
|
||
замена партиций на кластере.
|
||
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
|
||
партиция-донор.
|
||
- [Карта модели DDS](dds/README.md) — источники витрин.
|
||
- Резолюция — тикет #135 карты #127.
|