- Зачем:
- справочники читает человек со стороны, а «пульт мира», «опись мира»,
«работник» и «сырьё» звучали шуткой или калькой времён разработки
(тикет #214, выбор владельца).
- Что:
- CONTEXT.md: статьи «Паспорт мира», «Даги модельного времени»,
«Шаговый даг», «Сырые данные», старые слова в строках «Избегать»;
пометки в ADR 0005, 0008, 0009 без переписывания решений.
- замены по README, справочникам, путеводителю, лабам, спекам,
комментариям дагов и SQL, тегу дага и панели Grafana; имена в коде
не тронуты.
- world.md и stand.md: 35 находок вычитки внесены, регистр выровнен.
- Проверка:
- make config-test; make lint в корне и в generator/; make test в
generator/ (437 passed); скрипт ссылок из #212 пуст; grep старых
слов вне ADR, research и CONTEXT.md пуст.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
870 lines
84 KiB
Markdown
870 lines
84 KiB
Markdown
# Хранилище: слои и конвенции
|
||
|
||
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
|
||
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
|
||
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
|
||
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
||
|
||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
|
||
таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
|
||
вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
|
||
слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
|
||
Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
|
||
принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
|
||
Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
|
||
([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
|
||
события `dds.event_v` над точным состоянием ODS, а следом получил две физические
|
||
модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
|
||
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
|
||
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
|
||
от заложенного отличает карта таблиц в конце.
|
||
|
||
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
||
события — в [описании выгрузки](../formats/clickstream-event.md). Хранилище
|
||
строится по описанию выгрузки, как в бою строится по документации источника.
|
||
Целевая картина всего стенда — [спека «Боевой реализм стенда
|
||
(v2)»](../specs/2026-07-30-stand-v2-realism.md).
|
||
|
||
## Имена объектов
|
||
|
||
Имя объекта заканчивается тем, что это за объект:
|
||
|
||
| Суффикс | Что это |
|
||
|---|---|
|
||
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
|
||
| `_dist` | `Distributed` поверх одноимённой локальной |
|
||
| `_kafka` | таблица на движке `Kafka` |
|
||
| `_file` | таблица на движке `File` — чтец файла |
|
||
| `_mv` | материализованное представление |
|
||
| `_v` | обычное представление, публичная поверхность чтения |
|
||
|
||
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
|
||
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
||
именем в документах и разговоре понимается сущность, у которой этих объектов
|
||
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
||
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
|
||
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
|
||
словарь.
|
||
|
||
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
||
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
||
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
|
||
только по `Distributed`» такую тишину переживает плохо.
|
||
|
||
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
|
||
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
|
||
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
|
||
порядок выдачи `SHOW TABLES` документация не оговаривает.
|
||
|
||
Начало имени — сущность, и берётся она в разных слоях из разных мест. В 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 и отклонённые варианты записаны в
|
||
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
|
||
|
||
## Раскладка по шардам
|
||
|
||
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
|
||
операций с партициями, ручной переобработки. Правило не про удобство: при записи
|
||
через распределённую таблицу раскладку определяет ключ шардирования, то есть
|
||
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
|
||
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
|
||
(видно в колонке `consumer_host`), а куда легли данные — нет.
|
||
|
||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
|
||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||
шард.
|
||
|
||
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
|
||
разобранное из неё событие почти всегда оказываются на разных шардах.
|
||
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
|
||
сверять слои можно только через `_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` с другим ключом.
|
||
|
||
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||
раздела 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)`. Совпадение структуры требует сама операция, а совпадение
|
||
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
|
||
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
|
||
и задвоилась на другом. Доводы целиком — в
|
||
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
|
||
|
||
## Служебные колонки
|
||
|
||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
|
||
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
|
||
матвью перестанет читаться, что дано движком, а что положено нами, — а это
|
||
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
|
||
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
|
||
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
|
||
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
|
||
|
||
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
|
||
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
|
||
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
|
||
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
||
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
||
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
|
||
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
|
||
падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||
|
||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
|
||
колонка молча отвечала бы на другой вопрос.
|
||
|
||
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
||
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
||
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
|
||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||
0010](../adr/0010-order-versions-in-ods.md)).
|
||
|
||
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
||
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
||
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
||
|
||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||
|
||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||
|
||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||
`_load_id`; дневные партиции ODS он не заменяет.
|
||
|
||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||
технических колонок — распространённая запись,
|
||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||
|
||
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
||
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
||
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
||
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
||
|
||
## Актуальные события
|
||
|
||
`ods.event_rep` хранит физически принятые версии события в
|
||
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
|
||
диагностики доставки. Обычное чтение этой пары может показать несколько строк
|
||
одного `WatchID`, пока фоновые слияния их не схлопнули.
|
||
|
||
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
||
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
||
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
|
||
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
||
числе snake_case-имена и расшифровку кодов.
|
||
|
||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
||
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
||
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
||
с сырыми данными подтвердило: копейки всех покупок целы.
|
||
|
||
## Версии заказов
|
||
|
||
`ods.order_rep` хранит принятые версии в
|
||
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||
выбрал одного победителя.
|
||
|
||
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
|
||
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
|
||
дате слепка. Модель версионного ODS записана в
|
||
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
|
||
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
|
||
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
|
||
задан в [модели заказа в DDS](dds/order.md).
|
||
|
||
## Часовые пояса
|
||
|
||
Пояс — линза, а не свойство значения. `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](../adr/0005-event-ingestion.md).
|
||
|
||
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
|
||
генератором и лежит колонкой, так что суточные срезы группируются по ней и
|
||
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
|
||
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
|
||
|
||
## Путь реплицированных таблиц в keeper
|
||
|
||
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
|
||
обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел
|
||
в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы
|
||
пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном
|
||
стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама
|
||
по себе половина урока про то, чем занят keeper.
|
||
|
||
## Приём: поток и его свойства
|
||
|
||
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
|
||
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
|
||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||
заказов](orders/ingestion.md).
|
||
|
||
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
||
ошибок ODS.
|
||
|
||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
|
||
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
|
||
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
|
||
задано в `infra/clickhouse/config.d/cluster.xml`.
|
||
|
||
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
|
||
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
|
||
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
|
||
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
|
||
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
|
||
шардам.
|
||
|
||
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
|
||
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
|
||
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
|
||
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
|
||
промежутке — сообщения не перечитаются.
|
||
|
||
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
|
||
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
|
||
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
|
||
у него не бывает, так что настройка уровня запроса доехала бы только профилем
|
||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||
компромисс полезнее показать, чем спрятать за галочкой.
|
||
|
||
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
||
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
||
измерено и принято осознанно — подробности в разделе «Что проверено».
|
||
|
||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
||
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||
|
||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
||
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
||
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
||
конвейера к сырому слою не относится.
|
||
|
||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||
отсутствии механизма.
|
||
|
||
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
|
||
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
|
||
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
|
||
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
|
||
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
|
||
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
|
||
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
|
||
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
|
||
исключений: упавшая матвью
|
||
роняет вставку и останавливает потребление до починки
|
||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||
|
||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
||
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
||
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
||
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
||
красное в норме, учит не смотреть на оповещения ([ADR
|
||
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
||
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
||
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
||
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
||
постоянной целью не становится». Счёт актуальных событий идёт через
|
||
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
||
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
||
(раздел 6).
|
||
|
||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
||
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
||
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
||
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
||
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
||
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
||
|
||
## Срок жизни сырых данных
|
||
|
||
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
||
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
||
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
||
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
||
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
||
зависело от умолчания версии.
|
||
|
||
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
||
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||
снимается целиком, только когда в нём истекли все строки, а в партиции
|
||
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
||
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
||
срок неограниченно.
|
||
|
||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
||
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
||
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||
|
||
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
||
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
||
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||
|
||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
|
||
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
|
||
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
|
||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||
эталонные дни прямо на входе.
|
||
|
||
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
||
а не ноль.
|
||
|
||
## Таблица ошибок
|
||
|
||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
||
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
||
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
||
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
||
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
||
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
||
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
||
и куску незачем переживать срок из-за самой свежей строки.
|
||
|
||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
||
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||
|
||
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
||
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||
доставки.
|
||
|
||
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
|
||
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||
разрастается до имени отдельного поля. Точная граница приёма — в
|
||
[спецификации заказов](orders/ingestion.md).
|
||
|
||
## Справочники
|
||
|
||
Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
|
||
хранилище их никто не производит: содержимое приходит из файла репозитория, а
|
||
читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону
|
||
вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и
|
||
почему словарь читает подложку, а не файл, — [ADR
|
||
0012](../adr/0012-dictionary-home.md).
|
||
|
||
Первый и пока единственный справочник — каталог товаров: подложка
|
||
`dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё.
|
||
Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и
|
||
держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`,
|
||
и потому после правки каталога ноды какое-то время отвечают по-разному.
|
||
Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это
|
||
административная операция, роль `etl` права на неё не получает.
|
||
|
||
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
|
||
`Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
|
||
заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
|
||
([описание выгрузки](../formats/clickstream-event.md)).
|
||
|
||
Устройство обоих объектов — почему такой движок, такая форма пути, такой
|
||
пользователь и такое окно — расписано в
|
||
[`sql/ddl/05-dic-catalog.sql`](../../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](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
|
||
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
|
||
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
|
||
результаты приведены в разделе «Что проверено». Успех дага означает больше,
|
||
чем применённый DDL: оба стартовых
|
||
источника приняты, а позиция мира установлена последней задачей. Полный граф и
|
||
состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md).
|
||
|
||
Повторный `make up` поверх готового мира проходит зелёным без изменений:
|
||
`world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным
|
||
подъёмом не применяется. В разработке меняют или пересоздают только затронутый
|
||
объект, партицию либо базу слоя. Если нужен целиком канонический мир,
|
||
`make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы
|
||
`stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики,
|
||
затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция
|
||
возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не
|
||
нужен. Когда какая область очистки уместна перед проверкой, задаёт
|
||
[карта проверок](testing.md).
|
||
|
||
## Карта таблиц
|
||
|
||
Ниже — объекты, уже построенные этапами 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](../adr/0005-event-ingestion.md).
|
||
|
||
**Проверка `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](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же
|
||
разобрано, почему отказ в праве на движок называет не то право, которого не
|
||
хватает.
|
||
|
||
**Проверка ко-локации заказов 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`; это дефект генератора, разобранный в [спецификации
|
||
приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
|
||
строк (две версии одного заказа плюс по одной строке каждого класса брака)
|
||
годные ушли в `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](../adr/0005-event-ingestion.md), на том
|
||
же наборе даёт ровно те же две строки и тот же офсет.
|
||
|
||
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||
самой таблице 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`](../../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 и переехали выше.
|