Денормализация и широкие таблицы
В реляционных OLTP-системах нормализация — фундаментальный принцип: каждый факт хранится ровно один раз, связи выражены через внешние ключи, а JOIN соединяет таблицы при каждом запросе. В ClickHouse этот подход работает плохо. JOIN загружает правую таблицу целиком в оперативную память, а при миллиардах строк фактов — это узкое место, которое невозможно устранить индексами.
Альтернатива — денормализация: выполнить JOIN один раз при записи (в ETL pipeline), а не при каждом аналитическом запросе.
One Big Table (OBT)
Паттерн One Big Table (OBT) — это предельная форма денормализации: все dimension-атрибуты вносятся прямо в fact-таблицу на этапе INSERT. Результат — одна широкая таблица без JOIN при чтении.
ClickHouse оптимизирован именно для этого сценария:
- Столбцовое хранение: даже при 200+ столбцах читаются только запрошенные. Неиспользуемые столбцы не загружаются с диска.
- Сжатие: повторяющиеся dimension-значения (страна, категория) сжимаются эффективно через LowCardinality и кодеки.
- Отсутствие JOIN: каждый аналитический запрос — простой scan одной таблицы.
Пример: денормализованная таблица событий
CREATE TABLE events_denorm (
event_time DateTime,
user_id UInt64,
-- Денормализованные поля из users
user_name String,
user_country LowCardinality(String),
user_segment LowCardinality(String),
-- Денормализованные поля из products
product_id UInt32,
product_name String,
product_category LowCardinality(String),
-- Факты события
event_type LowCardinality(String),
revenue Decimal64(2),
quantity UInt16
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_country, event_type, event_time);
Ключевые решения в этой схеме:
- LowCardinality для столбцов с низкой кардинальностью (страна, категория, тип события). Словарное кодирование уменьшает хранение и ускоряет фильтрацию.
- PARTITION BY toYYYYMM — месячные партиции для управления жизненным циклом данных (TTL, DROP PARTITION).
- ORDER BY (user_country, event_type, event_time) — порядок от низкой кардинальности к высокой. Первые два столбца обеспечивают эффективную гранулярную фильтрацию; timestamp последним для range-сканов внутри группы.
Normalized vs. Denormalized: таблица компромиссов
| Аспект | Normalized | Denormalized (OBT) |
|---|---|---|
| Хранение | Меньше (без дублирования dimension) | Больше (dimension-данные повторяются в каждой строке) |
| Скорость чтения | Медленнее (JOIN при каждом запросе) | Быстрее (scan одной таблицы) |
| Стоимость обновления dimension | Лёгкая (UPDATE одной строки) | Тяжёлая (все строки с этим dimension нужно перезаписать) |
| Согласованность | Всегда актуальна | Может отставать (stale данные до следующего ETL) |
| Сложность запросов | Выше (многотабличные JOIN) | Ниже (простые WHERE + GROUP BY) |
| Параллелизм записи | Возможны конфликты блокировок | Нет конфликтов (append-only) |
Когда НЕ денормализовать
Денормализация — не универсальное решение. Есть сценарии, где она создаёт больше проблем, чем решает:
1. Часто обновляемые dimensions (high churn)
Если dimension-данные меняются десятки раз в день (например, цена товара, статус пользователя), каждое обновление требует перезаписи всех fact-строк с этим dimension. В ClickHouse это мутация — тяжёлая операция, перезаписывающая parts.
2. Many-to-many связи
Если один факт связан с несколькими dimension-сущностями (теги, категории), денормализация приводит к дублированию строк или вложенным массивам. Альтернатива: Array(LowCardinality(String)) для тегов или ARRAY JOIN для unnesting.
3. Dimension-аналитика без фактов
Если часто нужны запросы только по dimension-таблице (список всех пользователей, все продукты), отдельная dimension-таблица эффективнее, чем DISTINCT по wide table.
Мутации (ALTER TABLE UPDATE) в ClickHouse — асинхронные тяжёлые операции, перезаписывающие parts. Если dimension обновляется часто, рассмотрите dictGet() вместо полной денормализации.
Альтернатива: dictGet() для обогащения
Полная денормализация — не единственный путь. ClickHouse предлагает dictGet() (внешние словари из Module 05) как компромисс: dimension-данные хранятся в словаре, а обогащение происходит при SELECT, а не при INSERT.
-- Словарь пользователей (автоматически обновляется из источника)
-- Определён в Module 05: skip-indexes/05-dictionaries
-- Использование в запросе
SELECT
event_time,
user_id,
dictGet('users_dict', 'user_name', user_id) AS user_name,
dictGet('users_dict', 'country', user_id) AS country,
revenue
FROM events_raw
WHERE event_type = 'purchase';
Когда dictGet() лучше полной денормализации:
| Критерий | dictGet() | Полная денормализация |
|---|---|---|
| Dimension обновляется часто | Словарь обновляется автоматически | Нужна мутация всех строк |
| Объём dimension данных | Словарь в RAM (ограничен) | Хранится на диске (неограничен) |
| Задержка обогащения | При SELECT (микросекунды per lookup) | При INSERT (нулевая при SELECT) |
| Сложность ETL | Минимальная (сырые факты) | Выше (JOIN в ETL pipeline) |
dictGet() идеален для dimensions размером до нескольких миллионов строк: данные помещаются в RAM, lookup быстрый (hash table). Для dimension более 100 миллионов строк — рассмотрите полную денормализацию или JOIN с подзапросом через IN.
Ключевые выводы
- One Big Table (OBT) — основной паттерн денормализации в ClickHouse: все dimension-атрибуты вносятся в fact-таблицу при INSERT.
- Столбцовое хранение делает широкие таблицы эффективными: неиспользуемые столбцы не читаются с диска.
- ORDER BY от низкой кардинальности к высокой: country, event_type, timestamp.
- Не денормализуйте при: часто обновляемых dimensions, many-to-many связях, dimension-only аналитике.
- dictGet() — компромисс: dimension хранится в словаре (RAM), обогащение при SELECT. Подходит для dimensions до нескольких миллионов строк.