Денормализация данных
Денормализация данных — это подход в ClickHouse, при котором используются «плоские» таблицы для минимизации задержки выполнения запросов за счёт избегания соединений (JOIN).
Сравнение нормализованных и денормализованных схем
Денормализация данных предполагает намеренный возврат к состоянию до нормализации, чтобы оптимизировать производительность базы данных под определённые шаблоны запросов. В нормализованных базах данные разбиваются на несколько связанных таблиц для минимизации избыточности и обеспечения целостности данных. Денормализация повторно вводит избыточность за счёт объединения таблиц, дублирования данных и включения вычисляемых полей в одну таблицу или меньшее число таблиц — по сути, переносит операции JOIN с момента выполнения запроса на этап вставки данных.
Этот процесс снижает необходимость сложных JOIN во время выполнения запроса и может существенно ускорить операции чтения, что делает его идеальным для приложений с высокой нагрузкой на чтение и сложными запросами. Однако он может увеличить сложность операций записи и обслуживания, поскольку любые изменения в дублированных данных должны быть распространены по всем их копиям для поддержания согласованности.

Распространённый приём, получивший популярность благодаря NoSQL-решениям, заключается в денормализации данных при отсутствии поддержки JOIN, фактически сохраняя всю статистику или связанные строки в родительской строке в виде столбцов и вложенных объектов. Например, в примерной схеме данных для блога мы можем хранить все Comments как Array объектов в соответствующих постах.
Когда использовать денормализацию
В целом мы рекомендуем использовать денормализацию в следующих случаях:
- Денормализуйте таблицы, которые изменяются редко, или для которых допустима задержка перед доступностью данных для аналитических запросов, то есть данные могут быть полностью перезагружены пакетно.
- Избегайте денормализации связей «многие ко многим». Это может привести к необходимости обновлять большое число строк при изменении одной исходной строки.
- Избегайте денормализации связей с высокой кардинальностью. Если каждая строка в таблице имеет тысячи связанных записей в другой таблице, их необходимо будет представить в виде
Array— либо примитивного типа, либо кортежей. Как правило, использование массивов с более чем 1000 кортежей не рекомендуется. - Вместо денормализации всех столбцов в виде вложенных объектов рассмотрите вариант денормализовать только статистику, используя материализованные представления (см. ниже).
Нет необходимости денормализовывать всю информацию — только ключевые данные, к которым нужен частый доступ.
Работу по денормализации можно выполнять либо в ClickHouse, либо в upstream‑системах, например с использованием Apache Flink.
Избегайте денормализации для часто обновляемых данных
В ClickHouse денормализация — это один из вариантов оптимизации производительности запросов, но применять её следует осторожно. Если данные часто меняются и их нужно обновлять почти в режиме реального времени, от этого подхода лучше отказаться. Используйте его, если основная таблица в основном только пополняется (append-only) или может периодически полностью перезагружаться пакетами, например ежедневно.
У этого подхода есть одна ключевая проблема — производительность записи и обновления данных. Более конкретно, денормализация по сути переносит выполнение join-ов с момента выполнения запроса на момент ингестии. Хотя это может значительно повысить производительность запросов, это усложняет ингестию и означает, что конвейеры данных должны повторно вставлять строку в ClickHouse, если изменилась любая из строк, использованных для её формирования. Это может означать, что изменение одной исходной строки потенциально приводит к необходимости обновления множества строк в ClickHouse. В сложных схемах, где строки формируются из сложных соединений, изменение одной строки во вложенном компоненте join-а может потенциально привести к необходимости обновления миллионов строк.
Добиться этого в реальном времени часто нереалистично и требует значительных инженерных усилий по двум причинам:
- Инициация корректных операторов join при изменении строки таблицы. В идеале это не должно приводить к обновлению всех объектов, участвующих в join-е, а только тех, которые были затронуты. Эффективная модификация join-ов так, чтобы они фильтровали только нужные строки и сохраняли производительность при высокой пропускной способности, требует внешних инструментов или дополнительных доработок.
- Обновления строк в ClickHouse необходимо тщательно контролировать, что добавляет дополнительную сложность.
Поэтому чаще используется пакетный процесс обновления, при котором все денормализованные объекты периодически перезагружаются.
Практические примеры денормализации
Рассмотрим несколько практических примеров, когда денормализация может иметь смысл, а также случаи, когда предпочтительнее использовать альтернативные подходы.
Предположим, у нас есть таблица Posts, которая уже денормализована и содержит статистику, такую как AnswerCount и CommentCount — исходные данные предоставляются в таком виде. На практике мы можем захотеть нормализовать эту информацию, так как она, вероятно, будет часто меняться. Многие из этих столбцов также доступны через другие таблицы, например, комментарии к посту доступны через столбец PostId и таблицу Comments. В рамках данного примера мы предполагаем, что посты перезагружаются пакетным процессом.
Мы также рассматриваем только денормализацию других таблиц в Posts, так как считаем её нашей основной таблицей для аналитики. Денормализация в обратном направлении также будет уместна для некоторых запросов, с учётом тех же соображений, описанных выше.
Для каждого из следующих примеров предположим, что существует запрос, которому требуется использовать обе таблицы в соединении.
Posts и Votes
Голосования по постам представлены отдельными таблицами. Оптимизированная схема для этого показана ниже, а также команда INSERT для загрузки данных:
На первый взгляд, эти поля можно рассматривать как кандидатов для денормализации в таблице posts. У такого подхода есть несколько сложностей.
Голоса к постам добавляются часто. Хотя со временем их количество в пересчёте на один пост может снижаться, следующий запрос показывает, что у нас около 40 тысяч голосов в час по 30 тысячам постов.
Эту проблему можно решить с помощью пакетной обработки, если допустима задержка, но при этом нам всё равно нужно обрабатывать обновления, если только мы не будем периодически перезагружать все посты (что вряд ли желательно).
Более серьёзная проблема в том, что у некоторых постов чрезвычайно большое количество голосов:
Основное наблюдение здесь заключается в том, что для большинства аналитических задач будет достаточно агрегированной статистики голосов для каждого поста — нам не нужно денормализовывать всю информацию о голосованиях. Например, текущий столбец Score представляет собой такую статистику, то есть общее количество голосов «за» минус голоса «против». В идеале нам бы хотелось иметь возможность получать эту статистику при выполнении запроса с помощью простого обращения по ключу (см. dictionaries).
Пользователи и бейджи
Теперь рассмотрим наши Users и Badges:

Сначала вставим данные следующей командой:
Хотя пользователи могут часто получать бейджи, маловероятно, что этот набор данных нам нужно будет обновлять чаще одного раза в день. Связь между бейджами и пользователями — «один ко многим». Может быть, мы просто денормализуем бейджи на пользователей как список кортежей? Хотя это возможно, быстрая проверка максимального количества бейджей на одного пользователя показывает, что это не лучший вариант:
Вряд ли реалистично денормализовать 19 000 объектов в одну строку. Эту связь, вероятно, лучше оставить в виде отдельных таблиц или добавить к ней статистику.
Мы можем захотеть денормализовать статистику из
badgesвusers, например количество значков. Мы рассмотрим такой пример при использовании словарей для этого набора данных на этапе вставки.
Posts и PostLinks
PostLinks соединяют Posts, которые пользователи считают связанными или дубликатами. Следующий запрос показывает схему и команду загрузки:
Можем подтвердить, что ни одна запись не содержит чрезмерного количества ссылок, которое бы препятствовало денормализации:
Аналогично, такие связи не относятся к часто происходящим событиям:
Мы будем использовать это в качестве примера денормализации ниже.
Простой статистический пример
В большинстве случаев денормализация сводится к добавлению одного столбца или статистики в родительскую строку. Например, мы можем просто захотеть обогатить наши посты числом их дубликатов, и для этого нам нужно лишь добавить столбец.
Чтобы заполнить эту таблицу, мы выполняем запрос INSERT INTO SELECT, объединяя таблицу со статистикой по дубликатам с таблицей постов.
Использование сложных типов для отношений «один-ко-многим»
Для выполнения денормализации нам часто необходимо использовать сложные типы. Если денормализуется отношение «один-к-одному» с небольшим количеством столбцов, пользователи могут просто добавить их как столбцы с их исходными типами, как показано выше. Однако для более крупных объектов это часто нежелательно, а для отношений «один-ко-многим» — и вовсе невозможно.
В случаях сложных объектов или отношений «один-ко-многим» пользователи могут использовать:
- именованные
Tuple— позволяют представить связанную структуру как набор столбцов; Array(Tuple)илиNested— массив именованныхTuple, также известный какNested, где каждая запись представляет объект. Применимо к отношениям «один-ко-многим».
В качестве примера ниже мы показываем, как денормализовать PostLinks на Posts.
Каждый пост может содержать несколько ссылок на другие посты, как показано ранее в схеме PostLinks. В виде типа Nested мы можем представить эти связанные и дублирующиеся посты следующим образом:
Обратите внимание на использование настройки
flatten_nested=0. Мы рекомендуем отключить развёртывание вложенных данных.
Мы можем выполнить эту денормализацию с помощью запроса INSERT INTO SELECT с OUTER JOIN:
Обратите внимание на время выполнения. Нам удалось денормализовать 66 млн строк примерно за 2 минуты. Как мы увидим позже, эту операцию можно запускать по расписанию.
Обратите внимание на использование функций groupArray для агрегирования PostLinks в массив для каждого PostId перед выполнением соединения. Этот массив затем фильтруется в два списка: LinkedPosts и DuplicatePosts, при этом из внешнего соединения также исключаются любые пустые результаты.
Мы можем выбрать несколько строк, чтобы увидеть нашу новую денормализованную структуру:
Оркестрация и планирование денормализации
Пакетная обработка
Эффективное использование денормализации требует процесса преобразования, в рамках которого её можно выполнять и оркестрировать.
Выше мы показали, как ClickHouse можно использовать для выполнения этого преобразования после загрузки данных с помощью INSERT INTO SELECT. Это подходит для периодических пакетных преобразований.
У пользователей есть несколько вариантов оркестрации этого процесса в ClickHouse при условии, что их устраивает периодическая пакетная загрузка данных:
- Обновляемые материализованные представления — обновляемые материализованные представления можно использовать для периодического запуска запроса с отправкой результатов в целевую таблицу. При выполнении запроса представление гарантирует атомарное обновление целевой таблицы. Это предоставляет встроенный в ClickHouse способ планирования этой работы.
- Внешние инструменты — использование таких инструментов, как dbt и Airflow, для периодического запуска преобразования. Интеграция ClickHouse с dbt гарантирует, что это выполняется атомарно: создаётся новая версия целевой таблицы, которая затем атомарно меняется местами с версией, обслуживающей запросы (через команду EXCHANGE).
Потоковая обработка
Пользователи могут также выполнять этот процесс вне ClickHouse, до вставки данных, используя потоковые технологии, такие как Apache Flink. Либо можно использовать инкрементальные материализованные представления для выполнения этого процесса по мере вставки данных.