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

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

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

Набор данных 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. Обратите внимание, что ниже мы используем глоб-шаблон *.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 это немного, пользователи с более медленным интернет-соединением могут захотеть загрузить только часть данных. Для этого достаточно указать годы, которые нужно загрузить, с помощью глоб-шаблона, например 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. Подробнее о том, как использовать глоб-шаблоны для выбора подмножеств файлов, см. здесь.

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

Один из секретов высокой производительности запросов к ClickHouse — сжатие. Чем меньше данных на диске, тем меньше I/O и тем быстрее выполняются запросы и вставка. Накладные расходы любого алгоритма сжатия на CPU в большинстве случаев с избытком компенсируются снижением объема I/O. Поэтому при работе над ускорением запросов к ClickHouse в первую очередь стоит улучшать сжатие данных.
Если вам интересно, почему ClickHouse так хорошо сжимает данные, рекомендуем эту статью. Кратко: ClickHouse как столбцовая база данных записывает значения по столбцам. Если эти значения отсортированы, одинаковые значения оказываются рядом друг с другом. Алгоритмы сжатия эффективно используют такие непрерывные шаблоны в данных. Кроме того, в ClickHouse есть кодеки и типы данных разной точности, которые позволяют дополнительно настраивать методы сжатия.
На сжатие в ClickHouse влияют 3 основных фактора: ключ сортировки, типы данных и используемые кодеки. Все это настраивается через схему. Наибольшего начального прироста в сжатии и производительности запросов можно добиться простым процессом оптимизации типов. Для оптимизации схемы можно применить несколько простых правил:
  • Используйте строгие типы - В нашей исходной схеме для многих столбцов, которые явно являются числовыми, использовался тип String. Использование корректных типов обеспечит ожидаемую семантику при фильтрации и агрегации. То же самое относится и к типам дат, которые уже корректно заданы в файлах 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, мы можем определить оптимальный тип для каждого столбца:
СтолбецЧисловойМин., макс.Уникальные значенияNULLКомментарийОптимизированный тип
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 — неиспользуемое значение, его можно использовать для NULLInt32
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 превосходит FixedString по производительностиLowCardinality(String)
ParentIdНет*20696028ДаСчитать NULL пустой строкойString
CommunityOwnedDateНет2008-08-12 04:59:35.017000000, 2024-04-01 05:36:41.380000000-ДаРекомендуется использовать значение по умолчанию 1970-01-01 для значений NULL. Миллисекундная точность не требуется, используйте DateTimeDateTime
ClosedDateНет2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000*ДаДля NULL рассмотрите значение по умолчанию 1970-01-01. Миллисекундная точность не требуется, используйте 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 фрагментами, называемыми частями, а затем к этим частям в фоне применяются правила слияния. В 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 строк в наборе. Elapsed: 0.070 sec. Processed 59.82 million rows, 569.21 MB (852.55 million rows/s., 8.11 GB/s.)
Пиковое потребление памяти: 429.38 MiB.
Запрос здесь выполняется очень быстро, хотя все 60 млн строк были линейно просканированы — ClickHouse просто быстрый :) Вам придётся поверить нам на слово: на масштабах TB и PB ключи сортировки действительно того стоят!
Давайте выберем столбцы PostTypeId и CreationDate в качестве ключей сортировки. Возможно, в нашем случае пользователи всегда будут фильтровать по PostTypeId. Этот столбец имеет мощность 8 и выглядит логичным выбором для первого элемента ключа сортировки. Поскольку фильтрации по дате, скорее всего, будет достаточно (хотя это всё равно поможет и с фильтрами по дате и времени), в качестве 2-го компонента ключа мы используем toDate(CreationDate). Это также позволит создать индекс меньшего размера, так как дату можно представить 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'

--заполнить таблицу из существующей таблицы

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.
Наш предыдущий запрос сокращает время ответа более чем в 3 раза:
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.)
Если вас интересует, какого улучшения сжатия можно добиться за счёт использования определённых типов данных и подходящих ключей сортировки, см. Сжатие в ClickHouse. Если вам нужно дополнительно повысить степень сжатия, рекомендуем также раздел Выбор подходящего кодека сжатия для столбца.

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

До этого момента мы мигрировали только одну таблицу. Хотя это позволило познакомить вас с некоторыми базовыми концепциями ClickHouse, большинство схем, к сожалению, устроены сложнее. В других руководствах, перечисленных ниже, мы рассмотрим несколько подходов к перестройке более широкой схемы для оптимального выполнения запросов в ClickHouse. На протяжении всего этого процесса мы стремимся сохранить Posts как центральную таблицу, через которую будет выполняться большинство аналитических запросов. Хотя остальные таблицы по-прежнему можно запрашивать отдельно, мы исходим из того, что основная часть аналитики будет выполняться в контексте posts.
В этом разделе мы используем оптимизированные варианты других таблиц. Хотя мы приводим их схемы, ради краткости не останавливаемся на принятых решениях. Они основаны на правилах, описанных ранее, и выводы о том, почему были выбраны именно такие решения, мы оставляем читателю.
Все приведенные ниже подходы направлены на то, чтобы свести к минимуму необходимость использовать JOIN, оптимизировать чтение и повысить производительность запросов. Хотя JOIN в ClickHouse полностью поддерживаются, для достижения наилучшей производительности мы рекомендуем использовать их умеренно (JOIN-запросы с 2–3 таблицами — это нормально).
В ClickHouse нет понятия внешних ключей. Это не запрещает JOIN, но означает, что управление ссылочной целостностью остается на стороне пользователя и реализуется на уровне приложения. В OLAP-системах, таких как ClickHouse, целостность данных часто обеспечивается на уровне приложения или в процессе ингестии данных, а не самой базой данных, где это создавало бы существенные накладные расходы. Такой подход дает больше гибкости и ускоряет вставку данных. Это соответствует ориентации ClickHouse на скорость и масштабируемость запросов на чтение и вставку при работе с очень большими наборами данных.
Чтобы свести к минимуму использование JOIN на этапе выполнения запроса, у пользователей есть несколько инструментов и подходов:
  • Денормализация данных - Денормализуйте данные, объединяя таблицы и используя сложные типы для связей, отличных от 1:1. Это часто означает перенос JOIN с этапа выполнения запроса на этап вставки.
  • Dictionaries - Специфичная для ClickHouse возможность для direct joins и поиска по ключу-значению.
  • Incremental Materialized Views - Возможность ClickHouse, позволяющая перенести вычислительную нагрузку с этапа выполнения запроса на этап вставки, включая инкрементальное вычисление агрегированных значений.
  • Refreshable Materialized Views - Подобно materialized view в других СУБД, позволяет периодически вычислять результаты запроса и кэшировать их.
В каждом из руководств мы рассматриваем один из этих подходов, показывая, когда он уместен, и приводя пример того, как его можно применить для решения задач на наборе данных Stack Overflow.
Последнее изменение 10 июня 2026 г.