Справочник DBA: базы данных изнутри читается по порядку

Статья 12.4 · раздел 12 «Архитектуры популярных СУБД» · 90 из 109

Архитектура ClickHouse: блоки столбцов, конвейер процессоров, двухуровневая агрегация и кодеки сжатия

опубликовано
читать
≈ 9 мин
автор
среда и версия
ClickHouse 25.3.14.14 в контейнере clickhouse/clickhouse-server:25.3 (--memory 2g, 20 ядер на хосте); таблица ts: 5 000 000 строк, 100 датчиков, 88,70 МиБ на диске в 5 частях; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Столбец времени в таблице временных рядов ClickHouse может занимать килобайты вместо мегабайт, агрегация идёт в несколько потоков, а одна нужная строка по ключу стоит чтения десятков тысяч соседних. Всё это следствие одной схемы: данные живут столбцами, по запросу идут блоками по несколько десятков тысяч строк через конвейер параллельных процессоров, а на диске каждый столбец сжат своим кодеком. Как части и первичный ключ лежат на диске, описано в статье про MergeTree, как фоновые слияния и мутации переписывают столбцы — в статье про слияния. Здесь — исполнение запроса и кодеки, на таблице из 5 000 000 строк.

DBA / 01

Путь запроса в ClickHouse: от part'а до слияния агрегатов

Шесть стадий исполнения запроса в ClickHouse: части на диске, чтение, блок столбцов, конвейер, агрегатор и слияние Таблица из 5 000 000 строк лежит в пяти частях, столбец времени с кодеком DoubleDelta занимает 30,34 КиБ вместо 19,07 МиБ. Четыре потока MergeTreeSelect читают блоки по 65 409 строк, столбец Float64 в блоке занимает 523 272 байта. Четыре AggregatingTransform строят свои хеш-таблицы и при 100 000 ключей переходят на двухуровневые. Пик памяти при 250 000 ключей: 28,4 МБ с двухуровневой таблицей и от 43,3 до 45,7 МБ без неё. Затем таблицы потоков сливаются. части на диске ts: 5 частей, 11 столбцов время: 19,07 МиБ → 30,34 КиБ (DoubleDelta) чтение MergeTreeSelect × 4 по ключу: 49 152 строки ради одной блок столбцов ColumnVector<Float64>: 65 409 523 272 байта на столбец блока конвейер процессоров ExpressionTransform × 4 Resize 4 → 4, max_threads=4 агрегатор AggregatingTransform × 4 ≥ 100 000 ключей: key64_two_level слияние Merging aggregated data пик памяти: 28,4 МБ против 43,3–45,7 МБ
Запрос идёт сверху вниз, и на каждой стадии есть число из опыта. Размер блока и число потоков задают, сколько данных обрабатывается за шаг, а кодек и тип таблицы в агрегаторе решают, сколько памяти и диска уйдёт на тот же результат.

Стенд

docker run -d --name dba-clickhouse-architecture-overview --memory 2g clickhouse/clickhouse-server:25.3
docker exec dba-clickhouse-architecture-overview clickhouse-client -q "SELECT version()"   # 25.3.14.14

Дальше ch — сокращение для docker exec -i dba-clickhouse-architecture-overview clickhouse-client. Таблица: 100 датчиков, измерение каждые 10 секунд, по 50 000 строк на датчик; одно и то же значение положено в несколько столбцов, каждый со своим кодеком:

CREATE TABLE ts
(
    ts_lz4 DateTime,
    ts_delta DateTime CODEC(Delta, LZ4),
    ts_dd DateTime CODEC(DoubleDelta, LZ4),
    ts_dd_zstd DateTime CODEC(DoubleDelta, ZSTD(1)),
    sensor UInt32,
    temp_lz4 Float64,
    temp_gorilla Float64 CODEC(Gorilla, LZ4),
    temp_gorilla_zstd Float64 CODEC(Gorilla, ZSTD(1)),
    cnt_lz4 UInt64,
    cnt_delta UInt64 CODEC(Delta, LZ4),
    cnt_t64 UInt64 CODEC(T64, LZ4)
)
ENGINE = MergeTree ORDER BY (sensor, ts_lz4);

INSERT INTO ts
SELECT t, t, t, t, s, v, v, v, c, c, c
FROM
(
    SELECT
        toDateTime('2026-10-01 00:00:00') + intDiv(number, 100) * 10 AS t,
        toUInt32(number % 100) AS s,
        round(20 + 5 * sin(intDiv(number, 100) / 360.) + (cityHash64(number) % 100) / 100., 1) AS v,
        intDiv(number, 100) * 7 + (cityHash64(number + 1) % 5) AS c
    FROM numbers(5000000)
);

Температура — синусоида с шумом до сотых, округлённая до десятых; счётчик растёт на 7 за такт плюс случайные 0–4. Таблица после вставки: 5 000 000 строк, 5 активных частей, 88,70 МиБ на диске (system.parts). Размеры по столбцам относятся к этому состоянию частей: после слияний цифры сдвинутся.

Опыт 1: кодеки в system.columns

SELECT name, type, compression_codec,
       formatReadableSize(data_uncompressed_bytes) AS raw,
       formatReadableSize(data_compressed_bytes) AS packed,
       round(data_uncompressed_bytes / data_compressed_bytes, 2) AS ratio
FROM system.columns WHERE database = 'default' AND table = 'ts' ORDER BY name FORMAT TSVWithNames
вывод
name	type	compression_codec	raw	packed	ratio
cnt_delta	UInt64	CODEC(Delta(8), LZ4)	38.15 MiB	12.20 MiB	3.13
cnt_lz4	UInt64		38.15 MiB	19.20 MiB	1.99
cnt_t64	UInt64	CODEC(T64, LZ4)	38.15 MiB	6.05 MiB	6.3
sensor	UInt32		19.07 MiB	90.21 KiB	216.51
temp_gorilla	Float64	CODEC(Gorilla(8), LZ4)	38.15 MiB	14.78 MiB	2.58
temp_gorilla_zstd	Float64	CODEC(Gorilla(8), ZSTD(1))	38.15 MiB	10.13 MiB	3.76
temp_lz4	Float64		38.15 MiB	13.71 MiB	2.78
ts_dd	DateTime	CODEC(DoubleDelta, LZ4)	19.07 MiB	30.34 KiB	643.83
ts_dd_zstd	DateTime	CODEC(DoubleDelta, ZSTD(1))	19.07 MiB	28.80 KiB	678.1
ts_delta	DateTime	CODEC(Delta(4), LZ4)	19.07 MiB	99.97 KiB	195.36
ts_lz4	DateTime		19.07 MiB	12.34 MiB	1.55

Столбцы *_lz4 созданы без кодека: по документации это LZ4 по умолчанию. По типам:

  • Время. Внутри датчика шаг ровно 10 секунд. Delta превращает значения в константу 10, и LZ4 ужимает её до 99,97 КиБ. DoubleDelta считает разность разностей, у равномерного ряда она нулевая, и столбец занял 30,34 КиБ против 12,34 МиБ у LZ4: примерно в 416 раз меньше. Код кодека описывает это прямо:
 * This codec is best used against monotonic integer sequences with constant (or almost constant)
 * stride, like event timestamp for some monitoring application.
...
 *   double_delta = a[i] - 2 * a[i - 1] + a[i - 2]

Источник: CompressionCodecDoubleDelta.cpp L41-L50, тег v25.3.14.14-lts, комментарий к классу кодека, пропущены строки 43–49 (описание формата); ниже в файле при double_delta == 0 пишется один бит. Пять миллионов одиночных битов — 610 КиБ; получено 30,34 КиБ, потому что поверх DoubleDelta стоит второй этап, LZ4.

  • Счётчик. Delta с LZ4 дал 3,13 против 1,99 у LZ4 одного, T64 — 6,3. Максимум счётчика 349 997, то есть значения занимают 19 бит из 64, и на таких данных T64 выиграл у Delta; на других рядах порядок может быть другим.
  • Температура. Gorilla с LZ4 (2,58) оказался хуже LZ4 одного (2,78), а с ZSTD(1) — лучше (3,76). В этих данных значение — синусоида плюс случайная добавка до сотых, соседние значения не близки, и Gorilla не выиграл у LZ4. Подходит ли он твоим измерениям, показывает тот же запрос на твоих данных.
  • sensor. Первый столбец ключа сортировки: подряд идут одинаковые значения, и кодек не нужен — 216,51.

Опыт 2: блоки и конвейер

Столбец в памяти — простой массив значений одного типа: класс ColumnVector<T> (ColumnVector.h L26-L29, тег v25.3.14.14-lts) в комментарии назван «шаблоном для столбцов, которые хранятся простым массивом»; данные лежат в Container data (строка 319), а Container — PaddedPODArray<ValueType> (строка 45). Проверка на размере: blockSize() возвращает размер блока для каждой строки, и uniq -c считает строки по блокам:

ch -q "SELECT blockSize() FROM ts SETTINGS max_threads=1 FORMAT TSV" | sort -n | uniq -c
ch -q "SELECT toTypeName(temp_lz4), sum(byteSize(temp_lz4)) FROM (SELECT temp_lz4 FROM ts LIMIT 65409)"
ch -q "SELECT getSetting('max_block_size')"
вывод
  28916 28916
4971084 65409
Float64	523272
65409

Из 5 000 000 строк 4 971 084 пришли блоками по 65 409 (76 блоков), остальные 28 916 — одним блоком. Размер 65 409 равен значению max_block_size, которое отдаёт getSetting('max_block_size'). Столбец Float64 из 65 409 значений занимает 523 272 байта, ровно 8 байт на значение: без заголовков строк и пропусков. Функции вроде avg и sum обрабатывают такой массив целиком, а не строку за строкой.

Блоки идут через конвейер. Запрос с фильтром, группировкой и сортировкой:

EXPLAIN PIPELINE
SELECT cnt_lz4 % 1000 AS k, avg(temp_lz4) a FROM ts
WHERE ts_lz4 > '2026-10-01 08:00:00' GROUP BY k ORDER BY a LIMIT 3
SETTINGS max_threads=4
вывод
(Expression)
ExpressionTransform
  (Limit)
  Limit
    (Sorting)
    MergingSortedTransform 4 → 1
      MergeSortingTransform × 4
        LimitsCheckingTransform × 4
          PartialSortingTransform × 4
            (Expression)
            ExpressionTransform × 4
              (Aggregating)
              Resize 4 → 4
                AggregatingTransform × 4
                  (Expression)
                  ExpressionTransform × 4
                    (Expression)
                    ExpressionTransform × 4
                      (ReadFromMergeTree)
                      MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 4 0 → 1

Читать снизу вверх. × 4 — четыре копии процессора, по одной на поток: max_threads=4. MergeTreeSelect читает через пул ReadPool (0 → 1: ни одного входа, один выход). Каждый поток строит свою таблицу в AggregatingTransform, Resize 4 → 4 — перекладка между четырьмя входами и четырьмя выходами, а MergingSortedTransform 4 → 1 сливает четыре отсортированных потока в один. Каждый поток крутит один и тот же цикл по графу процессоров.

        while (!tasks.isFinished() && !context.hasTask())
            tasks.tryGetTask(context);

        while (context.hasTask() && !yield)
        {
            if (tasks.isFinished())
                break;

            if (!context.executeTask())
                cancel(ExecutionStatus::Exception);

Источник: PipelineExecutor.cpp L288-L297, тег v25.3.14.14-lts, PipelineExecutor::executeStepImpl: поток берёт готовую задачу (tryGetTask) и выполняет её. Следующий шаг в этой же функции — graph->updateNode, который пересчитывает соседей по портам.

Опыт 3: двухуровневая агрегация

Агрегация по 250 000 ключам (GROUP BY cnt_lz4) на 4 потоках. Метод агрегации виден в серверном логе; опцию collect_hash_table_stats_during_aggregation=0 ставим, чтобы сервер не подсказывал размер таблицы по прошлым запускам:

ch --send_logs_level=trace -q "SELECT cnt_lz4 AS k, count() FROM ts GROUP BY k FORMAT Null SETTINGS max_threads=4, collect_hash_table_stats_during_aggregation=0" 2>&1 | grep 'Aggregation method' | sed 's/.*<Trace> //' | sort | uniq -c
ch --send_logs_level=trace -q "SELECT sensor % 7 AS k, count() FROM ts GROUP BY k FORMAT Null SETTINGS max_threads=4, collect_hash_table_stats_during_aggregation=0" 2>&1 | grep 'Aggregation method' | sed 's/.*<Trace> //' | sort | uniq -c
вывод
      4 Aggregator: Aggregation method: key64
      4 Aggregator: Aggregation method: key8

Четыре потока — четыре независимые хеш-таблицы; метод выбран по типу ключа: key64 для UInt64, key8 для семи значений. Каждый поток пишет итог своей таблицы, а слияние идёт один раз (команда та же, вместо grep 'Aggregation method' — grep -E 'Aggregated\.|Merging'):

вывод
AggregatingTransform: Aggregated. 1248508 to 83207 rows (from 9.53 MiB) in 0.02596499 sec. (48084285.802 rows/sec., 366.85 MiB/sec.)
AggregatingTransform: Aggregated. 1032192 to 110166 rows (from 7.88 MiB) in 0.028783481 sec. (35860568.776 rows/sec., 273.59 MiB/sec.)
AggregatingTransform: Aggregated. 1330978 to 110510 rows (from 10.15 MiB) in 0.034099338 sec. (39032370.658 rows/sec., 297.79 MiB/sec.)
AggregatingTransform: Aggregated. 1388322 to 110984 rows (from 10.59 MiB) in 0.03462512 sec. (40095803.278 rows/sec., 305.91 MiB/sec.)
Aggregator: Merging aggregated data

Разбивка строк по потокам от запуска к запуску меняется. Потоки получили 1 248 508 + 1 032 192 + 1 330 978 + 1 388 322 = 5 000 000 строк и построили таблицы на 83 207, 110 166, 110 510 и 110 984 ключей — вместе 414 867 записей на 250 000 различных ключей: один ключ встречается в нескольких потоках, поэтому нужно слияние. Переход на двухуровневую таблицу делает Aggregator::executeOnBlock после каждого блока:

    bool worth_convert_to_two_level = worthConvertToTwoLevel(
        params.group_by_two_level_threshold, result_size, params.group_by_two_level_threshold_bytes, result_size_bytes);

    /** Converting to a two-level data structure.
      * It allows you to make, in the subsequent, an effective merge - either economical from memory or parallel.
      */
    if (result.isConvertibleToTwoLevel() && worth_convert_to_two_level)
        result.convertToTwoLevel();

Источник: Aggregator.cpp L1596-L1603, тег v25.3.14.14-lts. Порог по умолчанию — group_by_two_level_threshold = 100000 ключей (по числу записей в таблице потока) или group_by_two_level_threshold_bytes = 50000000 байт, оба значения получены через getSetting. Остальные таблицы перед слиянием тоже переводятся в двухуровневые, если хотя бы одна уже такая (prepareVariantsToMerge, L2787-L2803). Цена порога видна по пику памяти: тот же запрос с порогом 100 000 и с порогом 100 000 000 (перевод не срабатывает), по три запуска каждого:

SELECT log_comment, count() runs, min(memory_usage) mn, max(memory_usage) mx
FROM system.query_log WHERE type = 'QueryFinish' AND log_comment LIKE 'tl3-%'
GROUP BY log_comment ORDER BY log_comment
вывод
tl3-100000	3	28400848	28401504
tl3-100000000	3	43343306	45690088

Пик памяти с двухуровневой таблицей — 28,4 МБ, без неё — от 43,3 до 45,7 МБ. Время запроса одинаковое (в среднем 43 и 42 мс на трёх запусках), это не выигрыш по скорости. По комментарию в коде двухуровневая структура даёт слияние «либо экономное по памяти, либо параллельное»; в этом опыте разница — 15–17 МБ на 4 потоках и 250 000 ключей. По пику памяти запросы разбирает статья про диагностику ClickHouse.

Ещё одна деталь версии 25.3: при настройках по умолчанию сервер запоминает размеры таблиц прошлых агрегаций. Тот же запрос без collect_hash_table_stats_during_aggregation=0, запущенный подряд второй раз, вывел Aggregation method: key64_two_level во всех четырёх потоках: таблица двухуровневая с первого блока. «Холодный» и «прогретый» запросы строят таблицы по-разному.

Опыт 4: одна строка по ключу

SELECT * FROM ts WHERE sensor = 5 AND ts_lz4 = '2026-10-02 00:00:00' FORMAT Null SETTINGS log_comment = 'pt1';
SELECT temp_lz4 FROM ts WHERE sensor = 5 AND ts_lz4 = '2026-10-02 00:00:00' FORMAT Null SETTINGS log_comment = 'pt2';
SELECT log_comment, read_rows, formatReadableSize(read_bytes) FROM system.query_log
WHERE type = 'QueryFinish' AND log_comment IN ('pt1', 'pt2') ORDER BY event_time;
вывод
pt1	49152	704.00 KiB
pt2	49152	288.00 KiB

Каждый запрос вернул одну строку и прочитал 49 152 строки — 6 гранул по 8192 (гранулы описаны в статье про первичный ключ). SELECT * прочитал 11 столбцов (704 КиБ), запрос одного столбца — 288 КиБ (включая столбцы условия). Блоки и гранулы вместе дают ответ на вопрос, почему точечный SELECT и UPDATE в ClickHouse дороги: минимальная единица чтения — гранула каждого нужного столбца целиком, а UPDATE переписывает столбец в части, как показано в статье про мутации.

Что из этого следует

  • Кодек выбирай на своих данных по system.columns. DoubleDelta для равномерного времени дал 643,83 против 1,55 у LZ4; Gorilla на зашумлённых температурах проиграл LZ4; T64 выиграл у Delta на счётчике.
  • Параллелизм задан числом потоков. max_threads определяет число копий процессоров в EXPLAIN PIPELINE; блок по умолчанию 65 409 строк — единица работы каждого.
  • Агрегация по высокой кардинальности переходит на корзины. Порог 100 000 ключей на поток; пик памяти в опыте снизился с 43–46 МБ до 28 МБ.
  • Точечные операции не для ClickHouse. Одна строка по ключу — 6 гранул, 49 152 прочитанных строки.
  • Координация — отдельный сервис. По документации ClickHouse Keeper заменяет ZooKeeper и даёт репликацию и координацию; запись журнала при вставке разобрана в статье про репликацию.

Чек-лист

  1. Для таблицы временных рядов проверь system.columns: data_uncompressed_bytes / data_compressed_bytes по каждому столбцу.
  2. Для времени с постоянным шагом поставь CODEC(DoubleDelta, LZ4), для счётчиков сравни Delta и T64, для измерений проверь Gorilla против LZ4.
  3. В EXPLAIN PIPELINE убедись, что MergeTreeSelect и AggregatingTransform идут × max_threads, а не в один поток.
  4. Агрегацию по сотням тысяч ключей проверяй по пику памяти memory_usage в system.query_log, а не по времени.
  5. Контейнер для опытов удали вместе с томом: docker rm -f -v dba-clickhouse-architecture-overview.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru