Столбец времени в таблице временных рядов ClickHouse может занимать килобайты вместо мегабайт, агрегация идёт в несколько потоков, а одна нужная строка по ключу стоит чтения десятков тысяч соседних. Всё это следствие одной схемы: данные живут столбцами, по запросу идут блоками по несколько десятков тысяч строк через конвейер параллельных процессоров, а на диске каждый столбец сжат своим кодеком. Как части и первичный ключ лежат на диске, описано в статье про MergeTree, как фоновые слияния и мутации переписывают столбцы — в статье про слияния. Здесь — исполнение запроса и кодеки, на таблице из 5 000 000 строк.
Путь запроса в ClickHouse: от part'а до слияния агрегатов
Стенд
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 и даёт репликацию и координацию; запись журнала при вставке разобрана в статье про репликацию.
Чек-лист
- Для таблицы временных рядов проверь
system.columns:data_uncompressed_bytes / data_compressed_bytesпо каждому столбцу. - Для времени с постоянным шагом поставь
CODEC(DoubleDelta, LZ4), для счётчиков сравниDeltaиT64, для измерений проверьGorillaпротив LZ4. - В
EXPLAIN PIPELINEубедись, чтоMergeTreeSelectиAggregatingTransformидут× max_threads, а не в один поток. - Агрегацию по сотням тысяч ключей проверяй по пику памяти
memory_usageвsystem.query_log, а не по времени. - Контейнер для опытов удали вместе с томом:
docker rm -f -v dba-clickhouse-architecture-overview.