Моя команда использует 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.idx0000000 157 0 421 00000020 685 0 949 00000040 1213 0 1345 00000060
Файлы данных .bin
Файлы bin содержат значения колонок, отсортированные повыражению первичного ключа, внашем случае user_id. Данные хранятся всжатом виде, единица сжатия блок. N-ое значение принадлежит кN-ой строке.
banner_id.bin:
cat all_1_1_0/banner_id.bin | clickhouse-compressor -d | od -a0000000 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 c0000040 a H y 6 enq m a X Z D enq F x : B O0000060 enq \ v 8 j + enq s z E G )
user_id.bin:
cat all_1_1_0/user_id.bin | clickhouse-compressor -d | od -i0000000 157 0 289 00000020 421 0 553 00000040 685 0 817 00000060 949 0 1081 00000100 1213 0 1345 0
Ну и date.bin, в виде epoch-time:
cat all_1_1_0/date.bin | clickhouse-compressor -d | od -i0000000 1609515838 1609515938 1609516038 16095161380000020 1609516238 1609516338 1609516438 16095165380000040 1609516638 16095167380000050#от пятница, 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.mrk20000060 0 320000100 20000110
Пройдём поэтому указателю, пропустив 32байта внулевом блоке:
cat all_1_1_0/user_id.bin | clickhouse-compressor -d |od -j 32 -i -N 40000040 6850000044
Видим значение первичного ключа изчетвёртой строки. Это иесть начало третьей засечки вprimary.idx!
Тоесть mrk2-файлы нужны просто для чтения bin-файлов, вних лежит переход отномера строки кбайтовому смещению диска. Можно представить это как клей между реляционной абстракцией ифизическим хранилищем.
Поиск поMergeTree
Рассмотрим, как выполняется запрос сусловием напервичный ключ. ClickHouse спомощью бинарного поиска поprimary.idx вычисляет, скакой строки нужно читать данные. Тоесть попервичному индексу вычисляются области строк таблицы, которые могут удовлетворять запросу.
Видно, что 982 лежит между гранулами 949 и1213, поэтому можно прочесть только одну гранулу:
--включаем логи в clickhouse-clientset send_logs_level = 'trace';SELECT *FROM clicksWHERE user_id = 982Selected 1 parts by date, 1 parts by key, 1 marks by primary key, 1 marks to read from 1 rangesReading approx. 2 rows with 1 streams
Авот если сделать поиск поколонке вне первичного ключа, придётся делать фуллскан:
SELECT *FROM clicksWHERE banner_id = 'genbykj[';Selected 1 parts by date, 1 parts by key, 5 marks by primary key, 5 marks to read from 1 rangesReading 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 -i0000000 202101180000004
Иminmax индекс подате, вкотором хранятся минимальное имаксимальное значение даты впарте:
od -i 20210116_1_1_0/minmax_date.idx0000000 1610804069 16108230690000010date --utc -d @1610804069Sat Jan 16 13:34:29 UTC 2021date --utc -d @1610823069Sat Jan 16 18:51:09 UTC 2021
Теперь посмотрим, как партиции помогают впоиске:
--запрос по выражению, участвующего в партиционированииSELECT *FROM clicksWHERE (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]), andNot using primary index on part 20210117_2_2_0Selected 1 parts by date, 1 parts by key, 1 marks by primary key, 1 marks to read from 1 rangesReading approx. 8192 rows with 1 streamsdateuser_idbanner_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 clicksWHERE banner_id = 'genbykj['--приходится читать все парты, в три параллельных потокаNot using primary index on part 20210117_2_2_0Not using primary index on part 20210116_1_1_0Not using primary index on part 20210118_3_3_0Selected 3 parts by date, 3 parts by key, 3 marks by primary key, 3 marks to read from 3 rangesReading approx. 24576 rows with 3 streamsRead 10 rows, 140.00 B in 0.001798808 sec., 5559 rows/sec., 76.01 KiB/sec.
Дата-парты также удобно смотреть через системную таблицу:
SELECT name, rows, min_time, max_timeFROM system.partsWHERE table = 'clicks'namerowsmin_timemax_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. Всем рекомендую пользоваться!