Nested, Map и плоские схемы для событий
Event data — один из самых частых случаев в аналитике: клики, покупки, API-вызовы, телеметрия. Каждое событие несёт набор атрибутов (properties), и главный вопрос моделирования: как хранить эти атрибуты?
В ClickHouse четыре основных подхода: Nested, плоская схема (flattened), Map, и JSON type. У каждого свои trade-offs.
Подход 1: Nested type (параллельные массивы)
Nested — специальный тип данных ClickHouse, хранящий вложенные структуры как параллельные массивы одинаковой длины:
CREATE TABLE events_nested (
event_id UInt64,
timestamp DateTime,
event_type LowCardinality(String),
properties Nested(
key String,
value String
)
) ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
На уровне хранения ClickHouse разворачивает Nested в отдельные столбцы:
properties.key— Array(String)properties.value— Array(String)
Эти массивы всегда одинаковой длины (по строке).
Вставка данных
INSERT INTO events_nested VALUES
(1, '2024-01-15 10:00:00', 'click',
['device', 'browser', 'country'],
['mobile', 'safari', 'DE']),
(2, '2024-01-15 10:01:00', 'purchase',
['device', 'amount', 'currency'],
['desktop', '99.99', 'EUR']);
Чтение: ARRAY JOIN для развёртывания
-- Развернуть Nested в строки (key-value пары)
SELECT event_id, event_type, p.key, p.value
FROM events_nested
ARRAY JOIN properties AS p;
Результат: каждый элемент массива становится отдельной строкой (unnesting). Это аналог UNNEST в PostgreSQL.
flatten_nested: настройка поведения
По умолчанию ClickHouse “распрямляет” Nested в отдельные массивы (flatten_nested=1). Для настоящего вложенного поведения (sub-columns как единая структура):
SET flatten_nested = 0;
С flatten_nested=0 Nested ведёт себя как Array(Tuple(key String, value String)) — единая структура, а не набор независимых массивов.
flatten_nested=0 — глобальная или session-level настройка. Перед созданием таблиц с Nested убедитесь, что она установлена. Без неё INSERT в Nested с несколькими полями может давать неожиданные результаты.
Подход 2: Плоская схема (Flattened)
Если набор атрибутов фиксирован и заранее известен, плоская схема — самый производительный вариант:
CREATE TABLE events_flat (
event_id UInt64,
timestamp DateTime,
event_type LowCardinality(String),
-- Фиксированные атрибуты как отдельные столбцы
device_type LowCardinality(String),
browser LowCardinality(String),
country LowCardinality(String),
url String,
amount Decimal64(2) DEFAULT 0,
currency LowCardinality(String) DEFAULT ''
) ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
Преимущества:
- Прямой доступ к столбцу (
WHERE country = 'DE') без ARRAY JOIN - Полная поддержка индексов (primary key, skip indexes) на каждом столбце
- Оптимальное сжатие: LowCardinality, кодеки применяются к каждому столбцу индивидуально
- Самая высокая производительность запросов
Недостаток: при добавлении нового атрибута — ALTER TABLE ADD COLUMN. При десятках новых атрибутов в месяц это становится неудобным.
Подход 3: Map(K, V) для переменных атрибутов
Когда набор ключей заранее неизвестен или часто расширяется, Map предлагает гибкость:
CREATE TABLE events_map (
event_id UInt64,
timestamp DateTime,
event_type LowCardinality(String),
-- Переменные атрибуты в Map
properties Map(LowCardinality(String), String)
) ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
Вставка:
INSERT INTO events_map VALUES
(1, '2024-01-15 10:00:00', 'click',
{'device': 'mobile', 'browser': 'safari', 'country': 'DE'}),
(2, '2024-01-15 10:01:00', 'purchase',
{'device': 'desktop', 'amount': '99.99', 'currency': 'EUR',
'promo_code': 'WINTER24'});
Доступ к ключам:
-- Обращение по ключу
SELECT event_id, properties['device'] AS device
FROM events_map
WHERE properties['country'] = 'DE';
Map хранится как два массива (keys Array(K), values Array(V)) — аналогично Nested, но с удобным синтаксисом доступа по ключу.
LowCardinality(String) в Map ключах снижает storage overhead, когда набор ключей ограничен (device, browser, country) — словарь LowCardinality переиспользуется.
Подход 4: JSON type (GA 25.3)
Начиная с ClickHouse 25.3 (GA), JSON type хранит semi-structured данные с автоматическим выделением sub-columns:
CREATE TABLE events_json (
event_id UInt64,
timestamp DateTime,
event_type LowCardinality(String),
properties JSON
) ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
JSON type автоматически обнаруживает подколонки и хранит их в типизированных столбцах (columnar storage). Это сочетание гибкости Map и производительности плоской схемы.
Детальное покрытие JSON type — в Модуле 15 (Phase 60). Здесь упоминаем как перспективный вариант для новых проектов на ClickHouse 25.3+.
Decision framework: как выбрать подход
| Критерий | Nested | Flattened | Map | JSON |
|---|---|---|---|---|
| Схема известна заранее | Частично | Да | Нет | Нет |
| Производительность запросов | Средняя | Высокая | Средняя | Высокая |
| Гибкость схемы | Средняя | Низкая | Высокая | Очень высокая |
| Поддержка skip indexes | Ограничена | Полная | Нет | Частичная |
| Индивидуальные кодеки | Нет | Да | Нет | Автоматические |
| Рекомендуемый сценарий | Структурированные вложенные | Фиксированная аналитика | Переменные labels | Semi-structured данные |
Anti-pattern: Nested с 1000+ элементами на строку
Nested хранится как массивы. При 1000+ элементов на строку:
- Insert amplification: каждая строка содержит тысячи элементов, объём одного INSERT пропорционален числу элементов
- ARRAY JOIN explosion: развёртывание строки с 1000 элементами создаёт 1000 строк в результате. При миллионах строк — explosion до миллиардов
- Отсутствие индексов на элементах: фильтрация по конкретному ключу требует полного сканирования массива
Если event properties содержит сотни уникальных ключей — используйте Map или JSON type, не Nested.
Гибридная схема: flattened + Map
На практике лучший результат даёт комбинация: фиксированные высокочастотные атрибуты как столбцы, а переменные — в Map:
CREATE TABLE events_hybrid (
event_id UInt64,
timestamp DateTime,
event_type LowCardinality(String),
-- Высокочастотные атрибуты: отдельные столбцы (индексируемые)
device_type LowCardinality(String),
country LowCardinality(String),
-- Переменные атрибуты: Map (гибкие)
extra Map(LowCardinality(String), String)
) ENGINE = MergeTree()
ORDER BY (event_type, country, timestamp);
Запросы по device_type и country используют primary key и skip indexes. Запросы по extra['promo_code'] — менее эффективны, но не требуют ALTER TABLE при появлении нового атрибута.
Ключевые выводы
- Nested = параллельные массивы. ARRAY JOIN для развёртывания. Подходит для структурированных вложенных данных с умеренным количеством элементов.
- Flattened = максимальная производительность. Каждый атрибут — столбец с индексами и кодеками. Негибкая схема.
- Map(K, V) = гибкая key-value схема для переменных атрибутов. Нет индексов на отдельных ключах.
- JSON type (GA 25.3) = автоматические sub-columns. Перспективный вариант для semi-structured данных (детали в Модуле 15).
- Гибридная схема (flattened + Map) — оптимальный компромисс для production event pipelines.
- Anti-pattern: Nested с 1000+ элементами — используйте Map или JSON вместо этого.