ClickHouse: как устроен MergeTree

от автора

Моя команда использует 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. Всем рекомендую пользоваться!

ссылка на оригинал статьи https://habr.com/ru/post/539538/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *