Incremental materialized view
Общие сведения
Incremental Materialized Views (Materialized Views) позволяют перенести вычислительные затраты с момента выполнения запроса на момент вставки данных, что приводит к более быстрым SELECT‑запросам.
В отличие от транзакционных баз данных, таких как Postgres, materialized view в ClickHouse — это по сути триггер, который выполняет запрос над блоками данных по мере их вставки в таблицу. Результат этого запроса вставляется во вторую «целевую» таблицу. При вставке новых строк результаты снова записываются в целевую таблицу, где промежуточные результаты обновляются и сливаются. Этот объединённый результат эквивалентен запуску запроса над всеми исходными данными.
Основной мотив использования Materialized Views состоит в том, что результаты, вставляемые в целевую таблицу, представляют собой итог агрегации, фильтрации или трансформации строк. Часто эти результаты являются более компактным представлением исходных данных (частичным приближением в случае агрегаций). Это, вместе с тем, что запрос для чтения результатов из целевой таблицы получается простым, обеспечивает более быстрое выполнение запросов по сравнению с выполнением тех же вычислений над исходными данными, перенося вычисления (и, следовательно, задержку выполнения запроса) с момента выполнения запроса на момент вставки данных.
Materialized views в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на основе которой они построены, и работают скорее как постоянно обновляющиеся индексы. Это отличается от других баз данных, где Materialized Views, как правило, представляют собой статичные снимки результата запроса, которые необходимо периодически обновлять (аналогично ClickHouse Refreshable Materialized Views).

Пример
В качестве примера мы будем использовать набор данных Stack Overflow, описанный в разделе «Проектирование схемы».
Предположим, необходимо получить количество голосов «за» и «против» по дням для публикации.
Это довольно простой запрос в ClickHouse благодаря использованию функции toStartOfDay:
Этот запрос уже выполняется быстро благодаря ClickHouse, но можем ли мы сделать его ещё быстрее?
Если мы хотим выполнять эти вычисления на этапе вставки с использованием materialized view, нам нужна таблица для приёма результатов. В этой таблице должна храниться только одна строка на день. Если для уже существующего дня поступает обновление, остальные столбцы должны быть объединены с существующей строкой этого дня. Чтобы такое слияние инкрементальных состояний было возможно, для остальных столбцов необходимо хранить частичные состояния.
Для этого в ClickHouse требуется специальный табличный движок: SummingMergeTree. Он заменяет все строки с одинаковым ключом сортировки одной строкой, которая содержит суммы значений для числовых столбцов. Следующая таблица будет объединять все строки с одинаковой датой, суммируя все числовые столбцы:
Чтобы продемонстрировать нашу materialized view, предположим, что таблица votes пуста и в неё ещё не было записано ни одной строки. Наша materialized view выполняет приведённый выше запрос SELECT при вставке данных в votes, а результаты записываются в up_down_votes_per_day:
Клауза TO здесь ключевая: она определяет, куда будут отправлены результаты — в up_down_votes_per_day.
Мы можем заново заполнить таблицу голосов, используя ранее выполненную вставку:
По завершении мы можем проверить размер таблицы up_down_votes_per_day: в ней должна быть по одной строке на каждый день.
Мы фактически сократили количество строк с 238 миллионов (в votes) до 5000, сохранив результат нашего запроса. Важно, однако, что при вставке новых голосов в таблицу votes новые значения будут попадать в up_down_votes_per_day для соответствующего дня, где они будут автоматически асинхронно объединяться в фоновом режиме — при этом сохраняется только одна строка в день. Таким образом, up_down_votes_per_day всегда будет и небольшой по размеру, и актуальной.
Поскольку объединение строк происходит асинхронно, на момент выполнения запроса пользователем в таблице может существовать более одной строки на день. Чтобы гарантировать, что все ещё не объединённые строки будут учтены во время выполнения запроса, у нас есть два варианта:
- Использовать модификатор
FINALв имени таблицы в запросе. Мы делали это для запроса COUNT выше. - Агрегировать по ключу сортировки, используемому в нашей итоговой таблице, то есть
CreationDate, и суммировать метрики. Обычно это более эффективно и гибко (таблицу можно использовать и для других целей), но первый вариант может быть проще для некоторых запросов. Ниже мы показываем оба подхода:
Это ускорило наш запрос с 0,133 с до 0,004 с — более чем в 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:
В рабочей (production) среде вы, вероятнее всего, привяжете 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 позволяет инкапсулировать эту логику в ClickHouse DDL и упростить наш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 и используем её для заполнения нашей materialized view и таблицы comments_posts_users:
Теперь мы можем использовать это представление во вложенном запросе, чтобы ускорить предыдущий запрос:
Связывание / каскадирование materialized views
Materialized views можно связывать (или каскадировать), что позволяет выстраивать сложные конвейеры обработки. Для получения дополнительной информации см. руководство "Cascading materialized views".
Materialized views и JOIN
Следующее относится только к Incremental Materialized Views. Refreshable Materialized Views периодически выполняют свой запрос над всем целевым набором данных и полностью поддерживают JOIN. Рассмотрите возможность их использования для сложных JOIN, если допустимо снижение актуальности результатов.
Incremental materialized views в ClickHouse полностью поддерживают операции JOIN, но с одним критически важным ограничением: materialized view срабатывает только при вставках в исходную таблицу (самую левую таблицу в запросе). Таблицы справа в JOIN не инициируют обновления, даже если их данные меняются. Это поведение особенно важно при построении Incremental materialized views, где данные агрегируются или трансформируются на этапе вставки.
Когда Incremental materialized view определяется с использованием JOIN, самая левая таблица в запросе SELECT выступает в роли источника. При вставке новых строк в эту таблицу ClickHouse выполняет запрос materialized view только для этих вновь вставленных строк. Таблицы справа в JOIN полностью читаются во время этого выполнения, но изменения только в них не приводят к срабатыванию представления.
Такое поведение делает JOIN в materialized views аналогичным snapshot JOIN по статическим данным измерений.
Это хорошо подходит для обогащения данных с помощью справочных или dimension-таблиц. Однако любые обновления таблиц справа (например, пользовательских метаданных) не будут задним числом обновлять materialized view. Чтобы увидеть обновлённые данные, в исходную таблицу должны поступить новые вставки.
Пример
Рассмотрим конкретный пример с использованием набора данных Stack Overflow. Мы будем использовать materialized view для вычисления ежедневного числа значков на пользователя, включая отображаемое имя пользователя из таблицы users.
Напомним, схемы наших таблиц следующие:
Предположим, что таблица users уже заполнена:
materialized view и связанная с ней целевая таблица определены следующим образом:
Оператор GROUP BY в materialized view должен включать DisplayName, UserId и Day, чтобы соответствовать ORDER BY в целевой таблице на SummingMergeTree. Это гарантирует, что строки корректно агрегируются и сливаются. Пропуск любого из этих полей может привести к неверным результатам или неэффективным слияниям.
Если теперь назначить бейджи, представление сработает и заполнит нашу таблицу daily_badges_by_user.
Предположим, что мы хотим просмотреть значки, полученные конкретным пользователем; для этого можно написать следующий запрос:
Теперь, если этот пользователь получит новый бейдж и будет вставлена новая строка, наше материализованное представление будет обновлено:
Обратите внимание на задержку операции вставки. Вставленная строка пользователя соединяется со всей таблицей users, что существенно снижает производительность вставки. Ниже мы предлагаем подходы к решению этой проблемы в разделе "Использование исходной таблицы в фильтрах и соединениях".
В обратной ситуации, если мы сначала вставим бейдж для нового пользователя, а затем строку для этого пользователя, наша materialized view не сможет корректно зафиксировать метрики пользователей.
В этом случае представление выполняется только при вставке значка, до того как будет создана строка пользователя. Если мы вставим для этого пользователя ещё один значок, будет добавлена строка, как и должно быть:
Однако учтите, что этот результат некорректен.
Рекомендации по использованию JOIN в materialized views
-
Используйте левую таблицу как триггер. Только таблица слева в операторе
SELECTинициирует обновление materialized view. Изменения в таблицах справа не приводят к его обновлению. -
Предварительно вставляйте данные для JOIN. Убедитесь, что данные в присоединяемых таблицах уже существуют до вставки строк в исходную таблицу. JOIN вычисляется во время вставки, поэтому отсутствие данных приведёт к строкам без совпадений или значениям null.
-
Ограничивайте столбцы, выбираемые из JOIN. Выбирайте только необходимые столбцы из присоединяемых таблиц, чтобы минимизировать использование памяти и сократить задержку при вставке (см. ниже).
-
Оценивайте производительность вставок. JOIN увеличивает стоимость вставок, особенно при больших правых таблицах. Проведите бенчмарки скоростей вставки на репрезентативных production‑данных.
-
Предпочитайте словари для простых поисков. Используйте Dictionaries для key‑value‑поиска (например, сопоставление ID пользователя и имени), чтобы избежать дорогостоящих операций JOIN.
-
Согласуйте
GROUP BYиORDER BYдля эффективного слияния. При использованииSummingMergeTreeилиAggregatingMergeTreeубедитесь, чтоGROUP BYсоответствует выражениюORDER BYв целевой таблице, чтобы обеспечить эффективное слияние строк. -
Используйте явные псевдонимы столбцов. Когда в таблицах есть пересекающиеся имена столбцов, используйте псевдонимы, чтобы избежать неоднозначности и гарантировать корректные результаты в целевой таблице.
-
Учитывайте объём и частоту вставок. JOIN хорошо работает при умеренных нагрузках вставок. Для высокопроизводительной ингестии рассмотрите использование промежуточных (staging) таблиц, предварительных JOIN или других подходов, таких как Dictionaries и Refreshable Materialized Views.
Использование исходной таблицы в фильтрах и JOIN-ах
При работе с Materialized Views в ClickHouse важно понимать, как исходная таблица обрабатывается при выполнении запроса materialized view. В частности, исходная таблица в запросе materialized view заменяется на вставляемый блок данных. Такое поведение может приводить к неожиданным результатам, если его не учитывать должным образом.
Пример сценария
Рассмотрим следующую схему:
Пояснение
В приведённом выше примере у нас есть две materialized view mvw1 и mvw2, которые выполняют схожие операции, но с небольшим различием в том, как они обращаются к исходной таблице t0.
В mvw1 таблица t0 напрямую используется внутри подзапроса (SELECT * FROM t0) в правой части оператора JOIN. Когда данные вставляются в t0, запрос materialized view выполняется с вставленным блоком данных, подставленным вместо t0. Это означает, что операция JOIN выполняется только над вновь вставленными строками, а не над всей таблицей.
Во втором случае, при соединении с vt0, представление считывает все данные из t0. Это гарантирует, что операция JOIN учитывает все строки в t0, а не только недавно вставленный блок.
Ключевое различие заключается в том, как ClickHouse обрабатывает исходную таблицу в запросе materialized view. Когда materialized view срабатывает при вставке данных, исходная таблица (в данном случае t0) заменяется вставленным блоком данных. Такое поведение можно использовать для оптимизации запросов, но оно также требует внимательного учета, чтобы избежать неожиданных результатов.
Сценарии использования и ограничения
На практике вы можете использовать это поведение для оптимизации materialized views, которым нужно обрабатывать только часть данных исходной таблицы. Например, вы можете использовать подзапрос, чтобы отфильтровать исходную таблицу перед объединением её с другими таблицами. Это может сократить объём данных, обрабатываемых materialized view, и повысить производительность.
В этом примере множество, построенное из подзапроса IN (SELECT id FROM t0), содержит только вновь вставленные строки, что позволяет использовать его для фильтрации t1.
Пример со Stack Overflow
Рассмотрим наш предыдущий пример materialized view для вычисления ежедневных значков для каждого пользователя, включая отображаемое имя пользователя из таблицы users.
Это представление существенно влияло на задержку вставки в таблицу badges, например:
Используя описанный выше подход, мы можем оптимизировать это представление. Добавим фильтр к таблице users, используя идентификаторы пользователей из вставленных строк с бейджами:
Это не только ускоряет первоначальную вставку данных в таблицу badges:
Но это также означает, что последующие вставки бейджей будут выполняться эффективно:
В указанной выше операции из таблицы users извлекается только одна строка для идентификатора пользователя 2936484. Этот запрос также оптимизирован за счёт использования ключа сортировки таблицы Id.
materialized view и объединения
Запросы с UNION ALL обычно используются для объединения данных из нескольких исходных таблиц в один результирующий набор.
Хотя UNION ALL напрямую не поддерживается в incremental materialized view, вы можете добиться того же результата, создав отдельную materialized view для каждой ветки SELECT и записывая их результаты в общую целевую таблицу.
В нашем примере мы будем использовать набор данных Stack Overflow. Рассмотрим таблицы badges и comments ниже, которые представляют награды, полученные пользователем, и комментарии, которые он оставляет к постам:
Их можно заполнить с помощью следующих команд INSERT INTO:
Предположим, что мы хотим создать единое представление активности пользователей, в котором для каждого пользователя будет показана его последняя активность, объединив эти две таблицы:
Предположим, у нас есть целевая таблица для записи результатов этого запроса. Обратите внимание на использование движка таблицы AggregatingMergeTree и типа AggregateFunction, чтобы обеспечить корректное объединение результатов:
Поскольку мы хотим, чтобы эта таблица обновлялась по мере вставки новых строк в badges или comments, наивным подходом к решению этой задачи может быть попытка создать materialized view на основе предыдущего запроса с UNION:
Хотя это синтаксически корректно, оно приведёт к результатам, отличающимся от ожидаемых — представление будет срабатывать только на вставки в таблицу comments. Например:
INSERT-запросы в таблицу badges не будут приводить к срабатыванию представления, поэтому user_activity не будет получать обновления:
Чтобы решить эту задачу, достаточно создать отдельную materialized view для каждого запроса SELECT:
Теперь вставка в любую из таблиц даёт корректный результат. Например, если мы выполним INSERT в таблицу comments:
Аналогично, операции вставки в таблицу badges отражаются в таблице user_activity:
Параллельная и последовательная обработка
Как показано в предыдущем примере, таблица может служить источником для нескольких materialized view. Порядок их выполнения зависит от настройки parallel_view_processing.
По умолчанию эта настройка имеет значение 0 (false), что означает, что materialized view выполняются последовательно в порядке их uuid.
Например, рассмотрим следующую исходную таблицу source и три materialized view, каждая из которых отправляет строки в таблицу target:
Обратите внимание, что каждое из представлений делает паузу в 1 секунду перед вставкой своих строк в таблицу target, при этом добавляя своё имя и время вставки.
Вставка строки в таблицу source занимает примерно 3 секунды, причём каждое представление выполняется последовательно:
Мы можем проверить поступление строк с помощью запроса SELECT:
Это совпадает с uuid представлений:
Напротив, рассмотрим, что происходит, если мы вставим строку с включённым parallel_view_processing=1. При таком режиме представления выполняются параллельно, и порядок поступления строк в целевую таблицу никак не гарантируется:
Хотя в нашем случае порядок поступления строк из каждого представления одинаковый, это не гарантировано — что видно по близким значениям времени вставки каждой строки. Также обратите внимание на улучшенную производительность вставки.
Когда использовать параллельную обработку
Включение parallel_view_processing=1 может значительно повысить пропускную способность вставок, как показано выше, особенно когда к одной таблице привязано несколько Materialized Views. Однако важно понимать сопутствующие компромиссы:
- Повышенная нагрузка при вставке: Все Materialized Views выполняются одновременно, увеличивая использование CPU и памяти. Если каждый view выполняет тяжелые вычисления или JOIN, это может перегрузить систему.
- Необходимость строгого порядка выполнения: В редких сценариях, где порядок выполнения view имеет значение (например, при цепочках зависимостей), параллельное выполнение может приводить к неконсистентному состоянию или состояниям гонки. Хотя можно спроектировать систему с учетом этого, такие конфигурации хрупкие и могут перестать корректно работать в будущих версиях.
Последовательное выполнение долгое время было значением по умолчанию, в том числе из‑за сложности обработки ошибок. Ранее сбой в одном materialized view мог помешать выполнению других. В новых версиях это улучшено за счет изоляции сбоев на уровне блока, но последовательное выполнение по‑прежнему обеспечивает более понятную семантику ошибок.
В целом, включайте parallel_view_processing=1, когда:
- У вас есть несколько независимых Materialized Views
- Вы стремитесь максимизировать производительность вставок
- Вы понимаете, что система способна выдержать параллельное выполнение view
Оставляйте его выключенным, когда:
- Между Materialized Views существуют взаимные зависимости
- Вам требуется предсказуемое, упорядоченное выполнение
- Вы отлаживаете или аудируете поведение вставок и хотите детерминированное воспроизведение
materialized view и общие табличные выражения (CTE)
Нерекурсивные общие табличные выражения (CTE) поддерживаются в materialized view.
ClickHouse не материализует CTE; вместо этого он подставляет определение CTE непосредственно в запрос, что может приводить к многократному вычислению одного и того же выражения (если CTE используется более одного раза).
Рассмотрим следующий пример, который вычисляет дневную активность для каждого типа поста.
Хотя CTE здесь, строго говоря, не обязателен, в качестве примера представление будет работать как ожидается:
В ClickHouse CTE подставляются «на месте», то есть по сути копируются в запрос во время оптимизации и не материализуются. Это означает:
- Если ваш CTE ссылается на другую таблицу, отличную от исходной таблицы (то есть той, к которой привязан materialized view), и используется в
JOINилиINпредложении, он будет вести себя как подзапрос или JOIN, а не как триггер. - materialized view по-прежнему будет срабатывать только при вставках в основную исходную таблицу, но CTE будет выполняться повторно при каждой вставке, что может приводить к дополнительным накладным расходам, особенно если таблица, на которую он ссылается, большая.
Например,
В этом случае CTE users пересчитывается при каждом INSERT в posts, и materialized view не будет обновляться при добавлении новых пользователей — только при вставках в posts.
Как правило, используйте CTE для логики, которая работает с той же исходной таблицей, к которой привязана materialized view, или убедитесь, что таблицы, на которые есть ссылки, небольшие и маловероятно станут узким местом по производительности. В качестве альтернативы рассмотрите те же оптимизации, что и для JOIN с Materialized Views.