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
+21
-42
@@ -1,35 +1,17 @@
|
||||
-- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник.
|
||||
-- DDS: сборка сессий выбранных дней в донор dds.session_stage_dist.
|
||||
-- Донор очищается целиком; одна вставка собирает весь диапазон.
|
||||
--
|
||||
-- Двойник очищается целиком в начале, дни собираются одной вставкой на весь
|
||||
-- отрезок — по правилу заказа (sql/dds/order_rebuild.sql).
|
||||
-- Первое окно помечает событие единицей, если с предыдущего события того же
|
||||
-- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует
|
||||
-- эти единицы: получается номер визита внутри посетителя и дня.
|
||||
-- PARTITION BY (client_id, event_date) разделяет визиты по модельной полночи.
|
||||
-- Учитываются все типы событий.
|
||||
--
|
||||
-- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд.
|
||||
-- Первое спрашивает у каждого события, сколько прошло с предыдущего события
|
||||
-- той же куки в тот же день, и ставит единицу там, где пауза длиннее
|
||||
-- таймаута: это шов между визитами. Второе считает нарастающий итог этих
|
||||
-- единиц — номер визита внутри дня куки. Дальше визит становится обычной
|
||||
-- группировкой.
|
||||
--
|
||||
-- Границу суток окна не проверяют: она уже в разбиении PARTITION BY
|
||||
-- (client_id, event_date), поэтому визит через полночь не склеится и при
|
||||
-- короткой паузе. Так правило визита ложится в запрос целиком: одна кука,
|
||||
-- один день, паузы не длиннее таймаута (CONTEXT.md, «Визит»).
|
||||
--
|
||||
-- Первое событие куки за день сравнивается само с собой — третий аргумент
|
||||
-- lagInFrame. Паузы у него нет, шов ему не нужен, и номер первого визита
|
||||
-- получается нулём: номера различают визиты внутри дня куки, а не считают их
|
||||
-- от единицы.
|
||||
--
|
||||
-- Порядок в окнах полный — метка времени и event_id. Окна считаются двумя
|
||||
-- разными сортировками (видно в EXPLAIN PIPELINE), и при равных метках они
|
||||
-- вправе разойтись: шов достался бы одной строке, а нарастающий итог другой,
|
||||
-- и сессия разъехалась бы надвое.
|
||||
--
|
||||
-- Тридцать минут написаны числом, а не приехали параметром: это правило
|
||||
-- аналитики, а не константа мира. Генератор режет визиты тем же порогом;
|
||||
-- разойдись эти два числа — увидит счётная сверка с VisitID
|
||||
-- (docs/architecture/dds/session.md, «Сверка с эталоном»).
|
||||
|
||||
-- У первого события lagInFrame возвращает его собственное время: разница
|
||||
-- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю.
|
||||
-- Оба окна используют (event_time, event_id), чтобы при равном времени
|
||||
-- признак начала и накопленный номер визита относились к одним событиям.
|
||||
-- Порог 1800 секунд — правило визита; он не передается параметром.
|
||||
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
|
||||
|
||||
INSERT INTO dds.session_stage_dist
|
||||
@@ -93,8 +75,8 @@ WITH
|
||||
FROM seams
|
||||
)
|
||||
SELECT
|
||||
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи,
|
||||
-- поэтому id держится за содержание сессии, а не за порядок сборки.
|
||||
-- Идентификатор зависит от посетителя и начала визита.
|
||||
-- Повторная сборка тех же событий дает тот же session_id.
|
||||
cityHash64(client_id, min(event_time)),
|
||||
client_id,
|
||||
event_date,
|
||||
@@ -107,8 +89,10 @@ SELECT
|
||||
countIf(event_type = 'purchase'),
|
||||
argMin(url, event_time),
|
||||
argMax(url, event_time),
|
||||
-- Атрибуция и паспорт куки берутся с первого события визита. У паспорта
|
||||
-- любое событие дало бы то же: устройство и город у куки постоянны.
|
||||
-- UTM, устройство и город берутся по минимальному event_time.
|
||||
-- Как и у страниц выше, при равном времени выбор между событиями
|
||||
-- не определен: event_id в эти argMin/argMax не входит.
|
||||
-- Устройство и город на стенде постоянны для одной куки.
|
||||
argMin(utm_source, event_time),
|
||||
argMin(utm_medium, event_time),
|
||||
argMin(utm_campaign, event_time),
|
||||
@@ -120,12 +104,7 @@ SELECT
|
||||
now64(3, 'UTC')
|
||||
FROM numbered
|
||||
GROUP BY client_id, event_date, visit_no
|
||||
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш
|
||||
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от
|
||||
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись
|
||||
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
|
||||
-- называть ноль вслух: совпадение ключей у него случайное
|
||||
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
|
||||
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
|
||||
-- что-то решает.
|
||||
-- Источник и цель распределены по cityHash64(client_id): локальная вставка
|
||||
-- сохраняет нужную раскладку, parallel_distributed_insert_select менять
|
||||
-- не требуется. Перед заменой партиций ждем доставки строк на шарды.
|
||||
SETTINGS distributed_foreground_insert = 1;
|
||||
@@ -1,11 +1,10 @@
|
||||
-- DDS: подмена одного дня сессий собранной партицией двойника.
|
||||
-- DDS: замена одного дня в session_rep готовой партицией session_stage_rep.
|
||||
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
|
||||
-- Замена атомарна на одном шарде, общей транзакции для кластера нет.
|
||||
-- Повтор с тем же донором не добавляет копий строк.
|
||||
--
|
||||
-- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по
|
||||
-- локальным таблицам, шард за шардом, атомарность пошардовая, день
|
||||
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт.
|
||||
--
|
||||
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
|
||||
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
|
||||
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
|
||||
-- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в
|
||||
-- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего
|
||||
-- запуска не обнаружит неполный день, если часть его строк уже есть в DDS.
|
||||
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
|
||||
REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep;
|
||||
+15
-35
@@ -1,50 +1,30 @@
|
||||
-- DDS: объём пересборки сессий — какие дни собирает этот запуск.
|
||||
-- DDS: дни для сборки сессий, от first_day до last_day включительно.
|
||||
-- Пустой ответ означает пустой ODS; NULL в first_day — отсутствие работы.
|
||||
--
|
||||
-- Ответ — две даты, границы непрерывного отрезка; список дней между ними
|
||||
-- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый
|
||||
-- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно
|
||||
-- пропускает сборку.
|
||||
-- position — номер следующего дня для генерации. Последний прожитый день
|
||||
-- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта
|
||||
-- граница исключает еще идущий день. Ранние события ODS не удаляются по TTL,
|
||||
-- поэтому min(EventDate) сохраняет начало модельного календаря.
|
||||
--
|
||||
-- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql.
|
||||
-- До этого запроса даг ждет чтения hits и опустошения очереди ods.event_dist.
|
||||
-- Позиция мира сама по себе не подтверждает доставку событий.
|
||||
--
|
||||
-- Правый край — последний прожитый день, и знает его только позиция мира.
|
||||
-- События живой день от прожитого не отличают: и посреди дня D, и сразу после
|
||||
-- его конца max(EventDate) = D. А разница несущая — визит, начатый в 23:50,
|
||||
-- посреди дня ещё идёт, и собранная сессия оказалась бы обрезанной навсегда:
|
||||
-- пересобирать замёрзший день слой не станет
|
||||
-- (docs/architecture/dds/session.md, «Движок и наполнение»).
|
||||
--
|
||||
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
|
||||
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
|
||||
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
|
||||
-- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам
|
||||
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
|
||||
--
|
||||
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
|
||||
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
|
||||
-- недожеванный хвост заморозил бы недособранный день
|
||||
-- (docs/architecture/etl.md, «Готовность источника»).
|
||||
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
|
||||
WITH
|
||||
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
|
||||
first_source_day + ({position:UInt16} - 1) AS last_lived_day,
|
||||
-- Хвост считается от первой дыры, а не от последнего собранного дня:
|
||||
-- замены партиций падают порознь, и счёт по максимуму собранного дня
|
||||
-- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql).
|
||||
-- У сессий это единственный способ дыру найти: дышащего окна, которое
|
||||
-- пересобрало бы день заодно, здесь нет.
|
||||
--
|
||||
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
||||
-- бы дни источника с локальной частью слоя на каждом шарде.
|
||||
-- minOrNull отличает «дыр нет» от даты по умолчанию.
|
||||
-- Ищем первый день ODS, которого нет в DDS: максимум собранного дня
|
||||
-- пропустил бы более ранний пробел. Частично заполненный день так не найти.
|
||||
-- GLOBAL сравнивает со списком дней всего DDS, а не одного шарда.
|
||||
-- minOrNull возвращает NULL, если пропусков нет.
|
||||
(
|
||||
SELECT minOrNull(day)
|
||||
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
|
||||
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
|
||||
) AS first_missing_day
|
||||
SELECT
|
||||
-- Живой день в слое отсутствует всегда, и работой это не считается:
|
||||
-- дырой он станет, только когда мир его проживёт.
|
||||
-- Отсутствующий день попадет в сборку только после его завершения.
|
||||
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
|
||||
last_lived_day AS last_day
|
||||
-- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней.
|
||||
-- FINAL не нужен: повтор доставки не меняет начало календаря и набор дат.
|
||||
WHERE (SELECT count() FROM ods.event_dist) > 0
|
||||
@@ -1,7 +1,8 @@
|
||||
-- DM: сборка выручки в донор перед заменой дневных партиций.
|
||||
-- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не
|
||||
-- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql.
|
||||
|
||||
-- DM: сборка выручки за выбранный диапазон в донор.
|
||||
-- DROP PARTITION ALL очищает донор на каждом узле. Вставка ниже дает
|
||||
-- одну строку на день заказа и категорию товара: только оплаченные заказы.
|
||||
-- ARRAY JOIN разворачивает согласованные позиции трех массивов вместе.
|
||||
-- Причина выбора DROP вместо TRUNCATE — в sql/dds/order_rebuild.sql.
|
||||
ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
|
||||
DROP PARTITION ALL;
|
||||
|
||||
@@ -22,8 +23,8 @@ SELECT
|
||||
orders,
|
||||
units,
|
||||
CAST(revenue, 'Decimal(18, 2)'),
|
||||
-- Обычное деление Decimal усекает доли копейки вместо округления;
|
||||
-- запас масштаба сохраняет их до денежного результата.
|
||||
-- Делим с шестью знаками после запятой, затем округляем до копеек.
|
||||
-- Так дробная часть сохраняется до округления среднего чека.
|
||||
CAST(
|
||||
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
|
||||
'Decimal(18, 2)'
|
||||
@@ -49,7 +50,8 @@ FROM
|
||||
report_date,
|
||||
product_category
|
||||
)
|
||||
-- Источник лежит по заказу, цель — по категории. Ноль заставляет инициатор
|
||||
-- слить частичные агрегаты и разложить готовые строки по ключу цели.
|
||||
-- Источник распределен по заказу, цель — по категории. Значение 0 оставляет
|
||||
-- итоговую агрегацию и распределение по ключу цели на ноде, принявшей запрос.
|
||||
-- distributed_foreground_insert = 1 ждет доставки строк перед заменой.
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
@@ -1,8 +1,8 @@
|
||||
-- DM: подмена одного дня выручки готовой партицией донора.
|
||||
--
|
||||
-- День приходит из результата revenue_daily_scope.sql, а не от пользователя.
|
||||
-- Jinja нужна потому, что ClickHouse не подставляет параметр в PARTITION.
|
||||
-- Замена идет на локальной таблице ON CLUSTER: граница атомарности и повтор
|
||||
-- разобраны на таком же шаге в sql/dds/order_replace.sql.
|
||||
-- DM: замена дня выручки готовой партицией донора.
|
||||
-- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse
|
||||
-- в ALTER ... PARTITION здесь не поддерживается.
|
||||
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
|
||||
-- Замена атомарна на одном шарде, общей транзакции для кластера нет.
|
||||
-- Повтор с тем же донором не добавляет копий строк.
|
||||
ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster
|
||||
REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep;
|
||||
@@ -1,14 +1,11 @@
|
||||
-- DM: объем пересборки выручки.
|
||||
-- DM: диапазон пересборки выручки, first_day и last_day включительно.
|
||||
-- Последний слепок ODS задает даты оформления еще изменяемых заказов.
|
||||
-- Агрегат читает DDS; ODS нужен только для границ диапазона.
|
||||
-- Левая граница расширяется до первого дня с оплаченными заказами DDS,
|
||||
-- которого нет в витрине. Так первый запуск охватывает историю выручки.
|
||||
-- По наличию даты нельзя обнаружить частично заполненный день.
|
||||
--
|
||||
-- Последний слепок ODS называет дни, состояние которых источник еще может
|
||||
-- изменить. Сам агрегат читает только DDS; ODS здесь нужен лишь для границ.
|
||||
-- Первый запуск и повтор после частичного отказа расширяют левую границу до
|
||||
-- первого дня оплаченных заказов, которого еще нет в витрине.
|
||||
-- Почему хвост начинается с первого пропуска и чем за это платит ODS,
|
||||
-- разобрано в sql/dds/order_scope.sql.
|
||||
--
|
||||
-- Файл кончается без точки с запятой: даг читает его одним запросом и ждет
|
||||
-- одну строку с границами.
|
||||
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
|
||||
WITH
|
||||
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||
(
|
||||
@@ -24,8 +21,7 @@ WITH
|
||||
(
|
||||
SELECT minOrNull(report_date)
|
||||
FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid')
|
||||
-- Правая сторона тоже распределенная: GLOBAL собирает множество дней
|
||||
-- на инициаторе и не превращает запрос в запрещенное двойное чтение.
|
||||
-- GLOBAL собирает список дней витрины со всех шардов.
|
||||
WHERE report_date GLOBAL NOT IN (
|
||||
SELECT report_date FROM dm.revenue_daily_dist
|
||||
)
|
||||
@@ -33,7 +29,7 @@ WITH
|
||||
SELECT
|
||||
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
||||
window_end AS last_day
|
||||
-- Без ODS неизвестно окно. Без готового DDS донор останется пустым, а замена
|
||||
-- сотрет уже собранные дни выручки. Поэтому для прогона нужны оба источника.
|
||||
-- Для выбора диапазона нужны заказы в ODS, для расчета — в DDS.
|
||||
-- Эта проверка наличия строк не подтверждает полноту источников.
|
||||
WHERE (SELECT count() FROM dds.order_v) > 0
|
||||
AND (SELECT count() FROM ods.order_dist) > 0
|
||||
Reference in new issue
Block a user