Привет, Хабр!

В прошлой статье мы заглянули под капот ClickHouse и разобрались, как работает движок MergeTree. Мы узнали, как хранятся данные и что такое парт, зачем нужен разреженный индекс и как работает фоновое слияние.

В этой статье мы рассмотрим один из мощнейших инструментов ClickHouse — Materialized View.

1. Что такое Materialized View

Представьте, что у нас есть сложный и ресурсозатратный запрос, который мы выполняем снова и снова. Например, ежедневный отчёт по выручке с группировками и объединениями нескольких таблиц. Каждый раз, когда пользователь открывает дашборд, ClickHouse вынужден заново обрабатывать гигабайты сырых данных.

Materialized View — это механизм, который выполняет этот запрос заранее и сохраняет (материализует) его результат в отдельной, целевой таблице. Когда мы делаем запрос к целевой таблице, ClickHouse не выполняет сложные вычисления заново, а просто отдаёт уже готовый, предрассчитанный результат. Это как если бы бухгалтер не пересчитывал все чеки за месяц каждый раз, когда его просят назвать итоговую сумму, а держал бы на столе листок с уже посчитанным результатом.

В ClickHouse существует два фундаментально разных типа Materialized View: инкрементные и обновляемые. Выбор между ними — ключ к построению эффективной аналитической системы.

2. Инкрементные Materialized View

Это классический и наиболее часто используемый тип представлений в ClickHouse. Их главная особенность — обновление в реальном времени в момент вставки данных в исходную таблицу.

По своей сути, инкрементное Materialized View — это триггер на INSERT, который выполняет одно конкретное действие: он преобразует вставляемые данные согласно своему SELECT-запросу и помещает результат в целевую таблицу.

Когда мы вставляем новый блок данных INSERT в исходную (source) таблицу, ClickHouse «на лету» применяет к этому новому блоку SQL-запрос из представления и дописывает результат в целевую (target) таблицу.

Схематично процесс можно представить следующим образом:

Инкрементное Materialized View
Инкрементное Materialized View

Таким образом, вычислительная нагрузка переносится с момента чтения SELECT на момент записи INSERT и распределяется по времени. Запросы к целевой таблице выполняются молниеносно, так как они работают с уже агрегированными, компактными данными.

Использовать инкрементные представления рекомендуется, когда:

  • Нам нужны результаты в реальном времени, обновляемые при каждой новой партии данных.

  • Мы выполняем частые агрегации или фильтрацию на больших объёмах данных (логи, метрики, события).

  • Наши запросы — это в основном агрегации по одной таблице GROUP BY.

Для корректной работы с агрегатами (например, uniqavg) в целевой таблице рекомендуется использовать движок AggregatingMergeTree.

Рассмотрим практический пример.

Представим, что мы собираем логи посещений сайта и хотим в реальном времени видеть количество просмотров и уникальных пользователей по каждой странице за день.

У нас есть таблица-источник с сырыми логами logs:

event_date

url

user_id

2025-09-12

/home

101

2025-09-12

/products

254

2025-09-12

/home

101

2025-09-13

/contact

312

2025-09-13

/products

101

2025-09-14

/home

450

Давайте посмотрим, как мы можем решить задачу, используя инкрементное Materialized View.

Первое, что нам необходимо сделать, — создать целевую таблицу, в которую будем записывать агрегированные данные, обработанные представлением. Для хранения будем использовать движок AggregatingMergeTree, чтобы при запросах на чтение получать корректные значения агрегатов.

CREATE TABLE daily_page_stats (
    event_date Date,
    url String,
    page_views AggregateFunction(count),
    unique_users AggregateFunction(uniq, UInt32)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_date, url)

Схематично первый шаг можно представить следующим образом:

Шаг 1
Шаг 1

Затем создадим Materialized View, которое свяжет таблицу logs и daily_page_stats. Оно будет срабатывать при каждом INSERT в таблицу logs, брать пачку вставляемых данных, применять к ней SQL-запрос и записывать данные в таблицу daily_page_stats.

-- Создаем мат. представление и указываем целевую таблицу
CREATE MATERIALIZED VIEW daily_page_stats_mv TO daily_page_stats AS
-- Указываем запрос, который будет обрабатывать данные
SELECT
    event_date,
    url,
    countState() AS page_views,
    uniqState(user_id) AS unique_users
FROM logs
GROUP BY event_date, url

Итоговый процесс схематично будет выглядеть следующим образом:

Шаг 2
Шаг 2

Теперь мы можем обращаться к целевой таблице daily_page_stats для получения итогового результата.

SELECT
    event_date,
    url,
    countMerge(page_views) AS total_page_views,
    uniqMerge(unique_users) AS total_unique_users
FROM daily_page_stats
GROUP BY event_date, url

event_date

url

total_page_views

total_unique_users

2025-09-12

/home

2

1

2025-09-12

/products

1

1

2025-09-13

/contact

1

1

2025-09-13

/products

1

1

2025-09-14

/home

1

1

Этот запрос выполнится практически мгновенно, даже если в исходной таблице logs миллиарды записей.

Инкрементное представление «слушает» вставки только в ту таблицу, которая стоит в блоке FROM. Если вы добавите в запрос JOIN со справочником, то при обновлении данных в самом справочнике представление не сработает. 

3. Обновляемые Materialized View

Этот тип представлений появился в ClickHouse после инкрементных и работает по принципу, более знакомому пользователям традиционных баз данных, таких как PostgreSQL. Они выполняют запрос по расписанию и перезаписывают результат в целевой таблице.

Мы задаём расписание (REFRESH EVERY ...), и ClickHouse с указанной периодичностью выполняет весь SQL-запрос представления с нуля, а затем атомарно заменяет старые данные в целевой таблице новыми.

Схематично процесс можно представить следующим образом:

Обновляемое Materialized View
Обновляемое Materialized View

Использовать обновляемые Materialized View рекомендуется, когда:

  • Нам нужна максимальная скорость чтения (миллисекунды), и мы готовы мириться с небольшой задержкой данных, например, мы анализируем данные за прошлый час или день.

  • Нам нужно кешировать результаты сложных запросов, например с JOIN нескольких таблиц.

  • Объём результата запроса не растёт бесконечно со временем (например, «топ-100 товаров за последние 24 часа» или продажи за прошлый день, месяц).

Будьте осторожны с частотой обновления. Если сам запрос выполняется 10 секунд, не стоит ставить ему обновление каждые 5 секунд. Это создаст избыточную нагрузку.

Рассмотрим практический пример.

Вернёмся к нашим логам. Теперь задача — создать дашборд с топ-10 самых популярных страниц за последние 7 дней, обогатив их заголовками из справочника. Дашборд должен открываться мгновенно, данные могут обновляться раз в 15 минут.

Пример справочника pages_metadata:

url

title

/contact

Контакты

/home

Главная страница

/products

Наши продукты

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

CREATE TABLE top_10_pages_enrich (
    title String,
    page_views UInt32
)
ENGINE = MergeTree()
ORDER BY title

Схематично первый шаг можно представить следующим образом:

Шаг 1
Шаг 1

Следующим шагом создадим обновляемое Materialized View, которое будет каждые 15 минут выполнять запрос и перезаписывать данные в таблице top_10_pages_enrich.

CREATE MATERIALIZED VIEW top_10_pages_enrich_mv
-- Задаем время обновления
REFRESH EVERY 15 MINUTE TO top_10_pages_enrich AS
SELECT
    pm.title AS page_title,
    count(*) AS page_views
    FROM logs AS l
JOIN pages_metadata AS pm
    ON l.url = pm.url
-- Получаем данные за 7 дней от текущей даты
WHERE l.event_date >= today() - 7
GROUP BY
    l.url,
    pm.title
ORDER BY page_views DESC
LIMIT 10

Итоговый процесс схематично будет выглядеть следующим образом:

Шаг 2
Шаг 2

Теперь для получения топа страниц за последние 7 дней мы можем выполнить простой, сверхбыстрый запрос (в таблице всегда хранится не более 10 записей) к таблице top_10_pages_enrich:

SELECT * 
FROM top_10_pages_enrich

title

page_views

Главная страница

3

Наши продукты

2

Контакты

1

Также стоит отметить, что иногда необходимо принудительно обновить Materialized View или изменить частоту обновления. Для этого можно использовать следующие команды:

-- Принудительное обновление
SYSTEM REFRESH VIEW top_10_pages_enrich_mv

-- Изменение времени обновления
ALTER TABLE top_10_pages_enrich_mv
MODIFY REFRESH EVERY 10 MINUTE

Итог

Чтобы эффективно автоматизировать расчёты в ClickHouse, помните:

  • Инкрементные Materialized View : Это триггер на INSERT. Данные агрегируются в реальном времени пачками, представление «видит» обновления только в целевой таблице.

  • Обновляемые Materialized View : Работают по расписанию (REFRESH EVERY). Незаменимы для кэширования тяжелых запросов с JOIN справочников, когда допустима небольшая задержка.

  • AggregatingMergeTree: Главный движок для инкрементных представлений, позволяющий корректно хранить и дособирать агрегации (например, уникальных пользователей) при чтении.

Надеюсь, эта статья помогла вам лучше понять механику работы Materialized View и то, как с их помощью ускорить аналитику.

P.S. Если вы хотите систематизировать знания и получить прочную теоретическую базу для дальнейшего освоения ClickHouse на практике, буду рад видеть вас на моем бесплатном курсе ClickHouse с нуля который охватывает все самое необходимое для уверенного старта в работе с технологией.

Удачи в изучении!