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

Сжатие в ClickHouse

Одним из секретов высокой производительности запросов в ClickHouse является сжатие.

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

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

На сжатие в ClickHouse влияют три основных фактора:

  • ключ сортировки;
  • типы данных;
  • используемые кодеки.

Все они настраиваются в схеме.

Выберите подходящий тип данных для оптимизации сжатия

В качестве примера возьмём датасет Stack Overflow. Сравним статистику сжатия для следующих схем таблицы posts:

  • posts — схема без оптимизации типов и без ключа сортировки.
  • posts_v3 — схема с оптимизированными типами и разрядностью для каждого столбца и с ключом сортировки (PostTypeId, toDate(CreationDate), CommentCount).

Используя следующие запросы, мы можем измерить фактический размер данных в сжатом и несжатом виде для каждого столбца. Рассмотрим размер исходной оптимизированной схемы posts без ключа сортировки.

SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'posts'
GROUP BY name

┌─name──────────────────┬─compressed_size─┬─uncompressed_size─┬───ratio────┐
│ Body                  │ 46.14 GiB       │ 127.31 GiB        │ 2.76       │
│ Title                 │ 1.20 GiB        │ 2.63 GiB          │ 2.19       │
│ Score                 │ 84.77 MiB       │ 736.45 MiB        │ 8.69       │
│ Tags                  │ 475.56 MiB      │ 1.40 GiB          │ 3.02       │
│ ParentId              │ 210.91 MiB      │ 696.20 MiB        │ 3.3        │
│ Id                    │ 111.17 MiB      │ 736.45 MiB        │ 6.62       │
│ AcceptedAnswerId      │ 81.55 MiB       │ 736.45 MiB        │ 9.03       │
│ ClosedDate            │ 13.99 MiB       │ 517.82 MiB        │ 37.02      │
│ LastActivityDate      │ 489.84 MiB      │ 964.64 MiB        │ 1.97       │
│ CommentCount          │ 37.62 MiB       │ 565.30 MiB        │ 15.03      │
│ OwnerUserId           │ 368.98 MiB      │ 736.45 MiB        │ 2          │
│ AnswerCount           │ 21.82 MiB       │ 622.35 MiB        │ 28.53      │
│ FavoriteCount         │ 280.95 KiB      │ 508.40 MiB        │ 1853.02    │
│ ViewCount             │ 95.77 MiB       │ 736.45 MiB        │ 7.69       │
│ LastEditorUserId      │ 179.47 MiB      │ 736.45 MiB        │ 4.1        │
│ ContentLicense        │ 5.45 MiB        │ 847.92 MiB        │ 155.5      │
│ OwnerDisplayName      │ 14.30 MiB       │ 142.58 MiB        │ 9.97       │
│ PostTypeId            │ 20.93 MiB       │ 565.30 MiB        │ 27         │
│ CreationDate          │ 314.17 MiB      │ 964.64 MiB        │ 3.07       │
│ LastEditDate          │ 346.32 MiB      │ 964.64 MiB        │ 2.79       │
│ LastEditorDisplayName │ 5.46 MiB        │ 124.25 MiB        │ 22.75      │
│ CommunityOwnedDate    │ 2.21 MiB        │ 509.60 MiB        │ 230.94     │
└───────────────────────┴─────────────────┴───────────────────┴────────────┘
Замечание о компактных и широких частях

Если вы видите значения compressed_size или uncompressed_size, равные 0, это может быть связано с тем, что тип частей — compact, а не wide (см. описание part_type в system.parts). Формат части определяется настройками min_bytes_for_wide_part и min_rows_for_wide_part, то есть если вставленные данные приводят к созданию части, размер которой не превышает значения указанных настроек, часть будет компактной, а не широкой, и вы не увидите значения для compressed_size или uncompressed_size.

Для примера:

-- Создать таблицу с компактными частями
CREATE TABLE compact (
  number UInt32
)
ENGINE = MergeTree()
ORDER BY number 
AS SELECT * FROM numbers(100000); -- Недостаточно большая, чтобы превысить значение по умолчанию min_bytes_for_wide_part = 10485760

-- Проверить тип частей
SELECT table, name, part_type from system.parts where table = 'compact';

-- Получить сжатые и несжатые размеры столбцов для компактной таблицы
SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'compact'
GROUP BY name;

-- Создать таблицу с широкими частями 
CREATE TABLE wide (
  number UInt32
)
ENGINE = MergeTree()
ORDER BY number
SETTINGS min_bytes_for_wide_part=0
AS SELECT * FROM numbers(100000);

-- Проверить тип частей
SELECT table, name, part_type from system.parts where table = 'wide';

-- Получить сжатые и несжатые размеры для широкой таблицы
SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'wide'
GROUP BY name;
   ┌─table───┬─name──────┬─part_type─┐
1. │ compact │ all_1_1_0 │ Compact   │
   └─────────┴───────────┴───────────┘
   ┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 0.00 B          │ 0.00 B            │   nan │
   └────────┴─────────────────┴───────────────────┴───────┘
   ┌─table─┬─name──────┬─part_type─┐
1. │ wide  │ all_1_1_0 │ Wide      │
   └───────┴───────────┴───────────┘
   ┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 392.31 KiB      │ 390.63 KiB        │     1 │
   └────────┴─────────────────┴───────────────────┴───────┘

Здесь мы показываем как сжатый, так и несжатый размер. Оба важны. Сжатый размер соответствует объему данных, который потребуется прочитать с диска — то, что мы стремимся минимизировать для повышения производительности запросов (и снижения стоимости хранения). Эти данные необходимо распаковать перед чтением. Размер несжатых данных в данном случае будет зависеть от используемого типа данных. Минимизация этого размера уменьшит накладные расходы по памяти для запросов и объем данных, который должен быть обработан запросом, улучшая использование кэшей и, в конечном итоге, время выполнения запросов.

Приведенный выше запрос использует таблицу columns в системной базе данных. Эта база управляется ClickHouse и является кладезем полезной информации — от метрик производительности запросов до фоновых логов кластера. Мы рекомендуем материал "System Tables and a Window into the Internals of ClickHouse" и сопутствующие статьи[1][2] для заинтересованных читателей.

Чтобы получить общий размер таблицы, мы можем упростить приведённый выше запрос:

SELECT formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'posts'

┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 50.16 GiB       │ 143.47 GiB        │  2.86 │
└─────────────────┴───────────────────┴───────┘

Если повторить этот запрос для posts_v3 — таблицы с оптимизированным типом и ключом сортировки, — мы увидим значительное уменьшение как несжатого, так и сжатого объёма данных.

SELECT
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'

┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 25.15 GiB       │ 68.87 GiB         │  2.74 │
└─────────────────┴───────────────────┴───────┘

Подробный разбор по столбцам показывает существенную экономию для столбцов Body, Title, Tags и CreationDate, достигнутую за счёт упорядочивания данных перед сжатием и использования подходящих типов.

SELECT
    name,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'
GROUP BY name

┌─name──────────────────┬─compressed_size─┬─uncompressed_size─┬───ratio─┐
│ Body                  │ 23.10 GiB       │ 63.63 GiB         │    2.75 │
│ Title                 │ 614.65 MiB      │ 1.28 GiB          │    2.14 │
│ Score                 │ 40.28 MiB       │ 227.38 MiB        │    5.65 │
│ Tags                  │ 234.05 MiB      │ 688.49 MiB        │    2.94 │
│ ParentId              │ 107.78 MiB      │ 321.33 MiB        │    2.98 │
│ Id                    │ 159.70 MiB      │ 227.38 MiB        │    1.42 │
│ AcceptedAnswerId      │ 40.34 MiB       │ 227.38 MiB        │    5.64 │
│ ClosedDate            │ 5.93 MiB        │ 9.49 MiB          │     1.6 │
│ LastActivityDate      │ 246.55 MiB      │ 454.76 MiB        │    1.84 │
│ CommentCount          │ 635.78 KiB      │ 56.84 MiB         │   91.55 │
│ OwnerUserId           │ 183.86 MiB      │ 227.38 MiB        │    1.24 │
│ AnswerCount           │ 9.67 MiB        │ 113.69 MiB        │   11.76 │
│ FavoriteCount         │ 19.77 KiB       │ 147.32 KiB        │    7.45 │
│ ViewCount             │ 45.04 MiB       │ 227.38 MiB        │    5.05 │
│ LastEditorUserId      │ 86.25 MiB       │ 227.38 MiB        │    2.64 │
│ ContentLicense        │ 2.17 MiB        │ 57.10 MiB         │   26.37 │
│ OwnerDisplayName      │ 5.95 MiB        │ 16.19 MiB         │    2.72 │
│ PostTypeId            │ 39.49 KiB       │ 56.84 MiB         │ 1474.01 │
│ CreationDate          │ 181.23 MiB      │ 454.76 MiB        │    2.51 │
│ LastEditDate          │ 134.07 MiB      │ 454.76 MiB        │    3.39 │
│ LastEditorDisplayName │ 2.15 MiB        │ 6.25 MiB          │    2.91 │
│ CommunityOwnedDate    │ 824.60 KiB      │ 1.34 MiB          │    1.66 │
└───────────────────────┴─────────────────┴───────────────────┴─────────┘

Выбор подходящего кодека сжатия столбца

С помощью кодеков сжатия столбцов мы можем изменять алгоритм (и его настройки), используемый для кодирования и сжатия каждого столбца.

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

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

ClickHouse поддерживает большое количество кодеков и алгоритмов сжатия. Ниже приведены некоторые рекомендации в порядке важности:

RecommendationReasoning
ZSTD all the wayСжатие ZSTD обеспечивает наилучшие показатели степени сжатия. ZSTD(1) должен быть вариантом по умолчанию для большинства распространенных типов. Можно попробовать более высокие степени сжатия, изменяя числовое значение. Мы редко наблюдаем достаточные преимущества при значениях выше 3 с учетом возросших накладных расходов на сжатие (более медленная вставка данных).
Delta for date and integer sequencesКодеки на основе Delta хорошо работают, когда у вас есть монотонные последовательности или небольшие дельты в последовательных значениях. Более конкретно, кодек Delta хорошо работает при условии, что производные дают небольшие числа. В противном случае стоит попробовать DoubleDelta (это обычно мало что добавляет, если производная первого уровня из Delta уже очень мала). Последовательности с равномерным монотонным приращением будут сжиматься еще лучше, например поля DateTime.
Delta improves ZSTDZSTD — эффективный кодек на дельта‑данных; в свою очередь, дельта‑кодирование может улучшить сжатие ZSTD. При наличии ZSTD другие кодеки редко дают заметное дополнительное улучшение.
LZ4 over ZSTD if possibleЕсли вы получаете сопоставимую степень сжатия с LZ4 и ZSTD, отдавайте предпочтение первому, поскольку он обеспечивает более быстрое разжатие и требует меньше CPU‑ресурсов. Однако ZSTD в большинстве случаев значительно превосходит LZ4. Некоторые из этих кодеков могут работать быстрее в сочетании с LZ4, обеспечивая при этом схожую степень сжатия по сравнению с ZSTD без кодека. Однако это будет зависеть от данных и требует тестирования.
T64 for sparse or small rangesT64 может быть эффективен на разреженных данных или когда диапазон значений в блоке мал. Избегайте T64 для случайных чисел.
Gorilla and T64 for unknown patterns?Если данные имеют неизвестную структуру или характер распределения, может иметь смысл попробовать Gorilla и T64.
Gorilla for gauge dataGorilla может быть эффективен для данных с плавающей запятой, в частности тех, которые представляют собой показания gauge‑метрик, то есть случайные всплески.

Дополнительные варианты см. здесь.

Ниже мы указываем кодек Delta для Id, ViewCount и AnswerCount, предполагая, что они будут линейно коррелировать с ключом сортировки и, следовательно, должны выиграть от Delta‑кодирования.

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

Результаты сжатия для этих столбцов приведены ниже:

SELECT
    `table`,
    name,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (name IN ('Id', 'ViewCount', 'AnswerCount')) AND (`table` IN ('posts_v3', 'posts_v4'))
GROUP BY
    `table`,
    name
ORDER BY
    name ASC,
    `table` ASC

┌─table────┬─name────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ posts_v3 │ AnswerCount │ 9.67 MiB        │ 113.69 MiB        │ 11.76 │
│ posts_v4 │ AnswerCount │ 10.39 MiB       │ 111.31 MiB        │ 10.71 │
│ posts_v3 │ Id          │ 159.70 MiB      │ 227.38 MiB        │  1.42 │
│ posts_v4 │ Id          │ 64.91 MiB       │ 222.63 MiB        │  3.43 │
│ posts_v3 │ ViewCount   │ 45.04 MiB       │ 227.38 MiB        │  5.05 │
│ posts_v4 │ ViewCount   │ 52.72 MiB       │ 222.63 MiB        │  4.22 │
└──────────┴─────────────┴─────────────────┴───────────────────┴───────┘

6 rows in set. Elapsed: 0.008 sec

Сжатие в ClickHouse Cloud

В ClickHouse Cloud по умолчанию используется алгоритм сжатия ZSTD (с уровнем 1 по умолчанию). Скорость сжатия для этого алгоритма может меняться в зависимости от выбранного уровня (чем выше уровень, тем медленнее), но при этом он обеспечивает стабильно быструю распаковку (разброс около 20%) и хорошо масштабируется за счёт параллельной обработки. Наши ранее проведённые тесты также показывают, что этот алгоритм часто оказывается достаточно эффективным и может превосходить LZ4, используемый совместно с кодеком. Он хорошо работает для большинства типов данных и распределений, поэтому является разумным вариантом «по умолчанию» общего назначения и объясняет, почему исходное сжатие уже очень эффективно даже без дополнительной оптимизации.