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

Статья 4.7 · раздел 4 «Индексы изнутри» · 30 из 109

MergeTree в ClickHouse: разреженный первичный ключ, гранулы по 8192 строки, засечки и skip-индексы

опубликовано
читать
≈ 11 мин
автор
среда и версия
ClickHouse 25.3.14.14 в контейнере clickhouse/clickhouse-server:25.3; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Запрос 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.

DBA / 01

Индекс, засечки и гранула 6106 в файлах столбцов

Индекс MergeTree: засечка 6106 ведёт к гранулам четырёх столбцовСлева primary.cidx: 12 209 записей по 6 байт, 73 254 байта в памяти. Записи 6102, 6105, 6106, 6107 и 6115 с ключами tenant и ts; запись 6106 с ключом 500 и 9 января 03:19 выбрана условием tenant = 500 и ts за 10 января. Справа файлы part'а: tenant.bin, блок 449 518, смещение 32 768; ts.bin, блок 200 858 432, смещение 0; user_id.bin, блок 200 937 242, смещение 0; status.bin, блок 35 402 005, смещение 16 384. Внизу: по ключу из двух столбцов выбрана 1 гранула из 12 208, бинарный поиск за 25 шагов, прочитано 8 192 строки при 3 326 нужных; по одному ts выбрано 2 406 гранул из 12 208, исключающий перебор за 11 053 шага, прочитано 19 709 952 строки.primary.cidx: засечка, tenant, tsгранула 6106: блок в файле, смещение6102 · 499 · 01-29 05:206105 · 500 · 01-06 15:506106 · 500 · 01-09 03:196107 · 500 · 01-11 14:496115 · 501 · 01-01 05:4312 209 записей × 6 Б= 73 254 Б в памятиtenant.bin449 518 · +32 768ts.bin200 858 432 · +0user_id.bin200 937 242 · +0status.bin35 402 005 · +16 384каждый столбец: 8 192 значения гранулыts: гранулы 6106 и 6107 делят блок 200 858 432со смещениями +0 и +32 7681 из 12 208 гранулtenant = 500 и ts за 10.01: бинарный поиск, 25 шаговпрочитано 8 192 строки, нужных 3 3262 406 из 12 208 гранултолько ts за 10.01: исключающий перебор, 11 053 шагапрочитано 19 709 952 строки, нужных 3 334 116
Индекс отвечает «в какой грануле начинается ключ», засечка переводит номер гранулы в смещения в файлах столбцов. Условие на оба столбца ключа выбирает одну гранулу из 12 208; условие только на второй столбец оставляет 2 406, потому что `ts` упорядочен лишь внутри арендатора.

Три фильтра по одному индексу

Гранулы, прошедшие проверку ключа, показывает 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;
меткатаблица, ключфильтргранулстрок прочитанобайтмс
Pevents (tenant, ts)tenant = 50013106 496319 4882
Qevents (tenant, ts)ts за сутки2 40619 709 95286 250 95524
Revents (tenant, ts)оба18 19257 3442
Sevents_by_ts (ts, tenant)tenant = 50012 208100 000 000299 856 99631
Tevents_by_ts (ts, tenant)ts за сутки4083 342 33616 711 2995
Uevents_by_ts (ts, tenant)оба4083 342 33623 369 5726

Строки, подходящие под условие (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 208100 000 000400 395 76519
с bloom_filter4413 612 67214 848 71733

Строк в 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 и время до и после.

Чек-лист

  1. Перед выбором ORDER BY выпиши столбцы, по которым фильтруют чаще всего, и поставь первым тот, что с равенством; проверь EXPLAIN indexes = 1 на реальных запросах: в опыте условие только на второй столбец ключа оставило 2 406 гранул, на первый — 13.
  2. Размер индекса в памяти смотри в system.parts.primary_key_bytes_in_memory, а число гранул — в marks.
  3. Skip-индекс добавляй после того, как EXPLAIN показал Condition: true для столбца; измеряй размер индекса (system.data_skipping_indices) и прочитанные строки до и после.

Контейнер удаляется командой docker rm -f -v dba-clickhouse-mergetree.

Источники

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