ADG Оставить заявку
Блог Данные и аналитика 5 мин чтения

ClickHouse 26.1: JSON v2 хоронит наши костыли с CodecJSON-колонками

Обновили аналитический кластер до ClickHouse 26.1 с нативным JSON v2 и улучшенным параллельным чтением S3. Сравниваем размер хранения и скорость ingest до и после.

Контекст момента

ClickHouse 26.1 вышел с нативным типом JSON v2 и улучшенным параллельным чтением S3

Примерно год назад, когда нативный JSON в ClickHouse стал GA в 25.1, мы перевели несколько таблиц с String-хранения на нативный тип и остались довольны - про это писали тогда. Но часть таблиц с тяжёлым payload мы не трогали: там было сочетание требований к ingest-скорости и специфического профиля запросов, под которое мы в итоге написали свою обёртку - условный «CodecJSON»: колонка с кастомным codec и набором материализованных путей для горячих полей. Не красиво, зато предсказуемо.

ClickHouse 26.1 вышел на прошлой неделе. Основной сюрприз - JSON v2 с переработанным внутренним форматом хранения субколонок. И параллельное чтение S3 получило новый планировщик. Оба изменения бьют именно в наш незакрытый долг.

Что изменилось в JSON v2

В 25.x нативный JSON хранил субколонки в отдельных файлах на диске, по одному файлу на каждый обнаруженный путь. При плотном JSON с десятками полей это давало заметный overhead на мета-операциях: открытие/закрытие дескрипторов при ingest, merge-задачи с большим числом файлов в parts. Плюс разреженные поля, которые встречались в 1-2% строк, хранились неэффективно - субколонка под них всё равно создавалась со всеми вытекающими.

JSON v2 переходит на другую структуру: плотные и редкие пути хранятся по-разному. Часто встречающиеся субколонки - как раньше, в отдельных файлах. Редкие пути собираются в общий «sparse store» и сжимаются вместе. Это снижает количество файлов в part при широком JSON и убирает overhead merge на таблицах с высокой кардинальностью путей.

Параллельно добавили явный контроль через JSON(max_dynamic_paths=N, max_dynamic_types=M) - можно явно ограничить, сколько уникальных путей движок будет держать как отдельные субколонки, остальное пойдёт в sparse store. Для нашего случая это ключевое: payload частично структурированный, но в нём есть «хвост» из нескольких десятков полей, которые пишут разные источники без строгой схемы.

Кастомный CodecJSON: история возникновения

Год назад мы упёрлись в то, что нативный JSON 25.1 на ingest-интенсивном потоке давал нестабильность merge-давления. На таблице с ~50 полями в payload и вставкой через несколько потоков Kafka merge-деревья ReplicatedMergeTree набухали быстрее, чем успевали схлопываться. В итоге пришли к гибриду: материализованные колонки для 15 горячих полей (явные типы, быстрые фильтры), плюс оригинальный payload как String с кастомным codec ZSTD(3). Поверх этого - набор словарей для lookup.

Работало стабильно, но каждый новый «горячий» путь требовал DDL-миграции. И размер String-колонки оставался таким же, как год назад.

Замеры после обновления на 26.1

Обновили один кластер - тестовый с зеркальным трафиком от продуктового. Перевели таблицу с payload на нативный JSON с параметрами max_dynamic_paths=20 (горячие поля как субколонки) и дали остальным уйти в sparse store.

Размер хранения. Payload-колонка ужалась примерно на 35-40% по сравнению со String(ZSTD(3)). Это лучше, чем мы ожидали: наши материализованные колонки под горячие поля тоже занимали место, и их теперь не нужно дублировать. Итоговый суммарный размер part-ов на тестовом кластере снизился ощутимо - считаем в процентах от общего объёма диска, а не от одной колонки, поэтому не хочу называть число без финального замера в продуктиве.

Скорость ingest. Вот здесь сюрприз в другую сторону: при max_dynamic_paths=20 и плотном потоке insert давление на merge не исчезло, но стало предсказуемее. Merge-задач стало меньше (меньше файлов в part), но каждая из них чуть тяжелее - sparse store требует дополнительных операций при compaction. На нашем профиле это нейтрально или слегка лучше; на очень широком JSON с сотнями путей поведение нужно проверять отдельно.

Запросы по горячим полям. Фильтры и агрегации по полям, которые попали в субколонки (top-20 по частоте) - без изменений или чуть быстрее. Запросы по sparse-полям - там где раньше шёл JSONExtractString из String-колонки - стали заметно быстрее: движок всё равно разворачивает sparse store, но не парсит JSON целиком.

Параллельное чтение S3: новый планировщик

Второе заметное в 26.1 - переработанный планировщик параллельного чтения из S3 (и совместимых хранилищ). До этого количество параллельных потоков на чтение одного файла выбиралось эвристически по размеру файла с фиксированным порогом. В 26.1 планировщик учитывает текущую загрузку сети и наблюдаемый throughput предыдущих запросов к тому же эндпоинту.

У нас на одном кластере данные частично в S3-совместимом хранилище. На запросах по большим диапазонам дат (читают несколько сотен GB данных из S3) мы видели нестабильный throughput - зависело от времени суток и загрузки. После 26.1 throughput стал ровнее, задержка на «холодных» запросах уменьшилась. Пока рано говорить о цифрах - нужно больше наблюдений, но направление правильное.

Что делаем дальше

Кастомные CodecJSON-обёртки сносим. На тестовом кластере всё выглядит достаточно убедительно, чтобы планировать перевод продуктовой таблицы. DDL-миграция с конвертацией String -> JSON v2 на нашем объёме займёт несколько часов с нагрузкой на диск - будем делать в ночное окно.

Материализованные колонки под горячие поля тоже убираем: теперь они просто дублируют то, что JSON v2 делает сам. Это примерно -15% от числа колонок в таблице, что упрощает схему и уменьшает количество мест, которые надо обновлять при изменении структуры payload.

Если занимаетесь аналитикой на ClickHouse и тоже копили подобные обходные решения вокруг JSON - DWH/BI-проекты это то место, где мы помогаем разобраться с такими долгами.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.