docs(storage): конвенции и приём событий выправлены после ревью

- Зачем:
  - три холодных ревью и сверка с документацией ClickHouse нашли противоречия
    между докой, ADR и спекой: исполнитель #37 получал два разных ответа на
    один вопрос, а два утверждения о движке оказались неверными.
- Что:
  - раскладка файлов DDL перестроена — сначала таблицы, матвью приёма
    последней: иначе часть событий тихо минует ODS.
  - синхронная вставка снята с пути приёма: настройка недостижима для потока
    Kafka-движка и связывает шарды; на ETL-вставках осталась.
  - у таблицы ошибок появился класс брака с порядком проверки, у сырья и
    ошибок названы движки и ключи сортировки.
  - в доку добавлен раздел «Что проверено»: сверенное с документацией,
    проверяемое на стенде и сказанное по памяти разведены.
  - в спеке выправлены источник матвью разбора, пять опорных колонок, имена
    четырёх витрин и ссылка на несуществующую цель make.
- Проверка:
  - make config-test
  - grep по устаревшим именам файлов DDL и витрин — пусто

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 21:25:35 +03:00
co-authored by Claude Opus 5
parent dedb6c6739
commit 31b274175a
6 changed files with 284 additions and 65 deletions
+179 -35
View File
@@ -40,9 +40,17 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
только по `Distributed`» такую тишину переживает плохо.
Второе свойство — `SHOW TABLES` группирует объекты по сущности, а не по
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
хвостом, а не началом имени.
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
порядок выдачи `SHOW TABLES` документация не оговаривает.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
имя приходит от предметной области и стоит в единственном числе: `event`,
`order_snapshot`, `session`. Граница между «как привезли» и «что это такое»
проходит по STG, и имена её показывают.
## Раскладка по шардам
@@ -65,6 +73,18 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
сверять слои можно только через `_dist`.
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
Известное ограничение правила «пишем только в `_dist`»: пакетные слои собираются
заменой дневных партиций, а `DROP/REPLACE PARTITION` работает только по локальным
таблицам. Чем и как раскладывать партицию-донор по шардам до замены, здесь не
решено — вопрос встаёт вместе со сборкой DDS, и решать его нужно тогда, а не
задним числом.
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
@@ -76,14 +96,40 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
Типы у них такие: `kafka_topic` и `consumer_host``LowCardinality(String)`,
значений мало и они повторяются; `kafka_partition` и `kafka_offset``UInt64`.
С `kafka_timestamp` сложнее: виртуальная колонка `_timestamp` заполнена не
всегда, а разрядность у секундной и миллисекундной версий разная. Поэтому колонка
объявляется `Nullable(DateTime)`, а точная форма проверяется на стенде
(раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на
первом сообщении, либо получить тихие нули за 1970 год.
Заполняются обе группы колонок выражением в `SELECT` матвью приёма, а не
`DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, —
то есть колонка молча отвечала бы на другой вопрос.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
это ниже, в матвью ODS — см. [ADR 0005](../adr/0005-event-ingestion.md).
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. В ODS она же
служит колонкой версии `ReplacingMergeTree`. Имя согласовано с каноном служебных
полей соседнего учебного стенда на Greenplum, чтобы словарь был общим у двух
хранилищ; ведущее подчёркивание у технических колонок — распространённая запись,
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. Ставится она
один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка
отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
безразлично. Переобработки как стадии у ODS нет вовсе: слой наполняет матвью, а
не пакетное задание, и пакетная работа с партициями начинается выше.
Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
технических колонок — распространённая запись,
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
@@ -106,6 +152,13 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
таблица ошибок ODS.
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
кластера в `ON CLUSTER` и в движке `Distributed``clickstream_cluster`, оно
задано в `infra/clickhouse/config.d/cluster.xml`.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
@@ -113,14 +166,24 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
шардам.
**Вставка синхронная.** На пути приёма стоит
`distributed_foreground_insert = 1`. По умолчанию вставка в распределённую
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его нет. Синхронный режим не
добавляет работы, он её не прячет: кусок на шарде будет записан всё равно,
вопрос лишь в том, ждём ли мы этого внутри вставки. Платится ожидание один раз
на блок Kafka в десятки тысяч строк, а не на событие.
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
промежутке — сообщения не перечитаются.
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
у него не бывает, так что настройка уровня запроса доехала бы только профилем
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
**Гарантия — «хотя бы один раз», не транзакция.** Падение после записи на шард,
но до коммита офсетов даёт повтор при перечитывании. В ODS повтор схлопнет
@@ -129,38 +192,52 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
отрицанием первого, а сам предикат собирается только из функций, не возвращающих
NULL, — иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`,
ни `NOT условие`.
ни `NOT условие`. Те же функции не должны и бросать исключений: упавшая матвью
роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)).
Постоянной сверки счётчиков при этом нет и не должно быть. У сырья срок жизни
трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + ошибки»
разъедется само; переобработка добавит строк в ODS, перезаливка задвоит сырьё, а
ODS её схлопнет. Правило, красное в норме, учит не смотреть на оповещения
разъедется само: сырьё истечёт раньше, а перезаливка модельного дня задвоит его,
тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
смотреть на оповещения
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверяется разово в
smoke на управляемой пачке: отправили N сообщений — получили N строк сырья и N в
сумме событий и ошибок.
сумме событий и ошибок. Счёт по ODS идёт через `FINAL`: голый `count()` по
`ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это
прямо запрещает (раздел 6).
## Срок жизни сырья
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). Значение
этой настройки между версиями ClickHouse менялось, поэтому в DDL оно проставлено
явно.
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а обычный мерж внутри
партиции модельного дня склеит куски разного возраста, и данные переживут срок.
В партиции дня загрузки склеивать нечего: все строки в ней одного возраста, и
куски уходят по мере того, как истекает самый свежий из них — то есть сырьё
живёт трое суток с небольшим хвостом, а не ровно трое.
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
«уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
@@ -182,32 +259,60 @@ D0 и к реальному календарю не привязана; паке
## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом и метаданными доставки. Ключи её собственные, потому что у брака нет
разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у строки,
которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и сырьё;
живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
разбираться к моменту разбирательства будет уже нечем.
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы перечислены в
[ADR 0005](../adr/0005-event-ingestion.md) и проверяются по порядку, потому что
пересекаются: сообщение, не являющееся объектом JSON, проваливает заодно и сверку
набора ключей — `JSONExtractKeys` от скаляра даёт пустой массив. Побеждает первый
совпавший класс, тем же приёмом, что `mismatch_class` в витрине сверки.
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY``(error_class, kafka_partition,
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
доставки.
## Раскладка DDL
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Один файл — это слой и
роль: статичные объекты отдельно от матвью.
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
объекты, потом матвью — тогда к моменту создания матвью её цель уже существует.
| Файл | Что в нём |
|---|---|
| `00-databases.sql` | базы слоёв |
| `10-stg-tables.sql` | Kafka-таблица, локальная и распределённая таблицы сырья |
| `11-stg-views.sql` | матвью, наполняющая сырьё из Kafka |
| `20-ods-tables.sql` | типизированное событие и таблица ошибок |
| `21-ods-views.sql` | матвью разбора: сырьё в событие и в ошибки |
| `30-ods-views.sql` | матвью разбора: сырьё в событие и в ошибки |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
Матвью принадлежит слою своей цели, а не источника: разбор из STG в ODS лежит
среди файлов ODS, потому что наполняет ODS.
Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не
источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет
ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной
отладке.
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`. Порядок страхует от автосоздания топика
применяет файлы с ноды 1, `ON CLUSTER`.
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init`
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init`
таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них —
проверяется при исполнении #37. Порядок страхует от автосоздания топика
брокером с одной партицией: у потребителя librdkafka разрешение на автосоздание
по умолчанию выключено, так что случиться это не обязано, но урок «обе ноды
читают топик» умирает тихо, и полагаться на умолчание клиента здесь не стоит.
@@ -234,3 +339,42 @@ D0 и к реальному календарю не привязана; паке
Слои DDS и DM появляются на следующих этапах; их состав задан разделом 7
мастер-спеки и переносится сюда по мере постройки.
## Что проверено
Документ описывает устройство, которого в репозитории ещё нет, и на каждом шагу
опирается на поведение ClickHouse. Поэтому утверждения о движке разведены на три
группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть
от того, хорошо ли автор помнит документацию. Сверка — через 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` на существующем объекте не бросает.
**Записано как проверка на стенде** — раздел 11 спеки и ADR 0005. Срабатывание
матвью с источником-`Distributed` на вставку именно в неё: этого случая в
документации нет вовсе, утверждение держится на опыте владельца. Одна строка на
сообщение у `RawBLOB`. Обнуляемость и разрядность виртуальной колонки
`_timestamp`.
**Сказано по памяти, проверки пока нет.** Что `DROP/REPLACE PARTITION` не
работает по `Distributed` — прямого запрета в документации нет, все примеры даны
для семейства MergeTree. Что `DEFAULT hostName()` вычислился бы на
шарде-получателе, а не на вставляющей ноде, и что имя читавшей ноды после записи
в `Distributed` уже невосстановимо. Что у потребителя librdkafka автосоздание
топиков по умолчанию выключено. Что упавшая матвью роняет вставку и
останавливает потребление до починки — на этой фразе держится правило «грязные
записи не валят пайплайн», и стоит она пока на одном рассуждении. Проверяются
все пятеро дёшево и заодно с приёмкой #37; до тех пор это предположения, а не
знание.