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


image


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
L-- 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-T-user_id-T-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     ¦
L---------------------+---------+------------
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-----------T-rows-T------------min_time-T------------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 ¦
L----------------+------+---------------------+----------------------

Итого мы увидели, что каждый дата-парт принадлежит к одной партиции, и при поиске 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. Всем рекомендую пользоваться!