- Зачем:
- линия дефектов нашла три неверных утверждения и мёртвый замер, линия
уместности — три пересказа уже сказанного.
- Что:
- «тип колонки не решает, какое число ляжет» сужено до правды: разбор
отдаёт готовое число, а пояс приёмника решал бы судьбу строки.
- замер до правки типов помечен как неповторяемый на нынешнем стенде.
- правило о поясе сервера привязано к местам, где линза что-то решает:
матвью приёма пояс не называет, и это не нарушение.
- убраны: пересказ механики в ADR 0005, четыре строки учебного
комментария, утверждение о порядке файлов и «секунды от начала эпохи»
у миллисекундной метки.
- Проверка:
- make lint, make typecheck, make test (408 тестов)
- make clean && make up && make check-clickhouse — 9 из 9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
567 lines
55 KiB
Markdown
567 lines
55 KiB
Markdown
# Хранилище: слои и конвенции
|
||
|
||
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
|
||
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
|
||
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
|
||
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
||
|
||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка
|
||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
||
событие с таблицей ошибок и три матвью. Дальше по тексту устройство описано
|
||
так, как оно проектируется; построенное от заложенного отличает карта таблиц в
|
||
конце.
|
||
|
||
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||
его замысел — в [спеке генератора](../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` |
|
||
| `_mv` | материализованное представление |
|
||
| `_v` | обычное представление |
|
||
|
||
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
|
||
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
||
именем в документах и разговоре понимается сущность, у которой этих объектов
|
||
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
||
нечего, и суффикс ничего не различал бы.
|
||
|
||
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
||
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
||
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
|
||
только по `Distributed`» такую тишину переживает плохо.
|
||
|
||
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
|
||
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
|
||
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
|
||
порядок выдачи `SHOW TABLES` документация не оговаривает.
|
||
|
||
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
|
||
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
|
||
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
|
||
имя приходит от предметной области и стоит в единственном числе: `event`,
|
||
`order_snapshot`, `session`. Граница между «как привезли» и «что это такое»
|
||
проходит по STG, и имена её показывают.
|
||
|
||
## Раскладка по шардам
|
||
|
||
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
|
||
операций с партициями, ручной переобработки. Правило не про удобство: при записи
|
||
через распределённую таблицу раскладку определяет ключ шардирования, то есть
|
||
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
|
||
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
|
||
(видно в колонке `consumer_host`), а куда легли данные — нет.
|
||
|
||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
|
||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||
шард.
|
||
|
||
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
|
||
разобранное из неё событие почти всегда оказываются на разных шардах.
|
||
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
|
||
сверять слои можно только через `_dist`.
|
||
|
||
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
||
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по
|
||
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
|
||
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
|
||
|
||
Открытый вопрос на будущее — не сама замена партиций: операции с ними по
|
||
локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
|
||
должна быть уже разложена по шардам по тому же ключу, а разложить её можно
|
||
только вставкой через распределённую таблицу — значит у каждой пакетной сущности
|
||
появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
|
||
это вместе со сборкой DDS, а не задним числом.
|
||
|
||
## Служебные колонки
|
||
|
||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||
`_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).
|
||
|
||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
|
||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||
|
||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||
она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
|
||
колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
|
||
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
|
||
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
|
||
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
|
||
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
|
||
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
|
||
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
|
||
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
|
||
|
||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||
технических колонок — распространённая запись,
|
||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||
|
||
Идентификатора пачки загрузки (`_load_id`) пока нет. В STG и ODS данные приезжают
|
||
потоком через матвью, у которого нет ни батча, ни `run_id`, и колонка была бы
|
||
пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится
|
||
по-настоящему — тогда и заведём, тем же стилем имени.
|
||
|
||
## Часовые пояса
|
||
|
||
Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от
|
||
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
|
||
какие сутки оно попадёт.
|
||
|
||
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||
где линза что-то решает — в объявлении хранимой колонки и в выражении,
|
||
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
|
||
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
|
||
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
|
||
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||
поменяв его. Правило стоит на источнике пояса, а не на функции:
|
||
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
|
||
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
|
||
пишется тогда, когда нужна другая линза, чем у колонки:
|
||
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
|
||
|
||
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
||
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
||
значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp`
|
||
абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с
|
||
дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`,
|
||
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
|
||
пишет, он берёт готовую колонку. `Date` не участвует вовсе — у типа пояса нет.
|
||
|
||
Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те
|
||
же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с
|
||
`EventDate` всегда. Отвергнуто потому, что колонка зовётся `UTCEventTime` и в
|
||
настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок,
|
||
ради которого мир сделан с поясом счётчика.
|
||
|
||
**Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать
|
||
метку или свести её к дате. На записи — превратить строку в число: суффикс `Z`
|
||
на проводе зоны не даёт, маска разбора съедает его буквой, и
|
||
`parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу
|
||
сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор
|
||
отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы
|
||
судьбу строки, а не числа. Механика и выбор функции — [ADR
|
||
0005](../adr/0005-event-ingestion.md).
|
||
|
||
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
|
||
генератором и лежит колонкой, так что суточные срезы группируются по ней и
|
||
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
|
||
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9.
|
||
|
||
## Путь реплицированных таблиц в keeper
|
||
|
||
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
|
||
обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел
|
||
в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы
|
||
пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном
|
||
стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама
|
||
по себе половина урока про то, чем занят keeper.
|
||
|
||
## Приём: поток и его свойства
|
||
|
||
Цепочка одна: чтец топика → матвью → сырьё 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-движка, своего запроса
|
||
у него не бывает, так что настройка уровня запроса доехала бы только профилем
|
||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
|
||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||
компромисс полезнее показать, чем спрятать за галочкой.
|
||
|
||
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
|
||
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
|
||
осознанно — подробности в разделе «Что проверено».
|
||
|
||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
||
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||
|
||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
||
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
||
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
||
сырому слою не относится.
|
||
|
||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
|
||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||
отсутствии механизма.
|
||
|
||
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
|
||
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
|
||
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
|
||
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
|
||
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
|
||
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
|
||
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
|
||
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
|
||
исключений: упавшая матвью
|
||
роняет вставку и останавливает потребление до починки
|
||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||
|
||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
||
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
||
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
||
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
||
смотреть на оповещения
|
||
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
||
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
||
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
|
||
опыт не становится, и почему — в [карте
|
||
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
|
||
становится». Счёт по ODS идёт через `FINAL`: голый `count()` по
|
||
`ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это
|
||
прямо запрещает (раздел 6).
|
||
|
||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||
против [описи мира](../../data/world-inventory.json), лежащей в git
|
||
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
||
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
||
их датами, повтор заливки схлопывается под `FINAL`, а срок хранения ей не
|
||
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
||
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
||
одной стороны.
|
||
|
||
## Срок жизни сырья
|
||
|
||
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
||
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
|
||
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
|
||
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
|
||
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
|
||
|
||
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
|
||
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
|
||
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
|
||
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
|
||
|
||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
|
||
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
|
||
«уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||
|
||
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
|
||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
|
||
режется своими координатами: и разбор полётов, и переобработка фильтруют по
|
||
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||
|
||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
|
||
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
|
||
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
|
||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||
эталонные дни прямо на входе.
|
||
|
||
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
|
||
ноль.
|
||
|
||
## Таблица ошибок
|
||
|
||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
|
||
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
|
||
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
|
||
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
|
||
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
|
||
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
|
||
загрузки разного возраста не более чем на сутки, и куску незачем переживать
|
||
срок из-за самой свежей строки.
|
||
|
||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
||
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||
|
||
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
||
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||
доставки.
|
||
|
||
## Раскладка DDL
|
||
|
||
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
|
||
объекты, потом матвью — тогда к моменту создания матвью её цель уже существует.
|
||
|
||
| Файл | Что в нём |
|
||
|---|---|
|
||
| `00-databases.sql` | базы слоёв |
|
||
| `10-stg-tables.sql` | Kafka-таблица, локальная и распределённая таблицы сырья |
|
||
| `20-ods-tables.sql` | типизированное событие и таблица ошибок |
|
||
| `30-ods-views.sql` | матвью разбора: сырьё в событие и в ошибки |
|
||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||
|
||
Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не
|
||
источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет
|
||
ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
|
||
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
|
||
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
||
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
||
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся
|
||
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной
|
||
отладке.
|
||
|
||
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
|
||
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
|
||
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
|
||
применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
|
||
топика с одной партицией. Переключателей тут два, и путать их не надо: брокер
|
||
автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не
|
||
просит — оба конца измерены, см. «Что проверено». То есть стенд держится на
|
||
умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и
|
||
создаётся явно, до применения DDL.
|
||
|
||
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
|
||
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
|
||
таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init` —
|
||
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
|
||
от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом
|
||
без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает
|
||
его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому
|
||
зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а
|
||
`airflow-init` — `clickhouse-init`, и цепочка упирается в долгоживущий Airflow.
|
||
Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо
|
||
применён.
|
||
|
||
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
|
||
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
|
||
запуском не применяется, причём молча. Отдельного механизма для этого нет и не
|
||
нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство
|
||
уже есть — `make clean && make up`. Мир регенерируется, сырьё живёт трое суток,
|
||
терять нечего.
|
||
|
||
## Карта таблиц
|
||
|
||
Ниже — то, что закладывает этап 2; всё перечисленное лежит в `sql/ddl/`.
|
||
|
||
| Слой | Объект | Что это |
|
||
|---|---|---|
|
||
| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||
| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
||
| STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
||
| ODS | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||
| ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
||
| ODS | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
||
|
||
Слои DDS и DM появляются на следующих этапах; их состав задан разделом 7
|
||
мастер-спеки и переносится сюда по мере постройки.
|
||
|
||
## Что проверено
|
||
|
||
Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka.
|
||
Поэтому утверждения о них разведены на три группы: насколько фразе можно верить,
|
||
должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит
|
||
документацию. Сверка с
|
||
документацией — через MCP Context7, 5 августа 2026 года; то же разведение для
|
||
механики приёма — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||
|
||
**Сверено с документацией.** Собственная колонка с именем виртуальной делает
|
||
виртуальную недоступной. При вставке в `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) в сырьё не приехало ничего, а
|
||
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
|
||
доехало само, без повторной отправки, отставание ушло в ноль, событие
|
||
разобралось. Сносить надо именно распределённую таблицу: вставка в
|
||
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
|
||
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
|
||
опровергнутым.
|
||
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
|
||
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
||
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
||
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
||
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
||
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
|
||
после, легло в сырьё и не попало в 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`)
|
||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
|
||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
|
||
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
|
||
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
|
||
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
|
||
том же наборе даёт ровно те же две строки и тот же офсет.
|
||
|
||
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
|
||
|
||
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
||
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
||
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
|
||
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
|
||
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
|
||
служебные колонки мы заполняем выражением при любом ответе.
|
||
|
||
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|