Files
clickstream-data-platform/docs/architecture/storage.md
T
ddadminandClaude Opus 5 31b274175a docs(storage): конвенции и приём событий выправлены после ревью
- Зачем:
  - три холодных ревью и сверка с документацией ClickHouse нашли противоречия
    между докой, ADR и спекой: исполнитель #37 получал два разных ответа на
    один вопрос, а два утверждения о движке оказались неверными.
- Что:
  - раскладка файлов DDL перестроена — сначала таблицы, матвью приёма
    последней: иначе часть событий тихо минует ODS.
  - синхронная вставка снята с пути приёма: настройка недостижима для потока
    Kafka-движка и связывает шарды; на ETL-вставках осталась.
  - у таблицы ошибок появился класс брака с порядком проверки, у сырья и
    ошибок названы движки и ключи сортировки.
  - в доку добавлен раздел «Что проверено»: сверенное с документацией,
    проверяемое на стенде и сказанное по памяти разведены.
  - в спеке выправлены источник матвью разбора, пять опорных колонок, имена
    четырёх витрин и ссылка на несуществующую цель make.
- Проверка:
  - make config-test
  - grep по устаревшим именам файлов DDL и витрин — пусто

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:25:35 +03:00

36 KiB
Raw Blame History

Хранилище: слои и конвенции

Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами.

Что здесь описано и чего ещё нет. Собран этап 1: кластер из двух шардов, keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL закладывает этап 2 — на момент написания их в репозитории нет. Дальше по тексту устройство описано так, как оно проектируется; построенное от заложенного отличает карта таблиц в конце.

Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в спеке генератора, формат события — в описании выгрузки. Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — спека «Боевой реализм стенда (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). Ключи витрин появятся вместе с самими витринами.

Известное ограничение правила «пишем только в _dist»: пакетные слои собираются заменой дневных партиций, а DROP/REPLACE PARTITION работает только по локальным таблицам. Чем и как раскладывать партицию-донор по шардам до замены, здесь не решено — вопрос встаёт вместе со сборкой DDS, и решать его нужно тогда, а не задним числом.

Служебные колонки

Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: _topic, _partition, _offset, _timestamp у Kafka, _shard_num у Distributed и прочие. Если положить на диск колонку с таким же именем, в матвью перестанет читаться, что дано движком, а что положено нами, — а это ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они ложатся под именами kafka_topic, kafka_partition, kafka_offset, kafka_timestamp, рядом — consumer_host, имя читавшей ноды: виртуальные колонки его не несут, а после записи в Distributed он уже невосстановим.

Типы у них такие: kafka_topic и consumer_hostLowCardinality(String), значений мало и они повторяются; kafka_partition и kafka_offsetUInt64. С kafka_timestamp сложнее: виртуальная колонка _timestamp заполнена не всегда, а разрядность у секундной и миллисекундной версий разная. Поэтому колонка объявляется Nullable(DateTime), а точная форма проверяется на стенде (раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на первом сообщении, либо получить тихие нули за 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 нет вовсе: слой наполняет матвью, а не пакетное задание, и пакетная работа с партициями начинается выше.

Имя согласовано с каноном служебных полей соседнего учебного стенда на Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у технических колонок — распространённая запись, её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит: запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.

Идентификатора пачки загрузки (_load_id) пока нет. В STG и ODS данные приезжают потоком через матвью, у которого нет ни батча, ни run_id, и колонка была бы пустой формальностью. В слоях, которые наполняет Airflow, run_id появится по-настоящему — тогда и заведём, тем же стилем имени.

Путь реплицированных таблиц в keeper

Шаблон — /clickhouse/tables/{shard}/{database}/{table}. База в пути обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел в keeper и подерутся. Макрос {uuid} не используем, хотя он тоже развёл бы пути: он завязан на движок базы Atomic и делает путь нечитаемым, а на учебном стенде возможность открыть system.zookeeper и увидеть осмысленный путь — сама по себе половина урока про то, чем занят keeper.

Приём: поток и его свойства

Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и таблица ошибок ODS.

Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы clickstream_hits, и оно одинаково на обеих нодах по построению: DDL идёт ON CLUSTER и макросов в имени не содержит. Разные группы дали бы каждой ноде полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя кластера в ON CLUSTER и в движке Distributedclickstream_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 условие. Те же функции не должны и бросать исключений: упавшая матвью роняет вставку и останавливает потребление до починки (ADR 0005).

Постоянной сверки счётчиков при этом нет и не должно быть. У сырья срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не смотреть на оповещения (ADR 0002). Равенство проверяется разово в smoke на управляемой пачке: отправили N сообщений — получили N строк сырья и N в сумме событий и ошибок. Счёт по ODS идёт через FINAL: голый count() по ReplacingMergeTree зависит от того, сколько мержей успело пройти, и спека это прямо запрещает (раздел 6).

Срок жизни сырья

Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же колонке _load_ts, снятие — целыми кусками (ttl_only_drop_parts). В DDL значение проставлено явно, чтобы поведение не зависело от умолчания версии.

Нарезать сырьё по модельному дню события было бы соблазнительно — он единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок снимается целиком, только когда в нём истекли все строки, а в партиции модельного дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.

В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного возраста — но не более чем на сутки, потому что партицию закрывает календарный день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что «уходит само» здесь обещано, а «уходит вовремя» — нет.

Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а содержимое разбирает ODS — там EventDate и живёт, ключом партиции. Сырьё режется своими координатами: и разбор полётов, и переобработка фильтруют по _load_ts, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.

Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска разные часы, и обслуживают их разные средства. Декларативный TTL по модельной дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы эталонные дни прямо на входе.

В бою слой сырья иногда собирают на движке Null — тогда он не хранится вовсе. Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не ноль.

Таблица ошибок

ods.event_errors держит строки, не прошедшие строгий приём, вместе с их сырым текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у брака нет разобранных полей: шардируется cityHash64 сырой строки — ClientID у строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним, разбираться к моменту разбирательства будет уже нечем.

Класс брака лежит в колонке error_class типа LowCardinality(String). Без неё в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине качества не на что опереться. Сами классы перечислены в ADR 0005 и проверяются по порядку, потому что пересекаются: сообщение, не являющееся объектом JSON, проваливает заодно и сверку набора ключей — JSONExtractKeys от скаляра даёт пустой массив. Побеждает первый совпавший класс, тем же приёмом, что mismatch_class в витрине сверки.

Движок — обычный 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.

Образцы копируются не целиком, и в двух местах. clickhouse-init обязан ждать готовности обеих нод: ON CLUSTER ждёт исполнения на всех хостах и по таймауту бросает, а airflow-init ждёт только первую ноду, superset-init — только вторую. И второе: оба образца переживают make up --wait лишь потому, что от них зависят долгоживущие сервисы; у пары kafka-init / clickhouse-init таких зависимых нет, и как поведёт себя --wait с одноразовым сервисом без них — проверяется при исполнении #37. Порядок страхует от автосоздания топика брокером с одной партицией: у потребителя librdkafka разрешение на автосоздание по умолчанию выключено, так что случиться это не обязано, но урок «обе ноды читают топик» умирает тихо, и полагаться на умолчание клиента здесь не стоит.

Повторный make up поверх живого тома проходит зелёным: весь DDL идёт через CREATE ... IF NOT EXISTS. Оборотная сторона — изменённый объект тем же запуском не применяется, причём молча. Отдельного механизма для этого нет и не нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство уже есть — make clean && make up. Мир регенерируется, сырьё живёт трое суток, терять нечего.

Карта таблиц

Ниже — то, что закладывает этап 2. В репозитории этих объектов пока нет.

Слой Объект Что это
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. Поэтому утверждения о движке разведены на три группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит документацию. Сверка — через 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 на существующем объекте не бросает.

Записано как проверка на стенде — раздел 11 спеки и ADR 0005. Срабатывание матвью с источником-Distributed на вставку именно в неё: этого случая в документации нет вовсе, утверждение держится на опыте владельца. Одна строка на сообщение у RawBLOB. Обнуляемость и разрядность виртуальной колонки _timestamp.

Сказано по памяти, проверки пока нет. Что DROP/REPLACE PARTITION не работает по Distributed — прямого запрета в документации нет, все примеры даны для семейства MergeTree. Что DEFAULT hostName() вычислился бы на шарде-получателе, а не на вставляющей ноде, и что имя читавшей ноды после записи в Distributed уже невосстановимо. Что у потребителя librdkafka автосоздание топиков по умолчанию выключено. Что упавшая матвью роняет вставку и останавливает потребление до починки — на этой фразе держится правило «грязные записи не валят пайплайн», и стоит она пока на одном рассуждении. Проверяются все пятеро дёшево и заодно с приёмкой #37; до тех пор это предположения, а не знание.