Перейти к основному содержанию

Общие сведения

Incremental Materialized Views (Materialized Views) позволяют перенести вычислительную нагрузку с query time на insert time, ускоряя запросы SELECT. В отличие от транзакционных баз данных, таких как Postgres, materialized view в ClickHouse — это всего лишь trigger, который выполняет запрос над blocks данных по мере их вставки в таблицу. Результат этого запроса вставляется во вторую, “целевую”, таблицу. При вставке новых строк результаты снова отправляются в целевую таблицу, где промежуточные результаты обновляются и объединяются. Этот объединённый результат эквивалентен выполнению запроса по всем исходным данным. Основная причина использовать Materialized Views заключается в том, что результаты, вставляемые в целевую таблицу, представляют собой итог aggregation, фильтрации или преобразования строк. Эти результаты часто занимают меньше места, чем исходные данные (в случае aggregations — в виде частичного sketch). Это, наряду с тем, что итоговый запрос для чтения результатов из целевой таблицы остаётся простым, обеспечивает более быстрое выполнение запросов, чем если бы те же вычисления выполнялись на исходных данных, перенося вычисления (а значит, и latency запроса) с query time на insert time. Materialized Views в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на которой они основаны, и работают скорее как постоянно обновляемые indexes. Это отличается от других баз данных, где Materialized Views обычно представляют собой статические snapshots запроса, которые необходимо refresh (аналогично ClickHouse Refreshable Materialized Views).

Пример

В качестве примера мы будем использовать набор данных Stack Overflow, описанный в разделе «Проектирование схемы». Предположим, нам нужно получить количество голосов «за» и «против» за каждый день для определённого поста.
В ClickHouse это достаточно простой запрос благодаря функции toStartOfDay:
Этот запрос уже выполняется быстро благодаря ClickHouse, но можно ли сделать ещё лучше? Если мы хотим вычислять это во время вставки с помощью materialized view, нам нужна таблица для получения результатов. В этой таблице должна храниться только 1 строка в день. Если для существующего дня поступает обновление, остальные столбцы должны быть слиты со строкой этого дня. Чтобы такое слияние инкрементальных состояний стало возможным, для остальных столбцов необходимо хранить промежуточные состояния. Для этого требуется специальный тип движка в ClickHouse: SummingMergeTree. Он заменяет все строки с одинаковым ключом сортировки одной строкой, содержащей суммарные значения числовых столбцов. Следующая таблица будет объединять все строки с одинаковой датой, суммируя числовые столбцы:
Чтобы продемонстрировать работу materialized view, предположим, что таблица votes пуста и ещё не содержит никаких данных. Наш materialized view выполняет приведённый выше запрос SELECT над данными, вставленными в votes, и записывает результаты в up_down_votes_per_day:
Секция TO здесь является ключевой: она указывает, куда будут направлены результаты, то есть в up_down_votes_per_day. Мы можем заново заполнить нашу таблицу votes с помощью ранее выполненной вставки:
После этого мы можем проверить размер нашей up_down_votes_per_day — должна быть 1 строка на каждый день:
Здесь мы фактически сократили число строк с 238 миллионов (в votes) до 5000, сохранив результат нашего запроса. Однако ключевой момент в том, что при вставке новых голосов в таблицу votes новые значения будут отправляться в up_down_votes_per_day для соответствующего дня, где они будут автоматически асинхронно сливаться в фоновом режиме, — так что для каждого дня будет сохраняться только одна строка. Таким образом, up_down_votes_per_day всегда будет оставаться небольшой и актуальной. Поскольку слияние строк выполняется асинхронно, в момент запроса у пользователя для одного дня может оказаться больше одной записи о голосе. Чтобы гарантировать, что все ещё не слитые строки будут объединены во время выполнения запроса, у нас есть два варианта:
  • Использовать модификатор FINAL в имени таблицы. Именно так мы поступили для запроса count выше.
  • Выполнить агрегацию по ключу упорядочивания, используемому в нашей итоговой таблице, то есть CreationDate, и просуммировать метрики. Обычно это эффективнее и гибче (таблицу можно использовать и для других задач), но первый вариант может быть проще для некоторых запросов. Ниже мы покажем оба:
Это ускорило наш запрос с 0.133s до 0.004s — более чем в 25 раз!
Важно: ORDER BY = GROUP BYВ большинстве случаев столбцы, используемые в выражении GROUP BY при преобразовании materialized view, должны совпадать со столбцами, используемыми в выражении ORDER BY целевой таблицы, если применяются движки таблиц SummingMergeTree или AggregatingMergeTree. Эти движки используют столбцы ORDER BY для слияния строк с одинаковыми значениями во время фоновых операций слияния. Несоответствие между столбцами GROUP BY и ORDER BY может привести к снижению производительности запросов, неоптимальным слияниям или даже расхождениям в данных.

Более сложный пример

В приведённом выше примере Materialized Views используются для вычисления и поддержания двух сумм за день. Суммы — это самая простая форма агрегации для поддержания частичных состояний: по мере поступления новых значений их можно просто добавлять к уже существующим. Однако Materialized Views в ClickHouse можно использовать с любым типом агрегации. Предположим, мы хотим вычислять некоторую статистику по постам за каждый день: 99,9-й процентиль для Score и среднее значение CommentCount. Запрос для этого может выглядеть так:
Как и ранее, мы можем создать materialized view, которое будет выполнять приведённый выше запрос по мере вставки новых постов в нашу таблицу posts. В качестве примера и чтобы не загружать данные постов из S3, мы создадим дубликат таблицы posts_null с той же схемой, что и у posts. Однако эта таблица не будет хранить данные и будет использоваться только materialized view при вставке строк. Чтобы данные не сохранялись, мы можем использовать тип движка таблицы Null.
Движок таблицы Null — это мощная оптимизация; считайте, что это /dev/null. Наша materialized view будет вычислять и сохранять сводную статистику, когда в таблицу posts_null при вставке поступают строки, — это лишь триггер. Однако исходные данные сохраняться не будут. Хотя в нашем случае мы, вероятно, всё же хотим хранить исходные посты, этот подход можно использовать для вычисления агрегатов, избегая накладных расходов на хранение исходных данных. Таким образом, materialized view принимает следующий вид:
Обратите внимание, что мы добавляем суффикс State в конец агрегатных функций. Это гарантирует, что будет возвращаться состояние агрегатной функции, а не итоговый результат. Оно содержит дополнительную информацию, которая позволяет этому промежуточному состоянию объединяться с другими состояниями. Например, в случае среднего значения это будут количество и сумма значений в столбце.
Промежуточные состояния агрегации необходимы для вычисления корректных результатов. Например, при вычислении среднего значения простое усреднение средних по поддиапазонам даёт некорректный результат.
Теперь создадим целевую таблицу для этого представления post_stats_per_day, в которой хранятся эти промежуточные состояния агрегации:
Хотя ранее SummingMergeTree было достаточно для хранения счётчиков, для других функций нам нужен более продвинутый тип движка: AggregatingMergeTree. Чтобы ClickHouse понимал, что будут храниться состояния агрегатных функций, мы задаём для Score_quantiles и AvgCommentCount тип AggregateFunction, указывая функцию, из которой формируются частичные состояния, и тип их исходных столбцов. Как и в SummingMergeTree, строки с одинаковым значением ключа ORDER BY будут объединены (Day в примере выше). Чтобы заполнить post_stats_per_day через нашу materialized view, мы можем просто выполнить вставку всех строк из posts в posts_null:
В рабочей среде вы, вероятно, прикрепили бы materialized view к таблице posts. Здесь мы использовали posts_null, чтобы продемонстрировать null-таблицу.
В итоговом запросе нужно использовать суффикс Merge для наших функций (поскольку в столбцах хранятся промежуточные состояния агрегации):
Обратите внимание: здесь мы используем GROUP BY вместо FINAL.

Другие применения

Выше речь шла в основном об использовании Materialized Views для инкрементального обновления частичных агрегатов данных, что позволяет перенести вычисления с этапа выполнения запроса на этап вставки. Помимо этого распространённого сценария, у Materialized Views есть и другие применения.

Фильтрация и преобразование

В некоторых случаях может потребоваться при вставке сохранять только часть строк и столбцов. В таком случае вставку можно направлять в таблицу posts_null, а запрос SELECT будет фильтровать строки перед их вставкой в таблицу posts. Например, предположим, что мы хотим преобразовать столбец Tags в таблице posts. Он содержит список имён тегов, разделённых символом вертикальной черты. Преобразовав его в массив, мы сможем проще выполнять агрегацию по отдельным значениям тегов.
Это преобразование можно выполнить при запуске INSERT INTO SELECT. Однако materialized view позволяет инкапсулировать эту логику в DDL ClickHouse и сохранить INSERT простым, при этом преобразование будет автоматически применяться ко всем новым строкам.
Наша materialized view для этого преобразования показана ниже:

Таблица поиска

При выборе ключа сортировки в ClickHouse следует учитывать характер доступа к данным. Следует использовать столбцы, которые часто используются в секциях фильтрации и агрегации. Это может быть неудобно в сценариях, где у пользователей более разнообразные способы доступа к данным, которые нельзя выразить одним набором столбцов. Например, рассмотрим следующую таблицу comments:
Ключ упорядочивания здесь оптимизирует таблицу для запросов с фильтрацией по PostId. Предположим, пользователь хочет отфильтровать данные по определённому UserId и вычислить среднее значение Score:
Хотя это быстро (для ClickHouse объём данных небольшой), по числу обработанных строк — 90,38 миллиона — видно, что здесь требуется полное сканирование таблицы. Для более крупных датасетов мы можем использовать materialized view, чтобы получать значения ключа сортировки PostId для фильтрации по столбцу UserId. Затем эти значения можно использовать для эффективного поиска. В этом примере наша materialized view может быть очень простой и выбирать только PostId и UserId из comments при вставке. Эти результаты, в свою очередь, отправляются в таблицу comments_posts_users, которая упорядочена по UserId. Ниже мы создаём версию таблицы Comments с движком Null и используем её для заполнения нашего представления и таблицы comments_posts_users:
Теперь мы можем использовать это представление в подзапросе, чтобы ускорить предыдущий запрос:

Цепочки и каскады materialized views

materialized views можно выстраивать в цепочки (или каскады), что позволяет создавать сложные рабочие процессы. Подробнее см. в руководстве “Каскадные materialized views”.

Materialized views и JOINs

Refreshable Materialized ViewsНижеописанное относится только к Incremental Materialized Views. Refreshable Materialized Views периодически выполняют свой запрос по всему целевому набору данных и полностью поддерживают JOINs. Рассмотрите их использование для сложных JOINs, если допустимо некоторое снижение актуальности результатов.
Incremental Materialized Views в ClickHouse полностью поддерживают операции JOIN, но с одним важным ограничением: materialized view срабатывает только при вставке в исходную таблицу (крайнюю левую таблицу в запросе). Таблицы в правой части JOIN не вызывают обновление, даже если их данные изменяются. Это особенно важно при создании Incremental Materialized Views, где данные агрегируются или преобразуются во время вставки. Если Incremental materialized view определяется с использованием JOIN, источником выступает крайняя левая таблица в запросе SELECT. Когда в эту таблицу вставляются новые строки, ClickHouse выполняет запрос materialized view только для этих вновь вставленных строк. Таблицы в правой части JOIN при этом читаются целиком, но изменения только в них не запускают view. Из-за этого JOINs в Materialized Views напоминают JOIN со снимком по статическим таблицам измерений. Это хорошо подходит для обогащения данных справочными таблицами или таблицами измерений. Однако любые обновления таблиц в правой части (например, метаданных пользователей) не обновят materialized view задним числом. Чтобы увидеть обновленные данные, в исходную таблицу должны поступить новые вставки.

Пример

Давайте разберём конкретный пример, используя набор данных Stack Overflow. Мы будем использовать materialized view, чтобы вычислить ежедневное число значков для каждого пользователя, включая отображаемое имя пользователя из таблицы users. Напомним, что схемы наших таблиц выглядят следующим образом:
Будем считать, что таблица users уже заполнена:
materialized view и соответствующая целевая таблица определяются следующим образом:
Согласование группировки и сортировкиВ предложении GROUP BY в materialized view должны присутствовать DisplayName, UserId и Day, чтобы оно соответствовало ORDER BY в целевой таблице SummingMergeTree. Это гарантирует, что строки будут корректно агрегироваться и объединяться при слиянии. Если опустить любой из этих элементов, это может привести к неверным результатам или неэффективным слияниям.
Если теперь заполнить таблицу badges, представление сработает и заполнит нашу таблицу daily_badges_by_user.
Предположим, мы хотим посмотреть, какие значки получил конкретный пользователь. Для этого можно написать следующий запрос:
Теперь, если этому пользователю будет выдан новый бейдж и в таблицу будет вставлена строка, наше представление обновится:
Обратите внимание на задержку при вставке. Здесь для вставляемой строки пользователя выполняется JOIN со всей таблицей users, что существенно снижает производительность вставки. Ниже, в разделе “Использование исходной таблицы в фильтрах и JOIN”, мы предлагаем способы решения этой проблемы.
И наоборот, если сначала вставить значок для нового пользователя, а затем строку этого пользователя, наша materialized view не сможет собрать метрики пользователей.
В данном случае представление срабатывает только при вставке бейджа до того, как появляется строка пользователя. Если вставить для пользователя ещё один бейдж, строка будет вставлена, как и ожидается:
Однако учтите, что этот результат неверен.

Рекомендации по использованию JOIN в materialized view

  • Используйте крайнюю левую таблицу как триггер. Только таблица в левой части оператора SELECT запускает materialized view. Изменения в таблицах справа не вызовут обновления.
  • Сначала вставляйте данные для JOIN. Убедитесь, что данные в присоединяемых таблицах существуют до вставки строк в исходную таблицу. JOIN вычисляется во время вставки, поэтому отсутствие данных приведёт к несовпадающим строкам или значениям NULL.
  • Ограничивайте число столбцов, получаемых через JOIN. Выбирайте из присоединяемых таблиц только необходимые столбцы, чтобы минимизировать использование памяти и снизить задержку при вставке (см. ниже).
  • Оценивайте производительность вставки. JOIN увеличивает стоимость вставок, особенно при больших таблицах справа. Выполняйте бенчмарк скорости вставки на репрезентативных производственных данных.
  • Для простых поисков предпочитайте словари. Используйте Dictionaries для поиска по модели ключ-значение (например, сопоставления ID пользователя с именем), чтобы избежать дорогостоящих операций JOIN.
  • Согласовывайте GROUP BY и ORDER BY для эффективного слияния. При использовании SummingMergeTree или AggregatingMergeTree убедитесь, что GROUP BY соответствует ORDER BY в целевой таблице, чтобы обеспечить эффективное слияние строк.
  • Используйте явные псевдонимы столбцов. Если в таблицах есть совпадающие имена столбцов, используйте псевдонимы, чтобы избежать неоднозначности и обеспечить корректные результаты в целевой таблице.
  • Учитывайте объём и частоту вставок. JOIN хорошо работает при умеренной нагрузке на вставку. Для высокопроизводительной ингестии рассмотрите использование staging-таблиц, предварительных JOIN или других подходов, таких как Dictionaries и Refreshable Materialized Views.

Использование исходной таблицы в фильтрах и JOIN

При работе с materialized view в ClickHouse важно понимать, как используется исходная таблица во время выполнения запроса materialized view. В частности, в запросе materialized view исходная таблица заменяется вставляемым блоком данных. Если не учитывать это поведение, можно получить неожиданные результаты.

Пример сценария

Рассмотрим следующую схему:

Объяснение

В приведённом выше примере есть два materialized view — mvw1 и mvw2, — которые выполняют схожие операции, но немного различаются тем, как в них задаётся ссылка на исходную таблицу t0. В mvw1 таблица t0 указывается напрямую внутри подзапроса (SELECT * FROM t0) в правой части JOIN. Когда в t0 выполняется вставка данных, запрос materialized view исполняется так, что t0 заменяется вставленным блоком данных. Это означает, что операция JOIN выполняется только для вновь вставленных строк, а не для всей таблицы. Во втором случае, при JOIN с vt0, представление читает все данные из t0. Благодаря этому операция JOIN учитывает все строки в t0, а не только вновь вставленный блок. Ключевое различие заключается в том, как ClickHouse обрабатывает исходную таблицу в запросе materialized view. Когда materialized view срабатывает при вставке, исходная таблица (t0 в данном случае) заменяется вставленным блоком данных. Это поведение можно использовать для оптимизации запросов, но его также нужно учитывать, чтобы избежать неожиданных результатов.

Сценарии использования и предостережения

На практике это поведение можно использовать для оптимизации Materialized Views, которым требуется обрабатывать только подмножество данных исходной таблицы. Например, можно использовать подзапрос, чтобы отфильтровать исходную таблицу перед выполнением JOIN с другими таблицами. Это помогает уменьшить объем данных, обрабатываемых materialized view, и повысить производительность.
В этом примере множество, построенное из подзапроса IN (SELECT id FROM t0), содержит только что вставленные строки, что помогает фильтровать по нему t1.

Пример со Stack Overflow

Рассмотрим наш предыдущий пример materialized view для подсчёта ежедневного количества значков для каждого пользователя, включая отображаемое имя пользователя из таблицы users.
Это представление значительно увеличило задержку при вставке в таблицу badges, например:
Используя описанный выше подход, мы можем оптимизировать это представление. Мы добавим фильтр к таблице users, используя идентификаторы пользователей из вставленных строк badge:
Это не только ускоряет первоначальную вставку значков:
Но это также означает, что будущие вставки в badge будут выполняться эффективно:
В приведённой выше операции из таблицы users для пользователя с идентификатором 2936484 извлекается только одна строка. Эта выборка также оптимизирована благодаря ключу упорядочивания таблицы Id.

materialized views и объединения

Запросы UNION ALL обычно используются для объединения данных из нескольких исходных таблиц в один результирующий набор. Хотя UNION ALL напрямую не поддерживается в incremental materialized views, того же результата можно добиться, создав отдельную materialized view для каждой ветви SELECT и записывая результаты в общую целевую таблицу. В нашем примере мы будем использовать набор данных Stack Overflow. Рассмотрим приведённые ниже таблицы badges и comments, которые содержат сведения о значках, полученных пользователем, и комментариях, которые он оставляет к постам:
Их можно заполнить с помощью следующих команд INSERT INTO:
Предположим, мы хотим создать единое представление активности пользователей, показывающее последнее действие каждого из них за счёт объединения этих двух таблиц:
Предположим, у нас есть целевая таблица для получения результатов этого запроса. Обратите внимание на использование движка таблицы AggregatingMergeTree и AggregateFunction, чтобы результаты объединялись корректно:
Если вы хотите, чтобы эта таблица обновлялась при вставке новых строк в badges или comments, наивный подход — попытаться создать materialized view на основе предыдущего union-запроса:
Хотя с точки зрения синтаксиса это корректно, результат будет не таким, как ожидается: представление будет срабатывать только при вставке данных в таблицу comments. Например:
Вставки в таблицу badges не будут активировать представление, поэтому user_activity не будет обновляться:
Чтобы решить эту проблему, достаточно создать отдельное materialized view для каждого оператора SELECT:
Теперь вставка в любую из таблиц даёт правильный результат. Например, если вставить данные в таблицу comments:
Точно так же вставки в таблицу badges отражаются в таблице user_activity:

Параллельная и последовательная обработка

Как показано в предыдущем примере, таблица может служить источником для нескольких materialized view. Порядок их выполнения зависит от настройки parallel_view_processing. По умолчанию эта настройка имеет значение 0 (false), то есть materialized view выполняются последовательно в порядке uuid. Например, рассмотрим следующую таблицу source и 3 materialized view, каждая из которых отправляет строки в таблицу target:
Обратите внимание, что каждое представление делает паузу в 1 секунду перед вставкой своих строк в таблицу target, а также добавляет своё имя и время вставки. Вставка строки в таблицу source занимает ~3 секунды, при этом каждое представление выполняется последовательно:
Мы можем с помощью SELECT подтвердить, что строки поступают из каждой строки:
Это совпадает с uuid представлений:
И наоборот, рассмотрим, что произойдет, если вставить строку при включенном parallel_view_processing=1. Когда этот параметр включен, представления выполняются параллельно, поэтому порядок, в котором строки поступают в целевую таблицу, не гарантируется:
Хотя в нашем случае порядок поступления строк из каждого представления совпадает, это не гарантируется — на это указывает близкое время вставки у каждой строки. Также обратите внимание на более высокую производительность вставки.

Когда использовать параллельную обработку

Включение parallel_view_processing=1 может значительно повысить пропускную способность вставки, как показано выше, особенно если к одной таблице подключено несколько Materialized Views. Однако важно понимать связанные с этим компромиссы:
  • Повышенная нагрузка при вставке: все Materialized Views выполняются одновременно, что увеличивает использование CPU и памяти. Если каждое представление выполняет ресурсоёмкие вычисления или JOIN, это может перегрузить систему.
  • Необходимость строгого порядка выполнения: в редких сценариях, где порядок выполнения представлений имеет значение (например, при цепочке зависимостей), параллельное выполнение может привести к несогласованному состоянию или race condition. Хотя такие конфигурации возможны, они хрупки и могут перестать работать в будущих версиях.
Исторические значения по умолчанию и стабильностьПоследовательное выполнение долгое время использовалось по умолчанию, отчасти из-за сложности обработки ошибок. Исторически сбой в одном materialized view мог помешать выполнению остальных. В более новых версиях это улучшили за счёт изоляции сбоев на уровне каждого блока, но последовательное выполнение по-прежнему даёт более понятную семантику ошибок.
В целом включайте parallel_view_processing=1, если:
  • У вас есть несколько независимых Materialized Views
  • Вы хотите добиться максимальной производительности вставки
  • Вы понимаете, достаточно ли у системы ресурсов для параллельного выполнения представлений
Оставляйте этот параметр отключённым, если:
  • Materialized Views зависят друг от друга
  • Вам нужен предсказуемый порядок выполнения
  • Вы занимаетесь отладкой или аудитом поведения вставки и хотите детерминированного повторного воспроизведения

materialized views и общие табличные выражения (CTE)

Нерекурсивные общие табличные выражения (CTE) поддерживаются в materialized views.
Общие табличные выражения не материализуютсяClickHouse не материализует CTE; вместо этого он напрямую подставляет определение CTE в запрос, из-за чего одно и то же выражение может вычисляться несколько раз (если CTE используется более одного раза).
Рассмотрим следующий пример, который вычисляет ежедневную активность для каждого типа публикаций.
Хотя CTE здесь, строго говоря, не требуется, для наглядности представление будет работать как ожидается:
В ClickHouse CTE разворачиваются inline, то есть на этапе оптимизации фактически подставляются в запрос и не materialized. Это означает следующее:
  • Если ваш CTE ссылается на таблицу, отличную от исходной таблицы (то есть той, к которой привязано materialized view), и используется в JOIN или IN, он будет вести себя как подзапрос или JOIN, а не как триггер.
  • Materialized view по-прежнему будет срабатывать только при вставках в основную исходную таблицу, но CTE будет выполняться заново при каждой вставке, что может создавать лишние накладные расходы, особенно если таблица, на которую он ссылается, большая.
Например,
В этом случае CTE users пересчитывается при каждой вставке в posts, а materialized view не будет обновляться при добавлении новых пользователей — только при вставке в posts. Как правило, CTE стоит использовать для логики, которая работает с той же исходной таблицей, к которой привязана materialized view, либо нужно убедиться, что используемые таблицы небольшие и вряд ли создадут узкие места по производительности. В качестве альтернативы рассмотрите те же оптимизации, что и для JOIN в Materialized Views.
Последнее изменение 2 июля 2026 г.