Зачем: помочь начинающему инженеру проследить событие и версию заказа от генератора до хранилища, включая ошибки и повторные поступления. Что: обновлены вводная генератора, справочники приема и модели заказа, разделы хранилища и комментарии SQL и дага. Сохранены технические условия; исполняемый код, схемы и поведение стенда не изменены. Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI. SQL без комментариев и Python AST без строк документации совпали с исходными. Генерация данных и сброс состояния не выполнялись. Refs #250
75 KiB
Хранилище: слои и конвенции
Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и раздел «Что проверено» — чему в этом тексте верить и на каком основании.
Что здесь описано и чего ещё нет. Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 собран: в sql/ddl/ лежит вся цепочка
Kafka → STG → ODS — чтец топика hits, таблицы STG, типизированное событие с
таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
вход второго источника и довёл его до ODS: топик orders, свой чтец, свой сырой
слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
Наполняет всю цепочку даг orders_ingest двумя шагами, а не матвью. Тот же этап
принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
Живёт он вне цепочки слоёв, в зоне dic, и читает не файл, а подложку над ним
(ADR 0012). Этап 4 начат публичным договором
события dds.event_v над точным состоянием ODS, а следом получил две физические
модели слоя — заказ и сессию: их собирает заменой партиций даг dds_transform.
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
от заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в спеке генератора, формат события — в описании выгрузки. Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — спека «Боевой реализм стенда (v2)».
Имена объектов
Суффикс показывает назначение объекта:
| Суффикс | Что это |
|---|---|
_rep |
локальная таблица: хранит данные своего шарда, движок семейства Replicated* |
_dist |
таблица Distributed: читает данные шардов и распределяет запись между ними |
_kafka |
таблица на движке Kafka: читает сообщения топика |
_file |
таблица на движке File: читает файл |
_mv |
материализованное представление: обрабатывает вставляемые данные |
_v |
обычное представление: выполняет сохраненный запрос при чтении |
Для запросов к моделям DDS и витринам DM используйте _v. Представления
явно перечисляют колонки, поэтому добавление поля в физическую таблицу
не меняет набор полей для читателя. У dds.event_v нет отдельной таблицы
DDS: представление читает события ODS.
В документации имя без суффикса, например stg.hits_raw, обозначает
сущность с несколькими объектами. Таблицы с таким именем нет: в запросе
нужно указать суффикс. Исключение — словари ClickHouse: dic.products
обращается к каталогу товаров, а dic.products_file читает его файл.
В STG имя берется из топика: hits_raw, orders_raw. В типизированных
слоях оно называет сущность в единственном числе: event, order,
session. Объекты одной сущности различаются суффиксом и стоят рядом
при сортировке имен по алфавиту.
_rep и _dist нужны для загрузки, диагностики и обслуживания. Роль
bi_reader может читать их в ODS, DDS и DM, но для обычного анализа
принято использовать _v. Это соглашение о чтении, а не ограничение прав;
причина широкого доступа описана в
ADR 0007 и
исследовании.
Раскладка по шардам
Шард хранит часть строк. При записи через _dist место строки выбирает
ключ шардирования. При прямой записи в _rep она остается на той ноде,
где выполнена вставка. Поэтому данные записывают через _dist,
а операции с партициями выполняют на локальных _rep.
У событий ключ — cityHash64(ClientID), у заказов —
cityHash64(order_id), у STG и таблиц ошибок — хеш сырой строки.
Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые
сообщения на один шард. У сырой строки и разобранного события разные
ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают
по всему кластеру через _dist, а не отдельно по каждой ноде.
При INSERT INTO цель_dist SELECT … FROM источник_dist важна настройка
parallel_distributed_insert_select. При значении 2 и одном кластере
вставка может выполняться локально на шардах, сохраняя раскладку источника
вместо ключа цели. Если ключи различаются, укажите
parallel_distributed_insert_select = 0: нода, принявшая запрос,
соберет результат и распределит его по ключу цели. Так сделано в
sql/ods/order_parse.sql и при сборке
выручки. Неверную раскладку проверяют по hostName() и ожидаемому шарду
строки; общий счет по _dist ее не показывает.
События, dds.session_dist и dds.identity_map_dist распределены
по cityHash64(client_id) (в ODS поле называется ClientID). Данные
одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
сохраняют раскладку по cityHash64(order_id). Обычное соединение допускается
по ключу ко-локации; для остальных соединений применяется GLOBAL.
Само совпадение ключей еще не гарантирует локального выполнения запроса
через представления — пример есть в описании daily_traffic.
Для дневной пересборки DDS и DM результат сначала записывают
в промежуточную таблицу. Основная таблица в это время остается доступна
для чтения с прежними данными. Например, заказы записываются через
dds.order_stage_dist в dds.order_stage_rep, затем ее дневные разделы
заменяют разделы основной dds.order_rep.
У промежуточной и основной таблиц должны совпадать структура, ключи
партиции и сортировки, первичный ключ и политика хранения. Поэтому
dds.order_stage_rep объявлена через AS dds.order_rep с совпадающими
ключами. Требования сверены через Context7 с
документацией ClickHouse
для схемы на ClickHouse 26.3. DDL показан в
sql/ddl/45-dds-tables.sql.
Промежуточная таблица должна также распределять строки по тому же ключу,
здесь — cityHash64(order_id). REPLACE PARTITION копирует раздел между
локальными таблицами каждого шарда и не перераспределяет строки.
Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде.
Перед заменой вставка с distributed_foreground_insert = 1 дожидается
доставки данных на шарды.
В ods.order этот способ не применяется: туда добавляют версии заказов
из слепков, а актуальные строки выбираются при чтении
(ADR 0010).
Служебные колонки
STG сохраняет исходное сообщение в строке raw и добавляет сведения
о доставке. JSON на этом шаге не разбирается: некорректное содержимое
тоже попадает в таблицу.
| Колонка STG | Что хранит |
|---|---|
kafka_topic |
Имя топика из виртуальной колонки Kafka _topic |
kafka_partition |
Номер партиции из _partition |
kafka_offset |
Позицию сообщения внутри партиции из _offset |
kafka_timestamp |
Метку времени Kafka из _timestamp_ms, если она есть |
consumer_host |
Имя ноды, прочитавшей Kafka |
_load_ts |
Реальное время записи сообщения в STG |
_load_id |
Только у заказов: run_id запуска Airflow, который принял порцию |
Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
kafka_topic и consumer_host имеют тип LowCardinality(String),
kafka_partition и kafka_offset — UInt64.
kafka_timestamp имеет тип Nullable(DateTime64(3, 'UTC')): сохраняет
миллисекунды и допускает отсутствие метки.
Виртуальные колонки предоставляет движок, а колонки STG хранят значения
на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
собственной. Значения вычисляются в запросе приема. В частности,
hostName() должен выполняться на ноде, читающей Kafka:
DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.
Таблицы STG используют ReplicatedMergeTree с
ORDER BY (kafka_partition, kafka_offset). Повторная доставка остается
отдельной строкой; STG нужен в том числе для разбора таких повторов.
_load_ts имеет тип DateTime64(3, 'UTC'). При переходе в ODS метка
копируется из STG без пересчета. У событий она также выбирает последнюю
доставку; у заказов версию задает snapshot_date. _load_id заказа
переносится в годную строку ODS и в таблицу ошибок.
У непрерывного потока событий нет запуска Airflow, поэтому _load_id
в его STG и ODS отсутствует.
Время события UTCEventTime и дата EventDate приходят из счетчика сайта.
Поля заказа created_at и updated_at приходят из базы магазина.
Они относятся к модельному времени; _load_ts — к реальным часам стенда.
Например, событие июня можно загрузить в сентябре: его EventDate
останется июньской, а _load_ts будет сентябрьской.
События разбираются материализованными представлениями. Автоматической
пакетной переобработки у них нет; сохраненный срез STG можно разобрать
отдельным запросом до истечения срока хранения. При повторной вставке
с тем же ключом и _load_ts ReplacingMergeTree оставляет более поздно
вставленную строку. Заказы разбирает задача Airflow по _load_id.
В DDS метки загрузки уже относятся к собственной сборке слоя —
пример заказа.
Актуальные события
Повторная отправка события может дать несколько строк в ods.event_rep.
Движок ReplicatedReplacingMergeTree(_load_ts) считает версиями строки
с одинаковым полным ключом сортировки:
(CounterID, EventDate, intHash32(ClientID), WatchID).
При слиянии остается строка с максимальным _load_ts, то есть последняя
доставка. Один WatchID обозначает событие, но физический ключ замены шире.
ods.event_v читает ods.event_dist FINAL: выбирает актуальную строку
при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
считают через _v; число строк в _dist зависит от уже прошедших слияний.
dds.event_v использует этот результат, дает колонкам имена в snake_case
и расшифровывает коды.
Выбор по полному ORDER BY и максимальной версии, включая чтение с FINAL,
сверен через Context7 с документацией ClickHouse.
В стенде закреплена версия 26.3.17.56; _v использует FINAL, чтобы
результат не зависел от фоновых слияний.
Типы ODS повторяют описание выгрузки
с одним денежным исключением: purchaseRevenue объявлен источником
как Array(Float64), а из JSON разбирается сразу в
Array(Decimal(18, 2)). Это сумма в рублях с копейками; DDS наследует тип.
productPrice содержит округленные целые рубли и не подходит для
восстановления точной суммы покупки. Прямой разбор purchaseRevenue
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
Версии заказов
Слепки повторяют состояние заказов из семидневного окна источника.
ods.order_rep хранит принятые версии в
ReplicatedReplacingMergeTree(snapshot_date): ключ сортировки — order_id,
партиция — toDate(created_at) в UTC. Запись через ods.order_dist
распределяет их по cityHash64(order_id). У всех версий заказа одинаковы
день создания и шард, поэтому они могут заменять друг друга.
ods.order_v читает _dist FINAL и оставляет строку с максимальной датой
слепка. Дата snapshot_date показывает последнее принятое наблюдение,
а updated_at — время изменения заказа в магазине. У неизменившегося
заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
Источник выпускает один слепок на дату, а поздний слепок показывает не менее новое состояние. Отсутствие заказа в следующем слепке не означает удаления. Физическая таблица не служит архивом: старые версии исчезают при фоновых слияниях.
Прием добавляет версии отдельными строками. snapshot_date не доказывает,
что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется.
Подробности — прием заказов,
ADR 0010 и
ADR 0014.
Следующий слой описан в модели заказа DDS.
Часовые пояса
Пояс — линза, а не свойство значения. DateTime хранит одно число, секунды от
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
какие сутки оно попадёт.
Линза называется явно — в типе колонки либо в вызове функции. Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
дату, — его не остаётся. Прописать этот пояс своей рукой — <timezone> в
конфигурации ноды или TZ контейнеру — было бы той же болезнью с другим
умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции: toDate по
колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
партиций toDate(_load_ts) у таблицы STG и у таблицы ошибок. Имя пишется тогда,
когда нужна другая линза, чем у колонки: toDate(UTCEventTime, 'Europe/Samara')
— это «день по часам счётчика».
Какая линза, решает слой — по тому, кого он обслуживает. ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики UTCEventTime абсолютна,
значит DateTime('UTC'); служебные метки _load_ts и kafka_timestamp
абсолютны тоже — DateTime64(3, 'UTC'). DDS и витрины обслуживают человека с
дашбордом, поэтому время там лежит местным, в поясе счётчика (Europe/Samara,
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
пишет, он берёт готовую колонку. Date хранит только номер дня — чьи это
сутки, в нём не записано. Поэтому время по EventDate считают, назвав пояс
руками; промах виден сразу: часы суток уезжают за границы 0–23.
Объявить местным и ODS — DateTime('Europe/Samara') — соблазнительно: байты те
же, меняется одна линза, и toDate(UTCEventTime) начинает совпадать с
EventDate всегда. Отвергнуто потому, что колонка зовётся UTCEventTime и в
настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок,
ради которого мир сделан с поясом счётчика.
Линза выбирается дважды, и второй раз упустить легко. На чтении — показать
метку или свести её к дате. На записи — превратить строку в число: суффикс Z
на проводе зоны не даёт, маска разбора съедает его буквой, и
parseDateTimeOrNull без третьего аргумента трактует показания часов по поясу
сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор
отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы
судьбу строки, а не числа. Механика и выбор функции — ADR
0005.
День берётся из EventDate. Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий toDate(UTCEventTime) с ней
расходится, объясняет ADR 0015.
Путь реплицированных таблиц в keeper
Шаблон — /clickhouse/tables/{shard}/{database}/{table}. База в пути
обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел
в keeper и подерутся. Макрос {uuid} не используем, хотя он тоже развёл бы
пути: он завязан на движок базы Atomic и делает путь нечитаемым, а на учебном
стенде возможность открыть system.zookeeper и увидеть осмысленный путь — сама
по себе половина урока про то, чем занят keeper.
Приём: поток и его свойства
События принимаются непрерывно. Заказы читает отдельным запросом
даг orders_ingest — описание пакетного приема.
У событий путь такой:
stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
├→ ods.event_mv → ods.event_dist
└→ ods.event_errors_mv → ods.event_errors_dist
Таблицы Kafka на обеих нодах используют одну группу clickstream_hits.
Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
свою полную копию потока. Имя группы одинаково в DDL на всем
clickstream_cluster; конфигурация кластера —
infra/clickhouse/config.d/cluster.xml.
Материализованные представления разбора срабатывают на вставку
в stg.hits_raw_dist, до распределения строк по шардам.
Оба видят вставляемую порцию на той ноде, которая читает Kafka.
stg.hits_raw_mv создается после них: его подключение запускает
чтение топика. Если начать чтение раньше, сообщения успеют попасть
в STG без разбора в ODS.
Условие приема проверяет три вещи:
- корень
raw— объект JSON; - набор верхнеуровневых ключей совпадает с 47 полями формата;
- пять опорных полей,
WatchID,VisitID,ClientID,EventDateиUTCEventTime, разбираются в ожидаемые типы.
Остальные поля извлекаются по описанию выгрузки без отдельных проверок. Это граница приема: годная строка не означает, что проверен смысл всех 47 значений. Подробности — в ADR 0005.
ods.event_mv принимает строки, для которых условие истинно,
ods.event_errors_mv — для которых оно ложно. Условие не должно
возвращать NULL: такую строку не выбрали бы ни оно, ни его отрицание.
Разбор использует функции без исключений на некорректном сообщении,
поскольку ошибка материализованного представления прерывает вставку
и останавливает чтение Kafka до устранения причины.
Доставка и повторы
Вставка в Distributed на этом пути фоновая: блок сначала записывается в локальную очередь на диске, затем отправляется на шарды. Kafka подтверждает обработку после выполнения материализованных представлений, когда данные еще могут находиться в очереди. Если после подтверждения потерять ноду вместе с очередью, сообщения не перечитаются. Возможен и повтор: данные уже записались на шард, но подтверждение Kafka не успело сохраниться. Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
В ETL-запросах Airflow distributed_foreground_insert = 1 дожидается
записи на шарды. У фонового приема событий этой настройки нет:
недоступность одного шарда тогда остановила бы весь прием. В принятом
режиме доступная нода продолжает принимать и накапливает очередь
для недоступного шарда. Ограничение сохранено для учебного стенда;
подробности и наблюдения — в разделе «Что проверено».
В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
Хотя Replicated* умеет отбрасывать повторно вставленные одинаковые блоки,
при новом чтении Kafka меняется _load_ts, а вместе с ним и хеш блока.
Поэтому повторная отправка модельного дня увеличивает число строк STG.
Пустое значение сообщения и значение null не дают строки даже в STG:
чтец продвигает позицию Kafka, но не передает строку на разбор.
Генератор таких сообщений не создает. Поведение измерено на стенде
и описано в разделе «Что проверено».
Что можно сверять
Для короткого неизменного среза доставки можно проверить баланс: число строк STG равно сумме годных строк и ошибок до удаления повторов. Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные, ODS оставляет события и объединяет повторные доставки. Счетчики по слоям тогда расходятся при штатной работе.
make check-clickhouse сравнивает актуальные события в ods.event_v
с паспортом мира только за восемь
начальных дней. Эта проверка учитывает повторную отправку через FINAL
и не зависит от срока жизни STG. Границы остальных проверок —
в карте проверок.
Срок жизни сырых данных
TTL — правило автоматического удаления старых данных. В обеих таблицах
STG срок задан как три суток от _load_ts, по реальному времени загрузки.
Партиции тоже нарезаны по дню _load_ts. Это оставляет короткий период
для поиска ошибок и повторного разбора, после которого данные освобождают
место на диске.
Настройка ttl_only_drop_parts = 1 удаляет только целые части данных —
файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
дня загрузки возраст строк отличается не более чем на сутки.
Поэтому к трем суткам может добавиться до суток ожидания самой свежей
строки части, а затем ожидание фоновой обработки TTL. Точного времени
удаления правило не обещает.
Модельная дата для TTL не подходит: события июня могут загружаться
осенью, и срок от EventDate истек бы сразу. Партиция по модельному дню
тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
продлевать хранение старых строк после слияния частей.
Для разбора сохраненных сообщений выбирайте небольшой срез по _load_ts.
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
из которых модельную дату извлечь нельзя. EventDate появляется
отдельной колонкой только в ODS. В задании ступени 6
такой срез разбирается рядом с действующим приемом.
Таблица ошибок
ods.event_errors_dist содержит сообщения, отвергнутые разбором событий:
исходный raw, сведения о доставке и класс ошибки error_class.
Класс имеет тип LowCardinality(String) и выбирается по первому
нарушенному условию:
not_an_object— корень JSON не объект;keyset_mismatch— набор ключей не совпал с форматом;key_field_unparsed— одно из пяти опорных полей не разобралось.
Порядок важен: строка вместо объекта нарушает сразу несколько условий, но получает один класс. Полные условия — в ADR 0005.
ods.order_errors_dist хранит такой же набор сведений и _load_id
запуска приема. Первые два класса совпадают с событиями,
третий называется field_invalid: у заказа проверяются все поля
верхнего уровня. Точные правила — в описании приема заказов.
Таблицы ошибок используют ReplicatedMergeTree без замены версий:
у некорректного сообщения может не быть разобранного ключа сущности.
Шард выбирается по cityHash64(raw), ключ сортировки —
(error_class, kafka_partition, kafka_offset), партиция — день _load_ts.
Начать поиск можно с класса ошибки, затем перейти к сообщениям
по координатам Kafka.
Срок хранения — один месяц от _load_ts, удаление целыми частями
с ttl_only_drop_parts = 1, как в STG. Ошибка вместе с исходным текстом
остается доступна дольше остальных сырых сообщений. Повторная обработка
может добавить тот же брак еще раз; у заказов повтор виден по совпадению
_load_id и координат Kafka.
Справочники
Справочные данные живут в базе dic — вне цепочки STG → ODS → DDS → DM. В
хранилище их никто не производит: содержимое приходит из файла репозитория, а
читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону
вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и
почему словарь читает подложку, а не файл, — ADR
0012.
Первый и пока единственный справочник — каталог товаров: подложка
dic.products_file над CSV репозитория и словарь dic.products поверх неё.
Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и
держат свою копию словаря в памяти. Словарь обновляется сам, окном LIFETIME,
и потому после правки каталога ноды какое-то время отвечают по-разному.
Разогнать его раньше срока — SYSTEM RELOAD DICTIONARY ON CLUSTER; это
административная операция, роль etl права на неё не получает.
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
Decimal(18, 2), productPrice события — целые рубли. Поэтому точную сумму
заказа берут из purchaseRevenue, а не складывают округленные цены товаров
(описание выгрузки).
Устройство обоих объектов — почему такой движок, такая форма пути, такой
пользователь и такое окно — расписано в
sql/ddl/05-dic-catalog.sql.
Раскладка SQL
Исполняемые дагами запросы лежат по правилу sql/<слой>/<объект>_<роль>.sql.
Слой — слой цели запроса: забор заказов в STG живёт в
sql/stg/orders_raw_load.sql, разбор — в sql/ods/order_parse.sql. Даг
называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
же файле; подключаемого SQL-фрагмента между ними нет.
SQL-файлы исполняет штатный SQLExecuteQueryOperator. При разборе Python-кода
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow
разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и
не подставляются строкой. Каталог sql/ смонтирован во все службы Airflow
только для чтения.
DDL
Файлы лежат в sql/ddl/ и применяются по порядку имён: источник и цель
создаются до представления или матвью, которые их используют.
| Файл | Что в нём |
|---|---|
00-databases.sql |
базы слоёв и зона справочников |
05-dic-catalog.sql |
подложка над CSV-каталогом и словарь товаров поверх неё |
10-stg-tables.sql |
чтецы топиков hits и orders, локальные и распределённые таблицы STG обоих источников |
20-ods-tables.sql |
типизированное событие, версии заказа и обе таблицы ошибок |
30-ods-views.sql |
актуальные события, текущие заказы и матвью разбора в ODS |
40-stg-views.sql |
матвью приёма: чтец в STG |
45-dds-tables.sql |
физические модели заказа, сессии и карты идентичностей, доноры партиций |
50-dds-views.sql |
публичные договоры сущностей DDS |
55-dm-tables.sql |
физические витрины выручки, сверки и трафика, доноры дневных партиций |
60-dm-views.sql |
публичные договоры витрин DM |
Справочники идут сразу за базами: зона dic не зависит ни от одного слоя, и
правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё, что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной отладке.
Файлы применяет даг world_initialize с ноды 1 отдельной учётной записью
lifecycle: один файл — одна последовательная задача
SQLExecuteQueryOperator, а выражения внутри разделяет сам оператор. Всё
ON CLUSTER, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL
даг создаёт hits с двумя партициями и orders с одной
(ADR 0008). Этот порядок не даёт hits
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
результаты приведены в разделе «Что проверено». Успех дага означает больше,
чем применённый DDL: оба стартовых
источника приняты, а позиция мира установлена последней задачей. Полный граф и
состояния отказа описаны в спеке жизненного цикла.
Повторный make up поверх готового мира проходит зелёным без изменений:
world_initialize ветвится раньше DDL. Поэтому изменённый объект обычным
подъёмом не применяется. В разработке меняют или пересоздают только затронутый
объект, партицию либо базу слоя. Если нужен целиком канонический мир,
make rebuild-storage запускает world_recreate: тот удаляет прикладные базы
stg, dm, dds, ods, dic, позиции групп потребителей и прикладные топики,
затем вызывает world_initialize. Службы и их тома остаются на месте, а позиция
возвращается к концу стартовых дней. Полный make clean для изменения DDL не
нужен. Когда какая область очистки уместна перед проверкой, задаёт
карта проверок.
Карта таблиц
Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в
sql/ddl/.
| База | Объект | Что это |
|---|---|---|
stg |
stg.hits_raw_kafka |
чтец топика hits, формат RawBLOB |
stg |
stg.hits_raw_rep / _dist |
сырая строка сообщения плюс метаданные доставки |
stg |
stg.hits_raw_mv |
наполняет STG из чтеца |
stg |
stg.orders_raw_kafka |
чтец топика orders, формат RawBLOB, только на ноде 1 и без матвью |
stg |
stg.orders_raw_rep / _dist |
сырое сообщение слепка, метаданные доставки и _load_id |
ods |
ods.event_rep / _dist |
типизированное широкое событие |
ods |
ods.event_v |
актуальная версия события с полями источника |
ods |
ods.event_errors_rep / _dist |
строки, не прошедшие строгий приём |
ods |
ods.event_mv, ods.event_errors_mv |
разбор STG в событие и в ошибки |
ods |
ods.order_rep / _dist |
типизированные версии заказа |
ods |
ods.order_v |
текущая версия заказа на языке источника |
ods |
ods.order_errors_rep / _dist |
строки слепка, не прошедшие строгий приём |
dds |
dds.event_v |
событие на языке бизнес-модели без физической копии |
dds |
dds.order_rep / _dist |
заказ в текущем состоянии, партиция — день заказа |
dds |
dds.order_stage_rep / _dist |
двойник заказа: в нём день собирается перед заменой партиции |
dds |
dds.order_v |
публичный договор заказа |
dds |
dds.session_rep / _dist |
визит, нарезанный слоем; партиция — день сессии |
dds |
dds.session_stage_rep / _dist |
двойник сессии: в нём день собирается перед заменой партиции |
dds |
dds.session_v |
публичный договор сессии |
dds |
dds.identity_map_rep / _dist |
известная пара куки с пользователем; таблицы полного пересчёта |
dds |
dds.identity_map_v |
точный публичный договор карты идентичностей с FINAL |
dm |
dm.revenue_daily_rep / _dist |
дневная выручка по категориям |
dm |
dm.revenue_daily_stage_rep / _dist |
таблица-двойник, донор дневных партиций выручки |
dm |
dm.revenue_daily_v |
публичный договор дневной выручки |
dm |
dm.purchase_vs_orders_rep / _dist |
сверка покупок с заказами, партиция — день заказа |
dm |
dm.purchase_vs_orders_stage_rep / _dist |
донор дневных партиций сверки |
dm |
dm.purchase_vs_orders_v |
публичный договор сверки с NULL для отсутствующей стороны |
dm |
dm.daily_traffic_rep / _dist |
версии дневных счетчиков трафика |
dm |
dm.daily_traffic_v |
точные текущие счетчики трафика с FINAL |
dm |
dm.dq_summary_rep / _dist |
текущее качество дня по атомарным проверкам |
dm |
dm.dq_summary_stage_rep / _dist |
общий донор дневных партиций DQ-сводки |
dm |
dm.dq_summary_v |
публичная сводка качества дней |
dic |
dic.products_file |
чтец CSV-каталога, общего с генератором |
dic |
dic.products |
словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
INSERT SELECT одной задачи parse_batch из файла sql/ods/order_parse.sql.
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
Что проверено
Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka. Поэтому утверждения о них разведены на три группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит документацию. Сверка с документацией — через MCP Context7, 5 августа 2026 года; то же разведение для механики приёма — в ADR 0005.
Проверка ods.event_v 18 августа 2026 года. Документация ClickHouse через
MCP Context7 подтвердила обычное представление с FINAL как способ скрыть
схлопывание от потребителя. На закреплённом ClickHouse 26.3 в ODS добавили
вторую физическую версию одного события: обычный счёт вырос на один, а
ods.event_v остался равен ods.event_dist FINAL. Имена и типы всех колонок
представления совпали с распределённой таблицей.
Проверка словаря товаров 19 августа 2026 года (#95). Context7 подтвердил
DDL-словарь с файловым источником внутри user_files, хешированную раскладку
для строкового ключа и отключение фонового обновления через LIFETIME(0).
На закреплённом ClickHouse 26.3 цена известного sku после правки CSV и
SYSTEM RELOAD DICTIONARY ON CLUSTER
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
Проверка зоны справочников 20 августа 2026 года (#105). Подложка на движке
File перечитывает файл на каждом запросе, словарь поверх неё обновляется сам
внутри окна LIFETIME, а собранный с нуля стенд поднимает оба объекта и отдаёт
словарь читателям. Замеры целиком — в
ADR 0012, раздел «Что проверено»; там же
разобрано, почему отказ в праве на движок называет не то право, которого не
хватает.
Проверка ко-локации заказов 22 августа 2026 года (#137). Context7 и
system.settings закреплённого ClickHouse 26.3 назвали одну и ту же тройку
значений parallel_distributed_insert_select: 0 — выключено, 1 — SELECT
на каждом шарде, 2 — SELECT и INSERT на каждом шарде; заводское — 2. На
живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
обоих шардах, после явного нуля и повторного разбора того же _load_id — ни
одного. Вершина версий уцелела: деловые поля ods.order_v сошлись с эталоном,
посчитанным прямо из STG.
Проверка версий заказов 18 августа 2026 года (#94). MCP Context7 подтвердил,
что JSONType возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
Три находки касаются не заказов, а самого ClickHouse, и знать их стоит любому, кто пишет здесь строгий разбор:
toDateOrNullкалендарь не проверяет.'2026-02-30'он молча превращает в2026-03-02,'2026-13-01'— в1970-01-01. Для строгого приёма он поэтому не годится: нуженparseDateTimeInJodaSyntaxOrNull(…, 'yyyy-MM-dd', 'UTC'), который на обеих строках даётNULL.- Маска разбора формы не держит.
parseDateTime64InJodaSyntaxOrNullпо маскеyyyy-MM-dd'T'HH:mm:ss.SSS'Z'берёт и2026-6-3T14:21:07.123Z— без ведущих нулей. Форму приходится сверять отдельно, регулярным выражением; зато календарь маска проверяет честно (30 февраля, 13-й месяц, 25-й час даютNULL), как и лишние или недостающие знаки долей секунды и смещение+00:00вместоZ. - У временных типов есть потолок, и он ниже, чем кажется.
DateTime64(3)заканчивается на2299-12-31,DateTime— на2106-02-07; строки за потолком разбор отдаёт какNULL. Это ловит опыт, который берёт «заведомо далёкий» год: 2999-й не разбирается вовсе.
Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
661 строку STG, приехавшую при исполнении #93, без исключений и без NULL: 13
653 годных и 8 брака, сумма сошлась с общим счётом по каждому _load_id,
пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
отрицательным total; это дефект генератора, разобранный в спецификации
приёма, «Что проверено». На управляемой порции из пяти
строк (две версии одного заказа плюс по одной строке каждого класса брака)
годные ушли в ods.order, брак — в ods.order_errors с ожидаемыми классами.
Обе версии легли в одну партицию и на один шард; FINAL через распределённую
таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
физический счёт показывал две. Повтор задачи с тем же _load_id строку в
ods.order_v и её _load_ts не изменил, а таблица ошибок записала тот же брак
второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что сводка вставки не годится в счётчики строк: у
запроса с WHERE read_rows считает прочитанное с диска, а не подошедшее, —
на одном и том же срезе из пяти строк две вставки дали 4 и 5. Сколько чего
легло, спрашивают у самих таблиц по _load_id.
Сверено с документацией. Собственная колонка с именем виртуальной делает
виртуальную недоступной. При вставке в Distributed шард выбирается по ключу
шардирования; фоновый режим — умолчание, а distributed_foreground_insert = 1
завершает вставку только после записи на все шарды. У семейства Replicated*
есть дедупликация одинаковых блоков. Голый count() по ReplacingMergeTree
зависит от того, сколько мержей прошло. ttl_only_drop_parts снимает кусок
целиком и только когда истекли все строки в нём, а сам TTL исполняется на
фоновых мержах. Kafka-движок начинает читать топик, когда к нему привязывают
матвью, и одна группа потребителей на кластер спасает от дублей. Таблицы с
одинаковым путём в keeper становятся репликами друг друга, а макрос {uuid}
завязан на движок базы Atomic. ON CLUSTER ждёт все хосты и бросает по
таймауту; CREATE ... IF NOT EXISTS на существующем объекте не бросает.
parseDateTime и его родня принимают пояс необязательным последним аргументом,
а без него берут пояс сессии — он же по умолчанию серверный. У колонки с
объявленным поясом значения приводятся к нему; у колонки без объявленного
session_timezone перекрывает серверную настройку на выводе, но тип, с которым
считают функции, остаётся прежним (сверка 8 августа 2026 года).
Проверено на стенде. Опыты прогнаны на живом кластере: пять при исполнении #37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43 (7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь написано.
-
Разбор строки берёт пояс у сессии, а не из строки. Под
session_timezone = 'Europe/Samara'одна и та же строка2026-06-01T01:24:09Zпо маске%Y-%m-%dT%H:%i:%SZдала 1780262649 без третьего аргумента и 1780277049 с аргументом'UTC'— ровно четыре часа разницы. Тип результата без аргумента —Nullable(DateTime('Europe/Samara')). Отсюда имя пояса в разборе: суффиксZмаска съедает и выбрасывает. -
toDateберёт пояс у типа своего аргумента. Из одного момента: поDateTime('UTC')—2026-05-31, поDateTime('Europe/Samara')—2026-06-01. Отсюда форма правила: имя пояса нужно там, где его не несёт тип. -
У колонки без объявленного пояса глаз и
GROUP BYрасходятся. Замер снят до правки типов и на нынешнем стенде не повторяется — колонка уже с поясом. СобытиеWatchID = 113504893317,EventDate=2026-06-05: без настроек колонка показана2026-06-04 20:58:56, подsession_timezone = 'Europe/Samara'—2026-06-05 00:58:56, аtoDate(UTCEventTime)в обоих случаях2026-06-04. То есть вывод колонки идёт по поясу сессии, а функция — по поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя одинаково. -
С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по конвенции; событие
WatchID = 384218330540,EventDate=2026-06-01: колонкаDateTime('UTC')показана2026-05-31 23:37:00и без настроек, и подsession_timezone = 'Europe/Samara', по родному клиенту и по HTTP.toDate(UTCEventTime)даёт2026-05-31,toDate(UTCEventTime, 'Europe/Samara')—2026-06-01,EventDate—2026-06-01: расхождениеtoDate(UTCEventTime)сEventDateосталось, это мир, а не пояс колонки. -
Матвью с источником-
Distributedсрабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят надstg.hits_raw_dist, а пишет в неё матвью приёма — и события доезжают доods.event; значит блок она видит. В документации ClickHouse случая нет вовсе, до 7 августа утверждение держалось на опыте владельца. -
Упавшая матвью роняет вставку и останавливает потребление до починки. Проверено сносом цели — распределённой
ods.event_dist— при живом чтеце: за двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло в ноль, событие разобралось. Сносить надо именно распределённую таблицу: вставка вDistributedкладёт блок в спул и сразу возвращает управление, так что на сносе локальной ошибка всплыла бы фоном и утверждение показалось бы опровергнутым. -
_load_tsвods.event— это метка исходной строки STG, а не время разбора. Сверено поWatchIDна двух тысячах событий модельного дня, залитого дважды: у каждого события метка совпала с меткой одной из двух его доставок, а послеFINAL— с меткой поздней. Случаев «метки нет среди доставок» ноль, то естьnow64()в матвью разбора нет. -
Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после, легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL, только приходит он с другой стороны — не при первом создании, а при замене матвью на работающем стенде. Практический вывод один: правишь матвью — не верь ближайшей отправке, повтори её. Чем именно держится задержка, не измерено; наблюдение записано как наблюдение.
-
Форма ключа
ods.eventпринимается такой, как её задумала спека: выражениеintHash32(ClientID)стоит в ключе сортировкиReplacingMergeTree, аSAMPLE BY— по тому же выражению. Вопрос стоял открытым в разделе 11 мастер-спеки; ответ — DDL применяется и таблица работает. -
RawBLOBдаёт ровно одну строку на каждое непустое сообщение. Три сообщения с ключами, поставленные в очередь до одного сброса продюсера, стали тремя строками с тремя разными офсетами: пачка продюсера границы сообщений не стирает. Запасной форматLineAsStringне понадобился. Про границу «непустое» — сразу ниже. -
Меток времени у Kafka-движка две, и разрядность у них разная:
_timestamp—Nullable(DateTime),_timestamp_ms—Nullable(DateTime64(3)). Отсюда формаkafka_timestampв разделе о служебных колонках. -
DEFAULT hostName(), объявленный только на локальной таблице, вычисляется на шарде-получателе. Строки, вставленные с ноды 1 и уехавшие на ноду 2, несут в этой колонке ноду 2, а в соседней, заполненной явнымhostName()во вставляющемSELECT, — ноду 1. Довод за то, чтобы служебные колонки заполняла матвью выражением, держится. Отсюда же и невосстановимость: после записи вDistributedимя читавшей ноды взять больше неоткуда — своей колонкой оно не сохранено, а умолчание назовёт получателя. -
DROP PARTITIONиREPLACE PARTITIONпоDistributedне работают: обе операции отвечают кодом 48, «Table engine Distributed doesn't support partitioning», и оба шарда остаются нетронутыми. -
Автосоздания топиков ClickHouse не просит, и переключателей здесь два. На брокере автосоздание разрешено:
auto.create.topics.enable=true, и это умолчание образа, а не наша настройка. На клиенте — выключено: чтец с матвью, наведённые на несуществующий топик, ждали его с ошибкой «Broker: Unknown topic or partition», и топик не появился. Значит, от тихого топика с одной партицией стенд бережёт клиентское умолчание, а не брокер.
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
Kafka с пустым значением (ноль байт) и запись-надгробие (значение null)
читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
LineAsString, запасной по ADR 0005, на том
же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самой таблице STG, kafka_offset лежит там колонкой, и дырка в офсетах — это
он.
Оговорка к третьему опыту, и она сама непроверенная: CREATE ... Distributed AS <локальная> копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе.
Проверка замены партиций 26 августа 2026 года (#151). Context7 подтвердил
договор REPLACE PARTITION: ON CLUSTER в синтаксисе есть, а источник и цель
обязаны совпадать структурой, ключом партиции, ключом сортировки, первичным
ключом и политикой хранения. На закреплённом ClickHouse 26.3 замена по локальным
таблицам с ON CLUSTER прошла на обеих нодах, а сборка стартового мира в
двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные
cityHash64(order_id), шард 2 — только нечётные.
Проверка карты идентичностей 26 августа 2026 года (#153). Context7
подтвердил версию в ReplacingMergeTree и точное чтение через FINAL. Два
полных прогона оставили 3180 физических строк и 1590 пар в _v; все пары
подтверждены покупкой и заказом, а строк на чужом шарде нет.
Тем же днём измерены ещё три поведения ClickHouse.
- Параметр в
ALTER … PARTITIONне подставляется. Запрос с{day:Date}в выражении партиции отвечает кодом 62, синтаксической ошибкой; литерал проходит. Отсюда единственное место в репозитории, где значение попадает в запрос подстановкой Jinja, а не параметром ClickHouse (sql/dds/order_replace.sql). TRUNCATE— отдельное право, вALTERоно не входит. Рольetl, у которой естьALTER ON dds.*, получает наTRUNCATEкод 497, аALTER TABLE … DROP PARTITION ALLна той же таблице проходит. Двойник поэтому чистится снятием партиций: у операции нашлась форма, на которую право уже выдано, и расширять доступ не понадобилось.JSONExtractчитаетDecimalпрямо из строки JSON. Цена на проводе — строка с двумя знаками ("1289.99"), и разбор вArray(Tuple(sku String, qty UInt32, price Decimal(18, 2)))даёт число без промежуточногоtoDecimal64. Проверено на живом слепке; честность разбора сторожит инвариант бэкенда — сумма «цена × количество» совпала сitems_totalу всех 1662 заказов.JSONExtractчитаетDecimalи из числа JSON. МассивpurchaseRevenue, объявленный источником какArray(Float64), разбирается сразу вArray(Decimal(18, 2)). Синтаксис подтвержден через Context7 и на ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
Сказано по памяти, проверки нет. Группа пуста: оба утверждения, ждавшие матвью разбора, закрыты опытами при исполнении #43 и переехали выше.