Инкрементное Материализованное Представление
Контекст
Инкрементные материализованные представления (Materialized Views) позволяют пользователям перенести стоимость вычислений с времени запроса на время вставки, что приводит к более быстрым запросам SELECT
.
В отличие от транзакционных баз данных, таких как Postgres, материализованное представление в ClickHouse — это просто триггер, который выполняет запрос над блоками данных по мере их вставки в таблицу. Результат этого запроса вставляется во вторую "целевую" таблицу. Если будут вставлены дополнительные строки, результаты снова будут отправлены в целевую таблицу, где промежуточные результаты будут обновлены и объединены. Этот объединенный результат эквивалентен выполнению запроса над всеми оригинальными данными.
Основная мотивация для материализованных представлений заключается в том, что результаты, вставляемые в целевую таблицу, представляют результаты агрегации, фильтрации или преобразования строк. Эти результаты часто будут меньшим представлением оригинальных данных (частичным эскизом в случае агрегаций). Это, наряду с тем фактом, что запрос для чтения результатов из целевой таблицы простой, обеспечивает более быстрое время выполнения запросов, чем если бы те же вычисления выполнялись над оригинальными данными, перемещая вычисления (а, следовательно, и задержку запроса) с времени запроса на время вставки.
Материализованные представления в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на которую они основаны, функционируя больше как постоянно обновляющиеся индексы. Это контрастирует с другими базами данных, где материализованные представления обычно являются статическими снимками запроса, которые необходимо обновлять (аналогично обновляемым материализованным представлениям ClickHouse Refreshable Materialized Views).

Пример
Для примера мы будем использовать набор данных Stack Overflow, описанный в "Дизайне схемы".
Предположим, мы хотим получить количество положительных и отрицательных голосов за день для поста.
Это достаточно простой запрос в ClickHouse благодаря функции toStartOfDay
:
Этот запрос уже быстр благодаря ClickHouse, но можем ли мы сделать лучше?
Если мы хотим вычислить это во время вставки, используя материализованное представление, нам нужна таблица, чтобы получать результаты. Эта таблица должна хранить только 1 строку за день. Если обновление получено для существующего дня, другие столбцы должны быть объединены в строку существующего дня. Для этого слияния инкрементальных состояний необходимо хранить частичные состояния для других столбцов.
Это требует специального типа движка в ClickHouse: SummingMergeTree. Этот движок заменяет все строки с одинаковым ключом упорядочивания одной строкой, которая содержит суммированные значения для числовых столбцов. Следующая таблица будет объединять любые строки с одной и той же датой, суммируя любые числовые столбцы:
Чтобы продемонстрировать наше материализованное представление, предположим, что наша таблица голосов пуста и еще не приняла никаких данных. Наше материализованное представление выполняет вышеуказанный SELECT
на данных, вставленных в votes
, с результатами, отправляемыми в up_down_votes_per_day
:
Ключевым здесь является оператор TO
, который обозначает, куда результаты будут отправлены, т.е. up_down_votes_per_day
.
Мы можем повторно заполнить нашу таблицу голосов из нашей предыдущей вставки:
По завершении мы можем подтвердить размер нашего up_down_votes_per_day
— у нас должно быть 1 строка на день:
Мы эффективно снизили количество строк здесь с 238 миллионов (в votes
) до 5000, храня результат нашего запроса. Однако ключевым моментом здесь является то, что если новые голоса будут вставлены в таблицу votes
, новые значения будут отправлены в up_down_votes_per_day
для их соответствующего дня, где они будут автоматически объединены асинхронно в фоновом режиме, сохраняя только одну строку на день. Таким образом, up_down_votes_per_day
всегда будет и компактным, и актуальным.
Поскольку слияние строк происходит асинхронно, может быть больше одного голоса за день, когда пользователь выполняет запрос. Чтобы обеспечить слияние всех оставшихся строк во время запроса, у нас есть два варианта:
- Использовать модификатор
FINAL
в имени таблицы. Мы сделали это для запроса подсчета выше. - Выполнить агрегацию по ключу упорядочивания, использованному в нашей итоговой таблице, т.е.
CreationDate
, и суммировать метрики. Обычно это более эффективно и гибко (таблица может использоваться для других задач), но первый вариант может быть проще для некоторых запросов. Мы покажем оба варианта ниже:
Это ускорило наш запрос с 0.133с до 0.004с — более чем 25-кратное улучшение!
ORDER BY
= GROUP BY
В большинстве случаев столбцы, используемые в операторе GROUP BY
трансформации материализованных представлений, должны совпадать с теми, которые используются в операторе ORDER BY
целевой таблицы, если используются движки таблиц SummingMergeTree
или AggregatingMergeTree
. Эти движки полагаются на столбцы ORDER BY
для слияния строк с идентичными значениями во время фоновых операций слияния. Несоответствие между столбцами GROUP BY
и ORDER BY
может привести к неэффективной производительности запроса, неоптимальным слияниям или даже несоответствиям в данных.
Более сложный пример
Вышеуказанный пример использует материализованные представления для вычисления и поддержания двух сумм в день. Суммы представляют собой самую простую форму агрегации для поддержания частичных состояний — мы можем просто добавлять новые значения к существующим значениям по мере их поступления. Однако материализованные представления ClickHouse могут быть использованы для любого типа агрегации.
Предположим, мы хотим вычислить некоторые статистические данные для постов за каждый день: 99.9-йPercentile для Score
и среднее значение для CommentCount
. Запрос для вычисления этого может выглядеть так:
Как и прежде, мы можем создать материализованное представление, которое выполняет вышеуказанный запрос по мере вставки новых постов в нашу таблицу posts
.
Для целей примера и чтобы избежать загрузки данных постов из S3, мы создадим дубликат таблицы posts_null
с той же схемой, что и posts
. Однако эта таблица не будет хранить никаких данных и будет просто использоваться материализованным представлением, когда строки вставляются. Чтобы предотвратить хранение данных, мы можем использовать тип движка таблицы Null
.
Движок таблицы Null является мощной оптимизацией — считайте его как /dev/null
. Наше материализованное представление будет вычислять и хранить наши сводные статистики, когда в таблицу posts_null
будут поступать строки во время вставки — это просто триггер. Тем не менее, сырые данные не будут храниться. Хотя в нашем случае, вероятно, мы все же хотим хранить оригинальные посты, этот подход можно использовать для вычисления агрегатов, избегая накладных расходов на хранение сырых данных.
Таким образом, материализованное представление становится:
Обратите внимание, что мы добавляем суффикс State
к концу наших агрегатных функций. Это гарантирует, что состояние агрегата функции возвращается вместо окончательного результата. Это будет содержать дополнительную информацию, чтобы позволить этому частичному состоянию объединиться с другими состояниями. Например, в случае среднего значения это будет включать количество и сумму столбца.
Частичные состояния агрегации необходимы для вычисления правильных результатов. Например, для вычисления среднего просто усреднение средних значений поддиапазонов приводит к неправильным результатам.
Теперь мы создаем целевую таблицу для этого представления post_stats_per_day
, которая хранит эти частичные состояния агрегатов:
Хотя ранее SummingMergeTree
был достаточен для хранения подсчетов, нам требуется более продвинутый тип движка для других функций: AggregatingMergeTree
. Чтобы гарантировать, что ClickHouse знает, что будут храниться состояния агрегатов, мы определяем Score_quantiles
и AvgCommentCount
как тип AggregateFunction
, указывая функцию-источник частичных состояний и тип их исходных столбцов. Подобно SummingMergeTree
, строки с одинаковым значением ключа ORDER BY
будут объединены (Day
в приведенном выше примере).
Чтобы заполнить нашу таблицу post_stats_per_day
через наше материализованное представление, мы можем просто вставить все строки из posts
в posts_null
:
В производстве вы, вероятно, прикрепите материализованное представление к таблице
posts
. Мы использовалиposts_null
здесь, чтобы продемонстрировать нулевую таблицу.
Наш окончательный запрос должен использовать суффикс Merge
для наших функций (так как столбцы хранят частичные состояния агрегации):
Обратите внимание, что здесь мы используем GROUP BY
вместо использования FINAL
.
Другие применения
Вышеуказанное в основном сосредоточено на использовании материализованных представлений для инкрементного обновления частичных агрегатов данных, тем самым перемещая вычисления с времени запроса на время вставки. Помимо этого распространенного случая, материализованные представления имеют ряд других применений.
Фильтрация и преобразование
В некоторых ситуациях мы можем захотеть вставить лишь подмножество строк и столбцов при вставке. В этом случае наша таблица posts_null
может получать вставки, с запросом SELECT
, фильтрующим строки перед вставкой в таблицу posts
. Например, предположим, что мы захотим преобразовать столбец Tags
в нашей таблице posts
. Это содержит список имен тегов, разделенных символом «|». Преобразовав их в массив, мы можем легче агрегировать по отдельным значениям тегов.
Мы могли бы выполнить это преобразование при выполнении
INSERT INTO SELECT
. Материализованное представление позволяет нам инкапсулировать эту логику в DDL ClickHouse и упростить нашINSERT
, применяя преобразование к любым новым строкам.
Наше материализованное представление для этого преобразования показано ниже:
Таблица справочника
Пользователи должны учитывать свои шаблоны доступа при выборе ключа упорядочивания ClickHouse. Столбцы, которые часто используются в операторах фильтрации и агрегации, должны быть использованы. Это может быть ограничительным для сценариев, где пользователи имеют более разнообразные шаблоны доступа, которые не могут быть инкапсулированы в одном наборе столбцов. Например, рассмотрим следующую таблицу comments
:
Ключ упорядочивания здесь оптимизирует таблицу для запросов, которые фильтруют по PostId
.
Предположим, что пользователь хочет фильтровать по конкретному UserId
и вычислить его среднее Score
:
Хотя это быстро (данные малы для ClickHouse), мы можем видеть, что это требует полного сканирования таблицы по количеству обработанных строк — 90,38 миллионов. Для больших наборов данных мы можем использовать материализованное представление для поиска значений нашего ключа упорядочивания PostId
для фильтрации по столбцу UserId
. Эти значения затем могут быть использованы для выполнения эффективного поиска.
В этом примере наше материализованное представление может быть очень простым, выбирая только PostId
и UserId
из comments
при вставке. Эти результаты, в свою очередь, отправляются в таблицу comments_posts_users
, которая отсортирована по UserId
. Мы создаем нулевую версию таблицы Comments
, как показано ниже, и используем это для заполнения нашего представления и таблицы comments_posts_users
:
Теперь мы можем использовать это представление в подзапросе, чтобы ускорить наш предыдущий запрос:
Цепочка / каскадирование материализованных представлений
Материализованные представления могут быть связаны в цепочку (или каскадированы), позволяя устанавливать сложные рабочие процессы. Для получения дополнительной информации см. руководство "Каскадные материализованные представления".
Материализованные представления и JOINs
Следующее относится только к инкрементным материализованным представлениям. Обновляемые материализованные представления сводят свой запрос к полному целевому набору данных периодически и полностью поддерживают JOINs. Рассмотрите возможность их использования для сложных JOINs, если допустимо снижение свежести результата.
Инкрементные материализованные представления в ClickHouse полностью поддерживают операции JOIN
, но с одним важным ограничением: материализованное представление срабатывает только на вставках в исходную таблицу (самую левую таблицу в запросе). Правые таблицы в JOINs не вызывают обновления, даже если их данные изменяются. Это поведение особенно важно при построении инкрементных материализованных представлений, где данные агрегируются или преобразуются во время вставки.
Когда инкрементное материализованное представление определяется с использованием JOIN
, самая левая таблица в запросе SELECT
выступает в качестве источника. Когда новые строки вставляются в эту таблицу, ClickHouse выполняет запрос материализованного представления только с только что вставленными строками. Правые таблицы в JOIN читаются полностью во время этого выполнения, но изменения только в них не вызывают триггер представления.
Это поведение делает JOINs в материализованных представлениях похожими на снимок соединения против статических данных размерности.
Это хорошо работает для обогащения данных ссылочными или размерными таблицами. Тем не менее, любые обновления правых таблиц (например, метаданные пользователей) не будут ретроактивно обновлять материализованное представление. Чтобы увидеть обновленные данные, новые вставки должны поступить в исходную таблицу.
Пример
Давайте пройдемся через конкретный пример, используя набор данных Stack Overflow. Мы будем использовать материализованное представление для вычисления ежедневных значков на пользователя, включая отображаемое имя пользователя из таблицы users
.
Как напоминание, схемы наших таблиц:
Предположим, что наша таблица users
заранее заполнена:
Материализованное представление и его связанная целевая таблица определяются следующим образом:
Оператор GROUP BY
в материализованном представлении должен включать DisplayName
, UserId
и Day
, чтобы соответствовать ORDER BY
в целевой таблице SummingMergeTree
. Это гарантирует, что строки правильно агрегируются и объединяются. Упущение любого из этих может привести к неправильным результатам или неэффективным слияниям.
Если мы теперь заполним значки, представление будет вызвано — заполнив нашу таблицу daily_badges_by_user
.
Предположим, что мы желаем просмотреть значки, полученные конкретным пользователем, мы можем написать следующий запрос:
Теперь, если этот пользователь получит новый значок и строка будет вставлена, наше представление будет обновлено:
Обратите внимание на задержку вставки здесь. Вставленная строка пользователя объединяется с всей таблицей users
, что значительно влияет на производительность вставки. Мы предложим подходы, чтобы справиться с этим ниже в разделе "Использование исходной таблицы в фильтрах и JOINs".
И наоборот, если мы вставим значок для нового пользователя, а затем вставим строку для пользователя, наше материализованное представление не сможет захватить метрики пользователя.
В этом случае представление выполняется только для вставки значка, прежде чем будет существовать строка пользователя. Если мы вставим еще один значок для пользователя, строка будет вставлена, как и ожидалось:
Однако обратите внимание, что этот результат неверен.
Лучшие практики для JOINs в материализованных представлениях
-
Используйте левую таблицу в качестве триггера. Только таблица с левой стороны оператора
SELECT
вызывает материализованное представление. Изменения правых таблиц не будут вызывать обновления. -
Предварительно вставляйте объединенные данные. Убедитесь, что данные в объединенных таблицах существуют до вставки строк в исходную таблицу. JOIN оценивается во время вставки, поэтому отсутствующие данные приведут к несовпадающим строкам или null.
-
Ограничьте столбцы, извлекаемые из JOINs. Выбирайте только необходимые столбцы из объединенных таблиц, чтобы минимизировать использование памяти и снизить задержку времени вставки (см. ниже).
-
Оцените производительность во время вставки. JOINs увеличивают стоимость вставок, особенно с большими правыми таблицами. Проведите тестирование скорости вставки с помощью представительных производственных данных.
-
Предпочитайте словари для простых поисков. Используйте Словари для поиска по ключу-значению (например, ID пользователя к имени), чтобы избежать дорогих операций JOIN.
-
Согласуйте
GROUP BY
иORDER BY
для эффективности слияния. При использованииSummingMergeTree
илиAggregatingMergeTree
убедитесь, чтоGROUP BY
совпадает с операторомORDER BY
в целевой таблице, чтобы обеспечить эффективное слияние строк. -
Используйте явные псевдонимы столбцов. Когда таблицы имеют перекрывающиеся названия столбцов, используйте псевдонимы, чтобы избежать неоднозначности и гарантировать правильные результаты в целевой таблице.
-
Обратите внимание на объем и частоту вставок. JOINs хорошо работают при умеренной нагрузке вставок. Для высокопродуктивного приема данных рассмотрите возможность использования промежуточных таблиц, предварительных JOINs или других подходов, таких как Словари и Обновляемые материализованные представления.
Использование исходной таблицы в фильтрах и JOINs
Когда вы работаете с материализованными представлениями в ClickHouse, важно понимать, как исходная таблица обрабатывается во время выполнения запроса материализованного представления. В частности, исходная таблица в запросе материализованного представления заменяется вставленным блоком данных. Это поведение может привести к некоторым неожиданным результатам, если его не понять должным образом.
Пример сценария
Рассмотрим следующую настройку:
Пояснение
В приведенном выше примере у нас есть два материализованных представления mvw1
и mvw2
, которые выполняют похожие операции, но с небольшим различием в том, как они ссылаются на исходную таблицу t0
.
В mvw1
таблица t0
непосредственно ссылается в подзапросе (SELECT * FROM t0)
на правую сторону JOIN. Когда данные вставляются в t0
, запрос материализованного представления выполняется с вставленным блоком данных, заменяющим t0
. Это означает, что операция JOIN выполняется только на вновь вставленных строках, а не на всей таблице.
Во втором случае с присоединением vt0
представление читает все данные из t0
. Это гарантирует, что операция JOIN учитывает все строки в t0
, а не только вновь вставленный блок.
Ключевое различие заключается в том, как ClickHouse обрабатывает исходную таблицу в запросе материализованного представления. Когда материализованное представление вызывается вставкой, исходная таблица (t0
в данном случае) заменяется вставленным блоком данных. Это поведение можно использовать для оптимизации запросов, но также требует тщательного рассмотрения, чтобы избежать неожиданных результатов.
Сценарии использования и предостережения
На практике вы можете использовать это поведение для оптимизации материализованных представлений, которые нужно обрабатывать только с подмножеством данных исходной таблицы. Например, вы можете использовать подзапрос для фильтрации исходной таблицы, прежде чем объединять ее с другими таблицами. Это может помочь уменьшить объем данных, обрабатываемых материализованным представлением, и улучшить производительность.
В этом примере набор, созданный из подзапроса IN (SELECT id FROM t0)
, содержит только вновь вставленные строки, что может помочь фильтровать t1
против него.
Пример с Stack Overflow
Рассмотрим наш предыдущий пример материализованного представления для вычисления ежедневных значков для пользователя, включая имя пользователя из таблицы users
.
Это представление значительно повлияло на задержку вставок в таблице badges
, например.
Используя приведенный выше подход, мы можем оптимизировать это представление. Мы добавим фильтр к таблице users
, используя идентификаторы пользователей в вставленных строках значков:
Это не только ускоряет первоначальную вставку значков:
Но также означает, что будущие вставки значков будут эффективными:
В приведенной выше операции только одна строка извлекается из таблицы пользователей для идентификатора пользователя 2936484
. Этот поиск также оптимизирован с помощью ключа упорядочивания таблицы Id
.
Материализованные представления и объединения
Запросы UNION ALL
часто используются для объединения данных из нескольких исходных таблиц в один набор результатов.
Хотя UNION ALL
не поддерживается напрямую в инкрементных материализованных представлениях, вы можете добиться того же результата, создав отдельное материализованное представление для каждого ветвления SELECT
и записывая их результаты в общую целевую таблицу.
Для нашего примера мы будем использовать набор данных Stack Overflow. Рассмотрим таблицы badges
и comments
, которые представляют значки, заEarned пользователем, и комментарии, которые он делает к постам:
Эти таблицы можно заполнить следующими командами INSERT INTO
:
Предположим, мы хотим создать объединенное представление активности пользователя, показывающее последнюю активность каждого пользователя, объединив эти две таблицы:
Предположим, у нас есть целевая таблица для получения результатов этого запроса. Обратите внимание на использование движка таблицы AggregatingMergeTree и AggregateFunction, чтобы гарантировать, что результаты корректно объединяются:
Желая, чтобы эта таблица обновлялась по мере вставки новых строк в таблицы badges
или comments
, наивный подход к этой проблеме может заключаться в попытке создать материализованное представление с предыдущим объединяющим запросом:
Хотя это синтаксически корректно, оно приведет к непредвиденным результатам — представление сработает только при вставках в таблицу comments
. Например:
Вставки в таблицу badges
не вызовут срабатывание представления, что приведет к тому, что user_activity
не будет получать обновления:
Чтобы решить эту проблему, мы просто создаем материализованное представление для каждого оператора SELECT:
Вставка в любую таблицу теперь приводит к правильным результатам. Например, если мы вставим в таблицу comments
:
Аналогично, вставки в таблицу badges
отражаются в таблице user_activity
:
Параллельная обработка против последовательной
Как показано в предыдущем примере, таблица может выступать в качестве источника для нескольких материализованных представлений. Порядок, в котором они выполняются, зависит от настройки parallel_view_processing
.
По умолчанию эта настройка равна 0
(ложь
), что означает, что материализованные представления выполняются последовательно в порядке uuid
.
Например, рассмотрим следующую таблицу source
и 3 материализованных представления, каждое из которых отправляет строки в целевую таблицу target
:
Обратите внимание, что каждое из представлений останавливается на 1 секунду перед вставкой своих строк в целевую таблицу, также включая свое имя и время вставки.
Вставка строки в таблицу source
занимает ~3 секунды, при этом каждое представление выполняется последовательно:
Мы можем подтвердить прибытие строк из каждого представления с помощью SELECT
:
Это соответствует uuid
представлений:
Напротив, рассмотрим, что происходит, если мы вставляем строку с включенной настройкой parallel_view_processing=1
. С этим включенным, представления выполняются параллельно, не давая никаких гарантий о порядке, в котором строки поступают в целевую таблицу:
Хотя порядок поступления строк из каждого представления остается тем же, это не гарантировано — как иллюстрируется сходством времени вставки каждой строки. Обратите также внимание на улучшенную производительность вставки.
Когда использовать параллельную обработку
Включение parallel_view_processing=1
может существенно повысить пропускную способность вставки, как показано выше, особенно когда несколько материализованных представлений прикреплены к одной таблице. Тем не менее, важно понимать компромиссы:
- Увеличенное давление вставки: Все материализованные представления выполняются одновременно, увеличивая использование CPU и памяти. Если каждое представление выполняет тяжелые вычисления или JOINs, это может перегрузить систему.
- Необходимость строгого порядка выполнения: В редких рабочих процессах, где порядок выполнения представлений имеет значение (например, каскадные зависимости), параллельное выполнение может привести к несовместимым состояниям или состояниям гонки. Хотя возможно спроектировать решения для этого, такие настройки fragile и могут сломаться с будущими версиями.
Последовательное выполнение долгое время было значением по умолчанию, отчасти из-за сложностей с обработкой ошибок. Исторически неудача в одном материализованном представлении может предотвратить выполнение других. Новые версии улучшили это, изолируя ошибки по блокам, но последовательное выполнение все еще обеспечивает более ясные семантики ошибки.
В общем, включайте parallel_view_processing=1
, когда:
- У вас есть несколько независимых материализованных представлений
- Вы стремитесь максимизировать производительность вставок
- Вы осведомлены о возможностях системы для обработки параллельного выполнения представлений
Отключайте его, когда:
- Материализованные представления зависят друг от друга
- Вам требуется предсказуемое, упорядоченное выполнение
- Вы проводите отладку или аудит поведения вставок и хотите детерминированную реплику
Материализованные представления и общее табличное выражение (CTE)
Нерекурсивные общее табличное выражение (CTE) поддерживаются в материализованных представлениях.
ClickHouse не материализует CTE; вместо этого он напрямую подставляет определение CTE в запрос, что может привести к нескольким оценкам одного и того же выражения (если CTE используется более одного раза).
Рассмотрим следующий пример, который вычисляет ежедневную активность для каждого типа поста.
Хотя CTE здесь строго не требуются, для примера представление будет работать так, как ожидалось:
В ClickHouse CTE встроены, что означает, что они фактически копируются в запрос в процессе оптимизации и не материализованы. Это означает, что:
- Если ваш CTE ссылается на другую таблицу, отличную от исходной таблицы (т.е. той, к которой прикреплено материализованное представление), и используется в операторе
JOIN
илиIN
, он будет вести себя как подзапрос или соединение, а не как триггер. - Материализованное представление все равно будет срабатывать только на вставках в основную исходную таблицу, но CTE будет переоцениваться на каждой вставке, что может вызвать ненужные накладные расходы, особенно если ссылаемая таблица большая.
Например,
В этом случае CTE пользователей переоценивается на каждой вставке в посты, и материализованное представление не будет обновляться, когда новые пользователи вставляются — только когда вставляются посты.
В общем, используйте CTE для логики, которая работает с той же исходной таблицей, к которой прикреплено материализованное представление или убедитесь, что ссылаемые таблицы небольшие и маловероятно, чтобы вызвать узкие места по производительности. В качестве альтернативы рассмотрите те же оптимизации, что и JOINs с материализованными представлениями.