Идиомы запросов ClickHouse
Один и тот же запрос по одной и той же таблице может отработать за сорок миллисекунд и за сорок секунд. Разница обычно в двух вещах: попал ли фильтр в начало ключа сортировки и сколько колонок ты попросил. Всё остальное - детали.
Типовые формулировки: «запрос стал медленным, что проверишь?», «почему JOIN двух больших таблиц падает по памяти?», «как взять последнее значение по каждому пользователю?».
// В ClickHouse свой диалект. Запрос, написанный «как в Postgres», обычно работает - и обычно в разы дороже, чем мог бы.
Префикс ключа: считаем на гранулах
Возьмём таблицу событий на 2 миллиона строк с ORDER BY ts. Делим на гранулы по 8192 строки: 2 000 000 разделить на 8192 - это 244 гранулы. Столько единиц чтения в таблице.
Теперь фильтр по одному дню. ClickHouse смотрит в индекс, видит, какие гранулы попадают в диапазон, и читает только их: EXPLAIN indexes=1 показывает Granules 12/244. Двенадцать гранул - это около 98 тысяч строк вместо двух миллионов, в двадцать раз меньше работы.
Работает это только для префикса ключа - для первых его колонок. Если ORDER BY (tenant, ts), то фильтр по tenant отсекает гранулы, фильтр по tenant и ts отсекает ещё лучше, а фильтр по одному только ts не отсекает почти ничего: данные разных арендаторов перемешаны по всему диапазону.
// Поэтому первое, что смотрят при разборе медленного запроса, - строчка Granules в EXPLAIN indexes=1. Она сразу говорит, работал индекс или таблицу прочитали целиком.
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE ts >= '2024-01-10 00:00:00' AND ts < '2024-01-11 00:00:00';
-- Indexes:
-- PrimaryKey
-- Keys: ts
-- Granules: 12/244 <- прочитано 12 гранул из 244Функция на колонке ключа: в ClickHouse это не приговор
Правило, которое все привезли из других баз: функция вокруг индексируемой колонки убивает индекс, потому что база не умеет заглянуть внутрь функции. В ClickHouse это неверно, и проверяется за минуту.
Тот же запрос по 2 миллионам строк, но фильтр написан как toDate(ts) = '2024-01-10'. EXPLAIN показывает те же Granules 12/244, что и явный диапазон по ts. Причина в том, что ClickHouse знает про монотонные функции: toDate не переставляет значения местами, значит условие на результат можно превратить в диапазон по самому ключу. То же самое умеют toStartOfHour, toStartOfMonth и подобные.
А вот немонотонная функция так не разворачивается. Условие toHour(ts) = 5 нельзя свести к одному отрезку - пять утра встречается каждый день. Индекс тут помогает уже частично: в том же эксперименте прочиталось 57 гранул из 244 вместо двенадцати.
// Практический вывод спокойный: писать диапазоном по ключу всё равно надёжнее, потому что результат не зависит от того, признал ли оптимизатор твою функцию монотонной. Но фраза «toDate в WHERE убивает индекс» для ClickHouse просто неправда, и на собеседовании её лучше не повторять.
Колонки стоят денег
База колоночная: каждая колонка лежит в своём файле и читается отдельно. Значит счёт идёт не за строки, а за прочитанные колонки.
Измерим. Таблица на 500 тысяч строк с семью колонками занимает на диске 6,12 МиБ. Из них колонка uid - 1,91 МиБ. Запрос, которому нужен только uid, прочитает эти 1,91 МиБ. Тот же запрос со звёздочкой прочитает все 6,12 - втрое больше, чтобы получить ровно тот же ответ. На широкой таблице в полсотни колонок разница уже не втрое.
PREWHERE - вторая половина этой идеи. Сначала читаются только колонки из условия, по ним отсеиваются строки, и лишь для выживших дочитываются остальные колонки. Настройка optimize_move_to_prewhere включена по умолчанию, так что чаще всего ClickHouse переносит условие сам, но знать механику надо: она объясняет, почему дешёвое условие по узкой колонке ускоряет запрос сильнее, чем кажется.
// Отсюда же бытовое правило: SELECT * в ClickHouse - не лень, а прямые деньги. В разведочных запросах он допустим, в дашборде и в коде - нет.
Диалект: argMax, словари, приблизительные функции
argMax(значение, по_чему_максимум) возвращает значение той строки, где вторая колонка максимальна. Это местная замена оконным функциям и джойнам таблицы с самой собой: «последнее состояние по каждому пользователю» пишется как argMax(status, ts) с GROUP BY user_id, и считается это одним проходом.
JOIN устроен просто и потому больно: правая таблица целиком загружается в память и превращается в хеш-таблицу. Джойн большой таблицы с маленькой - нормально, большой с большой - упирается в память. Идиоматичные замены две: денормализация при записи (положить нужные поля рядом сразу) и словари - справочник, живущий в памяти сервера, к которому обращаются функцией dictGet вместо джойна.
Приблизительные функции - осознанный размен точности на скорость. На миллионе разных значений uniq вернул 1 001 943 против точного 1 000 000 у uniqExact: ошибка около 0,2 процента при кратно меньшей памяти и времени. Для разведки и дашбордов это честная сделка, для биллинга - нет.
// Массивы с функциями высшего порядка (arrayFilter, arrayMap) и ARRAY JOIN закрывают то, что в других базах пишется подзапросами по событиям. Это уже вторая ступень диалекта, но именно она отличает «пишу как в Postgres» от «пишу по-кликхаусовски».
- dictGet
- чтение атрибута из словаря в памяти по ключу; стандартная замена джойну со справочником
- uniq / uniqExact
- приближённый счёт уникальных с ошибкой около доли процента / точный и дорогой
Как отвечать: «Запрос по большой таблице медленный. Что проверишь?»
Иду по порядку стоимости причин. Первое - попадает ли фильтр в префикс ORDER BY. Смотрю EXPLAIN indexes=1 на строчку Granules: если прочитано 244 из 244, индекс не сработал и дальше можно не гадать. Второе - какие колонки читаются: база колоночная, и на нашей таблице звёздочка стоит 6 мегабайт против двух за одну нужную колонку, поэтому убираю SELECT *. Третье - джойны: правая таблица целиком уходит в память, так что большой с большим меняю на словарь или на денормализацию при записи. Дальше смотрю system.query_log - сколько строк и байт запрос реально прочитал, это разом подтверждает или опровергает все гипотезы. А вот функцию вокруг колонки ключа я бы сходу не обвинял: если она монотонная, вроде toDate, ClickHouse разворачивает её в диапазон и гранулы отсекаются так же, как при явном условии по ts.
Диагностический маршрут от самой дорогой причины к дешёвым, с проверкой по логу и без расхожего мифа про функцию на колонке.
На чём валят
- −Фильтровать по колонке вне префикса ORDER BY и удивляться чтению всей таблицы.
- −Джойнить две большие таблицы «как в Postgres»: правая часть целиком уходит в память.
- −Оставлять SELECT * там, где нужны три колонки: в колоночной базе это прямая плата за чтение.
- −Брать uniqExact на миллиардах, где хватило бы uniq с ошибкой в доли процента.
- −Повторять миф, что toDate(ts) в WHERE отключает индекс: монотонные функции ClickHouse разворачивает в диапазон.
- −Писать оконные функции там, где argMax с GROUP BY решает задачу одним проходом.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.
- Что делает ARRAY JOIN (или функция arrayJoin) в ClickHouse?A)Соединяет две отдельные таблицы по массиву ключей, аналогично обычному SQL JOINB)Разворачивает массив в строки: одна строка с массивом из N элементов даёт N строкC)Склеивает несколько отдельных строк таблицы обратно в один общий массив значений одной колонкиD)Сортирует элементы внутри массива по возрастанию, вообще не меняя число строк в результате
показать ответ и разбор
+B)Разворачивает массив в строки: одна строка с массивом из N элементов даёт N строк// разбор: ARRAY JOIN разворачивает (unnest) массив: строка со столбцом-массивом [a,b,c] превращается в три строки, по элементу в каждой. Ключевая идиома CH для вложенных структур (события, теги, параметры). Обратная операция — groupArray (собрать значения строк в массив). Функция arrayJoin делает то же в выражении. Проверено: [1,2,3] дало 3 строки.
- Что вычислит arrayFilter(x -> x > 0, arr) для массива arr?A)Булево значение — истину, если все элементы массива arr положительныB)Количество положительных элементов в массиве arr, возвращённое в виде одного целого числаC)Подмассив из элементов arr, для которых лямбда истинна (положительные)D)Исходный массив arr вообще без изменений: лямбда-функции к массивам в ClickHouse неприменимы
показать ответ и разбор
+C)Подмассив из элементов arr, для которых лямбда истинна (положительные)// разбор: ClickHouse богат функциями высшего порядка над массивами с лямбдами: arrayFilter(x -> cond, arr) оставляет подходящие элементы, arrayMap(x -> expr, arr) преобразует каждый, arrayExists/arrayAll — булевы проверки, arraySum/arrayReduce — свёртки. Это обработка вложенных данных без разворачивания в строки. Проверено: arrayFilter(x->x>1,[1,2,3])=[2,3], arrayMap(x->x*2,...)=[2,4,6].
- Что вернёт запрос с «ORDER BY ts DESC LIMIT 1 BY user_id»?A)Ровно одну строку на весь результат целиком, а именно самую первую по значению колонки user_idB)Все строки вообще без какого-либо ограничения: конструкции LIMIT... BY в языке запросов ClickHouse практически нетC)Только строки, у которых user_id в точности равен единице, отбрасывая всех остальных юзеровD)По одной строке (первой после сортировки) на каждый user_id — например, последнее событие юзера
показать ответ и разбор
+D)По одной строке (первой после сортировки) на каждый user_id — например, последнее событие юзера// разбор: LIMIT n BY expr оставляет первые n строк для каждого значения expr после сортировки — это не обычный LIMIT (тот режет весь результат). Идиома «последнее событие на пользователя»: ORDER BY ts DESC LIMIT 1 BY user_id. Компактная замена оконному row_number()=1. Проверено: LIMIT 1 BY g над 0..5 дал по одной строке на каждую чётность.
- Поддерживает ли ClickHouse стандартные оконные функции (OVER, row_number, lag)?A)Да: есть OVER, row_number, lagInFrame и др.; но нативные идиомы (argMax, LIMIT BY) часто дешевлеB)Нет, оконные функции в ClickHouse отсутствуют, их заменяют массивами и LIMIT BYC)Поддерживает, но в платной коммерческой версии ClickHouse, в открытой их нетD)Поддерживает лишь одну оконную функцию sum() OVER, других нет
показать ответ и разбор
+A)Да: есть OVER, row_number, lagInFrame и др.; но нативные идиомы (argMax, LIMIT BY) часто дешевле// разбор: Современный ClickHouse поддерживает стандартные оконные функции: sum() OVER (...), row_number(), rank(), lagInFrame/leadInFrame и рамки. Раньше их не было, и на собесах часто ждут «нативные» замены — argMax для «последнего», LIMIT BY для top-1-на-группу, массивы для последовательностей, — которые нередко быстрее и экономнее по памяти. Знать стоит оба пути. Проверено: sum() OVER и lagInFrame работают.
- Что делает multiIf(c1, v1, c2, v2, else)?A)Проверяет, что все перечисленные условия c1 и c2 истинны одновременно, и лишь тогда вернёт elseB)Возвращает v1, если c1; иначе v2, если c2; иначе else — цепочка условий, как CASE WHENC)Выполняет лишь первое условие c1 и игнорирует последующие аргументы функцииD)Складывает между собой все переданные значения v1, v2 и else в одну общую итоговую сумму
показать ответ и разбор
+B)Возвращает v1, если c1; иначе v2, если c2; иначе else — цепочка условий, как CASE WHEN// разбор: multiIf — компактная цепочка условий (аналог SQL CASE WHEN … ELSE): проверяет условия по порядку и возвращает значение первого истинного, иначе последний аргумент. Читабельнее и часто быстрее вложенных if(). Простой if(cond, then, else) — на одно условие. Проверено: multiIf(number=0,'z', number<3,'low','hi') дал z/low/low/hi.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.