Назад к блогу

Ускорение аналитики по JSON: от научных идей к продакшену

Ускорение аналитики по JSON: от научных идей к продакшену

Аналитика по JSON часто тормозит не из-за вычислений, а из-за лишних операций ввода-вывода и неэффективного хранения данных. В статье разбираются реальные инженерные подходы к решению этой проблемы — от динамического шрединга в Logfire до автоопределения типов в DuckDB и особенностей работы Velox и SQLite с кастами. Это полезный материал для тех, кто проектирует хранилища и аналитические движки, сталкиваясь с компромиссами между гибкостью JSON и скоростью запросов.

Аналитические запросы по JSON упираются не в процессор, а в ввод-вывод и в структуру хранения. Разберём, как Logfire перешёл от разбора JSON на лету к динамическому шредингу, как DuckDB автоопределяет типы внутри JSON, как Velox и SQLite обрабатывают касты и граничные случаи, и что именно даёт ускорение на практике.

Почему JSON-аналитика упирается в производительность

Изначально Logfire хранил все атрибуты как JSON-блобы в одной колонке и разбирал их на лету. Проблема оказалась не в скорости разбора. Данные лежат в объектном хранилище, и лишний ввод-вывод от скачивания больших JSON-блобов — особенно задержка начала загрузки — доминирует во времени выполнения запроса куда сильнее, чем любая работа процессора.

Вторая проблема — сжатие. Один варьирующийся атрибут делает словарное сжатие на уровне Parquet неэффективным, и даже zstd плохо справляется с сильно изменчивыми данными и синтаксическим оверхедом JSON.

Колоночное хранение решает это иначе: DataFusion и Parquet избегают лишней работы за счёт чтения только нужных колонок и zone maps — можно пропускать целые row groups или страницы, не подходящие под предикат фильтра.

Решение Logfire — динамический shredding: наиболее часто запрашиваемые атрибуты автоматически извлекаются в отдельные строго типизированные колонки во время приёма данных и compaction. Это происходит без конфигурации. Запросы к нешреддированным атрибутам, наоборот, требуют сканирования всей JSON-колонки, что ведёт к высокому вводу-выводу, плохому сжатию и дорогому разбору JSON.

Как устроен конвейер запроса

Первым решением было статическое шрединг: вручную выбирался небольшой набор часто запрашиваемых атрибутов (например, http.status_code, http.method) и извлекался в собственные строго типизированные колонки при приёме данных. После этого статистика используется для отсечения файлов, row groups и страниц, не подходящих под условие, и сканируется только нужная типизированная колонка, которая хорошо сжимается.

Вариант с отдельной колонкой _lf_attributes_http.response.status_code = 500 обращается к колонке типа int64, по статистике отсекает файлы, row groups и страницы и сканирует только её. Вариант с attributes->>'http.response.status_code' = 500 вынужден сканировать всю JSON-колонку attributes, потому что нужный атрибут не вынесен отдельно. У JSON-варианта четыре издержки:

  • скачивание всей _lf_attributes — возможно, гигабайтов данных;
  • плохое сжатие из-за переменной структуры — эффективность zstd 10%;
  • разбор JSON: Jiter делает ленивый разбор, но в худшем случае приходится разбирать всю строку;
  • операции в памяти: создание копий извлечённых значений и сравнение в памяти.

В итоге запрос получает выгоду от прогрессивного сужения и колоночного хранения, а в конце конвейера JSON уже не парсится: данные извлечены и типизированы, десериализации нет, копирования в память нет.

Как DuckDB автоопределяет структуру JSON

DuckDB выводит типы внутри JSON-значений, отслеживая для каждого узла структуры возможные типы и отбрасывая те, которые не подходят встреченным значениям, пока не останется один, наиболее точно описывающий все данные.

Инициализация и сужение кандидатов

InitializeCandidateTypes вызывается для узла структуры JSON и задаёт список возможных типов только тогда, когда у узла ровно одна описательная запись и её тип — VARCHAR, а флаг инициализации ещё не выставлен. При достижении максимальной глубины или при наличии более одной описательной записи функция сразу возвращает управление, иначе рекурсивно обходит детей. Набор возможных типов зависит от двух булевых параметров — преобразования строк в целые и наличия пользовательского формата времени:

if (convert_strings_to_integers) {
    if (user_specified_timestamp_format) {
        description.candidate_types = {UUID, BIGINT, TIMESTAMP, DATE, TIME};
    } else {
        description.candidate_types = {UUID, BIGINT, TIMESTAMP, TIMESTAMP_TZ, DATE, TIME};
    }
}

Без преобразования строк в целые BIGINT отсутствует, но UUID, TIMESTAMP, DATE, TIME остаются, и TIMESTAMP_TZ снова только при отсутствии пользовательского формата.

RefineCandidateTypes отбрасывает неподходящие типы: она также требует ровно одну описательную запись и наличия VARCHAR где-либо в поддереве, после чего в зависимости от типа описания вызывает специализацию для LIST, STRUCT или VARCHAR. Для строкового вектора вызывается EliminateCandidateTypes, которая идёт по типам с конца: для TIMESTAMP_TZ дополнительно требуется, чтобы состояние смещения было WITH_OFFSET, иначе тип отбрасывается; для типов с форматами вызывается EliminateCandidateFormats, для остальных — DefaultTryCast. Если у узла больше одной описательной записи, наличие VARCHAR в поддереве не подтверждается, и типы не сужаются — остаётся JSON по умолчанию.

Числовые типы

IsNumeric считает числовыми только четыре типа: DOUBLE, UBIGINT, BIGINT и HUGEINT. MaxNumericType принимает два числовых типа и выбирает тип, вмещающий оба диапазона: если хотя бы один DOUBLE — результат DOUBLE; иначе если хотя бы один HUGEINT — HUGEINT; иначе если один BIGINT, а другой UBIGINT — HUGEINT, потому что только HUGEINT покрывает оба диапазона; во всех остальных случаях — BIGINT.

В GetOrCreateDescription при добавлении нового типа сначала проверяется, нет ли уже такого же типа среди описаний, и если есть — используется существующее. Если тип новый, но и он, и имеющееся описание числовые, тип описания заменяется на MaxNumericType(type, description.type) — так разные числовые типы одного поля сливаются в один общий. Именно поэтому смешение BIGINT и UBIGINT даёт HUGEINT: по отдельности они не вмещают диапазоны друг друга.

Распознавание UTC-смещения

TimestampStringHasUtcOffset ищет позицию после разделителя 'T' или пробела, отделяющего дату от времени; если разделителя нет или он в конце строки, смещение не распознаётся. Затем она идёт по символам времени: при встрече 'Z' смещение признаётся сразу, при '+' или '-' — по результату разбора UTC-смещения. То есть настоящим смещением признаётся только 'Z' либо разбираемый ±HH[:MM].

UpdateTimestampOffsetState перебирает непустые строки вектора, пропускает null-значения, строки, не являющиеся timestamp-строками, и нефинитные значения (±infinity допустимо для TIMESTAMP и TIMESTAMPTZ). Если смещение есть, но TimestampStringHasUtcOffset его не распознаёт, строка пропускается как date special вроде epoch. Для каждой подходящей строки состояние обновляется через MergeTimestampOffsetState с WITH_OFFSET при наличии смещения и WITHOUT_OFFSET иначе, а при достижении MIXED функция немедленно возвращается.

MergeTimestampOffsetState — коммутативная таблица переходов: UNKNOWN возвращает второе состояние, MIXED получается при несовпадении двух разных состояний, иначе возвращается совпадающее. При параллельном слиянии MergeNodeVal объединяет состояние смещения родителя и потомка тем же правилом, и при MIXED очищает список возможных типов, чтобы тип стал VARCHAR.

Формат времени и TZ

Настройка timestamp_format= влияет на выбор возможных типов ещё до разбора значений: при заданном пользователем формате список формируется без TIMESTAMP_TZ, тогда как без него TZ включается. Поэтому в TryParse ветка для TIMESTAMP_TZ дополнительно защищается: если формат задан пользователем, тип TZ отбрасывается. Для остальных типов с форматами TryParse вызывает EliminateCandidateFormats, который перебирает форматы для данного типа и проверяет, парсится ли весь вектор; при успехе тип сохраняется, иначе отбрасывается.

Слияние и конвертация структур

MergeNodes обходит описания узла и для каждого вызывает специализацию по типу — для LIST, STRUCT или остальных, — предварительно суммируя счётчики: число значений и число null. MergeNodeArray получает или создаёт описание LIST и его единственного ребёнка, затем рекурсивно сливает в него каждого ребёнка списка. MergeNodeObject для каждого дочернего элемента по его ключу находит или создаёт соответствующего ребёнка и рекурсивно сливает в него структуру. MergeNodeVal получает или создаёт описание по типу значения, переносит флаг наличия большого беззнакового целого, а для строкового случая объединяет состояние смещения; если оно стало MIXED, кандидаты очищаются (смешанный offset/plain → VARCHAR), иначе кандидаты берутся из потомка при первой инициализации либо очищаются, если наборы различаются.

CalculateTypeSimilarity решает, можно ли слить два узла: при достижении максимальной глубины или если один из типов SQLNULL слияние считается совместимым, при несовместимости оно отклоняется, при JSON-типе одного из них или равенстве типов — допускается, а далее рекурсивно сравниваются STRUCT (через карту дочерних типов, отсутствие ключа отклоняет слияние), MAP (сравнивается тип значения) и LIST (сравнивается тип ребёнка). CalculateMapAndStructSimilarity усредняет попарные сходства между типом значения MAP и каждым дочерним типом STRUCT: если любая пара несовместима, слияние отклоняется, иначе берётся среднее по числу детей STRUCT.

IsStructureInconsistent определяет, насколько редко в среднем встречаются дочерние поля объекта: суммирует по всем детям отношение числа вхождений к числу не-null сэмплов, делит на число детей и сообщает о неоднородности структуры, если средняя встречаемость ниже порога. GetMergedType берёт единственное описание узла, рекурсивно сливает всех его детей и преобразует получившийся узел в тип.

Слияние в map допускается только для map и struct: в CalculateTypeSimilarity для STRUCT при не-struct типе слияние отклоняется, для MAP — только при MAP, для LIST аналогично требуется LIST. Несовместимые типы приводят к JSON: при нескольких описаниях узла преобразование в тип даёт JSON, а конвертация структуры — строку с именем JSON-типа.

Дубликаты ключей

ExtractStructureObject отслеживает дубликаты ключей двумя множествами: одно для точного совпадения, второе — без учёта регистра. Для каждого ключа он пытается вставить ключ в первое множество; если вставка не удалась и ошибки не игнорируются, выбрасывается ошибка о дубликате. Затем ключ вставляется во второе множество; если вставка не удалась, выбрасывается ошибка о дубликате с другим регистром. При игнорировании ошибок проверки пропускаются, и обработка продолжается. После проверок ключ добавляется или находится среди дочерних узлов.

Создание JSON: функции, касты и COPY

JSONCreateBindParams разрешает типы аргументов и имена полей struct: очищает список аргументов функции, затем для каждого аргумента берёт его тип возврата и либо требует VARCHAR для ключей объекта, либо преобразует тип через GetJSONType. Для struct-полей GetJSONType обходит дочерние типы, вставляет имя каждого поля в набор константных имён и рекурсивно преобразует тип поля. ToJSONBind — обёртка, которая проверяет, что передан ровно один аргумент, и вызывает JSONCreateBindParams без режима объекта. Логика вызывается и из разрешения типов, и из привязки, потому что JSON-тип от JSON-типа — тот же тип, поэтому повторное применение разрешения типов к уже JSON-типу даёт тот же результат.

Рекурсивный обход вложенных типов

CreateValues — диспетчер по типу вектора: он смотрит на тип и вызывает соответствующую специализацию, а для вложенных типов специализации рекурсивно вызывают CreateValues для дочерних векторов. CreateValuesStruct сначала создаёт для каждой строки JSON-объект, затем для каждого поля структуры формирует ключ и рекурсивно заполняет переиспользуемый массив вложенных значений, после чего отдельным проходом заменяет объект на JSON null, если вся структура NULL. CreateValuesMap рекурсивно создаёт ключи и значения, а затем для каждой не-NULL строки собирает объект, пропуская ключи, которые оказались JSON null. CreateValuesList рекурсивно заполняет вложенные значения для всего дочернего вектора, затем для каждой не-NULL строки создаёт массив и добавляет элементы по смещению и длине. CreateValuesUnion сначала создаёт объекты или null по валидности union, затем для каждого члена рекурсивно вызывает CreateValues и добавляет пару только если строка валидна, тег валиден, тег совпадает с индексом члена и ключ валиден.

NULL-запись целиком обрабатывается по-разному: в скалярных путях невалидная строка сразу даёт JSON null; в структурных, map, list и array после рекурсивного заполнения невалидная строка перезаписывается на JSON null; в union невалидная строка сразу получает JSON null и затем пропускается при добавлении пар.

Формат TIMESTAMPTZ в COPY

CreateValuesTimestampTZ сначала проверяет, задан ли формат COPY. Если формат не задан, берётся полный набор кастов — создаётся VARCHAR-вектор и применяется приведение с контекстом либо без него, — и именно этот путь приводит к применению сессионной таймзоны. Если формат задан, выбирается выражение форматтера в зависимости от типа TIMESTAMP_TZ_NS или иного; при отсутствии контекста или выражения выбрасывается внутренняя ошибка. Далее создаётся чанк с типом исходного вектора, в него помещается исходный вектор, проверяется кардинальность, и исполнитель выражений по выбранному выражению форматирования заполняет строковый вектор.

GeoJSON

Столбец геометрии выбирается опцией GEOMETRY_COLUMN: если она задана непустой строкой, столбец ищется по имени и должен иметь тип GEOMETRY; если не задана, берётся первый столбец типа GEOMETRY, а при его отсутствии бросается ошибка с подсказкой. Столбец id выбирается опцией ID_COLUMN аналогично, но без проверки типа; если опция не задана, по умолчанию берётся столбец с именем "id", что обеспечивает round-trip. Все остальные столбцы, кроме геометрии и id, становятся членами properties. Если геометрии нет, каждый Feature получает "geometry": null, поскольку Feature без геометрии всё ещё валиден. При записи bbox строки с невалидной геометрией и геометрии без конечного экстента пропускаются.

Трансформация JSON в типизированные значения

Форматы на первом значении

TryOneFormat пытается разобрать одну входную строку заданным форматом: для DATE — как дату, иначе как timestamp, и при успехе записывает значение в результат по индексу. TransformFromStringWithFormat сначала получает вектор строк, затем ищет первый не-null элемент, пропуская строки с недействительной валидностью, и для первого валидного значения перебирает форматы в обратном порядке, вызывая TryOneFormat; при первом успехе сохраняется индекс совпавшего формата и внешний цикл прерывается. Далее вычисляется предел разбора: если совпадение найдено — индекс плюс один, иначе — число форматов. Затем для каждой валидной строки форматы снова перебираются в обратном порядке от предела до начала до первого успешного разбора; если ни один формат не подошёл, строка помечается недействительной, а при строгом приведении записывается индекс объекта и признак неуспеха. TransformToString просто преобразует значения в строки и помечает null-значения недействительными, всегда возвращая успех.

Вложенные значения и индексация

TransformObjectInternal сначала помечает недействительными строки, где объект отсутствует или равен null, затем собирает имена и векторы дочерних полей структуры с учётом проекции; непроецированные дети заполняются NULL. Далее TransformObject строит карту имён в индексы и для каждого столбца выделяет массив вложенных значений длиной в число строк, заполняя значением по ключу или nullptr, если объект отсутствует. TransformArrayToTuple читает элементы JSON-массива позиционно в соответствующие дочерние поля структуры. TransformArrayToArray для каждого ребёнка и строки кладёт значение в общий массив по формуле «индекс ребёнка умножить на число строк плюс индекс строки», недостающие элементы становятся nullptr, лишние игнорируются. TransformObjectToMap резервирует list-вектор по суммарному числу ключей объектов и заполняет его записи, а вложенные значения передаёт дальше через дочерний вектор списка.

Индексация вложенных значений различается: в объектном случае — по столбцу, затем по строке; в массиве-в-массив — по ребёнку, затем по строке; в списке и map используется линейный индекс, который увеличивается на длину массива или число ключей.

Геометрия

TransformGeometry сначала проверяет все входные значения: если ни одно не является JSON-объектом, весь вектор считается состоящим только из WKT-строк, и применяется обычное преобразование строк. Если хотя бы один объект найден, элементы обрабатываются по отдельности: для объекта вызывается разбор GeoJSON, для строки — разбор WKT. Различие однозначно, потому что JSON-объект никогда не является валидным WKT, поэтому отдельная опция для различения форматов не нужна. При неудаче значение помечается недействительным, а при строгом приведении записывается сообщение об ошибке и индекс объекта.

Ошибки и граничные случаи при кастах

Детальные сообщения об ошибках

Детальное сообщение формируется в recordJsonCastTypeError: он получает смещение начала проблемного значения в буфере строки — как разность указателя и начала диапазона, если указатель лежит в диапазоне, иначе ноль, — затем пишет в thread-local хранилище строку вида «Cannot cast JSON value to the requested type. Source type: {}, target type: {}, byte offset: {}.» с именем исходного типа и целевым типом, и помечает сообщение валидным. Для скалярных кастов raiseScalarJsonCastError вызывает запись только если детализация включена, и всегда возвращает INCORRECT_TYPE. Для контейнерных кастов maybeRecordJsonCastTypeError записывает сообщение только при INCORRECT_TYPE, при включённой детализации и при успешном определении типа значения.

Потребитель — castFromJson: после ошибки строки он проверяет валидность отложенного сообщения и, если оно есть, ставит ошибку с этим сообщением и сбрасывает флаг, иначе использует предсобранное исключение. Детальные сообщения строятся только на пути ошибки, потому что флаг детализации выставляется из признака «ошибка реально всплывёт», а под TRY/TRY_CAST плохие строки становятся null и сообщение не увидит никто — тогда сохраняется дешёвый путь без форматирования.

Диапазоны целых и специальные значения

convertIfInRange при сужении целочисленного типа проверяет, что значение лежит в диапазоне целевого типа, иначе возвращает NUMBER_OUT_OF_RANGE. При преобразовании из плавающего в целочисленный тип используются границы, представимые точно и в int64_t, и в double:

constexpr double kMin = -9223372036854774784.0;
constexpr double kMax = 9223372036854774784.0;

При выходе за них также возвращается NUMBER_OUT_OF_RANGE, иначе значение округляется до ближайшего целого. В другом месте convertIfInRange проверяет диапазон только при меньшем размере целевого типа и при выходе за границы возвращает NUMBER_OUT_OF_RANGE.

castJsonToInt для чисел с плавающей точкой вызывает проверку диапазона с get_double, для знаковых целых — с get_int64, а для беззнаковых сразу возвращает NUMBER_OUT_OF_RANGE.

parseSpecialFloatingStrings сначала снимает обрамляющие кавычки, если они есть, затем распознаёт "NaN", "+INF"/"+Infinity"/"Infinity" и "-INF"/"-Infinity", записывая соответственно NaN, +inf и -inf, а для прочих значений возвращает INCORRECT_TYPE.

Даты

castJsonToDate принимает только строковые значения, иначе возвращает ошибку типа. Если длина строки меньше четырёх символов, значение интерпретируется как число дней с 1 января 1970 года: строка преобразуется в int32_t, и при ошибке возвращается INCORRECT_TYPE. Для строк длиной четыре и более символов дата разбирается в режиме Spark; если это не удалось и в строке найдена подстрока "GMT", она удаляется и разбор повторяется. Если и после очистки разбор не удался, возвращается INCORRECT_TYPE. При успехе вычисленное число дней записывается как int32_t.

Динамический шрединг в Logfire

Как выбираются атрибуты

Logfire шреддит атрибуты на основе их размера и частоты встречаемости в данных. Это позволяет удерживать эвристику внутри пути записи каждого Parquet-файла. Число шреддированных колонок ограничено жёстким пределом, чтобы контролировать накладные расходы по количеству колонок Parquet и избегать раздувания схемы и избыточных расходов на разбор метаданных Parquet. Эмпирические измерения показывают, что около 1000 колонок не проблема для Parquet и DataFusion, но больше — уже заметные накладные расходы. Конкретные пороги по частоте использования не раскрываются.

Переписывание запросов

Logfire переписывает запросы, обращающиеся к JSON-полю attributes через оператор ->, в обращения к отдельным шреддированным колонкам с префиксом _lf_attributes. Например, запрос с фильтром по attributes->'http.response.status_code' = 500 под капотом превращается в запрос к колонке _lf_attributes_http.response.status_code. Это позволяет использовать статистику для отсечения файлов, row groups и страниц, а затем сканировать только нужную колонку, которая имеет строгий тип int64 и хорошо сжимается. Пользователю не обязательно писать имя шреддированной колонки напрямую — можно продолжать использовать исходное выражение, и переписывание происходит автоматически. Извлечение выражения из GROUP BY и его pushdown в сканирование Parquet требует сложных проходов переписывания и оптимизации выражений, которые планируется добавить в DataFusion к февралю 2026 года.

Компромиссы

Нешреддированные атрибуты остаются в исходной JSON-колонке, и запросы по ним требуют сканирования всей колонки. Это особенно затратно, если нешреддированный атрибут большой, но редкий: он может встречаться лишь в малой доле строк, но запрос всё равно вынужден разбирать и сканировать потенциально гигабайты JSON-данных, большая часть которых нерелевантна, только чтобы получить те несколько строк, где он есть. Выигрыш от отсечения возникает только для шреддированных колонок: zone maps и полнотекстовые индексы позволяют пропускать целые файлы и row groups, после чего можно скачать только релевантную колонку. То есть шрединг даёт отсечение и ускорение для выбранных атрибутов, но для остальных цена сканирования всей JSON-колонки сохраняется.

Как избегают поломок

Logfire минимизирует поломки, сначала переписывая путь запросов на динамический шрединг, оставляя статический шрединг в пути записи. При этом весь код, связанный с жёстко заданными шреддированными колонками, выносится в отдельную самодостаточную секцию пути записи. Начальная эвристика динамического шрединга повторяет текущую статическую конфигурацию, что позволяет протестировать изменения без необратимых действий вроде записи нового физического формата. Только после уверенности в надёжности пути запросов переключается путь записи. Запросы к атрибутам, которые не были шреддированы (например, редким динамически сгенерированным ключам), продолжают работать с обычными накладными расходами на разбор JSON.

Определение типов шреддированных колонок

Типы определяются инкрементально: при сканировании данных для каждого кандидатного атрибута отслеживается текущий планируемый тип вывода, который расширяется по мере необходимости. Когда в разных строках одного атрибута встречаются значения разных типов, система выбирает наиболее широкий тип, способный представить все встреченные значения. Различие между String и JSON важно: для значений [{"user_id": "123"}, {"user_id": 456}] данные хранятся как JSON (колонка строк с JSON-строками), тогда как для ["123", "456"] — как строки напрямую без разбора JSON, что всё равно даёт выгоду от колоночного хранения, сжатия и в некоторой степени отсечения, но разбор JSON для чтения из такой колонки всё же нужен.

При изменении типа атрибута между батчами типы объединяются: при несовпадении кандидатных типов (проверяется последний элемент списка) список очищается, что означает откат к VARCHAR/JSON. Также при смешении offset-состояний кандидатные типы очищаются, что приводит к VARCHAR.

Форматы и спецификация JSON

RFC 8259 и дубликаты

RFC 8259 не требует от реализаций какого-либо определённого поведения при дублирующихся именах в объектах: сказано лишь, что одни реализации сообщают все пары, включая дубликаты, а другие — нет. DuckDB по умолчанию отвергает дубликаты (в том числе различающиеся только регистром), а в режиме игнорирования ошибок допускает их: при игнорировании проверки пропускаются, и дубликаты просто обрабатываются через поиск уже существующего ребёнка по карте ключей. Таким образом, RFC 8259 оставляет поведение на усмотрение реализации.

JSONB в SQLite

JSONB — это двоичное представление JSON, которое SQLite хранит как BLOB и использует только внутри себя; приложениям не следует использовать JSONB вне SQLite или пытаться восстанавливать его формат. Функции, возвращающие текст JSON, начинаются с "json_", а возвращающие двоичный JSONB — с "jsonb_". Преимущество JSONB в том, что он меньше и быстрее текстового JSON, потенциально в несколько раз, но сложность большинства операций остаётся O(N), как и у текстового JSON.

Обещание состоит в том, что некорректный JSONB никогда не вызовет ошибку памяти или подобную проблему, которая могла бы привести к уязвимости; он может дать бессмысленный ответ или прервать запрос, но не приведёт к краху. При этом SQLite следует правилу garbage-in/garbage-out: при подаче некорректного JSONB в JSON-функции возможны прерывание с ошибкой "malformed JSON", правильный ответ или бессмысленный ответ, а поведение может меняться от версии к версии. Для проверки корректности JSONB следует использовать json_valid().

BLOB, похожий на текстовый JSON

Если входные данные JSON-функции — это BLOB, который не является JSONB и при приведении к тексту выглядит как текстовый JSON, то он принимается как текстовый JSON. Это было давней ошибкой исходной реализации: документация утверждала, что BLOB-вход должен вызывать ошибку, но фактически вход принимался, если содержимое BLOB было допустимой JSON-строкой в текстовой кодировке базы данных. Ошибка была случайно исправлена при переписывании JSON-рутин для релиза 3.45.0 (2024-01-15), что сломало приложения, зависевшие от старого поведения. Для обратной совместимости прежнее поведение — интерпретировать BLOB как текстовый JSON, если никакая другая интерпретация не работает, — официально задокументировано и поддерживается в версии 3.45.1 (2024-01-30) и всех последующих релизах.

Следует учитывать, что существуют BLOB, которые одновременно являются допустимым JSONB и допустимым JSON после приведения к тексту; например, BLOB x'33343536' — это допустимый JSONB (целое 456), а при приведении к тексту — допустимый целочисленный литерал JSON 3456.

Как сравнивали и что получилось

Источники не описывают стенд, железо, ограничения ресурсов, версии участников и параметры нагрузки. Приводятся только итоговые цифры ускорения.

Общее ускорение: в некоторых случаях превышает 1000 раз. Запросы, которые ранее завершались по таймауту через 30 секунд, теперь выполняются менее чем за секунду. Ускорение достигается за счёт динамического шрединга — извлечения наиболее часто запрашиваемых атрибутов в отдельные типизированные столбцы. Эти цифры относятся к запросам по полуструктурированным атрибутам, которые ранее сканировали весь JSON-столбец, а после динамического шрединга используют отдельные типизированные столбцы.

Конкретные цифры ускорения по каждому отдельному сценарию и точный перечень запросов, к которым они применяются, не приводятся.

Оговорки

Цифры не показывают поведение на нешреддированных атрибутах. Шреддится только небольшой жёстко заданный набор атрибутов, остальные несут полную стоимость JSON. Запросы по нешреддированным атрибутам (например, user_id, session_id, region) требуют сканирования всего JSON-столбца с высокими затратами ввода-вывода и разбора. Поскольку данные хранятся чанками, даже один крупный нешреддированный атрибут может раздуть весь столбец и удорожить все запросы, даже не касающиеся его.

Цифры не показывают поведение при изменении схемы: тип каждого шреддированного столбца определяется динамически при сканировании данных с расширением типа по правилу Int -> Float -> JSON, Bool -> JSON и String -> JSON, при этом для JSON-значений всё ещё требуется разбор JSON при чтении из столбца. Есть жёсткий предел на число шреддированных столбцов: около 1000 столбцов не проблема, больше — заметные накладные расходы. Начальный подход шреддит по размеру и частоте атрибута в данных, а не по паттернам запросов, поэтому цифры не отражают будущую оптимизацию под частые запросы.

Что из этого следует на практике

  • Узкое место аналитики по JSON — не парсер, а ввод-вывод и сжатие. Даже самый быстрый JSON-парсер не спасает, если данные скачиваются гигабайтами из объектного хранилища и плохо сжимаются из-за одного изменчивого атри

Где смотреть в коде

Источники

Похожее