Привет, Хабр!
В прошлой статье мы заглянули под капот 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) таблицу.
Схематично процесс можно представить следующим образом:

Таким образом, вычислительная нагрузка переносится с момента чтения SELECT на момент записи INSERT и распределяется по времени. Запросы к целевой таблице выполняются молниеносно, так как они работают с уже агрегированными, компактными данными.
Использовать инкрементные представления рекомендуется, когда:
Нам нужны результаты в реальном времени, обновляемые при каждой новой партии данных.
Мы выполняем частые агрегации или фильтрацию на больших объёмах данных (логи, метрики, события).
Наши запросы — это в основном агрегации по одной таблице
GROUP BY.
Для корректной работы с агрегатами (например, uniq, avg) в целевой таблице рекомендуется использовать движок 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)
Схематично первый шаг можно представить следующим образом:

Затем создадим 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
Итоговый процесс схематично будет выглядеть следующим образом:

Теперь мы можем обращаться к целевой таблице 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 рекомендуется, когда:
Нам нужна максимальная скорость чтения (миллисекунды), и мы готовы мириться с небольшой задержкой данных, например, мы анализируем данные за прошлый час или день.
Нам нужно кешировать результаты сложных запросов, например с 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
Схематично первый шаг можно представить следующим образом:

Следующим шагом создадим обновляемое 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
Итоговый процесс схематично будет выглядеть следующим образом:

Теперь для получения топа страниц за последние 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 с нуля который охватывает все самое необходимое для уверенного старта в работе с технологией.
Удачи в изучении!
Комментарии (4)

ivannatarov Автор
19.03.2026 20:54Привет) MV действительно подходят не для всех задач, но когда строишь real-time аналитику на клике, то без них никак.

miksoft
19.03.2026 20:54А есть такая механика для Update? Или только Insert?

ivannatarov Автор
19.03.2026 20:54Конкретно у MV такой механики нет. Для этого существуют механизмы захвата изменений (CDC). Обычно они используются, когда в ClickHouse нужно тянуть все обновления из транзакционных БД. Часто это реализуют через Debezium.
YAKOROLEVAZAMKA
Сделал как-то один трудоёмкий отчёт на инкрементальном MV и честно говоря эксперимент считаю неудачным.
По сути это просто ETL-процесс вставки из source.table -> target.table который отрабатывает моментально, при этом посмотреть логи вставок довольно сложно (а для аналитиков - невозможно, прав нет), нужно верить что оно работает правильно (пока, правда, уже как полгода работает исправно))).
Какие есть проблемы:
- в источник пришло что-то не то = оно моментально вставилось
- нужно перезагрузить часть данных? удаляй из источника и заново инициируй вставку
И вот из-за этого как будто проще сделать вставки\расчёты именно на уровне ETL-процесса, а не на уровне MV..
Или вот решил сделать самую простую табличку - логин и дата его регистрации (из таблицы событий):
CREATE MATERIALIZED VIEW schema_mv.login_agg_reg TO schema_table.login_agg_reg(
login String,
min_reg_dt AggregateFunction(min, Date)
)
AS SELECT
login,
minState(toDate(reg_dt)) AS min_reg_dt
FROM schema_src.login
GROUP BY login;
Могу ли я посмотреть что в результирующей таблице? Нет, над ней надо делать view (что не особо удобно как по мне):
CREATE VIEW schema_view.login_agg_reg(
login String,
min_reg_dt Date
)
AS SELECT
login,
minMerge(min_reg_dt) AS min_reg_dt
FROM schema_table.login_agg_reg
GROUP BY login;
В общем повторять это я не стану))) видимо под мои задачи не подходит (или я просто не распробовал прелесть MV).
PS. а вот если аналитик создаст кривое MV над таблицей - то там вообще ETL-процесс процесс загружающий в src.table падает, пришлось писать скрипт поиска и удаления кривых MV...
PPS. поэтому поставил аналитикам Zeppelin (с ним тоже проблем не оберешься, но это уже совсем другая история), включил в нём cron
и настало всем счастье)