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

Производительность запросов ClickHouse

Зачем это спрашивают

Бэкенд пишет в ClickHouse по одному событию, как привык писать в Postgres. Через несколько часов приходит ошибка «too many parts», и запись встаёт целиком. Каждая вставка - это отдельный кусок на диске, и фоновые мержи перестали успевать их склеивать.

Типовые формулировки: «как правильно вставлять данные?», «почему too many parts?», «что нельзя поменять в таблице потом?».

// Мантра вставки короткая: батчами, редко и крупно. Всё остальное в этой теме - следствия.

Вставка батчами: считаем пороги

Пороги в ClickHouse не абстрактные, их видно в настройках. При 1000 активных кусков в партиции сервер начинает искусственно притормаживать вставки - это parts_to_delay_insert. При 3000 он отказывает с ошибкой too many parts - это parts_to_throw_insert. Плюс общий потолок 100 000 кусков на таблицу.

Теперь арифметика. Бэкенд пишет по строке, двести раз в секунду - это двести кусков в секунду. Тысяча наберётся за пять секунд, три тысячи - за пятнадцать. Фоновые мержи столько склеить не успевают физически, поэтому вопрос не в том, случится ли авария, а через сколько минут.

Норма - батчи от десятков тысяч строк с частотой примерно раз в секунду. Тогда кусков рождаются единицы в секунду, и мержи справляются с большим запасом. Заодно вставка становится дешевле: сортировка и сжатие на большом блоке эффективнее, чем на одной строке.

// Если поток мелких событий неизбежен, есть два пути. Буфер перед базой - очередь вроде Kafka или собственный батчер в приложении. Либо async_insert: сервер копит блоки сам, но меняется семантика подтверждения - по умолчанию клиент получает ответ до того, как данные записаны, и это решение надо принимать осознанно, а не по умолчанию.

too many parts
отказ вставки при 3000 активных кусков в партиции: мержи не успевают за мелкими вставками

ORDER BY и типы - решения навсегда

Ключ сортировки выбирается один раз: поменять его у существующей таблицы нельзя, только создать новую и перелить данные. На петабайте это событие масштаба проекта, поэтому «потом переделаем» не наступает никогда.

Порядок колонок в ключе строят под фильтры будущих запросов: сначала частые фильтры невысокой кардинальности (арендатор, тип события), потом время. Логика та же, что и с гранулами: индекс работает префиксом, и колонка, которую редко фильтруют, стоящая первой, обесценивает весь ключ.

Типы решают не меньше. Измерено: миллион строк из трёх повторяющихся значений занимает 31,9 КиБ обычной строкой и 14,0 КиБ в LowCardinality - в 2,3 раза меньше, потому что вместо текста хранятся номера в словаре. Дальше по мелочи, которая складывается: узкие числа вместо Int64 везде, DateTime вместо строковой даты, Enum вместо текстовых статусов.

// Кодеки сжатия задаются по колонке и подбираются под её профиль: Delta и DoubleDelta для времени и монотонных счётчиков, ZSTD вместо LZ4 там, где место дороже процессорного времени. Это уже тонкая настройка, но на больших колонках она даёт разы.

CREATE TABLE events (
  tenant   LowCardinality(String),
  event    LowCardinality(String),
  ts       DateTime CODEC(Delta, ZSTD),
  user_id  UInt64,
  value    UInt32
) ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (tenant, event, ts);
-- фильтры по арендатору и типу отсекают гранулы, время идёт последним
LowCardinality
словарное кодирование повторяющихся строк: вместо текста хранится номер значения

Диагностика: три взгляда вместо гадания

Фраза «ClickHouse медленный» без трёх конкретных цифр ничего не значит. Первая - system.query_log: сколько строк и байт запрос реально прочитал. Обычно там сразу видно, что вместо ожидаемых миллионов прочитаны миллиарды.

Вторая - EXPLAIN indexes=1 и строчка Granules: сколько гранул отсеклось. 12 из 244 - индекс работает, 244 из 244 - не работает, и дальше надо разбираться с ключом и фильтром.

Третья - system.parts: сколько активных кусков у таблицы. Рост числа кусков - ранний сигнал, что вставка организована неправильно, и он появляется задолго до ошибки too many parts.

// Вторичные индексы по пропуску данных (minmax, set, bloom_filter) существуют, но это заплатка для точечных фильтров вне ключа, а не замена правильному ORDER BY. Сначала ключ, потом они. И отдельно - лимиты на пользователей (max_memory_usage, max_execution_time): один аналитик со звёздочкой по всей истории не должен ронять кластер.

Как отвечать: «Как правильно организовать вставку данных в ClickHouse?»

Батчами, от десятков тысяч строк, с частотой около раза в секунду. Причина в устройстве: каждая вставка рождает отдельный кусок на диске, при тысяче кусков в партиции сервер начинает притормаживать вставки, при трёх тысячах отказывает с ошибкой too many parts. Если писать по строке двести раз в секунду, три тысячи кусков набегут за пятнадцать секунд, и мержи за этим не угонятся физически. Если источник - поток мелких событий, ставлю перед базой буфер: очередь или батчер в приложении. Либо включаю async_insert, но осознанно, потому что у него другая семантика подтверждения - клиент получает ответ раньше, чем данные легли. И вставляю в правильно спроектированную таблицу: ключ сортировки под будущие фильтры и экономные типы, потому что это решения, которые потом меняются только пересозданием таблицы. Слежу за числом кусков в system.parts - это ранний сигнал, что вставка организована неверно.

Правило с числами, объяснение через устройство кусков, обе альтернативы для потока и связь с необратимыми решениями таблицы.

На чём валят

  • Вставлять по одной строке из приложения: три тысячи кусков и остановка записи набегают за секунды.
  • Включить async_insert, не разобравшись, что подтверждение вставки теперь означает другое.
  • Держать String для статусов и дат вместо LowCardinality, Enum и DateTime - двукратная плата на каждом чтении.
  • Ставить ключ сортировки по времени первым, когда все запросы фильтруют по арендатору.
  • Вешать индексы пропуска на все колонки вместо продуманного ORDER BY.
  • Обещать «пересоздадим таблицу с новым ключом потом»: на реальном объёме это уже никогда.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.

  1. #ch_performance1 / 5
    Что даёт PREWHERE по сравнению с обычным WHERE в ClickHouse?
    A)PREWHERE выполняется уже после полной загрузки всех без исключения колонок строки в память, лишь ускоряя вывод
    B)PREWHERE и WHERE идентичны и компилируются в один и тот же план выполнения
    C)Сначала читает и фильтрует по «дешёвым» колонкам, а остальные подтягивает лишь для прошедших строк
    D)PREWHERE запрещает читать колонки, кроме входящих в первичный ключ таблицы
    показать ответ и разбор
    +C)Сначала читает и фильтрует по «дешёвым» колонкам, а остальные подтягивает лишь для прошедших строк

    // разбор: PREWHERE — оптимизация чтения: движок сначала читает только колонки из условия PREWHERE, фильтрует, и лишь для выживших строк дочитывает остальные (обычно тяжёлые) колонки, экономя I/O. ClickHouse часто переносит условия в PREWHERE автоматически (optimize_move_to_prewhere), но ручной PREWHERE по дешёвой селективной колонке помогает на широких таблицах. Проверено: PREWHERE v>5000 WHERE id<900 отработал.

  2. #ch_performance2 / 5
    Что даёт тип LowCardinality(String) для колонки?
    A)Жёстко ограничивает колонку хранением не более ста различных значений, отвергая лишние
    B)Запрещает индексировать эту колонку и использовать её в секции ORDER BY таблицы
    C)Автоматически шифрует значения этой строковой колонки при записи их на диск сервера ClickHouse
    D)Словарное кодирование колонки с малым числом уникальных значений — меньше места, быстрее фильтры/группировки
    показать ответ и разбор
    +D)Словарное кодирование колонки с малым числом уникальных значений — меньше места, быстрее фильтры/группировки

    // разбор: LowCardinality оборачивает тип в словарное (dictionary) кодирование: значения заменяются на короткие числовые ключи плюс словарь. Для колонок с небольшим числом различных значений (статусы, страны, платформы) это заметно сокращает размер и ускоряет GROUP BY/фильтры, ведь сравниваются числа. Порог «низкой» кардинальности эмпирический (обычно до десятков-сотен тысяч). На высокой кардинальности выгоды нет. Проверено: тип CAST(... AS LowCardinality(String)).

  3. #ch_performance3 / 5
    Запросы фильтруют по (event_date, app_id). Как выбрать ORDER BY таблицы MergeTree?
    A)Частые фильтры префиксом, от низкой кардинальности к высокой — обычно (event_date, app_id)
    B)Порядок колонок в ORDER BY значения не имеет — первичный индекс одинаково эффективен при их порядке
    C)Указать в ORDER BY все колонки таблицы, чтобы под индекс попал каждый запрос
    D)Отсортировать по самой уникальной колонке (id) — тогда индекс будет максимально эффективным
    показать ответ и разбор
    +A)Частые фильтры префиксом, от низкой кардинальности к высокой — обычно (event_date, app_id)

    // разбор: Разрежённый индекс эффективен на ПРЕФИКСЕ ключа сортировки, поэтому в ORDER BY первыми ставят колонки из частых фильтров, обычно от меньшей кардинальности к большей — так гранулы лучше отсекаются и данные лучше сжимаются. Для фильтра по (date, app) ключ (event_date, app_id) даёт отсечение по обоим. Пихать все колонки или начинать с самого уникального id вредно: индекс раздувается, а частые запросы не ускоряются.

  4. #ch_performance4 / 5
    Зачем нужны data skipping indexes (например, minmax) в ClickHouse?
    A)Чтобы заменить собой первичный ключ таблицы и сделать секцию ORDER BY необязательной
    B)Пропускать гранулы по колонке НЕ из первичного ключа — хранят min/max/набор на блок и отсекают лишние
    C)Чтобы проиндексировать каждую строку таблицы поимённо и обеспечить мгновенный точечный поиск, как в OLTP-СУБД
    D)Чтобы автоматически удалять из таблицы дубликаты строк во время фонового слияния её кусков
    показать ответ и разбор
    +B)Пропускать гранулы по колонке НЕ из первичного ключа — хранят min/max/набор на блок и отсекают лишние

    // разбор: Skip-индексы (minmax, set, bloom_filter, ngrambf) хранят компактную сводку по блокам гранул (мин/макс, множество значений, фильтр Блума) и позволяют пропускать гранулы для фильтров по колонкам, НЕ входящим в ORDER BY. Вторичное ускорение поверх первичного индекса: помогает, когда часто фильтруют по «неключевой» колонке. Работают вероятностно и только если данные по ней хоть немного локальны в кусках — на равномерно перемешанной колонке толку мало.

  5. #ch_performance5 / 5
    Аналитик добавил FINAL к каждому запросу над ReplacingMergeTree «для правильности». Чем это плохо?
    A)Ничем: FINAL — бесплатная операция, и добавлять его к каждому запросу является рекомендуемой практикой
    B)FINAL искажает данные, возвращая случайные версии строк вместо последней по ключу сортировки
    C)FINAL сливает куски на лету при каждом запросе — дорого; часто дешевле агрегировать argMax по версии
    D)FINAL применим к движку MergeTree и вызывает ошибку на таблице ReplacingMergeTree
    показать ответ и разбор
    +C)FINAL сливает куски на лету при каждом запросе — дорого; часто дешевле агрегировать argMax по версии

    // разбор: FINAL заставляет CH домердживать куски в момент запроса, чтобы отдать схлопнутый результат, — это чтение и слияние поверх обычного плана, заметно медленнее, особенно на больших таблицах и частых запросах. Часто дешевле обойти его: GROUP BY key с argMax(col, version) или запросы, устойчивые к дубликатам. FINAL уместен точечно, а не как обязательная обёртка на всё. Параллельный FINAL есть, но цена остаётся.

дальше

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

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