Learning Platform
Глоссарий Troubleshooting
Урок 08.01 · 20 мин
Средний
DenormalizationOne Big TableOBTWide TableJOINLowCardinalitydictGet

Денормализация и широкие таблицы

В реляционных 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 одной таблицы.
Денормализация: JOIN при вставке, а не при запросе
3 таблицы + JOINNormalized: events + users + products -- 3 таблицы. Каждый SELECT требует JOIN. В ClickHouse JOINs дорогие: правая таблица целиком в RAM.
Query timeJOIN при чтенииКаждый аналитический запрос выполняет JOIN. При масштабе миллиардов строк -- O(N) hash build на каждый запрос.
денормализация
1 wide tableDenormalized: одна wide table с предвычисленными полями. ETL обогащает данные при INSERT. Нет JOINs при чтении.
Insert timeJOIN при записиJOIN выполняется однократно в ETL pipeline. Все аналитические запросы -- простые сканы без JOIN.

Пример: денормализованная таблица событий

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: таблица компромиссов

АспектNormalizedDenormalized (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.

WARNING

Мутации (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)
TIP

dictGet() идеален для dimensions размером до нескольких миллионов строк: данные помещаются в RAM, lookup быстрый (hash table). Для dimension более 100 миллионов строк — рассмотрите полную денормализацию или JOIN с подзапросом через IN.


Ключевые выводы

  1. One Big Table (OBT) — основной паттерн денормализации в ClickHouse: все dimension-атрибуты вносятся в fact-таблицу при INSERT.
  2. Столбцовое хранение делает широкие таблицы эффективными: неиспользуемые столбцы не читаются с диска.
  3. ORDER BY от низкой кардинальности к высокой: country, event_type, timestamp.
  4. Не денормализуйте при: часто обновляемых dimensions, many-to-many связях, dimension-only аналитике.
  5. dictGet() — компромисс: dimension хранится в словаре (RAM), обогащение при SELECT. Подходит для dimensions до нескольких миллионов строк.
Star Schema по Kimball: fact в центре, dimensions вокруг Почему нормализованная схема плоха для аналитики

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. E-commerce платформа генерирует 500 миллионов событий в день. Dimension-таблица products содержит 50 000 товаров, которые обновляются реже одного раза в сутки. Какой подход к хранению событий оптимален в ClickHouse?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7