docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа от генератора до хранилища, включая ошибки и повторные поступления. Что: обновлены вводная генератора, справочники приема и модели заказа, разделы хранилища и комментарии SQL и дага. Сохранены технические условия; исполняемый код, схемы и поведение стенда не изменены. Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI. SQL без комментариев и Python AST без строк документации совпали с исходными. Генерация данных и сброс состояния не выполнялись. Refs #250
This commit is contained in:
1 parent
83434102d5
commit
75f28fe99f
13 files changed
+663
-974
No files matched your search
+205
-228
@@ -122,112 +122,110 @@ DDS: представление читает события ODS.
|
||||
|
||||
## Служебные колонки
|
||||
|
||||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||||
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
|
||||
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
|
||||
матвью перестанет читаться, что дано движком, а что положено нами, — а это
|
||||
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
|
||||
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
|
||||
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
|
||||
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
|
||||
STG сохраняет исходное сообщение в строке `raw` и добавляет сведения
|
||||
о доставке. JSON на этом шаге не разбирается: некорректное содержимое
|
||||
тоже попадает в таблицу.
|
||||
|
||||
Типы у них такие: `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 год.
|
||||
| Колонка STG | Что хранит |
|
||||
|---|---|
|
||||
| `kafka_topic` | Имя топика из виртуальной колонки Kafka `_topic` |
|
||||
| `kafka_partition` | Номер партиции из `_partition` |
|
||||
| `kafka_offset` | Позицию сообщения внутри партиции из `_offset` |
|
||||
| `kafka_timestamp` | Метку времени Kafka из `_timestamp_ms`, если она есть |
|
||||
| `consumer_host` | Имя ноды, прочитавшей Kafka |
|
||||
| `_load_ts` | Реальное время записи сообщения в STG |
|
||||
| `_load_id` | Только у заказов: `run_id` запуска Airflow, который принял порцию |
|
||||
|
||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||||
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
|
||||
колонка молча отвечала бы на другой вопрос.
|
||||
Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
|
||||
`kafka_topic` и `consumer_host` имеют тип `LowCardinality(String)`,
|
||||
`kafka_partition` и `kafka_offset` — `UInt64`.
|
||||
`kafka_timestamp` имеет тип `Nullable(DateTime64(3, 'UTC'))`: сохраняет
|
||||
миллисекунды и допускает отсутствие метки.
|
||||
|
||||
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
||||
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
||||
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
|
||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)).
|
||||
Виртуальные колонки предоставляет движок, а колонки STG хранят значения
|
||||
на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
|
||||
собственной. Значения вычисляются в запросе приема. В частности,
|
||||
`hostName()` должен выполняться на ноде, читающей Kafka:
|
||||
`DEFAULT hostName()` только в локальной таблице назвал бы шард-получатель.
|
||||
|
||||
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
||||
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
||||
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
||||
Таблицы STG используют `ReplicatedMergeTree` с
|
||||
`ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
|
||||
отдельной строкой; STG нужен в том числе для разбора таких повторов.
|
||||
|
||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||||
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||||
`_load_ts` имеет тип `DateTime64(3, 'UTC')`. При переходе в ODS метка
|
||||
копируется из STG без пересчета. У событий она также выбирает последнюю
|
||||
доставку; у заказов версию задает `snapshot_date`. `_load_id` заказа
|
||||
переносится в годную строку ODS и в таблицу ошибок.
|
||||
У непрерывного потока событий нет запуска Airflow, поэтому `_load_id`
|
||||
в его STG и ODS отсутствует.
|
||||
|
||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||
Время события `UTCEventTime` и дата `EventDate` приходят из счетчика сайта.
|
||||
Поля заказа `created_at` и `updated_at` приходят из базы магазина.
|
||||
Они относятся к модельному времени; `_load_ts` — к реальным часам стенда.
|
||||
Например, событие июня можно загрузить в сентябре: его `EventDate`
|
||||
останется июньской, а `_load_ts` будет сентябрьской.
|
||||
|
||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||
`_load_id`; дневные партиции ODS он не заменяет.
|
||||
|
||||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||||
технических колонок — распространённая запись,
|
||||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||||
|
||||
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
||||
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
||||
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
||||
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
||||
События разбираются материализованными представлениями. Автоматической
|
||||
пакетной переобработки у них нет; сохраненный срез STG можно разобрать
|
||||
отдельным запросом до истечения срока хранения. При повторной вставке
|
||||
с тем же ключом и `_load_ts` `ReplacingMergeTree` оставляет более поздно
|
||||
вставленную строку. Заказы разбирает задача Airflow по `_load_id`.
|
||||
В DDS метки загрузки уже относятся к собственной сборке слоя —
|
||||
[пример заказа](dds/order.md#служебные-колонки).
|
||||
|
||||
## Актуальные события
|
||||
|
||||
`ods.event_rep` хранит физически принятые версии события в
|
||||
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
|
||||
диагностики доставки. Обычное чтение этой пары может показать несколько строк
|
||||
одного `WatchID`, пока фоновые слияния их не схлопнули.
|
||||
Повторная отправка события может дать несколько строк в `ods.event_rep`.
|
||||
Движок `ReplicatedReplacingMergeTree(_load_ts)` считает версиями строки
|
||||
с одинаковым полным ключом сортировки:
|
||||
`(CounterID, EventDate, intHash32(ClientID), WatchID)`.
|
||||
При слиянии остается строка с максимальным `_load_ts`, то есть последняя
|
||||
доставка. Один `WatchID` обозначает событие, но физический ключ замены шире.
|
||||
|
||||
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
||||
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
||||
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
|
||||
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
||||
числе snake_case-имена и расшифровку кодов.
|
||||
`ods.event_v` читает `ods.event_dist FINAL`: выбирает актуальную строку
|
||||
при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
|
||||
считают через `_v`; число строк в `_dist` зависит от уже прошедших слияний.
|
||||
`dds.event_v` использует этот результат, дает колонкам имена в `snake_case`
|
||||
и расшифровывает коды.
|
||||
|
||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
||||
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
||||
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
||||
с сырыми данными подтвердило: копейки всех покупок целы.
|
||||
Выбор по полному `ORDER BY` и максимальной версии, включая чтение с `FINAL`,
|
||||
сверен через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree).
|
||||
В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
|
||||
результат не зависел от фоновых слияний.
|
||||
|
||||
Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md)
|
||||
с одним денежным исключением: `purchaseRevenue` объявлен источником
|
||||
как `Array(Float64)`, а из JSON разбирается сразу в
|
||||
`Array(Decimal(18, 2))`. Это сумма в рублях с копейками; DDS наследует тип.
|
||||
`productPrice` содержит округленные целые рубли и не подходит для
|
||||
восстановления точной суммы покупки. Прямой разбор `purchaseRevenue`
|
||||
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
|
||||
|
||||
## Версии заказов
|
||||
|
||||
Слепки повторяют состояние заказов из семидневного окна источника.
|
||||
`ods.order_rep` хранит принятые версии в
|
||||
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||
выбрал одного победителя.
|
||||
`ReplicatedReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`,
|
||||
партиция — `toDate(created_at)` в UTC. Запись через `ods.order_dist`
|
||||
распределяет их по `cityHash64(order_id)`. У всех версий заказа одинаковы
|
||||
день создания и шард, поэтому они могут заменять друг друга.
|
||||
|
||||
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
|
||||
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
|
||||
дате слепка. Модель версионного ODS записана в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
|
||||
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
|
||||
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
|
||||
задан в [модели заказа в DDS](dds/order.md).
|
||||
`ods.order_v` читает `_dist FINAL` и оставляет строку с максимальной датой
|
||||
слепка. Дата `snapshot_date` показывает последнее принятое наблюдение,
|
||||
а `updated_at` — время изменения заказа в магазине. У неизменившегося
|
||||
заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
|
||||
|
||||
Источник выпускает один слепок на дату, а поздний слепок показывает
|
||||
не менее новое состояние. Отсутствие заказа в следующем слепке
|
||||
не означает удаления. Физическая таблица не служит архивом:
|
||||
старые версии исчезают при фоновых слияниях.
|
||||
|
||||
Прием добавляет версии отдельными строками. `snapshot_date` не доказывает,
|
||||
что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется.
|
||||
Подробности — [прием заказов](orders/ingestion.md),
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||
[ADR 0014](../adr/0014-order-version-snapshot-date.md).
|
||||
Следующий слой описан в [модели заказа DDS](dds/order.md).
|
||||
|
||||
## Часовые пояса
|
||||
|
||||
@@ -289,169 +287,148 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
||||
|
||||
## Приём: поток и его свойства
|
||||
|
||||
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
|
||||
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
|
||||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||||
заказов](orders/ingestion.md).
|
||||
События принимаются непрерывно. Заказы читает отдельным запросом
|
||||
даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md).
|
||||
У событий путь такой:
|
||||
|
||||
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
||||
ошибок ODS.
|
||||
```text
|
||||
stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
|
||||
├→ ods.event_mv → ods.event_dist
|
||||
└→ ods.event_errors_mv → ods.event_errors_dist
|
||||
```
|
||||
|
||||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||||
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
|
||||
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
|
||||
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
|
||||
задано в `infra/clickhouse/config.d/cluster.xml`.
|
||||
Таблицы Kafka на обеих нодах используют одну группу `clickstream_hits`.
|
||||
Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
|
||||
свою полную копию потока. Имя группы одинаково в DDL на всем
|
||||
`clickstream_cluster`; конфигурация кластера —
|
||||
`infra/clickhouse/config.d/cluster.xml`.
|
||||
|
||||
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
|
||||
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
|
||||
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
|
||||
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
|
||||
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
|
||||
шардам.
|
||||
Материализованные представления разбора срабатывают на вставку
|
||||
в `stg.hits_raw_dist`, до распределения строк по шардам.
|
||||
Оба видят вставляемую порцию на той ноде, которая читает Kafka.
|
||||
`stg.hits_raw_mv` создается после них: его подключение запускает
|
||||
чтение топика. Если начать чтение раньше, сообщения успеют попасть
|
||||
в STG без разбора в ODS.
|
||||
|
||||
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
|
||||
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
|
||||
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
|
||||
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
|
||||
промежутке — сообщения не перечитаются.
|
||||
Условие приема проверяет три вещи:
|
||||
|
||||
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
|
||||
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
|
||||
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
|
||||
у него не бывает, так что настройка уровня запроса доехала бы только профилем
|
||||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
||||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||
компромисс полезнее показать, чем спрятать за галочкой.
|
||||
- корень `raw` — объект JSON;
|
||||
- набор верхнеуровневых ключей совпадает с 47 полями формата;
|
||||
- пять опорных полей, `WatchID`, `VisitID`, `ClientID`, `EventDate`
|
||||
и `UTCEventTime`, разбираются в ожидаемые типы.
|
||||
|
||||
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
||||
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
||||
измерено и принято осознанно — подробности в разделе «Что проверено».
|
||||
Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
|
||||
Это граница приема: годная строка не означает, что проверен смысл
|
||||
всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||
|
||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
||||
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||
`ods.event_mv` принимает строки, для которых условие истинно,
|
||||
`ods.event_errors_mv` — для которых оно ложно. Условие не должно
|
||||
возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
|
||||
Разбор использует функции без исключений на некорректном сообщении,
|
||||
поскольку ошибка материализованного представления прерывает вставку
|
||||
и останавливает чтение Kafka до устранения причины.
|
||||
|
||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
||||
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
||||
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
||||
конвейера к сырому слою не относится.
|
||||
### Доставка и повторы
|
||||
|
||||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||||
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
||||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||||
отсутствии механизма.
|
||||
Вставка в Distributed на этом пути фоновая: блок сначала записывается
|
||||
в локальную очередь на диске, затем отправляется на шарды.
|
||||
Kafka подтверждает обработку после выполнения материализованных
|
||||
представлений, когда данные еще могут находиться в очереди.
|
||||
Если после подтверждения потерять ноду вместе с очередью, сообщения
|
||||
не перечитаются. Возможен и повтор: данные уже записались на шард,
|
||||
но подтверждение Kafka не успело сохраниться.
|
||||
Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
|
||||
|
||||
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
|
||||
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
|
||||
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
|
||||
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
|
||||
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
|
||||
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
|
||||
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
|
||||
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
|
||||
исключений: упавшая матвью
|
||||
роняет вставку и останавливает потребление до починки
|
||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||
В ETL-запросах Airflow `distributed_foreground_insert = 1` дожидается
|
||||
записи на шарды. У фонового приема событий этой настройки нет:
|
||||
недоступность одного шарда тогда остановила бы весь прием. В принятом
|
||||
режиме доступная нода продолжает принимать и накапливает очередь
|
||||
для недоступного шарда. Ограничение сохранено для учебного стенда;
|
||||
подробности и наблюдения — в разделе «Что проверено».
|
||||
|
||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
||||
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
||||
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
||||
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
||||
красное в норме, учит не смотреть на оповещения ([ADR
|
||||
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
||||
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
||||
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
||||
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
||||
постоянной целью не становится». Счёт актуальных событий идёт через
|
||||
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
||||
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
||||
(раздел 6).
|
||||
В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
|
||||
Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
|
||||
при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
|
||||
Поэтому повторная отправка модельного дня увеличивает число строк STG.
|
||||
|
||||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
||||
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
||||
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
||||
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
||||
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
||||
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
||||
Пустое значение сообщения и значение `null` не дают строки даже в STG:
|
||||
чтец продвигает позицию Kafka, но не передает строку на разбор.
|
||||
Генератор таких сообщений не создает. Поведение измерено на стенде
|
||||
и описано в разделе «Что проверено».
|
||||
|
||||
### Что можно сверять
|
||||
|
||||
Для короткого неизменного среза доставки можно проверить баланс:
|
||||
число строк STG равно сумме годных строк и ошибок до удаления повторов.
|
||||
Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
|
||||
ODS оставляет события и объединяет повторные доставки.
|
||||
Счетчики по слоям тогда расходятся при штатной работе.
|
||||
|
||||
`make check-clickhouse` сравнивает актуальные события в `ods.event_v`
|
||||
с [паспортом мира](../../data/world-inventory.json) только за восемь
|
||||
начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
|
||||
и не зависит от срока жизни STG. Границы остальных проверок —
|
||||
в [карте проверок](testing.md).
|
||||
|
||||
## Срок жизни сырых данных
|
||||
|
||||
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
||||
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
||||
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
||||
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
||||
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
||||
зависело от умолчания версии.
|
||||
TTL — правило автоматического удаления старых данных. В обеих таблицах
|
||||
STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
|
||||
Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
|
||||
для поиска ошибок и повторного разбора, после которого данные освобождают
|
||||
место на диске.
|
||||
|
||||
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
||||
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||
снимается целиком, только когда в нём истекли все строки, а в партиции
|
||||
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
||||
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
||||
срок неограниченно.
|
||||
Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
|
||||
файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
|
||||
Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
|
||||
дня загрузки возраст строк отличается не более чем на сутки.
|
||||
Поэтому к трем суткам может добавиться до суток ожидания самой свежей
|
||||
строки части, а затем ожидание фоновой обработки TTL. Точного времени
|
||||
удаления правило не обещает.
|
||||
|
||||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||||
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
||||
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
||||
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||
Модельная дата для TTL не подходит: события июня могут загружаться
|
||||
осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
|
||||
тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
|
||||
продлевать хранение старых строк после слияния частей.
|
||||
|
||||
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
||||
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
||||
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||||
|
||||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||||
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
|
||||
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
|
||||
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
|
||||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||||
эталонные дни прямо на входе.
|
||||
|
||||
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
||||
а не ноль.
|
||||
Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
|
||||
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
|
||||
из которых модельную дату извлечь нельзя. `EventDate` появляется
|
||||
отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
|
||||
такой срез разбирается рядом с действующим приемом.
|
||||
|
||||
## Таблица ошибок
|
||||
|
||||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
||||
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
||||
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
||||
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
||||
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
||||
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
||||
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
||||
и куску незачем переживать срок из-за самой свежей строки.
|
||||
`ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
|
||||
исходный `raw`, сведения о доставке и класс ошибки `error_class`.
|
||||
Класс имеет тип `LowCardinality(String)` и выбирается по первому
|
||||
нарушенному условию:
|
||||
|
||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
||||
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||
1. `not_an_object` — корень JSON не объект;
|
||||
2. `keyset_mismatch` — набор ключей не совпал с форматом;
|
||||
3. `key_field_unparsed` — одно из пяти опорных полей не разобралось.
|
||||
|
||||
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
||||
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
||||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||||
доставки.
|
||||
Порядок важен: строка вместо объекта нарушает сразу несколько условий,
|
||||
но получает один класс. Полные условия — в
|
||||
[ADR 0005](../adr/0005-event-ingestion.md).
|
||||
|
||||
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
|
||||
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||
[спецификации заказов](orders/ingestion.md).
|
||||
`ods.order_errors_dist` хранит такой же набор сведений и `_load_id`
|
||||
запуска приема. Первые два класса совпадают с событиями,
|
||||
третий называется `field_invalid`: у заказа проверяются все поля
|
||||
верхнего уровня. Точные правила — в [описании приема заказов](orders/ingestion.md#граница-строгого-приёма).
|
||||
|
||||
Таблицы ошибок используют `ReplicatedMergeTree` без замены версий:
|
||||
у некорректного сообщения может не быть разобранного ключа сущности.
|
||||
Шард выбирается по `cityHash64(raw)`, ключ сортировки —
|
||||
`(error_class, kafka_partition, kafka_offset)`, партиция — день `_load_ts`.
|
||||
Начать поиск можно с класса ошибки, затем перейти к сообщениям
|
||||
по координатам Kafka.
|
||||
|
||||
Срок хранения — один месяц от `_load_ts`, удаление целыми частями
|
||||
с `ttl_only_drop_parts = 1`, как в STG. Ошибка вместе с исходным текстом
|
||||
остается доступна дольше остальных сырых сообщений. Повторная обработка
|
||||
может добавить тот же брак еще раз; у заказов повтор виден по совпадению
|
||||
`_load_id` и координат Kafka.
|
||||
|
||||
## Справочники
|
||||
|
||||
|
||||
Reference in new issue
Block a user