Files
clickstream-data-platform/docs/architecture/storage.md
T
ddadmin 319db308bf docs(storage): приняты решения по приёму событий и именам
- Зачем:
  - тикет #37 молча опирался на конвенции хранилища, которых в проекте не
    было; без них #43 и следующие этапы разъехались бы в именах, служебных
    колонках и механике приёма.
- Что:
  - ADR 0005: топик читается байтами в STG, разбор идёт функциями в матвью
    ODS; строгий приём — сверка набора ключей плюс Nullable на пяти опорных
    колонках.
  - ADR 0006: суффикс вида в именах объектов (_rep, _dist, _kafka, _mv, _v).
  - docs/architecture/storage.md: конвенции имён и служебных колонок, путь в
    keeper, раскладка по шардам, срок жизни сырья, свойства приёма, раскладка
    файлов DDL и карта таблиц.
  - спеки приведены в соответствие: механизм строгого приёма, имена объектов,
    контракт транспорта «одно событие — одно сообщение Kafka», три проверки
    при исполнении.
- Проверка:
  - make config-test
2026-08-04 00:14:13 +03:00

237 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Хранилище: слои и конвенции
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL
закладывает этап 2 — на момент написания их в репозитории нет. Дальше по тексту
устройство описано так, как оно проектируется; построенное от заложенного
отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../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`» такую тишину переживает плохо.
Второе свойство — `SHOW TABLES` группирует объекты по сущности, а не по
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
хвостом, а не началом имени.
## Раскладка по шардам
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
операций с партициями, ручной переобработки. Правило не про удобство: при записи
через распределённую таблицу раскладку определяет ключ шардирования, то есть
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
выбран против перекоса: структурированный числовой идентификатор распределяется
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
шард.
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
разобранное из неё событие почти всегда оказываются на разных шардах.
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
сверять слои можно только через `_dist`.
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
матвью перестанет читаться, что дано движком, а что положено нами, — а это
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
это ниже, в матвью ODS — см. [ADR 0005](../adr/0005-event-ingestion.md).
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. В ODS она же
служит колонкой версии `ReplacingMergeTree`. Имя согласовано с каноном служебных
полей соседнего учебного стенда на 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.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
шардам.
**Вставка синхронная.** На пути приёма стоит
`distributed_foreground_insert = 1`. По умолчанию вставка в распределённую
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его нет. Синхронный режим не
добавляет работы, он её не прячет: кусок на шарде будет записан всё равно,
вопрос лишь в том, ждём ли мы этого внутри вставки. Платится ожидание один раз
на блок Kafka в десятки тысяч строк, а не на событие.
**Гарантия — «хотя бы один раз», не транзакция.** Падение после записи на шард,
но до коммита офсетов даёт повтор при перечитывании. В ODS повтор схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
сырому слою не относится.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
отрицанием первого, а сам предикат собирается только из функций, не возвращающих
NULL, — иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`,
ни `NOT условие`.
Постоянной сверки счётчиков при этом нет и не должно быть. У сырья срок жизни
трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + ошибки»
разъедется само; переобработка добавит строк в ODS, перезаливка задвоит сырьё, а
ODS её схлопнет. Правило, красное в норме, учит не смотреть на оповещения
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверяется разово в
smoke на управляемой пачке: отправили N сообщений — получили N строк сырья и N в
сумме событий и ошибок.
## Срок жизни сырья
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). Значение
этой настройки между версиями ClickHouse менялось, поэтому в DDL оно проставлено
явно.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а обычный мерж внутри
партиции модельного дня склеит куски разного возраста, и данные переживут срок.
В партиции дня загрузки склеивать нечего: все строки в ней одного возраста, и
куски уходят по мере того, как истекает самый свежий из них — то есть сырьё
живёт трое суток с небольшим хвостом, а не ровно трое.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
режется своими координатами: и разбор полётов, и переобработка фильтруют по
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе.
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
ноль.
## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом и метаданными доставки. Ключи её собственные, потому что у брака нет
разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у строки,
которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и сырьё;
живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
разбираться к моменту разбирательства будет уже нечем.
## Раскладка DDL
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Один файл — это слой и
роль: статичные объекты отдельно от матвью.
| Файл | Что в нём |
|---|---|
| `00-databases.sql` | базы слоёв |
| `10-stg-tables.sql` | Kafka-таблица, локальная и распределённая таблицы сырья |
| `11-stg-views.sql` | матвью, наполняющая сырьё из Kafka |
| `20-ods-tables.sql` | типизированное событие и таблица ошибок |
| `21-ods-views.sql` | матвью разбора: сырьё в событие и в ошибки |
Матвью принадлежит слою своей цели, а не источника: разбор из STG в ODS лежит
среди файлов ODS, потому что наполняет ODS.
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`. Порядок страхует от автосоздания топика
брокером с одной партицией: у потребителя 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
мастер-спеки и переносится сюда по мере постройки.