сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · ClickHouse

Идиомы запросов 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, остальные разбираются в тренажёре.

  1. #ch_query1 / 5
    Что делает ARRAY JOIN (или функция arrayJoin) в ClickHouse?
    A)Соединяет две отдельные таблицы по массиву ключей, аналогично обычному SQL JOIN
    B)Разворачивает массив в строки: одна строка с массивом из N элементов даёт N строк
    C)Склеивает несколько отдельных строк таблицы обратно в один общий массив значений одной колонки
    D)Сортирует элементы внутри массива по возрастанию, вообще не меняя число строк в результате
    показать ответ и разбор
    +B)Разворачивает массив в строки: одна строка с массивом из N элементов даёт N строк

    // разбор: ARRAY JOIN разворачивает (unnest) массив: строка со столбцом-массивом [a,b,c] превращается в три строки, по элементу в каждой. Ключевая идиома CH для вложенных структур (события, теги, параметры). Обратная операция — groupArray (собрать значения строк в массив). Функция arrayJoin делает то же в выражении. Проверено: [1,2,3] дало 3 строки.

  2. #ch_query2 / 5
    Что вычислит 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].

  3. #ch_query3 / 5
    Что вернёт запрос с «ORDER BY ts DESC LIMIT 1 BY user_id»?
    A)Ровно одну строку на весь результат целиком, а именно самую первую по значению колонки user_id
    B)Все строки вообще без какого-либо ограничения: конструкции 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 дал по одной строке на каждую чётность.

  4. #ch_query4 / 5
    Поддерживает ли ClickHouse стандартные оконные функции (OVER, row_number, lag)?
    A)Да: есть OVER, row_number, lagInFrame и др.; но нативные идиомы (argMax, LIMIT BY) часто дешевле
    B)Нет, оконные функции в ClickHouse отсутствуют, их заменяют массивами и LIMIT BY
    C)Поддерживает, но в платной коммерческой версии 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 работают.

  5. #ch_query5 / 5
    Что делает multiIf(c1, v1, c2, v2, else)?
    A)Проверяет, что все перечисленные условия c1 и c2 истинны одновременно, и лишь тогда вернёт else
    B)Возвращает v1, если c1; иначе v2, если c2; иначе else — цепочка условий, как CASE WHEN
    C)Выполняет лишь первое условие 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.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.