docs(warehouse): упрощены объяснения витрин, сессий и пересчета

Зачем: читателю ступеней 1–4 нужно проследить выбор дней, расчет строки
и публикацию результата без расшифровки метафор.

Что: согласованы справочники DM, сессий, ETL и два раздела хранилища
с комментариями шести SQL-примеров. Уточнены ограничения повторного
запуска и выбора атрибутов при равном времени событий.

Проверка: make lint, git diff --check, локальные Markdown-ссылки и якоря.
SQL без комментариев совпадает; AST Python без строк документации совпадает.
Исторические разделы и материалы вне границ задачи сохранены.

Refs: #249
This commit is contained in:
ddadmin committed 2026-09-13 14:33:39 -04:00
1 parent 84dc718f62
commit 5f317ba8d2
11 files changed
+440 -490

No files matched your search

+69 -92
View File
@@ -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)).
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: