Другие подходы к моделированию JSON
Ниже приведены альтернативные подходы к моделированию JSON в ClickHouse. Они приведены для полноты и использовались до появления типа JSON, поэтому, как правило, не рекомендуются и не применяются в большинстве сценариев.
К разным объектам в одной и той же схеме можно применять разные техники. Например, для одних объектов лучше всего подойдет тип String, а для других — тип Map. Обратите внимание, что после выбора типа String больше не требуется принимать какие-либо решения о схеме. Напротив, в ключ Map можно вложить подчиненные объекты, включая String, представляющую JSON, как показано ниже:
Использование типа String
Если объекты очень динамичны, не имеют предсказуемой структуры и содержат произвольные вложенные объекты, следует использовать тип String. Значения можно извлекать во время выполнения запроса с помощью JSON‑функций, как показано ниже.
Обработка данных с использованием описанного выше структурированного подхода часто неприменима для пользователей, работающих с динамическим JSON, который либо подвержен изменениям, либо чья схема плохо известна. Для максимальной гибкости пользователи могут просто хранить JSON как String и затем использовать функции для извлечения требуемых полей. Это представляет собой полную противоположность обработке JSON как структурированного объекта. Такая гибкость имеет свою цену и приводит к существенным недостаткам — в первую очередь к увеличению сложности синтаксиса запросов, а также к ухудшению производительности.
Как отмечалось ранее, для исходного объекта person мы не можем гарантировать структуру столбца tags. Мы вставляем исходную строку (включая company.labels, который пока игнорируем), объявляя столбец Tags как String:
Мы можем выбрать столбец tags и увидеть, что JSON был вставлен в виде строки:
С помощью функций JSONExtract можно извлекать значения из этого JSON-документа. Рассмотрим простой пример ниже:
Обратите внимание, что функциям требуется как ссылка на столбец типа String tags, так и путь в JSON для извлечения. Вложенные пути требуют вложенного вызова функций, например JSONExtractUInt(JSONExtractString(tags, 'car'), 'year'), который извлекает столбец tags.car.year. Извлечение вложенных путей можно упростить с помощью функций JSON_QUERY и JSON_VALUE.
Рассмотрим крайний случай с датасетом arxiv, где мы рассматриваем всё тело как значение типа String.
Чтобы вставить данные в эту схему, нужно использовать формат JSONAsString:
Предположим, мы хотим посчитать количество статей, выпущенных по годам. Сравним следующий запрос, использующий только строковое поле, с структурированной версией схемы:
Обратите внимание на использование XPath-выражения для фильтрации JSON по методу, т.е. JSON_VALUE(body, '$.versions[0].created').
Строковые функции заметно медленнее (> 10x), чем явные преобразования типов с использованием индексов. Приведённым выше запросам всегда требуется полное сканирование таблицы и обработка каждой строки. Хотя такие запросы всё ещё будут быстрыми на небольших наборах данных, как в этом примере, по мере роста объёма данных производительность будет ухудшаться.
Гибкость такого подхода имеет очевидную цену в виде потерь производительности и усложнения синтаксиса, поэтому его следует использовать только для высокодинамичных объектов в схеме.
Простые JSON-функции
Выше приведены примеры использования семейства функций JSON*. Они используют полноценный JSON-парсер на базе simdjson, который строго относится к разбору и различает одноимённые поля на разных уровнях вложенности. Эти функции способны корректно обрабатывать синтаксически правильный, но плохо отформатированный JSON, например с двойными пробелами между ключами.
Доступен более быстрый и строгий набор функций. Эти функции simpleJSON* потенциально обеспечивают лучшую производительность, в первую очередь за счёт жёстких предположений о структуре и формате JSON. В частности:
-
Имена полей должны быть константами
-
Должна использоваться единообразная кодировка имён полей, например,
simpleJSONHas('{"abc":"def"}', 'abc') = 1, ноvisitParamHas('{"\\u0061\\u0062\\u0063":"def"}', 'abc') = 0 -
Имена полей должны быть уникальными во всех вложенных структурах. Уровни вложенности не различаются, сопоставление выполняется без учёта уровня вложенности. В случае нескольких совпадающих полей используется первое вхождение.
-
Вне строковых литералов не допускаются специальные символы. Это относится и к пробелам. Следующий пример является некорректным и не будет разобран:
В то время как следующий пример будет успешно разобран:
Приведённый выше запрос использует simpleJSONExtractString для извлечения ключа created, учитывая, что нам нужно только первое значение для даты публикации. В этом случае ограничения функций simpleJSON* приемлемы ради повышения производительности.
Использование типа Map
Если объект используется для хранения произвольных ключей (преимущественно одного типа), рассмотрите использование типа Map. В идеале количество уникальных ключей не должно превышать нескольких сотен. Тип Map также можно использовать для объектов с вложенными объектами при условии, что последние однородны по своим типам. В целом мы рекомендуем использовать тип Map для лейблов и тегов, например лейблов подов Kubernetes в логах.
Хотя Map предоставляет простой способ представления вложенных структур, у него есть несколько заметных ограничений:
- Все поля должны быть одного и того же типа.
- Доступ к подстолбцам требует специального синтаксиса
Map, поскольку поля не существуют как отдельные столбцы. Весь объект и есть столбец. - Доступ к подстолбцу загружает всё значение
Map, то есть все соседние ключи и их соответствующие значения. Для большихMapэто может приводить к существенным потерям производительности.
При моделировании объектов как Map в качестве ключа используется строка (String), в которой хранится имя ключа JSON. Поэтому Map всегда будет иметь вид Map(String, T), где T зависит от данных.
Примитивные значения
Простейшее применение Map — когда объект содержит значения одного и того же примитивного типа. В большинстве случаев это означает использование типа String для значения T.
Рассмотрим наш предыдущий JSON с описанием человека, где объект company.labels был определён как динамический. Важно, что мы ожидаем добавления в этот объект только пар ключ–значение типа String. Таким образом, мы можем объявить его как Map(String, String):
Мы можем вставить наш исходный полный JSON-объект:
Для выборки этих полей в объекте request нужно использовать синтаксис map, например:
Полный набор функций Map, доступных для выполнения запросов, описан здесь. Если ваши данные не имеют согласованного типа, существуют функции для выполнения необходимого приведения типов.
Значения объектов
Тип Map также можно использовать для представления объектов с вложенными объектами, при условии, что у последних согласованы их типы.
Предположим, ключ tags для нашего объекта persons требует согласованной структуры, где вложенный объект для каждого tag имеет столбцы name и time. Упрощённый пример такого JSON-документа может выглядеть следующим образом:
Это можно смоделировать с помощью типа Map(String, Tuple(name String, time DateTime)), как показано ниже:
Использование Map в данном случае встречается довольно редко и указывает на то, что модель данных следует изменить так, чтобы динамические имена ключей не имели вложенных объектов. Например, приведённую выше структуру можно изменить следующим образом, что позволит использовать Array(Tuple(key String, name String, time DateTime)).
Использование типа Nested
Тип Nested можно использовать для моделирования статических объектов, которые редко изменяются, в качестве альтернативы Tuple и Array(Tuple). В целом мы рекомендуем избегать использования этого типа для JSON, поскольку его поведение часто оказывается запутанным. Основное преимущество Nested заключается в том, что подколонки могут использоваться в ключах сортировки.
Ниже приведён пример использования типа Nested для моделирования статического объекта. Рассмотрим следующую простую запись журнала в формате JSON:
Мы можем объявить ключ request типом Nested. Подобно Tuple, необходимо явно указать подстолбцы.
flatten_nested
Параметр flatten_nested управляет поведением типа данных Nested.
flatten_nested=1
Значение 1 (по умолчанию) не поддерживает произвольную глубину вложенности. При таком значении проще всего рассматривать вложенную структуру данных как несколько столбцов Array одинаковой длины. Поля method, path и version фактически являются отдельными столбцами Array(Type) с одним критическим ограничением: длина полей method, path и version должна быть одинаковой. Если мы воспользуемся SHOW CREATE TABLE, это иллюстрируется следующим образом:
Ниже выполняем вставку в эту таблицу:
Несколько важных моментов, на которые стоит обратить внимание:
-
Необходимо использовать настройку
input_format_import_nested_json, чтобы вставить JSON в виде вложенной структуры. Без этого JSON нужно будет сплющивать, т.е. -
Вложенные поля
method,pathиversionдолжны передаваться как JSON-массивы, т.е.
К столбцам можно обращаться, используя точечную нотацию:
Обратите внимание, что использование Array для подстолбцов означает, что можно потенциально использовать весь спектр функций работы с массивами, включая клаузу ARRAY JOIN, что полезно, если ваши столбцы содержат несколько значений.
flatten_nested=0
Это позволяет использовать произвольный уровень вложенности и означает, что вложенные столбцы остаются одним массивом Tuple — по сути, они становятся тем же самым, что и Array(Tuple).
Это предпочтительный и часто самый простой способ использования JSON с Nested. Как показано ниже, для этого лишь требуется, чтобы все объекты представляли собой список.
Ниже мы заново создаём нашу таблицу и повторно вставляем строку:
Несколько важных моментов, на которые стоит обратить внимание:
-
input_format_import_nested_jsonне требуется указывать при вставке. -
Тип
Nestedсохраняется вSHOW CREATE TABLE. Под капотом этот столбец фактически имеет типArray(Tuple(Nested(method LowCardinality(String), path String, version LowCardinality(String)))). -
В результате поле
requestнужно вставлять как массив, то есть:
К столбцам снова можно обращаться, используя точечную нотацию:
Пример
Более объёмный пример приведённых выше данных доступен в общедоступном бакете в S3 по адресу: s3://datasets-documentation/http/.
С учетом ограничений и формата входных данных JSON мы вставляем этот пример набора данных с помощью следующего запроса. Здесь мы задаем flatten_nested=0.
Следующий запрос вставляет 10 миллионов строк, поэтому выполнение может занять несколько минут. При необходимости примените LIMIT:
Чтобы выполнять запросы к этим данным, нам необходимо обращаться к полям запроса как к массивам. Ниже мы агрегируем ошибки и HTTP-методы за фиксированный период времени.
Использование парных массивов
Парные массивы обеспечивают баланс между гибкостью представления JSON в виде строк (String) и производительностью более структурированного подхода. Схема гибкая в том смысле, что любые новые поля потенциально могут быть добавлены в корень. Однако это требует значительно более сложного синтаксиса запросов и несовместимо с вложенными структурами.
В качестве примера рассмотрим следующую таблицу:
Чтобы выполнить вставку в эту таблицу, нам нужно структурировать JSON в виде списка ключей и значений. Следующий запрос демонстрирует использование JSONExtractKeysAndValues для достижения этого:
Обратите внимание, что столбец request остаётся вложенной структурой, представленной в виде строки. Мы можем добавлять любые новые ключи на верхнем уровне. Также в самом JSON могут быть произвольные различия. Чтобы вставить данные в нашу локальную таблицу, выполните следующее:
Для выполнения запросов к этой структуре необходимо использовать функцию indexOf для определения индекса нужного ключа (который должен соответствовать порядку в массиве значений). Это можно использовать для доступа к столбцу массива значений, т.е. values[indexOf(keys, 'status')]. Для столбца request по‑прежнему требуется метод парсинга JSON — в данном случае simpleJSONExtractString.
5 строк в наборе. Прошло времени: 0.383 сек. Обработано 8.22 млн строк, 1.97 ГБ (21.45 млн строк/с, 5.15 ГБ/с.) Пиковое потребление памяти: 51.35 МиБ.