Files
clickstream-data-platform/docs/architecture/dm.md
T
ddadmin af3f23f9ea docs(dm): изложение выбора витрин переписано
- Зачем:
  - объяснение формы витрин читалось искусственно и использовало неуместное слово «менти».
- Что:
  - выбор таблицы или представления объяснён через конкретные действия в задании.
  - слово «менти» убрано из dm.md и AGENTS.md.
  - описание факультатива и ссылка на раздел DM приведены к новому тексту.
- Проверка:
  - git diff --check.
  - проверено отсутствие слова «менти» в изменённых документах и наличие целей ссылок.
2026-09-07 07:42:40 -04:00

22 KiB
Raw Blame History

Слой DM: витрины

Как модель DDS превращается в витрины для потребителя BI: какую форму принимает витрина, кто какие витрины строит и как слой проверяется. Состав колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма, наполнение и контроль.

Таблица или представление

Дневной агрегат и сверка по заказам меняют зерно источника. На этом стенде такие результаты хранятся в dm.<имя>_rep / _dist и читаются через публичное представление dm.<имя>_v с явным списком колонок. Имена и устройство объектов следуют общим конвенциям хранилища. Таблицы наполняет dm_transform; ритм и механика пересборки описаны ниже. В задании этот путь приходится пройти целиком: рассчитать агрегат, загрузить его и включить обновление в конвейер.

Переименование колонок и обогащение строки зерно не меняют. Такие витрины на стенде остаются обычными представлениями и не хранят вторую копию событий.

На объёмах стенда все витрины могли бы остаться представлениями: условия проходят сквозь их цепочку (исследование). Мы все равно оставили материализованные агрегаты ради практики: без них в программе не было бы загрузки и пересборки витрин. Представления дают другой случай: преобразование строк на прежнем зерне.

Что готово в стенде, а что остаётся заданием

Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и инфраструктуру контроля:

  • revenue_daily — выручка по дням и категориям; образец материализованной витрины, на него ссылаются учебные задания;
  • purchase_vs_orders — сверка A+C с классами расхождений; зерно — заказ, шардирование сохраняет ко-локацию заказов по cityHash64(order_id);
  • daily_traffic — пара «посетители» / «известные пользователи», обогащение картой идентичностей;
  • dq_summary — сводка проверок качества (ниже).

Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и образцу. Разминка — представление events_enriched_v (зерно события), затем агрегаты top_pages_daily, session_overview и dq_errors_daily, последней — самая содержательная utm_effectiveness с уроком declared_*. В финале каждого задания с агрегатом его задачу нужно добавить в dm_transform. До этого конвейер о витрине не знает, после — пересобирает её вместе со стендовыми. Представление из разминки добавлять некуда: его не нужно пересобирать. Тексты заданий лежат в docs/labs/.

Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не зависит от домашних заданий. Отдельный дашборд по учебным витринам можно собрать в факультативном задании, доступном после ступени 2. Устройство стендового дашборда описано в справочнике Superset.

Ритм пересборки

День витрины пересобирается, пока может измениться его источник, — поэтому ритмов три, по числу источников:

  • от событий и сессий — день замерзает сразу; пересборка догоняет хвост несобранных дней заменой дневных партиций;
  • от заказов — день дышит, пока входит в последний слепок; пересборка заменяет партиции этого отрезка, дни за ним закрыты по построению;
  • от карты идентичностей — не замерзает никогда: склейка ретроспективна, покупка сегодня делает куку известной во всех её прошлых днях, а карта пересчитывается целиком.

Поэтому daily_traffic наследует у своего источника и ритм, и механику: полный пересчёт всех дней каждый прогон в ReplacingMergeTree(_load_ts), как у самой карты. Дневных партиций у неё нет: партиция — единица замены, а эта витрина заменяется только целиком. На стенде такая работа остается быстрой; витрина читает сессии и карту через их публичные договоры. Пересчитывать точечно только затронутые дни не из чего: для этого надо знать, какие связки новые, а полный пересчёт карты этот след затирает — восстановление следа оставлено лабе повышенной сложности (issue #149).

У purchase_vs_orders два источника и одна непрерывная пересборка. Заказная сторона задает изменяемое окно последним принятым слепком ODS, событийная — последний прожитый день с purchase. Левая граница возвращает первую дыру и самый ранний день с awaiting_order: предварительная строка уже есть в витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный разгон длиннее K и не оставляет старое ожидание замороженным.

Витрины стенда

Публичные представления перечисляют деловые колонки и координаты запуска _load_id, _load_ts явно. Новая колонка физической таблицы сама в договор не попадет.

dm.revenue_daily_v

Зерно: день заказа × категория товара. Витрина читает только оплаченные заказы из dds.order_v, синхронно разворачивает три массива позиций и узнает категорию артикула через dic.products. Один заказ с несколькими позициями категории считается в orders один раз. units складывает количество единиц, revenue складывает цену позиции × количество. Скидка, доставка и итог заказа между категориями не делятся. aov равен revenue / orders в том же срезе и хранит два знака после запятой.

Первый запуск строит всю доступную историю. Следующие берут границы дней из последнего слепка ODS: только источник знает, какие даты еще может изменить. Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS отвечает только на вопрос об объеме.

dm.revenue_daily_rep нарезана по report_date; день собирается в таблице-доноре и заменяется через REPLACE PARTITION. Цель и донор шардируются по cityHash64(product_category). Кроме дня, у витрины только категория, а шесть категорий стартового мира близки по объему. Более сложный составной ключ распределил бы эту маленькую таблицу без нового урока.

dm.purchase_vs_orders_v

Зерно — заказ. Покупки из dds.event_v сначала сворачиваются по purchase_id[1]: контракт источника обещает один номер и одну объявленную сумму, а отдельный счет событий оставляет класс duplicate_event наблюдаемым. Заказы приходят из dds.order_v. Обе стороны записаны подзапросами, затем соединены FULL OUTER GLOBAL JOIN: голая левая Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда (исследование).

Непарная сторона остается NULL благодаря join_use_nulls = 1. При join_use_nulls = 0 ClickHouse заполнил бы ее нулем или пустой строкой и скрыл отсутствие источника. Клиентская 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_user, поэтому known_users <= visitors.

Сессии и карта лежат по client_id, но публичные _v скрывают Distributed-таблицы от локальной подстановки. На ClickHouse 26.3 EXPLAIN PLAN показал чтение обоих шардов на инициаторе: настройка distributed_product_mode = 'local' локального соединения не дала. Поэтому витрина использует явный GLOBAL LEFT JOIN через публичные договоры, а не подменяет их физическими таблицами. Это дороже по сети, зато граница слоев остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск пишет полную историю новой версией в ReplacingMergeTree(_load_ts), а публичное представление выбирает текущие строки через FINAL.

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 называют собравший запуск. Представление перечисляет колонки явно. Порогов и промежуточного состояния нет.

Звено 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.

Область каждой проверки выводится из объединения сторон. Полностью потерянный день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут скомпенсировать друг друга общим итогом: failed_rows считается после сопоставления деловых ключей.

classes_vs_inventory — техническая растяжка пути от генератора до DM. Она подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет сам генератор: у настоящего магазина такого оракула нет.

Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг заменяет проверенные дневные партиции. Затем именованные задачи читают опубликованную сводку и падают при failed_rows > 0. Расхождение успевает сохраниться до красного завершения, а ошибка сборки не затирает прежний результат. Красная задача печатает не больше двадцати нарушенных ключей за проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки заменяют на SELECT * FROM comparison WHERE failed ORDER BY data_date, business_key.

Если после починки день исчез с обеих сторон, его больше нет и в новом доноре. Такой день удаляется из сводки отдельно: старая красная строка не должна превращаться в историю попыток.

Даг самопроверки

Домашние задания проверяет homework_check — отдельный даг без расписания, который запускают вручную. В нём есть задача для каждого из пяти заданий по витринам. Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины, золотые константы не прибиваются. В dq_summary даг не пишет: он инструмент лабы, а не история качества конвейера.

Приём проверен на соседнем учебном стенде Airflow + Greenplum: валидационный даг с группами проверок по слоям давал обратную связь по каждому шагу задания, не трогая рабочий конвейер.

Отклонённые варианты

  • Весь слой представлениями. Дёшево и всегда свежо, но главный производственный паттерн слоя — пересборка агрегатов — остался бы невидим, а dm_transform — без работы.
  • Материализовать всё. Копия событий ради переименования колонок — лишняя; такой пример приучал бы без нужды дублировать исходные данные.
  • Гибрид по цене запроса. Выбор формы по стоимости, которой на объёмах стенда нет: сторож без настоящей опасности.
  • Проверки домашек в конвейерном звене DQ. Конвейер стоял бы красным до выполнения лаб, а проверка, которая всегда красная, приучает себя игнорировать.
  • «Жёлтый» статус недостроенной витрины. У задачи Airflow нет такого состояния; изображать его логами — семантика, которую пришлось бы расшифровывать. Красное «ещё не построено» честнее.
  • Самопроверка пишет в dq_summary. Результаты учебных попыток смешались бы с историей качества конвейера.
  • Окно заказов для daily_traffic — прошлое замерзает. Дни за окном хранили бы число известных на момент закрытия — витрина тихо расходится с картой и протаскивает обратно «знание на дату», отвергнутое развилкой карты (#133).
  • Инкрементальный пересчёт затронутых дней по first_matched_at. Производственный ход, но на объёмах стенда его экономия неизмерима, а след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой повышенной сложности — issue #149.
  • Superset поверх DDS без слоя DM. Терялся бы урок «витрина — контракт под потребителя», против раздела 7 спеки.

Связанные решения