Моя команда использует ClickHouse как хранилище для 100 млрд записей с трафиком по 300 млн в сутки и поиском по таблице. Я расскажу об устройстве движка таблиц MergeTree. Рассказ буду вести, показывая физические данные, а не абстрактные схемы.

MergeTree — это сердце ClickHouse, остальные движки скорее вспомогательные. Название отсылает к LSM-дереву, которое давно используется в других СУБД.

Последовательный доступ решает, даже для SSD
ClickHouse задумывался для аналитики, под большую пропускную способность записи и чтения. Чтобы получить максимальную пропускную способность записи можно просто писать в конец файла, без поддержания всяких индексов и произвольных seek’ов, но как без них обеспечить скорость чтения? Ведь поиск по произвольным данным без индекса быстрым быть не может. Получается, нужно либо упорядочивать в момент вставки, либо фуллсканить в момент чтения. Оба варианта гробят пропускную способность. ClickHouse решил упорядочивать посередине: в фоне.
Так и появился компромисс в виде MergeTree: пишем на диск небольшие куски отсортированных данных, которые ClickHouse сливает в фоне для поддержания упорядоченности.
Концептуальная идея, надеюсь, ясна, приступим к рассмотрению работы MergeTree под микроскопом.
Лабораторная работа
Достанем из-под парты ClickHouse версии 20.8.9.6 и создадим таблицу с небольшим числом колонок. В качестве первичного ключа выберем user_id. ClickHouse создал папку /var/lib/clickhouse/data/default/clicks.
Про index_granularity — чуть позже.
create table clicks ( date DateTime, user_id Int64, banner_id String ) engine = MergeTree() order by user_id settings index_granularity = 2;
Вставим 10 строчек:
+-------------------+-------+---------+ |date |user_id|banner_id| +-------------------+-------+---------+ |2021-01-01 15:43:58|157 |lqe(| | |2021-01-01 15:45:38|289 |freed | |2021-01-01 15:47:18|421 |08&N0 | |2021-01-01 15:48:58|553 |n5UD$ | |2021-01-01 15:50:38|685 |1?!Up | |2021-01-01 15:52:18|817 |caHy6 | |2021-01-01 15:53:58|949 |maXZD | |2021-01-01 15:55:38|1081 |Fx:BO | |2021-01-01 15:57:18|1213 |\v8j+ | |2021-01-01 15:58:58|1345 |szEG) | +-------------------+-------+---------+
На диске появился один кусок данных (data part) all_1_1_0. Посмотрим, из чего он состоит:
- all — название партиции. Поскольку выражения партиционирования в CREATE TABLE мы не задали, вся таблица будет в одной партиции.
- 1_1 — это срез блоков, который хранится в парте.
- 0 — это уровень в дереве слияний. Нулевой уровень у первых «протопартов», если два парта слить, то их уровень увеличится на 1.
all_1_1_0/ ├── banner_id.bin ├── banner_id.mrk2 ├── checksums.txt ├── columns.txt ├── count.txt ├── date.bin ├── date.mrk2 ├── primary.idx ├── user_id.bin └── user_id.mrk2
Появился первичный индекс primary.idx и по два файла на каждую колонку: mrk2 и bin.
- columns.txt — информация о колонках.
- count.txt — число строк в куске.
Первичный индекс primary.idx, он же разрежённый
В primary.idx лежат засечки: отсортированные значения выражения первичного ключа, заданного в CREATE TABLE, для каждой index_granularity строки: для строки 0, index_granularity, index_granularity*2 и т.д. Размер гранулы index_granularity — это степень разреженности индекса primary.idx. Для каждого запроса ClickHouse читает с диска целое количество гранул. Если задать большой размер гранулы, будет прочитано много лишних строк, если маленький — увеличится размер первичного индекса, который хранится в оперативке для быстродействия.
Последняя засечка (здесь 1345) нужна, чтобы знать, на чём заканчивается таблица.
od -i просто отображает байты как целые положительные четырёхбайтные числа.
od -i all_1_1_0/primary.idx 0000000 157 0 421 0 0000020 685 0 949 0 0000040 1213 0 1345 0 0000060
Файлы данных .bin
Файлы bin содержат значения колонок, отсортированные по выражению первичного ключа, в нашем случае — user_id. Данные хранятся в сжатом виде, единица сжатия — блок. N-ое значение принадлежит к N-ой строке.
banner_id.bin:
cat all_1_1_0/banner_id.bin | clickhouse-compressor -d | od -a 0000000 enq l q e ( | enq f r e e d enq 0 8 & 0000020 N 0 enq n 5 U D $ enq 1 ? ! U p enq c 0000040 a H y 6 enq m a X Z D enq F x : B O 0000060 enq \ v 8 j + enq s z E G )
user_id.bin:
cat all_1_1_0/user_id.bin | clickhouse-compressor -d | od -i 0000000 157 0 289 0 0000020 421 0 553 0 0000040 685 0 817 0 0000060 949 0 1081 0 0000100 1213 0 1345 0
Ну и date.bin, в виде epoch-time:
cat all_1_1_0/date.bin | clickhouse-compressor -d | od -i 0000000 1609515838 1609515938 1609516038 1609516138 0000020 1609516238 1609516338 1609516438 1609516538 0000040 1609516638 1609516738 0000050 #от пятница, 1 января 2021 г. 15:43:58 (UTC) #до пятница, 1 января 2021 г. 15:58:58 (UTC)
Стоп, а почему данные отсортированы? Ведь мы вставляли строки в произвольном порядке? Дело в том, что ClickHouse сортирует вставляемые строки в оперативке с использованием красно-чёрного дерева, и время от времени сбрасывает его на диск в виде immutable дата-парта.
Есть DELETE и UPDATE, но эти команды не для рутинного использования, они работают в фоне, и не меняют старые парты, а создают новые.
Благодаря тому, что столбцы хранятся в своих файлах, можно читать только те столбцы, которые указаны в SELECT, также эффективнее сжатие за счет однотипности данных. По этой причине ClickHouse и называется колончат��й СУБД.
Файлы засечек .mrk2
Для каждой засечки из primary.idx mrk2 знает, где именно в bin-файле начинаются значения соответствующей колонки.
Засечка в mrk2 состоит из 3 положительных 8-битных чисел, всего 24 байта: смещение блока в bin-файле, смещение в разжатом блоке и количество строк в грануле. Третье число пока не важно.
Читаем третью засечку:
#читаем 24 байта как числа long начиная с 48 байт смещения od -l -j 48 -N 24 all_1_1_0/user_id.mrk2 0000060 0 32 0000100 2 0000110
Пройдём по этому указателю, пропустив 32 байта в нулевом блоке:
cat all_1_1_0/user_id.bin | clickhouse-compressor -d |od -j 32 -i -N 4 0000040 685 0000044
Видим значение первичного ключа из четвёртой строки. Это и есть начало третьей засечки в primary.idx!
То есть mrk2-файлы нужны просто для чтения bin-файлов, в них лежит переход от номера строки к байтовому смещению диска. Можно представить это как клей между реляционной абстракцией и физическим хранилищем.
Поиск по MergeTree
Рассмотрим, как выполняется запрос с условием на первичный ключ. ClickHouse с помощью бинарного поиска по primary.idx вычисляет, с какой строки нужно читать данные. То есть по первичному индексу вычисляются области строк таблицы, которые могут удовлетворять запросу.
Видно, что 982 лежит между гранулами 949 и 1213, поэтому можно прочесть только одну гранулу:
--включаем логи в clickhouse-client set send_logs_level = 'trace'; SELECT * FROM clicks WHERE user_id = 982 Selected 1 parts by date, 1 parts by key, 1 marks by primary key, 1 marks to read from 1 ranges Reading approx. 2 rows with 1 streams
А вот если сделать поиск по колонке вне первичного ключа, придётся делать фуллскан:
SELECT * FROM clicks WHERE banner_id = 'genbykj['; Selected 1 parts by date, 1 parts by key, 5 marks by primary key, 5 marks to read from 1 ranges Reading approx. 10 rows with 1 streams
Это делает ClickHouse СУБД плохо справляющейся с точечными запросами, ведь приходится читать много лишних строк. Если размер гранулы по умолчанию 8192, а в результате только одна строка, то эффективность чтения 1/8192 = 0.0001.
Партиции
Теперь разберемся, что такое партиции. Добавим выражение партиционирования в CREATE TABLE, обрезая дату до дня:
create table clicks ( date DateTime, user_id Int64, banner_id String ) engine = MergeTree() order by user_id partition by toYYYYMMDD(date); +-------------------+-------+---------+ |date |user_id|banner_id| +-------------------+-------+---------+ |2021-01-16 13:34:29|157 ||^/g~ | |2021-01-16 18:51:09|289 |/y;ny | |2021-01-17 00:07:49|421 |@7bbc | |2021-01-17 05:24:29|553 |.[e/{ | |2021-01-17 10:41:09|685 |0Wj)m | |2021-01-17 15:57:49|817 |W6@Oo | |2021-01-17 21:14:29|949 |tvQZ& | |2021-01-18 02:31:09|1081 |ZPeCE | |2021-01-18 07:47:49|1213 |H|$PI | |2021-01-18 13:04:29|1345 |a'0^J | +-------------------+-------+---------+
После вставки 10 строк, на диске появились три парта вместо одного: на 16, 17, 18 января 2021 года:
clicks ├── 20210116_1_1_0 ├── 20210117_2_2_0 ├── 20210118_3_3_0
Внутри парты такие же, как и без партиционирования, но добавился файл, хранящий партицию, к которой относится парт:
cat clicks/20210118_3_3_0/partition.dat | od -i 0000000 20210118 0000004
И minmax индекс по дате, в котором хранятся минимальное и максимальное значение даты в парте:
od -i 20210116_1_1_0/minmax_date.idx 0000000 1610804069 1610823069 0000010 date --utc -d @1610804069 Sat Jan 16 13:34:29 UTC 2021 date --utc -d @1610823069 Sat Jan 16 18:51:09 UTC 2021
Теперь посмотрим, как партиции помогают в поиске:
--запрос по выражению, участвующего в партиционировании SELECT * FROM clicks WHERE (date >= toUnixTimestamp('2021-01-17 00:00:00', 'UTC')) AND (date < toUnixTimestamp('2021-01-17 16:00:00', 'UTC')) --зная границы дат каждого парта, легко узнать, какие парты читать не нужно MinMax index condition: (column 0 in [1610841600, +inf)), (column 0 in (-inf, 1610899199]), and Not using primary index on part 20210117_2_2_0 Selected 1 parts by date, 1 parts by key, 1 marks by primary key, 1 marks to read from 1 ranges Reading approx. 8192 rows with 1 streams ┌────────────────date─┬─user_id─┬─banner_id─┐ │ 2021-01-17 03:07:49 │ 421 │ @7bbc │ │ 2021-01-17 08:24:29 │ 553 │ .[e/{ │ │ 2021-01-17 13:41:09 │ 685 │ 0Wj)m │ │ 2021-01-17 18:57:49 │ 817 │ W6@Oo │ └─────────────────────┴─────────┴───────────┘ Read 4 rows, 104.00 B in 0.002051518 sec., 1949 rows/sec., 49.51 KiB/sec. --запрос без участия партиций SELECT * FROM clicks WHERE banner_id = 'genbykj[' --приходится читать все парты, в три параллельных потока Not using primary index on part 20210117_2_2_0 Not using primary index on part 20210116_1_1_0 Not using primary index on part 20210118_3_3_0 Selected 3 parts by date, 3 parts by key, 3 marks by primary key, 3 marks to read from 3 ranges Reading approx. 24576 rows with 3 streams Read 10 rows, 140.00 B in 0.001798808 sec., 5559 rows/sec., 76.01 KiB/sec.
Дата-парты также удобно смотреть через системную таблицу:
SELECT name, rows, min_time, max_time FROM system.parts WHERE table = 'clicks' ┌─name───────────┬─rows─┬────────────min_time─┬────────────max_time─┐ │ 20210116_1_1_0 │ 2 │ 2021-01-16 16:34:29 │ 2021-01-16 21:51:09 │ │ 20210117_2_2_0 │ 4 │ 2021-01-17 03:07:49 │ 2021-01-17 18:57:49 │ │ 20210118_3_3_0 │ 4 │ 2021-01-18 00:14:29 │ 2021-01-18 16:04:29 │ └────────────────┴──────┴─────────────────────┴─────────────────────┘
Итого мы увидели, что каждый дата-парт принадлежит к одной партиции, и при поиске ClickHouse старается читать только нужные партиции. Ещё партиции можно отдельно удалять, отключать и производить над ними другие операции.
Слияние дата-партов
Для того, чтобы количество партов не разрасталось, ClickHouse производит фоновое слияние кусков. При слиянии также срабатывает логика в Replacing, Summing, Collapsing и других вариациях движка MergeTree. При слиянии двух отсортированных партов появляется один отсортированный.
--остановим процесс слияния и вставим строк system stop merges clicks; insert into clicks(date, user_id, banner_id) select now() , number * 132 + 157, randomPrintableASCII(5) from system.numbers limit 50; Ok. --появился 1 парт +--------------+------+ |name |active| +--------------+------+ |20210116_1_1_0|1 | +--------------+------+ --вставим ещё строк insert into clicks(date, user_id, banner_id) select now() , number * 132 + 157, randomPrintableASCII(5) from system.numbers limit 50; --уже 2 парта +--------------+------+ |name |active| +--------------+------+ |20210116_1_1_0|1 | |20210116_2_2_0|1 | +--------------+------+ --возобновим процесс слияния system start merges clicks; -- и попросим ClickHouse запустить слияние optimize table clicks final; --через некоторое время видим, что два парта слились в один 20210116_1_2_1, у которого увеличился уровень. --Неактивные парты будут удалены со временем. +--------------+------+----+ |name |active|rows| +--------------+------+----+ |20210116_1_1_0|0 |50 | |20210116_1_2_1|1 |100 | |20210116_2_2_0|0 |50 | +--------------+------+----+
Выводы
Я осветил базовые конструкции, как видим, никакой магии нет. Идея MergeTree стара и проста как сам LSM. Всем рекомендую пользоваться!
