
Есть один миф про ClickHouse - он плохо джойнит. Подавляющее большинство не знает, с чем это утверждение связано, и просто верят на слово. А дальше срабатывает эффект сарафанного радио. В итоге в IT-сообществе есть твердое убеждение, что ClickHouse плохо джойнит. Но для меня апогеем стала статья от школы обучения программированию, в которой написано, что ClickHouse вообще не умеет джойнить:

В этот момент стало понятно, что пора внести ясность в происходящее (я на этот бред только недавно наткнулся, хотя статья 2023 года). Давайте разбираться, что же не так с джойнами в ClickHouse и откуда взялся миф, переросший в абсурд. У меня установлен ClickHouse версии 23.12 поднятый в Docker. Специально берем более старую версию, ведь миф зародился давно. Ограничение по RAM - 7ГБ. Для тестов большего не требуется.
Теперь создадим две таблицы по 20 000 000 записей в каждой.
-- создаем левую таблицу с тестовыми данными drop table if exists join_test_left; CREATE TABLE join_test_left ( id Int64, name_good String, event_time DateTime ) ENGINE = MergeTree() ORDER BY name_good SETTINGS index_granularity = 8192; -- вставляем данные INSERT INTO join_test_left (id, name_good, event_time) SELECT * FROM generateRandom('a Int64, b String, c DateTime', 1, 10, 2) LIMIT 20000000; -- 20 000 000 -- создаем правую таблицу с тестовыми данными drop table if exists join_test_right; CREATE TABLE join_test_right ( id Int64, name_good String, event_time DateTime ) ENGINE = MergeTree() ORDER BY name_good SETTINGS index_granularity = 8192; -- вставляем данные INSERT INTO join_test_right (id, name_good, event_time) SELECT * FROM generateRandom('a Int64, b String, c DateTime', 1, 10, 2) LIMIT 20000000; -- 20 000 000
Стоит уточнить, что generateRandom - функция, позволяющая генерировать случайные данные. Удобна как раз для таких тестов.
А теперь попробуем их соединить НЕ по индексу name_good, а по полю id. Оборачиваем это все в create table чтобы ��е видеть вывод при трейсинге. В качестве логического вида соединения выбираем left any join. Это как left join, только без декартового произведения. Выбирается только первое попавшееся совпадение. Это позволит сэкономить ресурсы. Код запускаемого запроса:
clickhouse client --send_logs_level='trace' --query " create table join_test_result engine=MergeTree order by id as select * from join_test_left t1 left any join join_test_right t2 on t1.name_good = t2.name_good"
Результат:

Сразу стоит отметить, что ClickHouse все-таки умеет джойнить. А корень всех зол и мифов кроется в ключевой особенности - типе физического соединения HashJoin. hash - алгоритм, при котором вся правая таблица помещается в оперативную память. И он является алгоритмом по умолчанию, в чем убедимся позже.
Вот ответ на все вопросы. Если справа большая таблица - то он либо выдаст ошибку ООМ (видимо поэтому авторы упомянутой ранее статьи решили, что клик джойнить не умеет), либо же засунет "впритык" всю правую таблицу в оперативную память.
И самое худшее - когда он все-таки засунул правую таблицу в оперативную память. ClickHouse работает по принципу "умри но сделай" - это касается очень многих функций, не только JOIN. И как вы понимаете, помимо вашего джойна на сервере происходит и множество других запросов ваших коллег/BI-систем и т.д., что может привести к падению клика. Именно поэтому и говорят, что клик плохо джойнит - он ради одного джойна может забрать 99% RAM.
Кстати, а давайте взглянем на то, сколько на пике было потрачено ресурсов на выполнение запроса и за сколько времени он выполнился:

Видим 2.33ГБ RAM и 15 секунд.
А теперь давайте попробуем соединить по ключу соединения name_good и посмотрим, какой алгоритм соединения выберет клик и сколько ресурсов затратит:

Видим все тот же HashJoin.

Ну а по ресурсам видим 1.93ГБ RAM и 49 секунд. Индексом специально сделал поле строкового типа, чтобы ему потяжелее было.
Но тут встает логичный вопрос - неужели в клике нет других видов соединения? И если есть - то почему он не выбрал более оптимальный вид соединения?
И вот тут вторая печаль - у клика по умолчанию выбран алгоритм hash + нет CBO (Cost-Based Optimizer, он только сейчас зарождается) + нет алгоритма выбора оптимального вида соединения (теоретически можно проверить ключи сортировки таблицы и выбрать другой вид соединения). А это значит, что он никогда не поменяет hash без ваших манипуляций.
Вот теперь мы окончательно разобрались, в чем проблема. Но давайте подумаем - а как мы можем это исправить?
partial_merge
Для начала разберемся с настройкой, отвечающей за вид соединения - https://clickhouse.com/docs/operations/settings/settings#join_algorithm.
В ней перечислены все виды физического соединения. Но не совсем верно указано значение по умолчанию. Давайте взглянем на него:

Видим, что по умолчанию выставлено легаси значение default, что на практике, как мы убедились, эквивалентно hash. Хорошая новость в том, что мы можем ее менять не только на уровне сервера, но и на уровне отдельных запросов.
А теперь подумаем: у нас две таблицы, обе отсортированы по ключу name_good. Здесь же прям напрашивается использование алгоритма partial_merge! Давайте попробуем:

Видим, что настройка действительно применилась, и теперь вместо HashJoin выбран алгоритм MergeJoin. Давайте взглянем на ресурсы:

Видим, что RAM было затрачено 1.71ГБ, что немного меньше, чем при hash. Но зато по скорости выполнения получили ускорение в 3 раза! Это отличный результат, но окончательные выводы делать рано.
Увеличиваем нагрузку
Дабы не мусорить скринами опишу текстом, что я сделал: пересоздал таблицы, наполнил 40 млн вместо 20 млн. И тот же самый left any join с разными видами физического соединения. Просто увеличил нагрузку, остальное все тоже самое. Начнем с hash и сразу же покажу ресурсы:

Видим 3.67ГБ RAM и 108 секунд время выполнения.
А теперь взглянем на partial_merge:

Видим все те же 1.8ГБ RAM, но уже 105 секунд время выполнения.
Какой вывод мож��о сделать? partial_merge довольно часто будет выполняться дольше, чем hash, но он не вылетит по памяти и не убьет сервер, если соединение производится по ключу сортировки. В клике это не просто индекс, а реальный ключ сортировки, т.е. данные на диске будут лежать сразу физически отсортированными по ключу. Что, кстати, при partial_merge уменьшает время выполнения соединения, так как он производит внешнюю сортировку, и эта операция осуществляется быстрее по уже отсортированным данным.
Но снова возникает резонный вопрос - а зачем он сортирует данные, если они и так отсортированы? Можем ли мы ему сказать "не сортируй"?
full_sorting_merge
full_sorting_merge в данном случае будет являться оптимальным алгоритмом. В старых версиях в этом виде соединения нужно явно указывать отключение сортировки - добавляем к запросу настройку max_rows_in_set_to_optimize_join = 0. Смотрим на ресурсы:

115 секунд против 105 у partial_merge, но зато 276МБ против 1.8ГБ, а если сравнивать с базовым hash - то разница колоссальная: 3.67 / 0.276 ~ 13. В 13 раз уменьшили потребление RAM!
Вывод
ClickHouse умеет джойнить, но не оптимально. По умолчанию выбран алгоритм hash (а в новых версиях parallel_hash, это как hash, только параллельно, что еще быстрее сожрет всю RAM), который является самым быстрым, но и самым ресурсоемким в то же время. Но вы можете влиять на это, указав более оптимальный вид соединения с помощью настройки join_algorithm. В общем - рубрика эксперименты + собери сам.
Ну а изучить ClickHouse и многие из его фишек (хотя бы 8192) вы можете на БЕСПЛАТНОМ курсе от автора статьи - https://stepik.org/course/277938/promo
