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

В прошлой статье мы заглянули под капот 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 с нуля который охватывает все самое необходимое для уверенного старта в работе с технологией.

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

Комментарии (4)


  1. YAKOROLEVAZAMKA
    19.03.2026 20:54

    Сделал как-то один трудоёмкий отчёт на инкрементальном 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 и настало всем счастье)


  1. ivannatarov Автор
    19.03.2026 20:54

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


  1. miksoft
    19.03.2026 20:54

    А есть такая механика для Update? Или только Insert?


    1. ivannatarov Автор
      19.03.2026 20:54

      Конкретно у MV такой механики нет. Для этого существуют механизмы захвата изменений (CDC). Обычно они используются, когда в ClickHouse нужно тянуть все обновления из транзакционных БД. Часто это реализуют через Debezium.