- Зачем:
- раздел «Часовые пояса» прошёл два холодных ревью — по дефектам и по
уместности. Первое поймало ложный замер и три расхождения с живым
стендом, второе — материал не своей зоны и дубли (#63).
- Что:
- замер «расхождение живёт по HTTP» отозван: мерил toString(UTCEventTime)
в родном клиенте против голой колонки по HTTP, а это разные вещи.
Перемерено — клиенты ведут себя одинаково; записан верный факт: вывод
колонки идёт по поясу сессии, функция — по поясу типа.
- «по поясу сервера» заменено на «по поясу сессии, а тот по умолчанию
серверный» — в разделе и в ADR 0005; утверждение в ледгере переписано
под измеренный раскол вывода и типа.
- PARTITION BY toDate(_load_ts) больше не выдаётся за уже соблюдённое
правило: _load_ts сегодня DateTime64(3) без пояса.
- абзац ADR 0005 больше не спорит с цитатой вызова строкой выше.
- вырезано: веер отклонённых вариантов под заголовком (живые отказы
разложены прозой по своим абзацам, как принято в этом документе),
ссылка на несуществующую связку в world.py, осиротевшая строка про
Grafana, абзац про пояс показа — он уехал комментарием в #63.
- Проверка:
- make lint
- замеры повторены на живом стенде 8 августа 2026 года
- DDL к конвенции по-прежнему не приведён: документы описывают цель
54 KiB
Хранилище: слои и конвенции
Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и раздел «Что проверено» — чему в этом тексте верить и на каком основании.
Что здесь описано и чего ещё нет. Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 идёт: в sql/ddl/ лежит вся цепочка
Kafka → STG → ODS — чтец топика hits, таблицы сырья, типизированное
событие с таблицей ошибок и три матвью. Дальше по тексту устройство описано
так, как оно проектируется; построенное от заложенного отличает карта таблиц в
конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в спеке генератора, формат события — в описании выгрузки. Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — спека «Боевой реализм стенда (v2)».
Имена объектов
Имя объекта заканчивается тем, что это за объект:
| Суффикс | Что это |
|---|---|
_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)) и заполняется из _timestamp_ms: у брокера метка
миллисекундная, соседняя _load_ts тоже DateTime64(3), а слой сырья хранит
приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности:
брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение
приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в SELECT матвью приёма, а не DEFAULT в
таблице. Для consumer_host это обязательно: DEFAULT hostName() вычисляется
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
колонка молча отвечала бы на другой вопрос.
Само сообщение лежит в колонке raw тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
это ниже, в матвью ODS — см. ADR 0005.
Движок таблицы сырья — обычный ReplicatedMergeTree, ORDER BY (kafka_partition, kafka_offset): разбор полётов идёт от «какое сообщение», другого ключа у сырья и
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся _load_ts, тип DateTime64(3). Ставится она
один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка
отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
разобрали». В ODS она же служит колонкой версии ReplacingMergeTree, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
руками можно — вставкой из сырья с фильтром по _load_ts, в пределах
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
Имя согласовано с каноном служебных полей соседнего учебного стенда на Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у технических колонок — распространённая запись, её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит: запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
Идентификатора пачки загрузки (_load_id) пока нет. В STG и ODS данные приезжают
потоком через матвью, у которого нет ни батча, ни run_id, и колонка была бы
пустой формальностью. В слоях, которые наполняет Airflow, run_id появится
по-настоящему — тогда и заведём, тем же стилем имени.
Часовые пояса
Пояс — линза, а не свойство значения. DateTime хранит одно число, секунды от
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
какие сутки оно попадёт.
Линза называется явно — в типе колонки либо в вызове функции. Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому в DDL
и запросах его не остаётся. Прописать этот пояс своей рукой — <timezone> в
конфигурации ноды или TZ контейнеру — было бы той же болезнью с другим
умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции:
toDate по колонке, чей тип пояс несёт, законен и имени не требует — так и
работают ключи партиций toDate(_load_ts) у сырья и у таблицы ошибок, когда
_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.
День берётся из EventDate. Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий toDate(UTCEventTime) с ней
расходится — спека генератора, раздел 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).
Постоянной сверки слоёв между собой при этом нет и не должно быть. У сырья
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
смотреть на оповещения
(ADR 0002). Равенство проверено разовым
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
опыт не становится, и почему — в карте
проверок, раздел «Интеграционная проверка постоянной целью не
становится». Счёт по ODS идёт через FINAL: голый count() по
ReplacingMergeTree зависит от того, сколько мержей успело пройти, и спека это
прямо запрещает (раздел 6).
Постоянная сверка одна, и она другого рода — не слой против слоя, а ODS
против описи мира, лежащей в 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.
Движок — обычный 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.
Сверено с документацией. Собственная колонка с именем виртуальной делает
виртуальную недоступной. При вставке в 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 ведут себя одинаково. После объявленияDateTime('UTC')расхождение уходит: проверено кастом на том же событии — колонка показывает20:58:56и при чужом поясе сессии. -
Матвью с источником-
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, на
том же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самом сырье, kafka_offset лежит там колонкой, и дырка в офсетах — это он.
Оговорка к третьему опыту, и она сама непроверенная: CREATE ... Distributed AS <локальная> копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе.
Сказано по памяти, проверки нет. Группа пуста: оба утверждения, ждавшие матвью разбора, закрыты опытами при исполнении #43 и переехали выше.