Проекции: автоматическая оптимизация
Проекция — это встроенный вариант MV, живущий внутри таблицы. Вместо отдельной target-таблицы проекция хранит дополнительную копию данных в каждом part, отсортированную или агрегированную по-другому. ClickHouse автоматически переключает запрос на проекцию, если она читает меньше гранул.
PROJECTION синтаксис
Проекция определяется внутри CREATE TABLE как часть DDL:
CREATE TABLE events (
event_date Date,
user_id UInt64,
event_type String,
duration_ms UInt32,
-- Проекция: альтернативный порядок сортировки
PROJECTION by_user (
SELECT event_date, user_id, event_type, duration_ms
ORDER BY user_id
),
-- Проекция: агрегация по дате
PROJECTION daily_agg (
SELECT
event_date,
count() AS total_events,
sum(duration_ms) AS total_duration
GROUP BY event_date
ORDER BY event_date
)
) ENGINE = MergeTree()
ORDER BY (event_date, event_type);
Каждая проекция создаёт дополнительную копию данных в каждом part:
by_user— те же столбцы, отсортированные по user_id (вместо event_date, event_type)daily_agg— агрегированные данные по event_date (неявно AggregatingMergeTree внутри)
Автоматическое переписывание запросов
ClickHouse анализирует запрос и выбирает между основной таблицей и проекциями. Критерий: какой вариант прочитает меньше гранул.
-- Запрос по user_id: таблица отсортирована по (event_date, event_type)
-- Без проекции: full scan. С проекцией by_user: binary search по user_id
SELECT * FROM events WHERE user_id = 12345;
-- ClickHouse автоматически использует проекцию by_user
-- Запрос COUNT по дате: проекция daily_agg содержит готовый результат
SELECT event_date, count() FROM events GROUP BY event_date;
-- ClickHouse автоматически использует проекцию daily_agg
Переписывание прозрачно — запрос пишется к основной таблице, оптимизатор решает использовать проекцию самостоятельно. Результат идентичен.
Проверка использования проекции
EXPLAIN показывает, какую проекцию (или основную таблицу) выбрал оптимизатор:
EXPLAIN indexes = 1
SELECT event_date, count() FROM events GROUP BY event_date;
-- В выводе: ReadFromMergeTree (daily_agg)
-- Если проекция не используется: ReadFromMergeTree (без имени проекции)
Настройка force_optimize_projection = 1 заставляет ClickHouse выдать ошибку, если подходящая проекция существует, но не используется — полезно для отладки.
GROUP BY в проекции
GROUP BY в проекции неявно создаёт AggregatingMergeTree-подобное хранение внутри part:
PROJECTION daily_agg (
SELECT
event_date,
count() AS total_events,
uniq(user_id) AS unique_users
GROUP BY event_date
ORDER BY event_date
)
Каждый part хранит агрегированную версию своих данных по event_date. При merge — агрегаты пересчитываются. При чтении — ClickHouse объединяет агрегаты из всех parts (аналогично -Merge, но автоматически).
ORDER BY в проекции
ORDER BY в проекции становится первичным ключом проекции. Это определяет физический порядок данных в проекционном хранении и структуру sparse index:
-- Основная таблица: ORDER BY (event_date, event_type)
-- Проекция: ORDER BY user_id
PROJECTION by_user (
SELECT event_date, user_id, event_type, duration_ms
ORDER BY user_id
)
Запросы с фильтром WHERE user_id = X используют sparse index проекции — binary search за микросекунды вместо full scan.
Ограничение FINAL
Проекции не работают с модификатором FINAL:
-- FINAL отключает использование проекций
SELECT * FROM events FINAL WHERE user_id = 12345;
-- Проекция by_user НЕ будет использована
-- ClickHouse читает основную таблицу и применяет merge-on-read
FINAL заставляет ClickHouse применить merge-on-read (ReplacingMergeTree, CollapsingMergeTree). Этот процесс несовместим с проекциями, поскольку проекция может хранить данные в другом порядке, несовместимом с логикой merge.
Если ваши запросы регулярно используют FINAL (например, с ReplacingMergeTree), проекции не дадут ускорения. Рассмотрите Materialized View с отдельной target-таблицей.
Storage tradeoff
Каждая проекция хранит дополнительную копию данных в каждом part:
-- Таблица с 2 проекциями:
-- Основные данные: 100 ГБ
-- Проекция by_user: ~100 ГБ (те же данные, другой порядок)
-- Проекция daily_agg: ~1 ГБ (агрегаты, значительно меньше)
-- Итого на диске: ~201 ГБ
Проекция без GROUP BY (альтернативный ORDER BY) хранит полную копию всех столбцов. Проекция с GROUP BY хранит только агрегаты — значительно компактнее.
Добавление проекции к существующей таблице
-- Добавить проекцию к существующей таблице
ALTER TABLE events ADD PROJECTION by_user (
SELECT event_date, user_id, event_type, duration_ms
ORDER BY user_id
);
-- Материализовать проекцию для существующих parts
ALTER TABLE events MATERIALIZE PROJECTION by_user;
ADD PROJECTION определяет проекцию для новых parts. MATERIALIZE PROJECTION пересчитывает проекцию для уже существующих parts — это тяжёлая операция.
Сравнение: проекция vs Materialized View
| Критерий | Projection | Materialized View |
|---|---|---|
| Хранение | Внутри каждого part | Отдельная таблица |
| Backfill | MATERIALIZE PROJECTION | Ручной INSERT INTO … SELECT |
| Query rewriting | Автоматическое | Ручной SELECT FROM target |
| FINAL | Не работает | Работает |
| JOIN в определении | Нет | Да |
| Storage overhead | Копия данных per part | Отдельная таблица |
| Лучше для | Альтернативный ORDER BY | Сложные агрегации, ETL |