Перейти к содержанию

Таблица событий и типы ClickHouse

Контракт «Событий» задаёт девять полей и различает действие, доставку и посетителя. Теперь переведём его в схему ClickHouse. Результат урока — исходная таблица доставок: она сохраняет строки как поступившие и пока не устраняет повтор одного события. Это позволит увидеть отличие физической доставки от логического факта.

Вам нужны основы CREATE TABLE и типизации SQL. Сервер ClickHouse в рамках подготовки серии не устанавливался и не запускался. Приведённый DDL является авторским исходником для будущего выполнения в выбранном совместимом релизе. Не будем называть таблицу созданной до фактического выполнения команды в вашей среде.

Тип выражает форму значения

UUID подходит идентификаторам фиксированного представления. В учебной фикстуре значения заполнены явно, чтобы можно было проследить повтор. Он не доказывает корректность источника: сборщик всё равно должен решить, какие поля принимает и сохраняет ли идентификатор при повторной доставке.

DateTime64(3, 'UTC') хранит время с миллисекундной точностью и выбранной зоной. Миллисекунды позволяют различать близкие действия, но не исправляют плохие часы клиента. UInt8 подходит номеру начальной версии схемы, а UInt64 даёт запас для монотонной версии доставки одного события. Не выбирайте минимальную разрядность без понимания будущего диапазона.

Полный файл sql/01-raw-table.sql начинает учебную базу и таблицу. Имя базы используется во всех дальнейших запросах, поэтому случайное изменение потребовало бы правки всего комплекта.

CREATE DATABASE IF NOT EXISTS events_lab;
CREATE TABLE IF NOT EXISTS events_lab.deliveries
(
  schema_version UInt8,
  event_id UUID,
  anon_id UUID,
  lesson_id LowCardinality(String),
  course_id LowCardinality(String),
  event_name LowCardinality(String),
  occurred_at DateTime64(3, 'UTC'),
  received_at DateTime64(3, 'UTC'),
  version UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (course_id, lesson_id, occurred_at, event_id);

IF NOT EXISTS делает создание повторяемым только в смысле существования объекта. Оно не проверяет, что прежняя таблица имеет ту же схему. После изменения DDL нужно сравнить фактическое определение, а не считать отсутствие ошибки доказательством обновления столбцов. В этой серии миграция рабочего внешнего хранилища не выполняется.

Небольшие словари строк

В названиях событий всего три значения, курсов пока один, уроков три. LowCardinality(String) соответствует признакам с повторяющимся набором строк. UUID посетителя не относится к такому небольшому словарю: разные посетители увеличивают разнообразие. Разумный тип определяется данными, а не одинаковым правилом для всех столбцов.

При росте каталога разнообразие уроков изменится. Нельзя обещать фиксированный выигрыш памяти без фактического набора и измерения. Здесь выбор сделан по характеру учебного поля. Рекомендации по строгим типам и строковым признакам приведены в руководстве схем ClickHouse.

Все поля обязательны. Мы не используем Nullable только на случай, если сборщик забудет значение. Отсутствующий lesson_id означает нарушение контракта, а не неизвестный урок для красивого отчёта. Если появится событие, которому урок не нужен, его модель нужно определить отдельно. Техническая возможность хранить null не объясняет предметную причину отсутствия данных.

Движок и физические части

MergeTree рассчитан на аналитическое хранение и обработку частей данных. Вставки создают части, которые со временем объединяются. Это отличается от представления, что каждый INSERT немедленно меняет одну строку с уникальным ключом, как в обычной рабочей таблице аккаунтов. Наш исходный слой специально сохраняет все доставки.

ORDER BY задаёт сортировку внутри частей и участвует в индексации чтения, но не является ограничением уникальности. Два одинаковых event_id могут находиться в таблице одновременно. Если прочитать count() после шестнадцати доставок, ожидаем шестнадцать строк, хотя логических событий пятнадцать. Дедупликацию добавим в отдельной главе.

Документация MergeTree объясняет ключ сортировки и партиционирование. Для нашего потока выбор начала ключа связан с отчётами по курсу и уроку. Запрос по одному посетителю не получает автоматически оптимальную схему только потому, что колонка существует.

Партиция не равна индексу посетителя

Месячная партиция группирует события по времени действия. Она помогает управлять крупными периодами хранения. Создавать партицию на каждого посетителя было бы неверным переносом модели аккаунта: число групп быстро вырастет, а аналитические запросы по дням не станут от этого автоматически удобнее.

Позднее событие попадёт в месяц occurred_at, даже если получено позже. Это согласуется с историей действий и одновременно влияет на дедупликацию: повтор одного события должен сохранить неизменными поля, определяющие партицию и ключ. Если отправитель исправляет время так, что событие переезжает в другой месяц, потребуется отдельная политика исправления, которую наша первая версия запрещает.

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

Как читать фактическую схему

После будущего выполнения DDL полезно получить SHOW CREATE TABLE events_lab.deliveries и сравнить типы, движок, порядок ключа и партицию с исходником. Это проверка конфигурации, а не проверка всех данных. Таблица может быть создана правильно и всё равно получать неверные события из сборщика.

Число версии не проверяет производителя

Поле schema_version имеет числовой тип, но таблица может принять значение, для которого у сборщика нет описания. Поэтому поддерживаемые версии проверяются до вставки. Если новый производитель послал вторую версию контракта, старый отчёт должен либо понимать её, либо явно исключить с объяснением, а не считать все строки одинаковыми.

Тип времени также не подтверждает допустимую дату. Клиент способен прислать далёкое будущее, и партиционирование создаст соответствующий месяц. Ограничение диапазона относится к приёмнику и контролю качества. Внешняя эксплуатация должна учитывать эти случаи до бесконтрольного роста таблицы.

Полезно различать ошибки формата и ошибки смысла. Неправильный UUID может остановить разбор, а корректный UUID нового повторного события будет принят, хотя нарушит договор доставки. Для второй ошибки нужна дополнительная диагностика источника; строгие типы улучшают модель, но не заменяют её.

Ожидаемый результат текущего шага — пустая таблица доставок. Ни один счётчик аудитории ещё не имеет смысла, потому что данные не вставлены. Следующий урок добавит JSONEachRow, покажет пакетную загрузку и объяснит, почему успешная отправка байтов не всегда равна подтверждённому приёму всей партии.

Оглавление серии