Skip to content
ClickHouse Docs
ClickHouse DocsClickHouse Docs

Runbook: схема JSON

Ваши данные поступают в формате JSON. ClickHouse предлагает несколько способов их хранения: от полностью типизированных столбцов до хранения в виде необработанного String. Правильный выбор зависит от того, насколько предсказуема ваша схема и нужны ли вам запросы по отдельным полям.

Область охвата: Эта страница посвящена выбору схемы для хранения данных JSON. Она не охватывает форматы ввода/вывода JSON, функции JSON или синтаксис запросов. Общие сведения о самом типе столбца JSON см. в разделе Используйте JSON там, где это уместно.

Предполагается: Знакомство с созданием таблиц ClickHouse, основами MergeTree и синтаксисом типов столбцов.

Быстрый выбор

  • Если у каждого поля известный стабильный тип и схема меняется редко Типизированные столбцы
  • Если большинство полей стабильны, но какая-то часть данных динамическая или непредсказуемая Гибридная схема (типизированные столбцы + JSON)
  • Если вся структура динамическая, а ключи в разных записях то появляются, то исчезают Нативный JSON-столбец
  • Если динамические поля представляют собой пары ключ-значение с единым типом значений (например, строковые теги или числовые метрики) Map вместо JSON
  • Если вы только сохраняете и извлекаете JSON-объект без запросов по отдельным полям Непрозрачное хранение в String

Подробности о подходе

Типизированные столбцы

Когда использовать: Структура JSON полностью известна на этапе проектирования. Поля и типы не меняются от записи к записи. Даже сложные вложенные структуры (массивы объектов, вложенные словари) можно выразить с помощью типов Array, Tuple и Nested.

Компромиссы: Для изменения схемы требуется ALTER TABLE. Непредусмотренные поля при вставке молча отбрасываются, если схема не была обновлена.

Настройка, проверка и подводные камни

Настройка

CREATE TABLE events
(
    `timestamp` DateTime,
    `service`   LowCardinality(String),
    `level`     Enum8('DEBUG' = 1, 'INFO' = 2, 'WARN' = 3, 'ERROR' = 4),
    `message`   String,
    `host`      LowCardinality(String),
    `duration_ms` UInt32
)
ENGINE = MergeTree
ORDER BY (service, timestamp)

Проверка

-- Убедитесь, что типы столбцов соответствуют ожидаемым
DESCRIBE TABLE events FORMAT Vertical

-- Выполните вставку и запрос, чтобы проверить, что схема корректно обрабатывает ваши данные
INSERT INTO events FORMAT JSONEachRow
{"timestamp":"2025-03-19 10:00:00","service":"api","level":"INFO","message":"request handled","host":"node-1","duration_ms":42}

SELECT service, level, duration_ms FROM events WHERE service = 'api'

Обратите внимание

  • Если вы вставляете JSON-данные с JSONEachRow и JSON содержит поля, которых нет в схеме, ClickHouse по умолчанию молча их отбрасывает. Установите input_format_skip_unknown_fields в 0, если хотите вместо этого получать ошибки.

Гибридный подход (типизированные столбцы + JSON)

Когда использовать: Базовый набор полей стабилен (временные метки, ID, коды состояния), но часть данных динамическая. Например, пользовательские атрибуты, теги, метаданные или поля расширений, которые различаются от записи к записи.

Компромиссы: Максимальная производительность на типизированных столбцах и гибкость в JSON-столбце. При этом JSON-столбец по-прежнему добавляет накладные расходы на вставку и затраты на хранилище для своей динамической части.

Настройка, проверка и подводные камни

Настройка

CREATE TABLE events
(
    `timestamp`  DateTime,
    `service`    LowCardinality(String),
    `level`      Enum8('DEBUG' = 1, 'INFO' = 2, 'WARN' = 3, 'ERROR' = 4),
    `message`    String,
    `host`       LowCardinality(String),
    `duration_ms` UInt32,
    `attributes` JSON(
        max_dynamic_paths = 256,
        `http.status_code` UInt16,
        `http.method` LowCardinality(String),
        SKIP REGEXP 'debug\..*'
    )
)
ENGINE = MergeTree
ORDER BY (service, timestamp)

Проверка

-- Вставьте пример данных и посмотрите, какие пути были определены
INSERT INTO events FORMAT JSONEachRow
{"timestamp":"2025-03-19 10:00:00","service":"api","level":"INFO","message":"request handled","host":"node-1","duration_ms":42,"attributes":{"http.status_code":200,"http.method":"GET","user.region":"eu-west","custom_tag":"abc"}}

SELECT JSONAllPathsWithTypes(attributes)
FROM events
FORMAT PrettyJSONEachRow

Обратите внимание

  • Используйте подсказки типов для JSON-путей, которые известны заранее. Подсказки обходят столбец-дискриминатор и сохраняют путь как обычный типизированный столбец — с той же производительностью и без накладных расходов.
  • Используйте SKIP или SKIP REGEXP для путей, которые вы никогда не запрашиваете (отладочные метаданные, внутренние ID трассировки), чтобы экономить хранилище и уменьшать число подстолбцов.
  • Устанавливайте max_dynamic_paths пропорционально числу различных путей, которые вы действительно запрашиваете. Значение по умолчанию (1024) подходит для большинства случаев. Уменьшите его, если динамическая часть у вас небольшая.
  • Не устанавливайте max_dynamic_paths выше 10,000. Высокие значения увеличивают потребление ресурсов и снижают эффективность.

Нативный JSON-столбец

Когда использовать: Когда структура действительно непредсказуема и ключи появляются и исчезают от записи к записи. Схемы, создаваемые пользователями, системы плагинов или ингестия в озеро данных, где вы не контролируете исходную схему.

Компромиссы: Вставка медленнее, чем в типизированные столбцы. Полное чтение объекта медленнее, чем у String. Дополнительные затраты хранилища на управление подстолбцами. Хорошо подходит для запросов по отдельным полям на конкретных путях.

Настройка, проверка и подводные камни

Настройка

CREATE TABLE dynamic_events
(
    `id`   UInt64,
    `ts`   DateTime DEFAULT now(),
    `data` JSON(
        max_dynamic_paths = 512,
        `event_type` LowCardinality(String),
        `version` UInt8
    )
)
ENGINE = MergeTree
ORDER BY (data.event_type, ts)

Используйте формат JSONAsObject при вставке целых JSON-документов в JSON-столбец. В нём каждая входная строка интерпретируется как полный объект JSON, сопоставленный со столбцом.

Проверка

INSERT INTO dynamic_events (id, data) FORMAT JSONEachRow
{"id": 1, "data": {"event_type": "click", "version": 2, "page": "/home", "button_id": "cta-1"}}
{"id": 2, "data": {"event_type": "purchase", "version": 1, "item_id": "SKU-99", "amount": 49.99, "currency": "USD"}}

-- Проверьте, какие пути ClickHouse обнаружил и их типы
SELECT JSONAllPathsWithTypes(data) FROM dynamic_events FORMAT PrettyJSONEachRow

-- Выполните запрос по конкретному пути
SELECT data.page FROM dynamic_events WHERE data.event_type = 'click'

На что обратить внимание

  • Без подсказок типов ClickHouse определяет тип для каждого пути по первым встретившимся значениям. Если score приходит как "10" (строка) в одной записи и как 10 (целое число) в другой, для этого пути создаётся столбец-дискриминатор, и запросы становятся медленнее. Добавляйте подсказки для путей с известными типами.
  • Когда количество путей превышает max_dynamic_paths, значения сверх лимита перемещаются в общую структуру данных, что снижает производительность запросов. Отслеживайте это с помощью JSONDynamicPaths() и держите лимит ниже 10 000.
  • Каждый динамический путь поддерживает до max_dynamic_types (по умолчанию 32) различных типов данных. Если один путь превышает этот предел, дополнительные типы переключаются на общее хранилище Variant. Обычно это несущественно, если только в ваших данных нет сильно различающихся типов для одного и того же поля.

Непрозрачное хранение в String

Когда использовать: JSON-документы хранятся и извлекаются целиком, а затем передаются в приложение, архивируются или отправляются дальше по конвейеру. Без фильтрации по отдельным полям и агрегации внутри ClickHouse.

Компромиссы: Самые быстрые вставки и самая простая схема. Запросы по отдельным полям невозможны без разбора во время выполнения (семейство JSONExtract), а это плохо масштабируется.

Настройка, проверка и подводные камни

Настройка

CREATE TABLE raw_events
(
    `id`        UInt64,
    `received`  DateTime DEFAULT now(),
    `payload`   String
)
ENGINE = MergeTree
ORDER BY (received)

Проверка

INSERT INTO raw_events (id, payload) VALUES
(1, '{"type":"click","page":"/home"}'),
(2, '{"type":"purchase","item":"SKU-99","amount":49.99}')

-- Убедитесь, что данные записываются и читаются без изменений
SELECT payload FROM raw_events WHERE id = 1

-- Убедитесь, что при необходимости поля всё ещё можно разбирать на лету
SELECT JSONExtractString(payload, 'type') AS event_type FROM raw_events

На что обратить внимание

  • Если требования изменятся и позже вам понадобятся запросы по отдельным полям, придётся создать новую таблицу с типизированными столбцами или JSON-столбцами и выполнить дозагрузку данных. Если есть хоть какая-то вероятность, что вам потребуется обращаться к отдельным полям, лучше сразу выбрать гибридный подход.
  • Функции JSONExtract разбирают строку при каждом запросе. Это приемлемо для разового анализа, но не для панелей мониторинга в продакшн и не для рабочих нагрузок с высоким QPS.
  • Если JSON-полезная нагрузка велика, рассмотрите кодеки сжатия (ZSTD) для столбца String — такие данные хорошо сжимаются.

Сравнение

Критерий Типизированные столбцы Гибридный Нативный JSON String
Пропускная способность вставки Самая высокая Высокая Умеренная Самая высокая
Запрос по отдельным полям Самые быстрые Быстрые (типизированные); хорошие (JSON с hint) Хорошие (с hint); медленнее (Dynamic) Медленные (разбор во время выполнения)
Чтение объекта целиком Быстрое Умеренное Медленное Самое быстрое
Эффективность хранения Лучшая Хорошая Умеренная Хорошая (хорошо сжимается)
Гибкость схемы Отсутствует (ALTER TABLE) Частичная (жёсткое ядро, гибкий tail) Полная Полная
Сложность Низкая Средняя Средняя–высокая Низкая

Когда лучше подходит Map

Если ваши динамические поля представляют собой однородные пары «ключ-значение» — то есть все значения имеют один и тот же тип, — Map(String, T) проще и эффективнее, чем JSON-столбец. Типичные примеры: строковые теги (Map(String, String)), числовые метрики (Map(String, Float64)) или feature flags (Map(String, Bool)).

CREATE TABLE tagged_events
(
    `timestamp` DateTime,
    `service`   LowCardinality(String),
    `tags`      Map(String, String)  -- e.g., {"env": "prod", "region": "us-east-1", "team": "platform"}
)
ENGINE = MergeTree
ORDER BY (service, timestamp)

Map поддерживает фильтрацию на уровне ключей (tags['env'] = 'prod'), хранится дешевле, чем JSON, и позволяет избежать накладных расходов на подстолбцы, характерных для типа JSON. Обратите внимание, что поиск по ключу по умолчанию выполняет линейное сканирование map — это нормально для небольших наборов тегов, но для map со 100+ ключами стоит рассмотреть сериализацию with_buckets. Используйте JSON, когда значения имеют смешанные типы или структура содержит вложенные данные, — используйте Map, когда это плоские пары ключ-значение с единым типом значений.

Navigation