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

Проектирование схемы

Понимание эффективного проектирования схемы является ключом к оптимизации производительности ClickHouse и включает в себя выбор, который часто связан с компромиссами, при этом оптимальный подход зависит от обслуживаемых запросов, а также от таких факторов, как частота обновления данных, требования к задержке и объем данных. Это руководство предоставляет обзор лучших практик проектирования схем и техник моделирования данных для оптимизации производительности ClickHouse.

Набор данных Stack Overflow

Для примеров в этом руководстве мы используем подмножество набора данных Stack Overflow. Он содержит все посты, голоса, пользователей, комментарии и значки, которые появились на Stack Overflow с 2008 года по апрель 2024 года. Эти данные доступны в формате Parquet по схемам, представленным ниже, в корзине S3 s3://datasets-documentation/stackoverflow/parquet/:

Первичные ключи и связи, указанные здесь, не применяются через ограничения (Parquet — это формат файла, а не таблицы) и только указывают, как данные связаны и какие уникальные ключи они имеют.

Схема Stack Overflow

Набор данных Stack Overflow содержит ряд связанных таблиц. В любой задаче моделирования данных мы рекомендуем пользователям сначала сосредоточиться на загрузке основной таблицы. Это необязательно самая большая таблица, а скорее та, для которой вы ожидаете получать больше всего аналитических запросов. Это позволит вам ознакомиться с основными концепциями и типами ClickHouse, что особенно важно, если вы пришли из преимущественно OLTP-среды. Эта таблица может потребовать перемоделирования по мере добавления дополнительных таблиц для полного использования функций ClickHouse и достижения оптимальной производительности.

Приведённая выше схема намеренно не является оптимальной для целей данного руководства.

Создание начальной схемы

Поскольку таблица posts будет целью большинства аналитических запросов, мы сосредоточимся на создании схемы для этой таблицы. Эти данные доступны в публичной корзине S3 s3://datasets-documentation/stackoverflow/parquet/posts/*.parquet с файлом для каждого года.

Загрузка данных из S3 в формате Parquet представляет собой наиболее распространенный и предпочтительный способ загрузки данных в ClickHouse. ClickHouse оптимизирован для обработки Parquet и потенциально может читать и вставлять десятки миллионов строк из S3 в секунду.

ClickHouse предоставляет возможность автоматического определения схемы для автоматической идентификации типов набора данных. Это поддерживается для всех форматов данных, включая Parquet. Мы можем использовать эту функцию для определения типов ClickHouse для данных через табличную функцию s3 и команду DESCRIBE. Обратите внимание, что ниже мы используем glob-шаблон *.parquet для чтения всех файлов в папке stackoverflow/parquet/posts.

DESCRIBE TABLE s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet')
SETTINGS describe_compact_output = 1

┌─name──────────────────┬─type───────────────────────────┐
│ Id                    │ Nullable(Int64)               │
│ PostTypeId            │ Nullable(Int64)               │
│ AcceptedAnswerId      │ Nullable(Int64)               │
│ CreationDate          │ Nullable(DateTime64(3, 'UTC')) │
│ Score                 │ Nullable(Int64)               │
│ ViewCount             │ Nullable(Int64)               │
│ Body                  │ Nullable(String)              │
│ OwnerUserId           │ Nullable(Int64)               │
│ OwnerDisplayName      │ Nullable(String)              │
│ LastEditorUserId      │ Nullable(Int64)               │
│ LastEditorDisplayName │ Nullable(String)              │
│ LastEditDate          │ Nullable(DateTime64(3, 'UTC')) │
│ LastActivityDate      │ Nullable(DateTime64(3, 'UTC')) │
│ Title                 │ Nullable(String)              │
│ Tags                  │ Nullable(String)              │
│ AnswerCount           │ Nullable(Int64)               │
│ CommentCount          │ Nullable(Int64)               │
│ FavoriteCount         │ Nullable(Int64)               │
│ ContentLicense        │ Nullable(String)              │
│ ParentId              │ Nullable(String)              │
│ CommunityOwnedDate    │ Nullable(DateTime64(3, 'UTC')) │
│ ClosedDate            │ Nullable(DateTime64(3, 'UTC')) │
└───────────────────────┴────────────────────────────────┘

Табличная функция s3 позволяет запрашивать данные в S3 непосредственно из ClickHouse. Эта функция совместима со всеми форматами файлов, которые поддерживает ClickHouse.

Это формирует для нас начальную, не оптимизированную схему. По умолчанию ClickHouse отображает их в соответствующие типы Nullable. Мы можем создать таблицу ClickHouse, используя эти типы, с помощью простой команды CREATE EMPTY AS SELECT.

CREATE TABLE posts
ENGINE = MergeTree
ORDER BY () EMPTY AS
SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet')

Несколько важных моментов:

Наша таблица posts пуста после выполнения этой команды. Никакие данные не были загружены. Мы указали MergeTree в качестве движка таблицы. MergeTree — это наиболее распространенный движок таблиц ClickHouse, который вы, вероятно, будете использовать. Это универсальный инструмент в вашем арсенале ClickHouse, способный обрабатывать петабайты данных и обслуживать большинство аналитических случаев использования. Другие движки таблиц существуют для таких случаев использования, как CDC, которым необходима поддержка эффективных обновлений.

Предложение ORDER BY () означает, что у нас нет индекса и, более конкретно, нет порядка в наших данных. Подробнее об этом позже. Пока просто знайте, что для всех запросов потребуется линейное сканирование.

Чтобы подтвердить создание таблицы:

SHOW CREATE TABLE posts

CREATE TABLE posts
(
        `Id` Nullable(Int64),
        `PostTypeId` Nullable(Int64),
        `AcceptedAnswerId` Nullable(Int64),
        `CreationDate` Nullable(DateTime64(3, 'UTC')),
        `Score` Nullable(Int64),
        `ViewCount` Nullable(Int64),
        `Body` Nullable(String),
        `OwnerUserId` Nullable(Int64),
        `OwnerDisplayName` Nullable(String),
        `LastEditorUserId` Nullable(Int64),
        `LastEditorDisplayName` Nullable(String),
        `LastEditDate` Nullable(DateTime64(3, 'UTC')),
        `LastActivityDate` Nullable(DateTime64(3, 'UTC')),
        `Title` Nullable(String),
        `Tags` Nullable(String),
        `AnswerCount` Nullable(Int64),
        `CommentCount` Nullable(Int64),
        `FavoriteCount` Nullable(Int64),
        `ContentLicense` Nullable(String),
        `ParentId` Nullable(String),
        `CommunityOwnedDate` Nullable(DateTime64(3, 'UTC')),
        `ClosedDate` Nullable(DateTime64(3, 'UTC'))
)
ENGINE = MergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')
ORDER BY tuple()

После определения начальной схемы мы можем заполнить данные с помощью INSERT INTO SELECT, считывая данные с помощью табличной функции s3. Следующая загрузка данных posts занимает около 2 минут на 8-ядерном экземпляре ClickHouse Cloud.

INSERT INTO posts SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet')

0 rows in set. Elapsed: 148.140 sec. Processed 59.82 million rows, 38.07 GB (403.80 thousand rows/s., 257.00 MB/s.)

Приведённый выше запрос загружает 60 миллионов строк. Хотя это небольшой объём для ClickHouse, пользователи с более медленным интернет-соединением могут предпочесть загрузить подмножество данных. Это можно сделать, просто указав годы, которые нужно загрузить, с помощью glob-шаблона, например https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/2008.parquet или https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/{2008, 2009}.parquet. См. здесь, как glob-шаблоны можно использовать для выбора подмножеств файлов.

Оптимизация типов

Один из секретов производительности запросов ClickHouse — это сжатие.

Меньше данных на диске означает меньше операций ввода-вывода и, следовательно, более быстрые запросы и вставки. Накладные расходы любого алгоритма сжатия в отношении CPU в большинстве случаев будут перевешены сокращением операций ввода-вывода. Поэтому улучшение сжатия данных должно быть первым фокусом при работе над обеспечением быстрых запросов ClickHouse.

Чтобы понять, почему ClickHouse так хорошо сжимает данные, мы рекомендуем эту статью. Вкратце, как столбцовая база данных, значения будут записываться в порядке столбцов. Если эти значения отсортированы, одинаковые значения будут находиться рядом друг с другом. Алгоритмы сжатия используют непрерывные шаблоны данных. Кроме того, ClickHouse имеет кодеки и детализированные типы данных, которые позволяют пользователям дополнительно настраивать методы сжатия.

На сжатие в ClickHouse будут влиять 3 основных фактора: ключ сортировки, типы данных и любые используемые кодеки. Все это настраивается через схему.

Наибольшее первоначальное улучшение сжатия и производительности запросов можно получить с помощью простого процесса оптимизации типов. Можно применить несколько простых правил для оптимизации схемы:

  • Используйте строгие типы - Наша начальная схема использовала строки для многих столбцов, которые явно являются числовыми. Использование правильных типов обеспечит ожидаемую семантику при фильтрации и агрегировании. То же самое относится к типам дат, которые были правильно предоставлены в файлах Parquet.
  • Избегайте Nullable столбцов - По умолчанию приведенные выше столбцы предполагались Null. Тип Nullable позволяет запросам определять разницу между пустым значением и значением Null. Это создает отдельный столбец типа UInt8. Этот дополнительный столбец должен обрабатываться каждый раз, когда пользователь работает со столбцом nullable. Это приводит к дополнительному использованию пространства хранения и почти всегда негативно влияет на производительность запросов. Используйте Nullable только в том случае, если есть разница между пустым значением по умолчанию для типа и значением Null. Например, значение 0 для пустых значений в столбце ViewCount, вероятно, будет достаточным для большинства запросов и не повлияет на результаты. Если пустые значения должны обрабатываться по-другому, их также часто можно исключить из запросов с помощью фильтра.
  • Используйте минимальную точность для числовых типов - ClickHouse имеет ряд числовых типов, предназначенных для различных числовых диапазонов и точности. Всегда стремитесь минимизировать количество битов, используемых для представления столбца. Помимо целых чисел разного размера, например Int16, ClickHouse предлагает беззнаковые варианты, минимальное значение которых равно 0. Они могут позволить использовать меньше битов для столбца, например, UInt16 имеет максимальное значение 65535, что в два раза больше, чем у Int16. Предпочитайте эти типы более крупным знаковым вариантам, если это возможно.
  • Минимальная точность для типов дат - ClickHouse поддерживает ряд типов дат и дат-времени. Date и Date32 могут использоваться для хранения чистых дат, причем последний поддерживает больший диапазон дат за счет большего количества битов. DateTime и DateTime64 обеспечивают поддержку дат-времени. DateTime ограничен детализацией до секунды и использует 32 бита. DateTime64, как следует из названия, использует 64 бита, но обеспечивает поддержку с точностью до наносекунды. Как всегда, выбирайте более грубую версию, приемлемую для запросов, минимизируя количество необходимых битов.
  • Используйте LowCardinality - Столбцы с числами, строками, Date или DateTime с небольшим количеством уникальных значений потенциально могут быть закодированы с использованием типа LowCardinality. Это кодирует значения в словарь, уменьшая размер на диске. Рассмотрите это для столбцов с менее чем 10 тысячами уникальных значений.
  • FixedString для особых случаев - Строки с фиксированной длиной могут быть закодированы типом FixedString, например, коды языков и валют. Это эффективно, когда данные имеют длину ровно N байтов. Во всех остальных случаях это, вероятно, снизит эффективность, и предпочтительнее использовать LowCardinality.
  • Enum для проверки данных - Тип Enum можно использовать для эффективного кодирования перечисляемых типов. Enum может быть 8 или 16 бит в зависимости от количества уникальных значений, которые они должны хранить. Рассмотрите возможность использования этого, если вам нужна либо связанная проверка во время вставки (необъявленные значения будут отклонены), либо вы хотите выполнять запросы, использующие естественный порядок значений Enum, например, представьте столбец обратной связи, содержащий ответы пользователей Enum(':(' = 1, ':|' = 2, ':)' = 3).

Совет: Чтобы найти диапазон всех столбцов и количество различных значений, пользователи могут использовать простой запрос SELECT * APPLY min, * APPLY max, * APPLY uniq FROM table FORMAT Vertical. Мы рекомендуем выполнять это на меньшем подмножестве данных, так как это может быть дорогостоящим. Этот запрос требует, чтобы числовые значения были хотя бы определены как таковые для получения точного результата, то есть не как String.

Применяя эти простые правила к нашей таблице posts, мы можем определить оптимальный тип для каждого столбца:

СтолбецЧисловойМин, МаксУникальные значенияNullsКомментарийОптимизированный тип
PostTypeIdДа1, 88НетEnum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8)
AcceptedAnswerIdДа0, 7828517012282094ДаРазличать Null со значением 0UInt32
CreationDateНет2008-07-31 21:42:52.667000000, 2024-03-31 23:59:17.697000000-НетДетализация миллисекунд не требуется, использовать DateTimeDateTime
ScoreДа-217, 349703236НетInt32
ViewCountДа2, 13962748170867НетUInt32
BodyНет--НетString
OwnerUserIdДа-1, 40569156256237ДаInt32
OwnerDisplayNameНет-181251ДаСчитать Null пустой строкойString
LastEditorUserIdДа-1, 99999931104694Да0 — неиспользуемое значение, может использоваться для NullsInt32
LastEditorDisplayNameНет-70952ДаСчитать Null пустой строкой. Тестировали LowCardinality без преимуществString
LastEditDateНет2008-08-01 13:24:35.051000000, 2024-04-06 21:01:22.697000000-НетДетализация миллисекунд не требуется, использовать DateTimeDateTime
LastActivityDateНет2008-08-01 12:19:17.417000000, 2024-04-06 21:01:22.697000000-НетДетализация миллисекунд не требуется, использовать DateTimeDateTime
TitleНет--НетСчитать Null пустой строкойString
TagsНет--НетСчитать Null пустой строкойString
AnswerCountДа0, 518216НетСчитать Null и 0 одинаковымиUInt16
CommentCountДа0, 135100НетСчитать Null и 0 одинаковымиUInt8
FavoriteCountДа0, 2256ДаСчитать Null и 0 одинаковымиUInt8
ContentLicenseНет-3НетLowCardinality превосходит FixedStringLowCardinality(String)
ParentIdНет-20696028ДаСчитать Null пустой строкойString
CommunityOwnedDateНет2008-08-12 04:59:35.017000000, 2024-04-01 05:36:41.380000000-ДаСчитать значением по умолчанию 1970-01-01 для Nulls. Детализация миллисекунд не требуется, использовать DateTimeDateTime
ClosedDateНет2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000-ДаСчитать значением по умолчанию 1970-01-01 для Nulls. Детализация миллисекунд не требуется, использовать DateTimeDateTime

Приведенное выше дает нам следующую схему:

CREATE TABLE posts_v2
(
   `Id` Int32,
   `PostTypeId` Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
   `AcceptedAnswerId` UInt32,
   `CreationDate` DateTime,
   `Score` Int32,
   `ViewCount` UInt32,
   `Body` String,
   `OwnerUserId` Int32,
   `OwnerDisplayName` String,
   `LastEditorUserId` Int32,
   `LastEditorDisplayName` String,
   `LastEditDate` DateTime,
   `LastActivityDate` DateTime,
   `Title` String,
   `Tags` String,
   `AnswerCount` UInt16,
   `CommentCount` UInt8,
   `FavoriteCount` UInt8,
   `ContentLicense`LowCardinality(String),
   `ParentId` String,
   `CommunityOwnedDate` DateTime,
   `ClosedDate` DateTime
)
ENGINE = MergeTree
ORDER BY tuple()
COMMENT 'Optimized types'

Мы можем заполнить эту таблицу простым INSERT INTO SELECT, прочитав данные из нашей предыдущей таблицы и вставив их в неё:

INSERT INTO posts_v2 SELECT * FROM posts

0 rows in set. Elapsed: 146.471 sec. Processed 59.82 million rows, 83.82 GB (408.40 thousand rows/s., 572.25 MB/s.)

Мы не храним значения NULL в нашей новой схеме. Приведенная выше операция вставки неявно преобразует их в значения по умолчанию для соответствующих типов — 0 для целых чисел и пустое значение для строк. ClickHouse также автоматически преобразует все числовые значения к их целевой точности. Первичные (упорядочивающие) ключи в ClickHouse Пользователи, переходящие с OLTP-баз данных, часто ищут аналогичную концепцию в ClickHouse.

Выбор ключа сортировки

В масштабе, в котором часто используется ClickHouse, эффективность памяти и диска имеет первостепенное значение. Данные записываются в таблицы ClickHouse фрагментами, известными как части (parts), с правилами, применяемыми для слияния частей в фоновом режиме. В ClickHouse каждая часть имеет свой собственный первичный индекс. Когда части объединяются, первичные индексы объединенной части также объединяются. Первичный индекс для части имеет одну запись индекса на группу строк — этот метод называется разреженным индексированием.

Разреженное индексирование в ClickHouse

Выбранный ключ в ClickHouse будет определять не только индекс, но и порядок, в котором данные записываются на диск. Из-за этого он может значительно влиять на уровни сжатия, что, в свою очередь, может влиять на производительность запросов. Ключ сортировки, который заставляет значения большинства столбцов записываться в смежном порядке, позволит выбранному алгоритму сжатия (и кодекам) сжимать данные более эффективно.

Все столбцы в таблице будут отсортированы на основе значения указанного ключа сортировки, независимо от того, включены ли они в сам ключ. Например, если CreationDate используется в качестве ключа, порядок значений во всех других столбцах будет соответствовать порядку значений в столбце CreationDate. Можно указать несколько ключей сортировки — это будет упорядочено с той же семантикой, что и предложение ORDER BY в запросе SELECT.

Можно применить некоторые простые правила, чтобы помочь выбрать ключ сортировки. Следующие правила иногда могут конфликтовать, поэтому рассматривайте их по порядку. Пользователи могут определить несколько ключей из этого процесса, при этом обычно достаточно 4-5:

  • Выберите столбцы, которые соответствуют вашим общим фильтрам. Если столбец часто используется в предложениях WHERE, отдайте приоритет включению их в ваш ключ над теми, которые используются реже.
  • Предпочитайте столбцы, которые помогают исключить большой процент от общего количества строк при фильтрации, тем самым уменьшая объем данных, которые необходимо прочитать.
  • Предпочитайте столбцы, которые, вероятно, сильно коррелируют с другими столбцами в таблице. Это поможет обеспечить смежное хранение этих значений, улучшая сжатие.
  • Операции GROUP BY и ORDER BY для столбцов в ключе сортировки могут быть более эффективными с точки зрения памяти.

При определении подмножества столбцов для ключа сортировки объявите столбцы в определенном порядке. Этот порядок может существенно повлиять как на эффективность фильтрации по столбцам вторичного ключа в запросах, так и на коэффициент сжатия файлов данных таблицы. В общем, лучше всего упорядочивать ключи в порядке возрастания кардинальности. Это следует сбалансировать с тем фактом, что фильтрация по столбцам, которые появляются позже в ключе сортировки, будет менее эффективной, чем фильтрация по тем, которые появляются раньше в кортеже. Сбалансируйте эти поведения и рассмотрите свои шаблоны доступа (и, что наиболее важно, тестируйте варианты).

Пример

Применяя приведенные выше рекомендации к нашей таблице posts, предположим, что наши пользователи хотят выполнять анализ с фильтрацией по дате и типу поста, например:

«У каких вопросов было больше всего комментариев за последние 3 месяца».

Запрос для этого с использованием нашей предыдущей таблицы posts_v2 с оптимизированными типами, но без ключа сортировки:

SELECT
    Id,
    Title,
    CommentCount
FROM posts_v2
WHERE (CreationDate >= '2024-01-01') AND (PostTypeId = 'Question')
ORDER BY CommentCount DESC
LIMIT 3

┌───────Id─┬─Title─────────────────────────────────────────────────────────────┬─CommentCount─┐
│ 78203063 │ How to avoid default initialization of objects in std::vector?     │               74 │
│ 78183948 │ About memory barrier                                               │               52 │
│ 77900279 │ Speed Test for Buffer Alignment: IBM's PowerPC results vs. my CPU │        49 │
└──────────┴───────────────────────────────────────────────────────────────────┴──────────────

10 rows in set. Elapsed: 0.070 sec. Processed 59.82 million rows, 569.21 MB (852.55 million rows/s., 8.11 GB/s.)
Peak memory usage: 429.38 MiB.

Запрос выполняется очень быстро, хотя все 60 млн строк были линейно просканированы — ClickHouse просто очень быстрый :) Поверьте, на тера- и петабайтных масштабах ключи сортировки действительно себя оправдывают!

Давайте выберем столбцы PostTypeId и CreationDate в качестве ключей сортировки.

Возможно, в нашем случае мы ожидаем, что пользователи всегда будут фильтровать по PostTypeId. У него кардинальность 8, и он представляет собой логический выбор для первой записи в нашем ключе сортировки. Признавая, что фильтрации с детализацией до даты, вероятно, будет достаточно (это все равно принесет пользу фильтрам datetime), мы используем toDate(CreationDate) в качестве 2-го компонента нашего ключа. Это также создаст меньший индекс, так как дата может быть представлена 16 битами, ускоряя фильтрацию. Наша последняя запись ключа — это CommentCount для помощи в поиске постов с наибольшим количеством комментариев (окончательная сортировка).

CREATE TABLE posts_v3
(
        `Id` Int32,
        `PostTypeId` Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
        `AcceptedAnswerId` UInt32,
        `CreationDate` DateTime,
        `Score` Int32,
        `ViewCount` UInt32,
        `Body` String,
        `OwnerUserId` Int32,
        `OwnerDisplayName` String,
        `LastEditorUserId` Int32,
        `LastEditorDisplayName` String,
        `LastEditDate` DateTime,
        `LastActivityDate` DateTime,
        `Title` String,
        `Tags` String,
        `AnswerCount` UInt16,
        `CommentCount` UInt8,
        `FavoriteCount` UInt8,
        `ContentLicense` LowCardinality(String),
        `ParentId` String,
        `CommunityOwnedDate` DateTime,
        `ClosedDate` DateTime
)
ENGINE = MergeTree
ORDER BY (PostTypeId, toDate(CreationDate), CommentCount)
COMMENT 'Ordering Key'

--populate table from existing table

INSERT INTO posts_v3 SELECT * FROM posts_v2

0 rows in set. Elapsed: 158.074 sec. Processed 59.82 million rows, 76.21 GB (378.42 thousand rows/s., 482.14 MB/s.)
Peak memory usage: 6.41 GiB.

Our previous query improves the query response time by over 3x:

SELECT
    Id,
    Title,
    CommentCount
FROM posts_v3
WHERE (CreationDate >= '2024-01-01') AND (PostTypeId = 'Question')
ORDER BY CommentCount DESC
LIMIT 3

10 rows in set. Elapsed: 0.020 sec. Processed 290.09 thousand rows, 21.03 MB (14.65 million rows/s., 1.06 GB/s.)

Для пользователей, которые интересуются улучшением сжатия, достигаемым за счёт использования определённых типов и подходящих ключей сортировки, см. раздел Compression in ClickHouse. Если вам нужно ещё больше повысить степень сжатия, мы также рекомендуем раздел Choosing the right column compression codec.

Далее: техники моделирования данных

До этого момента мы мигрировали только одну таблицу. Хотя это позволило нам познакомиться с некоторыми базовыми концепциями ClickHouse, большинство схем, к сожалению, не настолько просты.

В других руководствах, перечисленных ниже, мы рассмотрим ряд техник реструктуризации нашей общей схемы для оптимального выполнения запросов в ClickHouse. На протяжении этого процесса мы стремимся к тому, чтобы Posts оставалась нашей центральной таблицей, через которую выполняется большинство аналитических запросов. Хотя к другим таблицам по‑прежнему можно обращаться изолированно, мы предполагаем, что основная часть аналитики будет выполняться в контексте Posts.

В этом разделе мы используем оптимизированные варианты наших других таблиц. Хотя мы предоставляем их схемы, ради краткости мы опускаем объяснение принятых решений. Они основаны на правилах, описанных ранее, и мы оставляем вывод этих решений читателю.

Все описанные ниже подходы нацелены на минимизацию необходимости использования JOIN для оптимизации чтения и повышения производительности запросов. Хотя JOIN полностью поддерживается в ClickHouse, мы рекомендуем использовать его умеренно (JOIN‑запрос с 2–3 таблицами приемлем), чтобы добиться оптимальной производительности.

В ClickHouse отсутствует понятие внешних ключей. Это не запрещает JOIN, но означает, что поддержание ссылочной целостности перекладывается на пользователя, который должен управлять ею на уровне приложения. В OLAP‑системах, таких как ClickHouse, целостность данных часто обеспечивается на уровне приложения или в процессе ингестии данных, а не принудительно на уровне самой базы данных, где это создает значительные накладные расходы. Такой подход обеспечивает большую гибкость и более быструю вставку данных. Это соответствует ориентации ClickHouse на скорость и масштабируемость выполнения запросов на чтение и вставку для очень больших наборов данных.

Чтобы минимизировать использование JOIN на этапе выполнения запросов, у пользователей есть несколько инструментов/подходов:

  • Денормализация данных — Денормализуйте данные, объединяя таблицы и используя сложные типы для связей, отличающихся от 1:1. Это часто включает перенос любых JOIN с момента выполнения запроса на момент вставки.
  • Dictionaries — Специфичная для ClickHouse возможность для обработки прямых JOIN и поиска по ключу.
  • Incremental Materialized Views — Возможность ClickHouse, позволяющая перенести стоимость вычислений с момента выполнения запроса на момент вставки, включая возможность инкрементального вычисления агрегированных значений.
  • Refreshable Materialized Views — Аналог materialized view, используемых в других системах управления базами данных; позволяет периодически выполнять запрос и кэшировать его результат.

Мы рассматриваем каждый из этих подходов в отдельных руководствах, показывая, когда какой из них уместен, на примере того, как его можно применить для решения задач на наборе данных Stack Overflow.