Запрос WHERE tenant = 500 по таблице в 100 млн строк читает 13 гранул, а WHERE ts за сутки по второму столбцу ключа — 2 406, хотя в обоих случаях ClickHouse смотрит в один и тот же индекс. Первичный ключ MergeTree не находит строку, как B-tree в PostgreSQL: он хранит одну запись на гранулу в 8192 строки и отвечает только на вопрос «какие гранулы можно не читать». Каталог part’а и столбцы по файлам разобраны в статье про раскладку строки в трёх движках; здесь мы идём по индексу: от primary.cidx к засечкам и файлам столбцов, и смотрим, как порядок столбцов в ORDER BY решает, сколько прочитает запрос.
Таблица на 100 млн строк
docker run -d --name dba-clickhouse-mergetree -e CLICKHOUSE_SKIP_USER_SETUP=1 clickhouse/clickhouse-server:25.3
docker exec -i dba-clickhouse-mergetree clickhouse-client -mn
У каждого rand32 свой аргумент: без него два вызова в одном запросе дают одно число (rand32() % 1000 совпал сам с собой в 100 000 строках из 100 000, с аргументами — в 91), и столбцы получаются функциями друг друга. Ключ — (tenant, ts): тысяча арендаторов, 30 дней по секундам:
CREATE TABLE events (tenant UInt16, ts DateTime, user_id UInt32, status UInt8)
ENGINE = MergeTree ORDER BY (tenant, ts);
INSERT INTO events
SELECT rand32(1) % 1000, toDateTime('2026-01-01') + rand32(2) % (86400 * 30), rand32(3) % 1000000, rand32(4) % 5
FROM numbers(100000000);
OPTIMIZE TABLE events FINAL;
SELECT name, rows, marks, formatReadableSize(bytes_on_disk) AS size, primary_key_bytes_in_memory AS pk_mem, index_granularity_bytes_in_memory AS gran_mem
FROM system.parts WHERE table = 'events' AND active;
вывод
all_1_90_3 100000000 12209 834.58 MiB 73254 25
Один INSERT разбился на 90 блоков, каждый стал своим part’ом, фоновые слияния и OPTIMIZE FINAL собрали их в all_1_90_3: блоки с 1 по 90, уровень слияний 3. Журнал system.part_log по этой таблице:
SELECT event_type, count(), sum(rows) FROM system.part_log WHERE table_uuid = (SELECT uuid FROM system.tables WHERE database = 'default' AND name = 'events') GROUP BY event_type ORDER BY 1;
вывод
MergeParts 18 280060616
MergePartsStart 18 0
NewPart 90 100000000
Слияния переписали 280 млн строк при 100 млн вставленных: сборка отсортированного part’а стоит записи, и цену платит каждая вставка.
Что лежит в part’е
docker exec dba-clickhouse-mergetree sh -c "cd /var/lib/clickhouse/data/default/events/all_1_90_3 && ls -l | awk '/cidx|cmrk2|\.bin/{print \$5, \$9}'"
вывод
61545 primary.cidx
70797298 status.bin
16081 status.cmrk2
898988 tenant.bin
19002 tenant.cmrk2
401553912 ts.bin
32296 ts.cmrk2
401710709 user_id.bin
32230 user_id.cmrk2
primary.cidx — индекс: 61 545 байт на диске и 73 254 в памяти (pk_mem выше). Это 12 209 записей по 6 байт, UInt16 для tenant и DateTime для ts: 12 208 гранул плюс одна финальная запись, у которой строк нет. Файл сжат (compress_primary_key = 1, ZSTD(3)), загружается лениво (primary_key_lazy_load = 1) и лежит в памяти целиком. Сто миллионов строк и 73 254 байта индекса: в нём нет строк, только ключ первой строки каждой гранулы. Суффикс .cmrk2 вместо .mrk2 из справочников означает сжатые засечки (compress_marks = 1).
Гранулы в части тоже без массива: класс постоянной гранулы хранит одно число на весь part.
/// Class that stores constant index granularity for whole part, except
/// last non-zero granule and final granule which always has zero rows.
class MergeTreeIndexGranularityConstant final : public MergeTreeIndexGranularity
{
private:
size_t constant_granularity;
size_t last_mark_granularity;
size_t num_marks_without_final = 0;
bool has_final_mark = false;
MergeTreeIndexGranularityConstant.h (v25.3.14.14-lts): три size_t и bool, то есть 25 байт, и это ровно gran_mem в выводе. Адаптивная гранула по размеру в байтах (index_granularity_bytes = 10485760) здесь не срабатывает, потому что 8192 строки по 11 байт (2 + 4 + 4 + 1) — 90 112 байт, далеко до 10 МиБ.
Что хранит каждая запись индекса и куда ведёт её засечка, показывает mergeTreeIndex:
SELECT mark_number, tenant, ts FROM mergeTreeIndex('default', 'events') WHERE mark_number IN (0, 12, 13);
вывод
0 0 2026-01-01 00:00:35
12 0 2026-01-30 08:40:01
13 1 2026-01-02 19:57:02
Запись — это ключ первой строки гранулы. У арендатора 0 тринадцать гранул (0–12, около 100 тысяч строк): ts растёт внутри арендатора и сбрасывается на следующем, 13-я гранула начинается с арендатора 1. В середине таблицы те же поля вместе с засечками (with_marks = true) для гранул 6105–6107:
SELECT mark_number, tenant, ts, `tenant.mark`, `ts.mark`, `user_id.mark`, `status.mark`
FROM mergeTreeIndex('default', 'events', with_marks = true) WHERE mark_number IN (6105, 6106, 6107);
вывод
6105 500 2026-01-06 15:50:54 (449518,16384) (200792620,32768) (200871439,32768) (35402005,8192)
6106 500 2026-01-09 03:19:51 (449518,32768) (200858432,0) (200937242,0) (35402005,16384)
6107 500 2026-01-11 14:49:21 (449518,49152) (200858432,32768) (200937242,32768) (35402005,24576)
Засечка — пара «смещение сжатого блока в .bin, смещение внутри распакованного блока». В ts гранулы 6106 и 6107 делят блок 200 858 432: гранула DateTime занимает 8192 × 4 = 32 768 байт, и смещения 0 и 32 768 в распакованном блоке это показывают. У tenant (UInt16) шаг 16 384, у status (UInt8) — 8192.
Индекс, засечки и гранула 6106 в файлах столбцов
Три фильтра по одному индексу
Гранулы, прошедшие проверку ключа, показывает EXPLAIN indexes = 1:
EXPLAIN indexes = 1 SELECT count() FROM events WHERE tenant = 500;
EXPLAIN indexes = 1 SELECT count() FROM events WHERE tenant = 500 AND ts >= '2026-01-10' AND ts < '2026-01-11';
EXPLAIN indexes = 1 SELECT count() FROM events WHERE ts >= '2026-01-10' AND ts < '2026-01-11';
К каждому EXPLAIN добавь | grep -E 'Condition|Granules':
вывод
Condition: (tenant in [500, 500])
Granules: 13/12208
Condition: and((ts in (-Inf, 1768089599]), and((ts in [1768003200, +Inf)), (tenant in [500, 500])))
Granules: 1/12208
Condition: and((ts in (-Inf, 1768089599]), (ts in [1768003200, +Inf)))
Granules: 2406/12208
Фильтр по tenant оставил 13 гранул: арендатор занимает 100 тысяч строк, это 12,2 гранулы и ещё одна на стыке. Фильтр по обоим столбцам — одну. Фильтр только по ts не сводится к нулю: индекс знает только «первая строка гранулы», а день каждого арендатора лежит внутри его гранул: 2 406 гранул — около двух с половиной на каждого из тысячи арендаторов. Журнал запроса показывает, какой алгоритм сработал:
docker exec -i dba-clickhouse-mergetree clickhouse-client --send_logs_level=trace -q "SELECT count() FROM events WHERE tenant = 500 AND ts >= '2026-01-10' AND ts < '2026-01-11' FORMAT Null" 2>&1 | grep SelectExecutor | sed 's/^.*(SelectExecutor): //'
вывод
Running binary search on index range for part all_1_90_3 (12209 marks)
Found (LEFT) boundary mark: 6106
Found (RIGHT) boundary mark: 6107
Found continuous range with exact range in 25 steps
Selected 1/1 parts by partition key, 1 parts by primary key, 1/12208 marks by primary key, 1 marks to read from 1 ranges
Reading 1 ranges in order from part all_1_90_3, approx. 8192 rows starting from 50020352
То же с условием ts без tenant (выбраны строки журнала с алгоритмом и итогом):
вывод
Used generic exclusion search with exact ranges over index for part all_1_90_3 with 11053 steps
Selected 1/1 parts by partition key, 1 parts by primary key, 2406/12208 marks by primary key, 2406 marks to read from 1999 ranges
(Если ты уже создал skip-индексы из последнего раздела, part называется all_1_90_3_92: суффикс добавили две мутации.) Стартовая строка 50 020 352 — это 6106 × 8192. Развилка в коде markRangesFromPKRange: условие, которое задаёт один непрерывный диапазон ключа, ищется бинарным поиском по засечкам, любое другое идёт исключающим перебором.
// Returns whether the condition is one continuous range of the primary key,
// where every field is matched by range or a single element set.
// This allows to use a more efficient lookup with no extra reads.
bool KeyCondition::matchesExactContinuousRange() const
KeyCondition.cpp, v25.3.14.14-lts: tenant = 500 — точка, ts в диапазоне — отрезок, вместе это один непрерывный кусок ключа, и хватает 25 шагов. Одного ts для такого куска недостаточно: допустимые строки разбросаны по тысяче арендаторов.
else
{
/// Break the segment and put the result on the stack from right to left.
size_t step = (range.end - range.begin - 1) / settings[Setting::merge_tree_coarse_index_granularity] + 1;
size_t end;
for (end = range.end; end > range.begin + step; end -= step)
ranges_stack.emplace_back(end - step, end);
ranges_stack.emplace_back(range.begin, end);
}
Тот же файл, исключающий перебор: отрезок засечек делится на merge_tree_coarse_index_granularity частей (по умолчанию 8), части без пересечения с условием отбрасываются, остальные делятся дальше до одной засечки. Отсюда 11 053 шага вместо 25.
Порядок столбцов в ключе
Те же данные с ключом (ts, tenant):
CREATE TABLE events_by_ts (tenant UInt16, ts DateTime, user_id UInt32, status UInt8) ENGINE = MergeTree ORDER BY (ts, tenant);
INSERT INTO events_by_ts SELECT * FROM events;
OPTIMIZE TABLE events_by_ts FINAL;
Каждый запрос — пять раз, медиана из system.query_log (метка в log_comment, кэш ОС горячий, поэтому сравнивай строки, а не миллисекунды):
for i in 1 2 3 4 5; do docker exec -i dba-clickhouse-mergetree clickhouse-client --log_comment P -q "SELECT count(), sum(status) FROM events WHERE tenant = 500 FORMAT Null"; done
SELECT log_comment, count() n, any(read_rows) rows, any(read_bytes) bytes, median(query_duration_ms) med_ms, any(ProfileEvents['SelectedMarks']) marks
FROM system.query_log WHERE type = 'QueryFinish' AND log_comment IN ('P','Q','R','S','T','U') GROUP BY log_comment ORDER BY log_comment;
| метка | таблица, ключ | фильтр | гранул | строк прочитано | байт | мс |
|---|---|---|---|---|---|---|
| P | events (tenant, ts) | tenant = 500 | 13 | 106 496 | 319 488 | 2 |
| Q | events (tenant, ts) | ts за сутки | 2 406 | 19 709 952 | 86 250 955 | 24 |
| R | events (tenant, ts) | оба | 1 | 8 192 | 57 344 | 2 |
| S | events_by_ts (ts, tenant) | tenant = 500 | 12 208 | 100 000 000 | 299 856 996 | 31 |
| T | events_by_ts (ts, tenant) | ts за сутки | 408 | 3 342 336 | 16 711 299 | 5 |
| U | events_by_ts (ts, tenant) | оба | 408 | 3 342 336 | 23 369 572 | 6 |
Строки, подходящие под условие (count()): 99 716 для tenant = 500, 3 334 116 для суток, 3 326 для обоих. Запрос R читает 8 192 строки ради 3 326 нужных: единица чтения — гранула, а не строка. Ключ (ts, tenant) проигрывает фильтру по арендатору полностью (12 208 гранул из 12 208, 100 млн строк) и выигрывает у фильтра по ts (408 гранул, 3,3% таблицы, против 2 406). В (ts, tenant) на одно значение ts приходится около 39 строк (100 млн на 2,59 млн секунд), а гранула — 8 192, поэтому tenant в таком ключе гранулы не делит. Побочный эффект порядка: размер на диске.
вывод
events all_1_90_3 100000000 12209 834.58 MiB
events_by_ts all_1_92_3 100000000 12209 643.18 MiB
В events (tenant, ts) tenant.bin занял 898 988 байт, а ts.bin — 401 553 912; в events_by_ts наоборот: ts.bin 13 204 901, tenant.bin 188 541 686. Столбец, по которому строки отсортированы первым, сжимается в 30–200 раз; остальные остаются около сырого размера (ts.bin в events — 401 553 912 байт при 400 000 000 сырых). Ключ выбирает не только какие гранулы читать, но и какой столбец будет маленьким.
Skip-индексы: другой столбец, другая цена
Фильтр по user_id первичный ключ не ускорит: условие не касается ключа, EXPLAIN даёт Condition: true и Granules: 12208/12208. Для таких столбцов есть индексы пропуска: на каждую гранулу (или группу гранул, GRANULARITY) хранится сводка значений, и гранулу пропускают, если сводка говорит «значения здесь нет». Два вида на одном столбце:
ALTER TABLE events ADD INDEX idx_user_minmax user_id TYPE minmax GRANULARITY 1;
ALTER TABLE events ADD INDEX idx_user_bloom user_id TYPE bloom_filter GRANULARITY 1;
ALTER TABLE events MATERIALIZE INDEX idx_user_minmax SETTINGS mutations_sync = 2;
ALTER TABLE events MATERIALIZE INDEX idx_user_bloom SETTINGS mutations_sync = 2;
SELECT name, type, granularity, data_compressed_bytes, data_uncompressed_bytes FROM system.data_skipping_indices WHERE table = 'events';
EXPLAIN indexes = 1 SELECT count() FROM events WHERE user_id = 424242;
SELECT count() FROM events WHERE user_id = 424242;
EXPLAIN пропущен через grep -E 'Name|Granules':
вывод
idx_user_minmax minmax 1 70019 97664
idx_user_bloom bloom_filter 1 100042213 99615899
Granules: 12208/12208
Name: idx_user_minmax
Granules: 12208/12208
Name: idx_user_bloom
Granules: 441/12208
90
minmax за 70 019 байт не отсёк ничего: user_id не связан с порядком ключа, диапазон min–max любой гранулы покрывает 424242. bloom_filter оставил 441 гранулу из 12 208: запрос возвращает 90 строк, то есть нужны не больше 90 гранул, остальные — ложные срабатывания фильтра. Цена и выигрыш при пяти прогонах каждого варианта (SETTINGS use_skip_indexes = 0 против значения по умолчанию):
| вариант | гранул | строк прочитано | байт | мс |
|---|---|---|---|---|
| без skip-индексов | 12 208 | 100 000 000 | 400 395 765 | 19 |
с bloom_filter | 441 | 3 612 672 | 14 848 717 | 33 |
Строк в 27,7 раза меньше, а запрос медленнее: индекс весит 100 042 213 байт (skp_idx_idx_user_bloom.idx, четверть user_id.bin), и его читают, чтобы решить, какие гранулы пропускать. На горячем кэше ОС столбец целиком (400 МБ) прочитался за 19 мс, индекс с остатком данных — за 33. Холодный диск не мерялся: там соотношение может быть другим, поэтому проверяй на своём железе, прежде чем оставлять такой индекс.
Что из этого следует
Индекс не находит, а исключает. 12 209 записей по 6 байт держат в памяти весь part в 100 млн строк, но точечный запрос по tenant и ts читает 8 192 строки ради 3 326 нужных. Для выборки «карточка по id» это плохая цена, её лучше отдать строковой базе; для аналитики — хорошая; в статье про раскладку то же видно по read_rows.
Столбцы ключа — порядок вопросов. Условие на оба столбца ключа дало бинарный поиск за 25 шагов и одну гранулу, на один только второй — перебор, 11 053 шага и 2 406 гранул. Столбец, который чаще всего стоит в WHERE с равенством, ставь первым.
Ключ меняет размер таблицы. Порядок (ts, tenant) на тех же данных дал 643 МиБ против 834 МиБ: отсортированный первым ts занял 13 МБ вместо 401 МБ.
Skip-индекс — не бесплатное «второе дерево». На случайном столбце minmax не отсёк ничего, а bloom_filter сократил чтение до 441 гранулы, но занял четверть размера столбца и не ускорил запрос на горячем кэше. Сравнивай Granules в EXPLAIN и время до и после.
Чек-лист
- Перед выбором
ORDER BYвыпиши столбцы, по которым фильтруют чаще всего, и поставь первым тот, что с равенством; проверьEXPLAIN indexes = 1на реальных запросах: в опыте условие только на второй столбец ключа оставило 2 406 гранул, на первый — 13. - Размер индекса в памяти смотри в
system.parts.primary_key_bytes_in_memory, а число гранул — вmarks. - Skip-индекс добавляй после того, как
EXPLAINпоказалCondition: trueдля столбца; измеряй размер индекса (system.data_skipping_indices) и прочитанные строки до и после.
Контейнер удаляется командой docker rm -f -v dba-clickhouse-mergetree.