- в теле пакетов
Data,Totals,Extremes,LogиProfileEventsв собственном TCP-протоколе (пакетTableColumnsне является блоком Native — он содержит две двоичные строки, поэтому его структура относится к спецификации собственного протокола); - в выводе
SELECT ... FORMAT Nativeпо HTTP; - в файлах, экспортируемых с помощью
INTO OUTFILE ... FORMAT Native; - в полезных нагрузках межсерверной репликации.
Обзор
data столбца организованы в соответствии с семейством, к которому относится его тип. Семейства в порядке возрастания сложности декодера:
- Типы фиксированной ширины представляют
dataкак необработанные байты размеромbytes_per_value × num_rows, без какого-либо построчного фреймирования. - Составные типы (
Nullable,Array,Tuple,Map,Nested) имеют рекурсивную структуру, полностью выводимую из строки типа, без префикса версии и без состояния, общего для разных блоков. - Версионируемые / с сохранением состояния типы (
LowCardinality,JSON,Variant,Dynamic) начинают каждый непустой block с префикса версии сериализации/состояния. В протоколеNativeэтот префикс и любой словарь являются отдельными для каждого блока — формат не хранит состояние между блоками (writer создает новое состояние сериализации для каждого блока и устанавливаетlow_cardinality_max_dictionary_size = 0). Состояние между блоками относится к хранению MergeTree на диске, а не к структуре протокола Native.
Wire-примитивы
VarUInt
1, если за ним следуют дополнительные байты, и 0 в последнем байте.
Кодирование значения
300:
0xAC 0x02:
Целые числа фиксированной разрядности
Например, значение UInt32
1 кодируется как 01 00 00 00, а значение Int32 -1 — как FF FF FF FF.
String
0x00, а строки могут содержать любые значения байтов, включая встроенный NUL. Строка "ab" кодируется как 02 61 62; чтобы декодировать её, сначала прочитайте длину VarUInt (2), затем — указанное число байтов.
Bool
0x00 означает false; любое ненулевое значение — true (канонически 0x01).
Структура блока и столбцов
Структура блока в формате передачи данных
BlockInfo зависит от канала, поскольку пишущая сторона параметризуется значением ревизии (полное описание, включая то, что client_protocol_version влияет только на вывод, см. в разделе Protocol revision and the Native format):
-
В собственном TCP-протоколе сервер записывает блоки с ревизией, согласованной для соединения (это большое значение —
DBMS_TCP_PROTOCOL_VERSION, см.src/Core/ProtocolDefines.h).BlockInfoзаписывается всегда, когда это значение ревизии больше нуля, а для реального соединения это верно всегда. Байтhas_custom_serializationв каждом столбце (см. структуру столбца в формате передачи данных) записывается начиная с ревизии54454. -
Nativeoutput format —SELECT ... FORMAT Nativeпо HTTP,INTO OUTFILE ... FORMAT Nativeи форматNative, создаваемыйclickhouse-client, — по умолчанию сериализуется с ревизией0. При ревизии0и префиксBlockInfo, и байтhas_custom_serializationотсутствуют, поэтому блок состоит только изnum_columns,num_rowsи столбцов. Для HTTP это значение ревизии не фиксировано: клиент может увеличить его с помощью параметра запроса?client_protocol_version=<n>, и сервер использует это значение как ревизию сериализации для ответа. При достаточно большом значении HTTP-вывод включает префиксBlockInfo(он записывается всегда, когда ревизия больше0) и байтhas_custom_serialization(записывается начиная с ревизии54454) — точно так же, как и при передаче по TCP. Поэтому клиенты не должны предполагать, что любая полезная нагрузка HTTPFORMAT Nativeимеет ревизию0.
BlockInfo, описывают полезную нагрузку TCP-пакета Data. Тот же запрос, выполненный через FORMAT Native, даёт более короткую форму, показанную рядом.
BlockInfo
0. Формат передачи данных не является самоописывающимся: ID поля не содержит информации о длине или типе его значения, поэтому считыватель должен заранее знать тип каждого ID поля, который может встретиться. Собственный считыватель ClickHouse считает нераспознанный ID поля повреждением данных и выдаёт Исключение (UNKNOWN_BLOCK_INFO_FIELD). Прямая совместимость обеспечивается не этим, а ревизией протокола: отправитель записывает поле, только если согласованная ревизия не ниже минимальной ревизии этого поля, поэтому более старый приёмник никогда не увидит незнакомое ему поле.
Поля
1 и 2 имеют минимальную ревизию 0, поэтому присутствуют всегда, если BlockInfo вообще записывается. Поле 3 записывается только начиная с ревизии 54480. Структура формата передачи данных для типичного случая (ревизия ниже 54480):
Структура столбца в формате передачи данных
num_columns раз внутри Block.
Декодер выбирает способ обработки по строке
type. Строки типов часто содержат параметры в скобках; декодер убирает суффикс (...), чтобы определить базовый тип, а затем разбирает параметры, чтобы определить размер, scale или внутренний тип. Разбор списка параметров со вложенными типами (например, Tuple внутри Array) требует разделения по запятым с учётом глубины вложенности скобок, а не простого разбиения по ,.
Двоичное кодирование типаПоле
type является текстовым String только в режиме по умолчанию. Если установлена настройка запроса output_format_native_encode_types_in_binary_format = 1, это поле вместо текста содержит двоичное кодирование типа — то же кодирование на основе тегов, которое описано в двоичном кодировании типов данных, — а списки сглаженных типов Dynamic также используют это двоичное кодирование для имён отдельных типов. Декодер, который всегда читает поле 2 как строку с префиксом длины, примет первый двоичный тег типа за длину строки и потеряет синхронизацию, поэтому он должен знать, в каком режиме передаётся поток.kind_stack и разреженное кодирование
kind_stack перечисляет нестандартную сериализацию для каждого столбца:
Для полезной нагрузки
COMBINATION используется другое перечисление. Пять строк выше — это компактные однобайтовые коды. COMBINATION (0x05) — это общий escape-код для любого стека, не покрытого ими: после него идут VarUInt count, а затем count однобайтовых записей. Эти записи — не компактные коды из таблицы, а исходные значения ISerialization::Kind:
Значения байтов отличаются от компактных кодов:
REPLICATED в этом вложенном перечислении имеет значение 0x03, а в виде компактного кода — 0x04, и записи DETACHED_OVER_SPARSE нет — эта комбинация представляется двумя последовательными записями SPARSE, DETACHED. Декодер, который продолжит использовать таблицу компактных кодов для вложенных байтов, неверно сопоставит 0x03/0x04 и потеряет синхронизацию.
count — это полная длина стека включая начальную запись DEFAULT, с которой начинается каждый стек. Компактные коды уже покрывают все стеки из одной и двух записей, поэтому у COMBINATION значение count всегда не меньше трёх.
Рекурсивный kind_stack для столбцов Tuple. Полезная нагрузка kind_stack, описанная выше, — это байт (или последовательность COMBINATION) с информацией о сериализации самого столбца. Tuple содержит SerializationInfoTuple, который сначала записывает полезную нагрузку kind-stack самого tuple, а затем — по одной полной полезной нагрузке kind-stack для каждого элемента по порядку; декодер считывает ту же рекурсивную структуру в обратном порядке. Поэтому для Tuple(A, B, C) байты поля 4 имеют вид [tuple_kind][A_kind][B_kind][C_kind], и полезная нагрузка каждого элемента сама по себе рекурсивна, если этот элемент тоже составной. Байт has_custom_serialization (поле 3) устанавливается всегда, когда информация самого tuple или любого элемента отличается от значения по умолчанию, поэтому Tuple, у которого единственный специальный элемент является sparse, replicated или detached, всё равно приводит к передаче полезной нагрузки kind-stack. Декодер, который для Tuple считывает только один начальный байт перечисления, остановится слишком рано и ошибочно воспримет оставшиеся байты kind элементов как данные столбца.
Разреженный формат передачи данных. Когда kind_stack = 0x01, data столбца представляют собой два потока, записанных подряд в одном общем TCP-потоке:
- Поток смещений — последовательность
VarUInt. Каждое значениеv— это либо:v, у которого старший бит в позиции 62 не установлен:(v & 0x3FFFFFFFFFFFFFFF)= число позиций со значением по умолчанию перед следующим явно заданным значением, отличным от значения по умолчанию. Позиция этого значения —cursor + group_size, гдеcursor— текущая позиция; после этогоcursorувеличивается наgroup_size + 1.vс установленным битом 62 (END_OF_GRANULE_FLAG): значение со снятым флагом = число завершающих позиций со значением по умолчанию после последнего нестандартного значения. Это отмечает конец потока смещений для блока.
- Поток значений —
countзначений, отличных от значений по умолчанию, плотно закодированных во внутреннем типе, гдеcount— число считанных выше VarUInt без EOG.
num_rows записей, заполняя каждую позицию, не указанную явно, значением по умолчанию внутреннего типа (0 для целых чисел и чисел с плавающей точкой, "" для String, 0 дней для Date и так далее).
Разреженный столбец Nullable(T) — особый случай, поскольку значением по умолчанию для Nullable(T) является NULL. Разреженное кодирование полностью убирает обычный поток null-map для Nullable: поток смещений указывает позиции со значениями, отличными от значения по умолчанию, то есть не-NULL; поток значений плотно хранит только эти не-NULL значения в T, а каждая позиция, не указанная явно, восстанавливается как NULL. Поэтому декодер не должен искать null map в потоке значений и не должен заполнять пропуски присутствующим 0; вместо этого он заполняет их значением NULL.
Формат передачи данных Replicated. Когда kind_stack = 0x04, столбец data представляет собой словарь: список различных значений элементов и индекс для каждой строки в этом списке (та же схема, что и у LowCardinality). Если внутренний тип сам является версионируемым — например, LowCardinality(T) — его префикс состояния записывается сначала, перед потоком индексов: сериализация Replicated делегирует этап записи префикса внутреннему типу перед записью num_rows. Внутренние типы с пустым префиксом (листовые типы и обычные составные типы) не добавляют здесь никаких байтов.
elements[indexes[i]] для каждой выходной строки i. Для составных внутренних типов это делается рекурсивно: список элементов материализуется во внутреннем типе, а затем индексируется. Поддерживаются следующие внутренние типы: листовые типы, Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...) (каждое поле разворачивается как Array) и LowCardinality(T) (общий словарь сохраняется; индексируются только ключи отдельных элементов).
Отсоединённый формат передачи данных. DETACHED (0x02) и DETACHED_OVER_SPARSE (0x03) действительно передаются в формате передачи данных — это не только внутренние представления. В TCP-пути, когда включено сжатие и согласованная ревизия не ниже DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING (v54478), столбец проходит три этапа:
- Каждый подходящий столбец (не
const, неTuple, в блоке более чем с одной строкой) оборачивается вColumnBLOB, который содержит столбец, уже маршалированный и сжатый вне основного потока. DETACHEDдобавляется в стек kind обёрнутого столбца.dataстолбца записываются как размер blob в форматеVarUInt, за которым следуют ровно столько байтов blob.
{DEFAULT, SPARSE, DETACHED}, что сериализуется как DETACHED_OVER_SPARSE. Клиент, декодирующий такой столбец, считывает длину blob и сами байты, затем распаковывает blob, чтобы восстановить полезную нагрузку внутреннего столбца (см. примечание о ColumnBLOB в разделе о сжатии).
Варианты блока
Примеры на уровне байт
BlockInfo и байт has_custom_serialization. В FORMAT Native те же блоки короче — там, где это уместно, приведена эквивалентная короткая форма.
Пустой блок (с BlockInfo), всего 8 байт:
SELECT 1 объявляет один столбец с именем "1" типа UInt8 и нулевое число строк. В протоколе ≥ 54454 включается байт has_custom_serialization:
FORMAT Native (ревизия 0) у того же блока результата нет ни BlockInfo, ни байта has_custom_serialization — SELECT 1 FORMAT Native занимает 11 байт:
FORMAT Native: формат вывода не выводит пустые блоки.)
Ревизия протокола и Формат Native
UInt64, и и NativeWriter, и NativeReader принимают его как аргумент конструктора. Пишущая сторона называет его client_revision, а считыватель — server_revision, но это одно и то же число. Самая новая ревизия, известная этому release, — DBMS_TCP_PROTOCOL_VERSION (см. src/Core/ProtocolDefines.h).
Что определяют пороги ревизии
DBMS_MIN_REVISION_WITH_*. Пишущая сторона начинает передавать возможность только когда её ревизия достигает этого порога, а читающая сторона ожидает её по точно такому же правилу, поэтому обе стороны остаются синхронизированы — если на любой из сторон указать неверную ревизию, они рассинхронизируются. Для Формата Native важны следующие пороги:
Поэтому ревизия
0 даёт наиболее консервативное кодирование почти для всего: поток не содержит BlockInfo, байта has_custom_serialization, использует V1 Dynamic/JSON, не содержит версии агрегатной функции и передаёт DateTime без параметра timezone.
Единственное исключение — LowCardinality, и это важно. Проверка на стороне записи выглядит так: remove_low_cardinality || (client_revision && client_revision < DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE). Ключевой момент — начальное client_revision &&: когда ревизия равна 0, всё условие из-за short-circuit сразу становится false.
Поэтому при ревизии 0 — значении по умолчанию для FORMAT Native — LowCardinality(T) не убирается. Его строка типа и префикс состояния для каждого блока остаются в потоке, и reader с ревизией 0 просто считывает их обратно. Удаление происходит только при ненулевой ревизии ниже 54405 либо когда оно принудительно включено независимо от ревизии.
За это принудительное удаление отвечает флаг remove_low_cardinality. Вывод FORMAT Native его никогда не устанавливает, а вот путь нативного TCP — устанавливает, если low_cardinality_allow_in_native_format = 0 (по умолчанию 1). Иными словами, эта настройка меняет вывод нативного TCP, но никак не влияет на FORMAT Native.
Практический вывод: поток FORMAT Native по умолчанию вполне может содержать LowCardinality, поэтому не считайте, что эта возможность отсутствует при ревизии 0.
Откуда берётся ревизия в зависимости от способа передачи данных
Собственный TCP-протокол — согласование в обоих направлениях
DBMS_TCP_PROTOCOL_VERSION, сервер в ответ отправляет свою версию, и дальше каждая сторона сериализует данные с ревизией, объявленной другой стороной: сервер строит свои NativeReader/NativeWriter на основе client_tcp_protocol_version, а клиент использует полученную server_revision. Явного min нет, но ни одна из сторон не может использовать возможность, которую не реализовала, поэтому в каждом направлении фактический предел задается более старой из двух сторон.
Когда обе стороны работают на одной и той же современной сборке, в обоих направлениях получается одна и та же ревизия (DBMS_TCP_PROTOCOL_VERSION, см. src/Core/ProtocolDefines.h), и все условия выполняются. Это типичный случай, но не гарантированный. Если взаимодействуют стороны разных версий или сторонние узлы, в двух направлениях могут использоваться разные ревизии, поэтому условия нужно рассматривать отдельно для каждого направления: BlockInfo присутствует при любой ненулевой ревизии, а все остальное — включая has_custom_serialization — появляется только после того, как эффективная ревизия в этом направлении достигает соответствующего порога. Например, сторона, объявившая ревизию ниже 54454, не отправляет и не принимает байт has_custom_serialization.
Вывод FORMAT Native — по умолчанию ревизия 0, которую можно повысить через HTTP
Native по умолчанию используется ревизия 0. Это относится к SELECT ... FORMAT Native по HTTP, INTO OUTFILE ... FORMAT Native и к выводу Native, который записывает clickhouse-client; во всех этих случаях фабрика вывода передаёт FormatSettings::client_protocol_version напрямую в NativeWriter.
Но в случае HTTP на этом история не заканчивается. Клиент может повысить ревизию с помощью параметра запроса ?client_protocol_version=<n>; обработчик HTTP рассматривает его как зарезервированный параметр, а не как SQL-настройку: он попадает в контекст запроса, а слой форматов копирует его в FormatSettings. Если указать достаточно большое значение, HTTP-вывод FORMAT Native начинает включать префикс BlockInfo и байт has_custom_serialization, как и при передаче по TCP, — поэтому не стоит считать, что полезная нагрузка HTTP FORMAT Native всегда имеет ревизию 0. У экспорта в файл и локального вывода clickhouse-client такого механизма нет, и они остаются на 0.
Входной FORMAT Native — всегда ревизия 0
Native работает наоборот: он жёстко зафиксирован на ревизии 0 и полностью игнорирует client_protocol_version. Независимо от того, разбирает ли он тело INSERT ... FORMAT Native или читает файл Native, он создаёт NativeReader с литеральным значением 0, поэтому никогда не ожидает префикс BlockInfo, никогда не читает байт has_custom_serialization и всегда предполагает сериализацию по умолчанию.
Итак, client_protocol_version влияет только на вывод. Указание большого значения ?client_protocol_version= (например, DBMS_TCP_PROTOCOL_VERSION) в запросе INSERT ... FORMAT Native никак не меняет способ чтения тела — оно по-прежнему должно быть ревизии 0. Если передать тело, в котором есть префикс BlockInfo или байт has_custom_serialization, чтение собьётся, и в результате вернётся ошибка разбора (INCORRECT_DATA или CANNOT_READ_ALL_DATA), а не успешная вставка.
Последствия для обратимого обмена
FORMAT Native безопаснее всего использовать ревизию 0 на обеих сторонах — именно она используется по умолчанию. Данные, записанные через SELECT ... FORMAT Native с ревизией 0, затем без проблем читаются обратно через INSERT ... FORMAT Native.
Проблемы начинаются только тогда, когда вы намеренно повышаете выходную ревизию. SELECT ... FORMAT Native, выполненный с ?client_protocol_version=<large>, создает поток, содержащий байты BlockInfo и has_custom_serialization, а входной путь с ревизией 0 не может их прочитать. Если вам нужен такой обратимый обмен данными, либо не указывайте client_protocol_version в SELECT, который формирует данные, либо передавайте их через собственный TCP-протокол, где для каждого направления используется ревизия, согласованная при рукопожатии, а не через FORMAT Native.
Ревизия протокола и версия сериализацииНе путайте ревизию протокола с версией сериализации. Здесь ревизия действует на уровне соединения или запроса и никогда не записывается в байты. Версия сериализации задается для каждого столбца, передается в версионируемых типах и записывается в каждый непустой блок. Ревизия определяет, доступна ли возможность вообще; версия сериализации, когда вы уже внутри версионируемого столбца, выбирает, какой вариант кодирования этого типа идет дальше.
Типы данных
data столбца; они сгруппированы в четыре семейства по возрастанию сложности декодирования. Два типа — AggregateFunction(func, ...) и QBit(T, N[, stride]) — являются допустимыми типами столбцов Native, но имеют полезную нагрузку, зависящую от функции или типа, и здесь не рассматриваются; ниже они отдельно отмечены там, где их иначе можно было бы принять за псевдонимы.
Типы фиксированной ширины
M строк занимает в wire-формате ровно bytes_per_row × M байт; данные идут подряд, без разделителей и заполнения.
Целочисленные типы
UInt8–UInt256 и Int8–Int256 — это прямое двоичное кодирование целочисленных значений. Декодер считывает bytes_per_row × num_rows байт и интерпретирует их в соответствии с типом.
Столбец UInt32, содержащий [1, 256, 65536]:
Int32 со значениями [-1, 42]:
Float32 и Float64
binary32) и 8-байтовые двойной точности (binary64), оба в порядке little-endian. NaN, ±Infinity, ±0.0 и субнормальные числа сохраняются без нормализации при преобразовании туда и обратно.
Значение 1.5 типа Float32 (0x3FC00000):
Float64, значение 1.5 (0x3FF8000000000000):
BFloat16
Float32 — 1 бит знака, 8 бит экспоненты, 7 бит мантиссы. Каждое значение занимает 2 байта, хранится в порядке little-endian и содержит сырое 16-битное представление. Чтобы восстановить числовое значение, его нужно снова расширить до Float32, поместив этот шаблон в старшую половину и обнулив младшую (bits << 16, переинтерпретированное как Float32); после этого расширенное значение использует то же текстовое форматирование, что и Float32.
Значение BFloat16 1.5 (шаблон 0x3FC0, верхняя половина Float32 0x3FC00000):
Bool
UInt8 на уровне двоичного представления: 1 байт на строку, 0x00 = false, 0x01 = true. Строка типа в двоичном формате буквально имеет вид Bool (а не UInt8), поэтому декодер, который определяет тип по этой строке, должен распознавать его отдельно.
Столбец Bool [true, false, true]:
Date и Date32
1970-01-01. Ни один из них не содержит временной составляющей.
Значение
Date 1970-01-02 (1 день):
1900-01-01 типа Date32 (-25567 дней):
DateTime
UInt32 на уровне двоичного представления: Unix-временная метка в секундах, 4 байта, порядок байтов little-endian. Тип может быть представлен как DateTime или DateTime('Timezone'); часовой пояс влияет только на отображение и не входит в двоичное значение. Два столбца DateTime с разными параметрами часового пояса дают одинаковые байты для одного и того же момента времени. Декодер удаляет суффикс параметра (...) и обрабатывает столбец как UInt32.
Значение DateTime('UTC') 2024-03-15 14:30:00 UTC (временная метка 1710513000):
DateTime64(scale[, timezone])
Int64 в формате little-endian, представляющий тики с шагом 10^-scale секунды, отсчитываемые от Unix-эпохи. Параметр scale (0–9) указывается в строке типа и задаёт единицу времени:
Тип записывается как
DateTime64(s) (неявный часовой пояс сервера по умолчанию) или DateTime64(s, 'TimezoneName') (явный часовой пояс, только для отображения). Отрицательные значения представляют тики до эпохи.
Значение DateTime64(3, 'UTC') 2024-01-15 12:30:45.123 UTC (1705321845123 мс):
DateTime64(0) 2024-01-15 12:30:45 UTC (1705321845 s):
Time и Time64(scale)
Time — это знаковое число секунд, 4-байтовый Int32 в формате little-endian; Time64(scale) — знаковое число тиков с указанным десятичным масштабом (0–9), 8-байтовый Int64 в формате little-endian — то же wire-представление, что и у DateTime64.
Текстовая форма имеет вид [-]HH:MM:SS[.fraction], но, в отличие от DateTime, поле часов не приводится к 24-часовым суткам: это общее число часов, и оно может быть больше 23. Отображаемая величина ограничена значением 999:59:59 (3599999 секунд); если величина больше, отображается это предельное значение с обнулённой дробной частью (999:59:59.000). CAST также ограничивает сохраняемое значение этим диапазоном, хотя арифметические операции могут давать значения вне диапазона, которые ограничиваются только при отображении. Всё это не влияет на wire-байты, которые представляют собой обычное знаковое целое число.
Значение Time 45296 (12:34:56):
Time64(3) 45296789 тиков (12:34:56.789):
Time и Time64 являются экспериментальными и требуют включения allow_experimental_time_time64_type = 1 на сервере.Interval
Interval<Unit> — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond и так далее. Для всех единиц используется одно и то же wire-кодирование: значение хранится как знаковый 8-байтовый Int64 в формате little-endian. Единица указана только в строке типа — она не влияет ни на байты wire-представления, ни на текстовую форму, которая представляет собой просто целое число. Для всех единиц используется один и тот же путь декодирования.
Значение IntervalDay 5:
UUID
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, где байты по соглашению записываются в порядке big-endian. В представлении по wire эти 16 канонических байтов делятся на две 8-байтные половины, и каждая половина записывается в порядке little-endian:
- Байты wire 0..7 = канонические байты 0..7 в обратном порядке.
- Байты wire 8..15 = канонические байты 8..15 в обратном порядке.
550e8400-e29b-41d4-a716-446655440000:
IPv4 и IPv6
IPv4 занимает 4 байта и кодируется как UInt32 в формате little-endian, содержащий канонический 32-битный адрес (значение (a << 24) | (b << 16) | (c << 8) | d из a.b.c.d). Байты на wire — это байты в сетевом порядке, записанные в обратном порядке.
192.168.1.10 (каноническое 32-битное значение 0xC0A8010A):
IPv6 — 16 байт, записывается как есть в сетевом порядке байтов, без swap — в том же порядке байтов, что и inet_pton(AF_INET6, ...).
2001:db8::1:
u32 для арифметических операций и компактных запросов по диапазонам, тогда как IPv6 сохраняет структуру в сетевом порядке байтов, характерную для большинства сетевых API.
Enum8 and Enum16
Int8 и Int16 соответственно: 1 или 2 байта на строку, для 16-битного варианта — дополнительный код в порядке little-endian. Полное сопоставление вариантов содержится в строке типа:
(...) и обрабатывать тип как Int8 / Int16 — в wire-представлении передаются просто байты целочисленного индекса. Клиент, который показывает метку, разбирает отображение 'name' = value из строки типа и хранит его вместе со столбцом: само по себе целое число не позволяет восстановить метку. В текстовом выводе отображается метка (active), а не индекс; если enum вложен в составной тип, она заключается в одинарные кавычки ('active'). Поскольку это отображение нельзя восстановить по целочисленному столбцу, его нужно сохранять для вложенных enum, таких как Array(Enum8(...)) или Map(Enum16(...), V).
Столбец Enum8('active' = 1, 'inactive' = 2) со значениями [active, inactive, active]:
30000 типа Enum16(...):
Decimal(P, S)
P; scale S — это отрицательная степень (количество цифр после десятичной точки). Оба параметра указываются в строке типа.
Кодирование на wire — это базовое целое число в формате little-endian с дополнительным кодом, а логическое десятичное значение равно
wire_integer × 10^(-S).
ClickHouse всегда выводит Decimal(P, S) независимо от того, как был объявлен тип. Decimal32(S), Decimal64(S) и так далее на wire всегда приводятся к Decimal(P, S) (при этом P устанавливается в естественный максимум для данной ширины: 9, 18, 38, 76). Декодер, распознающий только Decimal(P, S), охватывает все варианты записи, которые выводит server.
Значение 123.4567 типа Decimal(9, 4) → базовое целое число 1234567:
Decimal(18, 1) со значением -1.5 → базовое целое число -15:
Decimal(38, 4), значение 123.4567 (всего 16 байт):
Nothing
Nothing не содержит значений. На практике он встречается только как внутренний тип Nullable(Nothing) — именно его сервер возвращает для выражения вроде SELECT NULL, где единственно допустимое значение — отсутствие значения. Концептуально это единичный тип.
В бинарном представлении он занимает ровно один байт-плейсхолдер на строку. Сервер записывает ASCII-символ '0' (0x30), но десериализатор игнорирует эти байты — их содержимое не определено, и декодеры не должны полагаться на какое-либо конкретное значение. Количество записываемых байтов равно num_rows × 1, поэтому num_rows в заголовке столбца полностью определяет, сколько данных нужно прочитать.
Один байт на строку сохраняет инвариант Block: для каждого столбца длина выводится из num_rows, поэтому декодеры могут продвигаться вперёд без префиксов длины для каждой ячейки. Внешний Nullable всегда помечает каждую позицию как NULL, поэтому плейсхолдеры никогда не проверяются.
Столбец Nullable(Nothing) с 3 строками (все NULL):
Nullable (см. Nullable); три внутренних байта — это полезная нагрузка Nothing, которую декодер пропускает.
Типы переменной длины
String
String. Столбец String представляет собой последовательность из num_rows байтовых последовательностей с префиксом длины:
0x00. В ClickHouse String ориентирован на байты, а не на текст: корректность UTF-8 не проверяется, и значение может содержать любые байты, включая встроенный NUL. Декодер, рассчитанный на строковый тип UTF-8, либо проверяет данные при чтении, либо возвращает вызывающей стороне raw bytes. Общее количество байтов, занимаемых столбцом, равно Σ (varuint_size(len_i) + len_i) по всем строкам.
Столбец из 3 строк ["ab", "", "c"] (всего 6 байт):
FixedString(N)
FixedString(N), где N — положительное целое число (например, FixedString(16)). Столбец содержит ровно N × num_rows байтов в исходном виде, без префиксов длины и разделителей. Декодер извлекает N из строки типа и считывает по столько байтов на строку.
Когда SQL вставляет значение короче N байтов (например, CAST('abc' AS FixedString(5))), сервер дополняет его справа байтами NUL (0x00) до объявленной длины. Эти байты заполнения являются частью сохранённого значения и передаются по wire как есть; их обрезка выполняется на стороне клиента. Подобно String, FixedString(N) больше похож на массив байтов, чем на текст, и обычно используется для идентификаторов фиксированной длины, байтов адресов или хеш-дайджестов.
Два значения FixedString(3) ["abc", "de\0"] (всего 6 байтов):
Составные типы
- Фиксированная форма для каждой схемы. Структура полностью определяется строкой типа на этапе декодирования.
Array(UInt32)всегда имеет одну и ту же структуру потоков от block к block. - Нет собственного префикса версии. Сама составная обёртка не добавляет байт версии; её фрейминг (
offsets,null-map, потоки элементов) остаётся стабильным между релизами ClickHouse. Это относится только к самой обёртке — о внутренних типах с версиями см. примечание о фазе префикса ниже. - Нет собственного состояния между block. Фрейминг обёртки полностью самоописывающийся в пределах каждого block; любые вопросы, связанные с состоянием между block, относятся к внутреннему типу с версией, а не к обёртке.
SerializationArray выполняет фазу префикса внутреннего типа до записи смещений массива, а Tuple, Map, Nested и Nullable делают то же самое через сериализации своих элементов (Nullable выполняет внутренний префикс перед своей null map).
Поэтому, когда составной тип оборачивает тип с версией/с сохранением состояния (LowCardinality, Variant, Dynamic, JSON), префикс версии/состояния этого внутреннего типа записывается первым, перед смещениями обёртки и полезной нагрузкой элементов. Например, Array(LowCardinality(String)) имеет структуру [префикс состояния LowCardinality] → [смещения массива] → [выпрямленная полезная нагрузка элементов LowCardinality], а не сначала смещения.
Декодер, который читает смещения до выполнения фазы внутреннего префикса, рассинхронизируется на любом составном типе, содержащем LowCardinality, Variant, Dynamic или JSON. Если каждый внутренний тип — это простой листовой тип или другой составной тип без версии, фаза префикса не выводит никаких байтов, и приведённое ниже описание со смещениями вначале применимо дословно.
Nullable(T)
Nullable(InnerType). Примеры: Nullable(UInt32), Nullable(String), Nullable(FixedString(16)), Nullable(DateTime('UTC')).
Как и другие составные типы, Nullable передаёт фазу префикса своей внутренней сериализации, прежде чем записывать null-map: если внутренний тип версионируемый, сначала записывается префикс состояния внутреннего типа. Поэтому Nullable(Tuple(LowCardinality(String))) начинается с префикса состояния LowCardinality, а не с null-map. Если внутренний тип — листовой или другой неверсионируемый тип, на фазе префикса байты не записываются.
Структура в формате передачи данных — это внутренняя фаза префикса (пустая, если внутренний тип не версионируемый), за которой следуют два объединённых потока, причём сначала идёт null-map:
num_rows байт, по одному на строку:
Поток значений содержит стандартное кодирование внутреннего типа для всех
num_rows строк, включая позиции с NULL. Декодер всё равно должен считывать байты-заполнители в позициях с NULL, чтобы продвигаться по потоку, но перед интерпретацией каждого отдельного значения он должен сверяться с null-map. Отправители могут записывать в позициях с NULL любые байты, поэтому декодеры не должны полагаться на какое-либо конкретное значение заполнителя.
Значения-заполнители по семействам внутренних типов:
Nullable(T) может находиться внутри Array, Tuple, Map и Nested — часто встречаются Array(Nullable(T)) и Tuple(Nullable(T1), T2). Nullable не может вкладываться само в себя: Nullable(Nullable(T)) отклоняется сервером.
Nullable(UInt8) с тремя строками [5, NULL, 9] (всего 6 байт):
Nullable(String) из трёх строк ["hello", NULL, "world"] (всего 15 байт):
Array(T)
Array(InnerType). Примеры: Array(UInt32), Array(String), Array(Nullable(UInt32)), Array(Array(UInt8)).
Структура в формате передачи данных состоит из внутренней фазы префикса (пустой, если только внутренний тип не является версионируемым), за которым следуют два объединённых потока, сначала смещения:
num_rows значений UInt64 в формате little-endian, каждое из которых задаёт накопленную конечную позицию в потоке значений после элементов соответствующей строки:
- Начальный индекс элемента для строки
N=offsets[N - 1](или0, еслиN == 0). - Конечный индекс элемента (исключая его) для строки
N=offsets[N]. - Количество элементов в строке
N=offsets[N] - offsets[N - 1].
offsets[num_rows - 1] — это общее количество элементов во всех строках, а поток значений содержит именно столько внутренних значений, записанных подряд.
Смещения монотонно не убывают; одинаковые соседние смещения означают пустую строку, а декодер должен отклонять немонотонные смещения как повреждённые данные. Пустой столбец (num_rows == 0) занимает ноль байт — нет ни потока смещений, ни потока значений. Внутренние типы могут быть любыми, включая другие составные типы: Array(Array(T)), Array(Tuple(...)) и Array(Nullable(T)) — все они допустимы.
Array(UInt32) со строками [[10, 20, 30], [], [40, 50]] (всего 44 байта):
0 для строки 0). Одинаковые последовательные смещения обозначают пустую строку:
Array(String) со строками [["a", "bb"], []] (всего 20 байт):
Array(Array(UInt32)) со строками [[[1,2]], [], [[3], [4,5]]] имеет такую же вложенную структуру:
- Внешние смещения:
[1, 1, 3]— в строке 0 находится 1 внутренний массив, в строке 1 — 0, в строке 2 — 2. - Средний
Array(UInt32)декодирует 3 строки со смещениями[2, 3, 5]. - Самый внутренний
UInt32декодирует 5 значений:[1, 2, 3, 4, 5].
Tuple(T1, T2, …)
Tuple(T1, T2, ..., Tn). Примеры: Tuple(UInt32, String), Tuple(Int32), Tuple(Array(UInt32), String), Tuple(UInt8, Tuple(Int32, String)). ClickHouse также поддерживает именованные Tuple через Tuple(a UInt32, b String); имена служат только метаданными и не влияют на формат передачи данных.
Структура в формате передачи данных состоит из фазы префикса элементов (каждый версионируемый элемент добавляет свой префикс состояния в порядке объявления; для неверсионируемых элементов он пуст) и затем N объединённых потоков — по одному для каждого типа элемента, в порядке объявления:
num_rows значений. Здесь нет префикса длины, потока смещений и разделителей между потоками. Пустой столбец (num_rows == 0) записывает ноль байт на поток. Типы элементов могут быть любыми, включая другие составные типы — Tuple(Tuple(...), ...), Tuple(Array(...), ...) и Tuple(Nullable(T1), T2) допустимы.
Кортеж из нуля элементов Tuple() тоже допустим — он возникает из выражений вроде SELECT tuple() или CAST(x AS Tuple()). Поскольку у него нет потоков элементов, он сериализуется как Nothing: один байт-заполнитель (0x30, ASCII '0') на строку, который десериализатор отбрасывает. Число строк берётся из заголовка блока, в точности как для Nothing.
Tuple(UInt8, UInt8) с 3 строками (1,4), (2,5), (3,6):
[1, 2, 3] для элемента 0 и [4, 5, 6] для элемента 1.
Tuple(UInt32, String) с 2 строками (10, "a"), (20, "bb") (всего 13 байт):
Map(K, V)
Map(KeyType, ValueType). Примеры: Map(String, UInt32), Map(String, Array(UInt32)), Map(UInt8, Tuple(Int32, String)), Map(Array(String), Int8). Формат передачи данных не накладывает ограничений ни на один из типов — и K, и V могут быть любыми поддерживаемыми типами, включая составные. (Правила SQL в ClickHouse относительно допустимых типов ключей различались между релизами; см. SQL-документацию для целевой версии сервера.)
Структура в формате передачи данных побайтно идентична Array(Tuple(K, V)), поэтому начинается с внутренней фазы префикса (пустой, если ни K, ни V не являются версионируемыми типами):
total_pairs = offsets[num_rows - 1] (или 0, если num_rows == 0). Поток offsets имеет ту же семантику, что и Array. Ключи позиционно соответствуют значениям: пара i — это (keys[i], values[i]).
В памяти ClickHouse столбец Map представлен как массив кортежей; в системе типов он выделен в отдельный тип для удобства работы с SQL (m['key'], mapKeys, mapValues). Формат передачи данных — это прямая сериализация этого представления, поэтому Map и Array(Tuple(K, V)) взаимозаменяемы байт в байт.
Offsets монотонно не убывают, а потоки ключей и значений содержат ровно total_pairs значений. Пустой столбец записывает ноль байт. В пределах одной строки ключи обычно уникальны, но это семантическое правило, а не ограничение, накладываемое форматом передачи данных: формат передачи данных допускает повторяющиеся ключи без потерь, а семантика на стороне сервера разрешает дубликаты только тогда, когда строку обрабатывает функция, работающая с Map.
Map(UInt8, UInt8) с 2 строками {1:10, 2:20}, {3:30} (всего 22 байта):
i восстанавливается при одновременном чтении keys[i] и values[i].
Map(String, UInt32) с 1 строкой {'a':1, 'b':2} (всего 20 байт):
Nested(name1 T1, name2 T2, …)
Nested при передаче зависит от настройки flatten_nested на стороне сервера, поэтому возможны два различных случая.
Случай A: flatten_nested = 1 (значение сервера по умолчанию). Если таблица создана с настройками по умолчанию, Nested не является wire-типом. Сервер хранит и представляет столбец как N параллельных столбцов Array(T_i) с именами через точку (outer.field1, outer.field2 и так далее). На уровне формата здесь нет ничего нового — каждый столбец с именем через точку является обычным Array:
flatten_nested = 0. Если таблица была создана с flatten_nested = 0, столбец передаётся по wire как один столбец со строкой типа Nested(name1 T1, name2 T2, ...), а его структура после строки типа побайтово идентична Array(Tuple(T1, T2, ..., Tn)) — включая внутреннюю фазу префикса, поэтому любое версионируемое поле T_i сначала записывает свой префикс состояния, перед смещениями. В примере ниже используются неверсируемые поля, поэтому фаза префикса пуста:
Nested сохраняет имена полей (a, b), тогда как Array(Tuple) не сохраняет их в виде именованных слотов.
Строка типа в случае B — это список пар (имя, тип), разделённых запятыми. Первый пробел отделяет имя от типа; сам тип может содержать дополнительные пробелы, запятые и скобки, поэтому для разбора нужен тот же разделитель с учётом глубины вложенности, что и для Tuple. Структура в формате передачи данных:
total_elements = offsets[num_rows - 1] (или 0, если num_rows == 0). Смещения монотонно не убывают, и поток каждого поля содержит ровно total_elements значений. Сервер во время INSERT проверяет, что в пределах одной строки все поля содержат одинаковое количество элементов. Для пустого столбца записывается ноль байт.
Nested(a UInt8, b String) с 2 строками [(10,'x'),(20,'y')] и [(30,'z')] (25 байт после строки типа):
Псевдонимы типов
Поэтому столбец
Point декодируется точно так же, как Tuple(Float64, Float64) (отображается как (1,2)), Ring — как Array(Tuple(Float64, Float64)) ([(0,0),(1,1)]) и так далее по иерархии.
Geometry тоже является псевдонимом, но для Variant, а не для вложенного массива: его полезная нагрузка представляет собой Variant из шести перечисленных выше гео-типов. Заголовок столбца содержит только строку типа Geometry — он не раскрывает Variant явно, — поэтому декодер должен развернуть его самостоятельно. Как и у любого Variant, дискриминаторы идут в каноническом порядке гео-псевдонимов, отсортированных по имени: 0 = LineString, 1 = MultiLineString, 2 = MultiPolygon, 3 = Point, 4 = Polygon, 5 = Ring. Затем каждое выбранное значение декодируется через соответствующий гео-псевдоним выше (NULL использует дискриминатор NULL типа Variant со значением 255).
SimpleAggregateFunction(func, T) — это псевдоним для типа значения T. Он хранит уже финализированное агрегированное значение, поэтому его форма в формате передачи данных и отображение в точности совпадают с T (SimpleAggregateFunction(sum, UInt64) декодируется как UInt64). Псевдонимом в этом смысле является только форма с одним типом значения; сам базовый тип при этом может быть составным.
Два связанных типа не являются псевдонимами. Это корректные типы столбцов
Native — например, клиент может получить столбец AggregateFunction от комбинатора -State или при распределённой агрегации, — но каждый из них содержит собственную специализированную полезную нагрузку, которая выходит за рамки этой страницы:AggregateFunction(func, ...)содержит промежуточное состояние агрегации (а не финализированное значение); его двоичная структура зависит от агрегатной функции и версии.QBit(T, N[, stride])хранит вектор, у которого битовые плоскости транспонированы для рабочих нагрузок векторного поиска; его структура потока в формате передачи данных (потоки битовых плоскостейFixedString, упорядоченные по группам, в количествеelement_size * (N / stride)с явнымstride) и его двоичное кодирование типа (тег0x36, или0x37QBitWithStride, когдаstride != N) задокументированы на странице типа данныхQBitи в справочнике по двоичному кодированию типов, поэтому читателюNativeне нужно восстанавливать их по исходному коду C++.
Версионируемые типы
Native префикс и словарь задаются для каждого блока отдельно — эти типы не хранят состояние между блоками (см. примечание о префиксе для каждого блока ниже); межблочное состояние сериализации существует только в дисковом потоке MergeTree.
Эти типы существенно сложнее, чем составные типы с фиксированной структурой, поэтому клиенту, ориентированному на простые аналитические запросы, их поддержку можно отложить.
Версия сериализации: концепция
Большинство типов с версией записывают её как UInt64 в формате little-endian непосредственно перед любыми другими данными префикса состояния; некоторые используют VarUInt или UInt8. Декодер сначала считывает версию и отклоняет неизвестные значения — более высокая версия означает более новый формат отправителя, который декодер не понимает, а неправильный разбор повреждает каждый последующий байт.
Префикс состояния выводится в начале каждого блока, количество строк в котором больше нуля, непосредственно перед полезной нагрузкой этого блока.
Средства записи и чтения Native не сохраняют состояние сериализации между блоками:
NativeWriter создаёт новое состояние serialize и записывает префикс состояния для каждого записываемого непустого блока столбца, а NativeReader создаёт новое состояние deserialize и считывает его для каждого читаемого непустого блока (оба полностью пропускают префикс, когда rows == 0).
Поэтому блоки заголовка (rows = 0) и пустые блоки ничего не выводят, и декодер должен заново считывать префикс состояния в начале каждого непустого блока. Если декодер считывает префикс только один раз и считает последующие блоки содержащими только полезную нагрузку, он прочитает префикс следующего блока как данные и потеряет синхронизацию:
Справочник версий сериализации
Несколько моментов, на которые стоит обратить внимание в таблице:
- Значения не идут подряд.
Dynamicиспользует1,2,3,4, гдеV3имеет значение4, аFLATTENED—3. Большее число не обязательно означает более новую версию. - Некоторые значения существуют только для нативного формата.
Object::STRING,Object::FLATTENEDиDynamic::FLATTENEDнужны для совместимости нативного протокола с клиентами, которые не реализуют полную поддержку Object/Dynamic. В дисковом хранилище MergeTree они не встречаются. V3в основном используется на диске. Клиенты, работающие по нативному TCP-протоколу, обычно видятFLATTENED(значение3), а неV3(значение4).
LowCardinality(T)
N внутренних значений компактным словарём уникальных значений и N индексами в этом словаре.
Строка типа: LowCardinality(InnerType). Примеры: LowCardinality(String), LowCardinality(FixedString(4)), LowCardinality(Nullable(String)).
sharedDictionariesWithAdditionalKeys; остальные значения зарезервированы.
Метаданные UInt64 для каждого блока представляют собой битовое поле:
Для типичного ответа на запрос с одним data block на столбец метаданные равны
0x600 (HasAdditionalKeys + NeedUpdateDictionary).
Значения dict — это dict_size значений, закодированных с использованием внутреннего типа T. В словаре начальные слоты зарезервированы под специальные значения: для столбца без Nullable резервируется один слот (dict[0] содержит значение по умолчанию внутреннего типа, например "" для String), а реальные уникальные значения начинаются с dict[1].
Для LowCardinality(Nullable(T)) dict по-прежнему кодируется как обычный T (без потока null-map), но резервируются два слота: dict[0] — это маркер NULL, а dict[1] — значение по умолчанию внутреннего типа (например, "" для String); реальные уникальные значения начинаются с dict[2]. Ключ строки со значением NULL указывает на dict[0], и этот слот записывается в wire-представлении как байты значения по умолчанию внутреннего типа.
Ключи — это индексы в dict; каждый индекс занимает 1 << key_type_code байт (1, 2, 4 или 8), а значение N восстанавливается как dict[keys[N]].
keys_count — это число значений LowCardinality на текущем рекурсивном уровне, а не обязательно количество строк в блоке. Для столбца LowCardinality верхнего уровня эти величины совпадают. Но если LowCardinality находится внутри составного типа, это число соответствует количеству значений в развёрнутом виде, которое составной тип передаёт ниже: для Array(LowCardinality(String)) с тремя строками и пятью элементами в сумме keys_count равно 5, а не 3; для Map(K, LowCardinality(V)) это общее количество пар и так далее. Декодер должен брать keys_count из этого поля, а не считать, что это количество строк в блоке. Когда это развёрнутое количество равно нулю — например, в блоке, где все массивы пусты, — фаза данных LowCardinality вообще ничего не записывает: присутствует только префикс состояния (выводимый в фазе префикса составных типов), без каких-либо последующих метаданных, словаря или keys_count.
Префикс состояния считывается в начале каждого блока, в котором число строк больше нуля — заголовочные блоки (rows = 0) и пустые блоки ничего не выводят. Внутри блока keys_count равен числу строк, dict_size — числу значений в потоке словаря, а каждый ключ помещается в 1 << key_type_code байт.
В формате
Native каждый блок передаёт самодостаточный словарь, локальный для этого блока — общего состояния словаря между блоками нет. Средство записи Native устанавливает low_cardinality_max_dictionary_size = 0, поэтому SerializationLowCardinality никогда не строит общий словарь: каждый непустой блок записывает свои ключи как дополнительные ключи, локальные для блока, с неустановленным NeedGlobalDictionaryBit (метаданные 0x600), а средство чтения Native отклоняет NeedGlobalDictionaryBit, когда native_format имеет значение true. Поэтому декодер должен сбрасывать словарь для каждого блока и считывать dict_size записей, присутствующих в этом блоке; если переносить словарь из предыдущего блока, ключи следующего блока будут прочитаны неверно. (Сохранение словаря LC между блоками — это особенность хранения MergeTree на диске, а не часть Native-представления данных на wire.)LowCardinality(String) со значениями ['a', 'b', 'a', 'c', 'b']:
dict[1], dict[2], dict[1], dict[3], dict[2] = ["a", "b", "a", "c", "b"].
LowCardinality(Nullable(String)) со значениями ['a', NULL, '', 'b'] показывает оба зарезервированных слота — dict[0] для NULL и dict[1] для пустой строки по умолчанию:
dict[2] = "a", dict[0] = NULL, dict[1] = "", dict[3] = "b", то есть ["a", NULL, "", "b"]. И dict[0], и dict[1] в wire-представлении — это пустые байты; признак NULL возникает из-за того, что ключ указывает на слот 0, а не из-за самих байтов.
JSON (уровень 1: резервный вариант String)
JSON в ClickHouse поддерживает несколько вариантов кодирования на wire-уровне (см. справочник по версиям сериализации). Уровень 1 — самый простой: когда включена настройка запроса output_format_native_write_json_as_string = 1, сервер преобразует каждое значение JSON в сериализованный текст и выводит столбец как String с префиксным маркером состояния.
Строка типа: JSON.
1 для этого резервного варианта String. Другие значения обозначают разные кодировки JSON/Object: 0 = V1, 2 = V2 (по умолчанию в нативном TCP-протоколе), 3 = FLATTENED, 4 = V3 (см. справочник по версии сериализации). Декодер, который видит здесь значение, отличное от 1, имеет дело не с резервным вариантом String. Префикс считывается в начале каждого блока с числом строк > 0, а поток значений представляет собой стандартный столбец String для num_rows строк.
JSON-значение '{\"a\":1}' (одна строка):
'{\"a\":1}', при этом целое число остаётся целым числом. Текст — это просто значение String, поэтому клиент получает JSON для непрозрачной передачи, но не восстанавливает отдельные пути и их типы ClickHouse; для точной типизации по каждому пути требуется кодирование уровня 2, описанное ниже.
Variant(T1, T2, …)
Variant(T1, T2, ...). Сервер канонизирует порядок (типы варианта сортируются по имени), поэтому полученная строка типа уже перечисляет типы в порядке глобального дискриминатора: дискриминатор 0 выбирает первый указанный тип, 1 — второй и так далее. 255 (NULL_DISCRIMINATOR) означает, что строка имеет значение NULL. Элементы Variant никогда не бывают Nullable — за NULL отвечает дискриминатор. Примеры: Variant(String, UInt64), Variant(Array(UInt8), String).
Префикс состояния содержит режим дискриминаторов UInt64 LE: 0 = BASIC (дискриминатор каждой строки записывается в явном виде), 1 = COMPACT (кодирование granule по длинам серий). По умолчанию сервер использует BASIC через собственный протокол (use_compact_variant_discriminators_serialization = false); здесь определён только BASIC.
r с дискриминатором d (≠ 255) получает значение по индексу counter[d] из последовательности значений типа варианта d, после чего counter[d] увеличивается. Строки с дискриминатором 255 имеют значение NULL и не берут значение ни из одной последовательности, поэтому сумма счетчиков по типам равна числу строк, отличных от NULL.
Префикс состояния (режим UInt64) считывается в начале каждого блока с rows > 0; заголовок и пустые блоки ничего не выводят. Каждый дискриминатор, отличный от NULL, меньше числа типов варианта, а тип варианта i декодируется ровно для count[i] строк.
Элементы Variant, которые сами являются типами с сохранением состояния (
LowCardinality, Variant, Dynamic, JSON), выводят собственный префикс состояния на фазе префикса состояния для каждого элемента, после режима UInt64. Листовые типы и простые составные типы (Array, Tuple, Map из листовых типов) имеют пустые префиксы состояния и свободно комбинируются.Variant(String, UInt64) со значениями [42, 'hi', NULL] (канонический порядок помещает String перед UInt64, поэтому дискриминатор 0 = String, 1 = UInt64):
42; строка 1 = String run[0] = "hi"; строка 2 = NULL.
Поток дискриминаторов служит индексом; каждый дискриминатор, отличный от NULL, берёт следующее значение из плотной последовательности своего типа, тогда как 255 (NULL) ничего не потребляет. Этот же проход восстанавливает Dynamic, который отличается только способом кодирования NULL:
Dynamic
NULL. В отличие от Variant, набор типов не указывается в строковом представлении типа столбца — он хранится в префиксе состояния.
Строка типа: Dynamic или Dynamic(max_types=N). Параметр max_types ограничивает количество различных типов, которые отслеживает столбец, но не влияет на описанный ниже формат передачи данных.
У Dynamic есть четыре кодировки — V1 = 1, V2 = 2, FLATTENED = 3, V3 = 4. Какую из них отправляет сервер, зависит от канала и настроек запроса:
- В
clickhouse-clientи HTTPFORMAT Nativeревизия средства записи равна0(если не повышена черезclient_protocol_version), поэтому по умолчанию используется V1. - В нативном TCP-протоколе при согласованной ревизии по умолчанию используется V2. Средство записи
Nativeоставляет статистику отключённой, поэтому полезная нагрузкаV2по умолчанию не содержит статистики по отдельным вариантам — после списка типов сразу идут вложенный префиксVariantи данные. (Статистика по отдельным вариантам относится к on-disk-представлению MergeTree, а не к нативному формату передачи данных.) - Настройка запроса
output_format_native_use_flattened_dynamic_and_json_serialization = 1переопределяет оба варианта и выводит FLATTENED (version 3) независимо от ревизии.
Область примененияНа этой странице описывается только структура
FLATTENED. Неплоские бинарные структуры V1/V2/V3 являются внутренним/on-disk-представлением (списки типов в бинарной кодировке, статистика по отдельным вариантам) и не специфицируются здесь. Клиент, который хочет декодировать Dynamic по этой странице, должен запросить FLATTENED, установив output_format_native_use_flattened_dynamic_and_json_serialization = 1; приведённая ниже структура предполагает эту настройку. Поскольку байт версии стоит в начале префикса, декодер может определить фактически полученную кодировку и отклонить V1/V2/V3, если поддерживает только FLATTENED.num_types типов и слота NULL: UInt8 для num_types ≤ 255, затем UInt16, UInt32, UInt64. NULL соответствует значению дискриминатора num_types, в отличие от Variant, где NULL имеет фиксированное значение 255. Восстановление выполняется тем же плотным проходом, что и для Variant: для каждого типа ведётся отдельный счётчик, и строка r с дискриминатором d (≠ num_types) берёт значение counter[d] из последовательности типа d.
Префикс состояния (версия + список типов) считывается в начале каждого блока, содержащего строки (> 0); заголовок и пустые блоки ничего не выводят.
Некорректный счётчик
num_types считывается из потока до любых имён типов. Декодер должен считать его недоверенным и не должен напрямую использовать для определения объёма выделяемой памяти — ни значение, близкое к SIZE_MAX (которое может вызвать переполнение промежуточных арифметических операций или сгенерировать не-DB::Exception), ни большое, но представимое значение, такое как 100000000 (которое значительно меньше max_size() вектора, но всё же привело бы к выделению гигабайтов памяти ещё до чтения хотя бы одного имени типа). ClickHouse считывает список типов по одной записи за раз, используя лишь ограниченную подсказку для предварительного выделения памяти, поэтому повреждённый num_types отклоняется либо как INCORRECT_DATA (“у столбца Dynamic слишком много типов”, когда количество превышает вместимость контейнера), либо как обычная ошибка чтения, когда в потоке заканчиваются записи типов, — и никогда как ошибка нехватки памяти.Однако не ограничивайте flattened num_types значением ColumnDynamic::MAX_DYNAMIC_TYPES_LIMIT (254 в ClickHouse): flattened-список типов содержит все различные типы времени выполнения, включая те, которые переполнились в общий variant, поэтому корректный flattened block вполне может на законных основаниях содержать гораздо больше типов. Ограничение MAX_DYNAMIC_TYPES_LIMIT применяется только к значению num_dynamic_types в не-flat префиксах V1/V2/V3, которое считает слоты обычного variant и ограничивается этим пределом (ClickHouse проверяет его там до + 1 для общего variant).Типы времени выполнения, чья сериализация выполняется с сохранением состояния (
LowCardinality, Variant, Dynamic, JSON), содержат вложенные префиксы состояния после списка имён типов.Variant — обычные слоты variant записываются в порядке DataTypeVariant (имён типов), поэтому порядок в wire не соответствует порядку вставки. Однако он не всегда глобально отсортирован: типы, которые переполнились в общий variant (например, при Dynamic(max_types=N)), добавляются после обычных слотов в порядке первого появления, поэтому хвост списка может нарушать порядок по имени типа. Поэтому декодер должен считать переданный список типов авторитетным при назначении дискриминаторов и не должен самостоятельно пересортировывать его. Для строк [42::UInt64, "hi", NULL] есть два типа: String и UInt64, а "String" сортируется перед "UInt64", поэтому дискриминаторы будут такими: 0 = String, 1 = UInt64, 2 = NULL:
42; строка 1 = String run[0] = "hi"; строка 2 = NULL. Последовательности для каждого типа следуют тому же порядку в wire-представлении, что и список типов (String перед UInt64).
JSON (уровень 2: FLATTENED Object)
output_format_native_write_json_as_string = 0) при включённом флаге flattened-сериализации (output_format_native_use_flattened_dynamic_and_json_serialization = 1); в этом случае сервер выдаёт версию 3 сериализации.
Есть два вида путей:
- Типизированные пути объявляются в строке типа, например
JSON(a UInt32, b String), и декодируются в объявленный для них тип. Имя пути, содержащее точки, в строке типа заключается в обратные кавычки. - Динамические пути определяются во время выполнения, и каждый из них декодируется как столбец Dynamic.
num_rows значений.
Dynamic динамического пути (в фазе префиксов) отделён от его данных (в фазе данных). Префикс состояния считывается в начале каждого блока, где число строк > 0, и каждый столбец пути (типизированный или динамический) содержит ровно num_rows значений. Объект строки r собирается чтением значения каждого пути по индексу r; динамический путь, у которого дискриминатор Dynamic для этой строки равен NULL, не добавляет ключ.
Некорректные счётчики
num_dynamic_paths в описанной здесь структуре FLATTENED — и счётчик dynamic-paths в неуплощённых кодированиях V1/V2/V3 — считывается из потока до имён путей. (В неуплощённых префиксах нет отдельного поля flattened-paths: V1/V2/V3 содержат только счётчик dynamic-paths, а также значение max_dynamic_paths в V1, которое считывается и отбрасывается, и описанные ниже метаданные общих данных V3.) Как и в случае с Dynamic, декодер должен считать эти счётчики недоверенными и не должен напрямую выделять память по их значениям — ни для счётчика семейства SIZE_MAX, ни для большого, но представимого значения. ClickHouse считывает имена путей по одному элементу за раз, используя лишь ограниченную подсказку для предварительного выделения памяти, поэтому повреждённый счётчик отклоняется либо как INCORRECT_DATA (“JSON/Object column has too many paths”, когда он превышает вместимость контейнера), либо как обычная ошибка чтения, когда в потоке заканчиваются имена путей.Неуплощённый префикс V3 дополнительно содержит счётчик shared_data_buckets (он присутствует, когда версия сериализации общих данных — MAP_WITH_BUCKETS или ADVANCED). По нему напрямую задаются состояние чтения для каждого бакета и векторы столбцов (а не через цикл с расширением по требованию), поэтому декодер должен сразу отклонять неправдоподобное число бакетов. В отличие от счётчиков путей и типов, для этого счётчика существует строгий инвариант на стороне записи: число бакетов выбирается из небольших настроек MergeTree (object_shared_data_buckets_for_compact_part / object_shared_data_buckets_for_wide_part), которые не равны нулю и ограничены значением 256, поэтому единственный допустимый on-wire диапазон — 1 … 256. ClickHouse отклоняет любое значение вне этого диапазона — включая большое, но представимое значение вроде 100000, которое значительно меньше max_size() контейнера, — с ошибкой INCORRECT_DATA (“JSON/Object column has an invalid number of shared data buckets”).JSON {"a": 42, "b": "hi"} (одна строка, оба пути динамические). Целое число в JSON автоматически выводится как Int64:
JSON в неуплощённом виде (V2/V3)
Object в неуплощённом виде (V1/V2/V3) используются в дисковом хранилище MergeTree и именно их сервер передаёт по сети, когда флаг flattened отключён: V1 — через clickhouse-client / HTTP FORMAT Native (ревизия 0), V2 — через нативный TCP-протокол. Они содержат столбец shared-data и не описаны на этой странице. Обратите внимание, что по каналу Native они не передают статистику для отдельных путей: NativeWriter оставляет статистику отключённой, поэтому у префикса структуры Object нет секции статистики, а следующие за ним байты — это сразу префиксы typed/dynamic/shared-data и сами данные. Статистика появляется только в тех дисковых путях MergeTree, где она включена. Чтобы декодировать столбец JSON с помощью этой страницы, клиент должен выбрать один из описанных уровней: установить output_format_native_write_json_as_string = 1 для резервного варианта String или output_format_native_use_flattened_dynamic_and_json_serialization = 1 (с output_format_native_write_json_as_string = 0) для структуры FLATTENED Object.
Фрейм сжатия
Native, используя внутренний формат фреймов. Приведенная ниже структура фрейма не зависит от транспорта — одни и те же фреймы используются как в нативном TCP-протоколе, так и поверх HTTP, — однако способ запроса сжатия и то, что окружает фреймы, зависит от транспорта.
- Нативный TCP-протокол. Сжатие включается отдельно для каждого запроса через флаг
compressionв пакете Query. Когда оно активно, тело каждого пакетаData,Totals,Extremes,LogиProfileEvents— байты после строкиtable_name— оборачивается в этот формат фреймов. Сама оболочка пакета, код типа пакета и строкаtable_nameне сжимаются; сервер записывает их в сырой поток. Всё, что выводитNativeWriter, попадает в сжатый поток, поэтому префиксBlockInfoидет первым внутри фрейма, вместе с размерностями и столбцами. Поэтому клиент должен сначала распаковать фрейм, прежде чем сможет прочитатьBlockInfo. - HTTP.
SELECT ... FORMAT Native&compress=1оборачивает весь байтовый потокFORMAT Nativeв те же фреймы (сервер использует тот же внутреннийCompressedWriteBuffer), а?decompress=1ожидает такие же фреймы во входном телеNative, декодируя их через соответствующийCompressedReadBuffer. На этом пути нет ни типа TCP-пакета, ниtable_name, ни оболочки пакета: вся сжатая полезная нагрузка представляет собой просто блокиNative, упакованные во фреймы (префиксBlockInfoприсутствует только в том случае, если согласованная ревизия больше0, точно так же, как и в несжатой структуре выше). Это внутреннее фреймированиеcompress/decompressотличается от HTTP-сжатия на транспортном уровне (Content-Encoding: gzip/zstd, включаемого черезenable_http_compression), которое оборачивает ответ на уровне HTTP и не является форматом фреймов, описанным ниже.
FORMAT Native, всё равно должен добавить этот слой фреймирования, чтобы читать сжатый HTTP-ответ Native или отправлять тело запроса decompress=1.
Формат фрейма
16 + compressed_size = 16 + 9 + body_size = 25 + body_size. Обратите внимание на два диапазона: контрольная сумма охватывает 9-байтный заголовок и тело, тогда как compressed_size включает заголовок и тело, но не саму контрольную сумму:
Значения байта метода
Native на уровне всего потока: HTTP-вывод с compress=1 всегда использует LZ4, а собственный TCP-протокол использует LZ4, ZSTD или NONE в зависимости от network_compression_method. Универсальному Native-клиенту достаточно уметь только создавать и обрабатывать их.
Байт метода также кодирует кодеки на уровне столбца. Они применяются к каждому столбцу в путях хранения MergeTree на диске, а не к фреймингу всего потока, однако входной HTTP-путь
decompress=1 берёт кодек из байта метода каждой рамки окна, поэтому на входе может корректно встретиться любой из этих байтов. Соответствующий декодер должен поэтому распознавать всё выделенное пространство значений и отклонять байт, который он не поддерживает, а не неверно интерпретировать тело. Их тела зависят от конкретного кодека и выходят за рамки этого общего контракта рамки окна:
0x9d (SZ3) — экспериментальный кодек с потерями и ограниченной ошибкой для Float32, Float64 и Array этих типов. Таблицу можно создать с CODEC(SZ3) только если задан allow_experimental_codecs, но при распаковке байт метода принимается всегда, чтобы ранее записанные данные оставались читаемыми. 0x9f (ZXC) — асимметричный LZ-кодек: медленный при сжатии, но очень быстрый при распаковке, со степенью сжатия между LZ4 и ZSTD. Байты 0x99 (DeflateQpl) и 0x9b (ZSTD_QPL) были назначены кодекам, которые с тех пор удалены; они зарезервированы и не используются повторно.
0x9e (Quantized) — экспериментальный кодек столбца для плотных векторных столбцов (Array(Float32) и подобных). Как и NONE, он работает в режиме passthrough — тело с полной точностью сохраняется как есть, — но его наличие подключает сериализацию, которая записывает компактный сопутствующий квантованный поток, используемый для ускорения векторного поиска. Таблицу можно создать с CODEC(Quantized(...)) только если задан allow_experimental_codecs, и при распаковке байт метода принимается всегда.
Контрольная сумма
Границы отдельных блоков
CompressedWriteBuffer, который выдаёт фрейм каждый раз, когда заполняется его внутренний буфер (≈1 MB, DBMS_DEFAULT_BUFFER_SIZE), и ещё один, завершающий фрейм, когда выполняется сброс блока. Поэтому небольшой блок занимает один фрейм, а большой — несколько последовательных фреймов.
Этот инвариант работает только в одну сторону: поскольку отправитель сбрасывает сжатый буфер в конце каждого блока, каждое окончание блока совпадает с границей фрейма — но обратное неверно. Промежуточная граница фрейма, возникающая, когда буфер заполняется в середине блока, находится внутри блока и не является его границей. Поэтому декодер должен использовать собственные параметры блока (num_columns/num_rows), чтобы определить, где он заканчивается; нельзя считать, что каждый фрейм представляет собой один полный блок.
Получатель обрабатывает фреймы как поток: считайте 16 + 9 байт, затем считайте ровно compressed_size - 9 байт тела, распакуйте их ровно в uncompressed_size байт и передайте эти байты декодеру блока; когда декодеру требуется больше данных, чем содержит текущий фрейм, подгрузите следующий фрейм. Поскольку отправитель выполняет сброс для каждого блока, после полного декодирования блока буфер фрейма пуст, и следующий блок начинается с нового фрейма.
В протоколе нативный TCP внешняя часть пакета — VarUInt с типом пакета и строка table_name — записывается в raw-поток, вне сжатой полезной нагрузки; фреймируется только тело блока (BlockInfo + столбцы). В HTTP-пути compress/decompress такой внешней части нет: весь поток состоит из фреймированных блоков.
Согласование
compression: bool пакета Query запрашивает его для одного конкретного запроса. Сервер учитывает этот запрос и отправляет сжатые тела Data/Totals/Extremes/Log/ProfileEvents на протяжении всего времени выполнения запроса (Log/ProfileEvents — только начиная с v54481+). Он также ожидает, что исходящие data block клиента — внешние таблицы, пустой маркер конца данных и строки INSERT — будут оформлены таким же образом. Последующие запросы в рамках того же соединения могут отличаться.
В HTTP пакета Query нет: query parameter compress=1 выбирает фреймированный вывод для этого запроса, а decompress=1 указывает, что body запроса тоже фреймировано. Вывод с compress=1 записывается с кодеком сервера по умолчанию (LZ4), а не network_compression_method; обработчик decompress=1 берёт кодек из байта метода в каждом фрейме, поэтому на входе принимается любой кодек.
При включённом сжатии сервер также может направлять столбцы через параллельный путь маршалинга блоков /
ColumnBLOB (PARALLEL_BLOCK_MARSHALLING, v54478) для блоков, содержащих более одной строки. Реализация, которая сжимает данные INSERT, должна быть готова обработать этот путь (или явно отказаться от него), чтобы избежать рассинхронизации потока.Глоссарий
Native, который сериализуется с revision 0. См. BlockInfo.
Column body — байты столбца, содержащие фактические значения после заголовка столбца (name, type, байт has_custom_serialization). Структура зависит от типа. См. структура столбца в формате передачи данных.
Composite type — тип, построенный из одного или нескольких внутренних типов и кодируемый как несколько потоков на столбец. Формат передачи данных стабилен и не версионируется. См. составные типы.
Dictionary (LowCardinality) — массив уникальных значений, на который столбец LowCardinality(T) ссылается через целочисленные индексы. См. LowCardinality.
Empty block — Block с num_columns = 0 и num_rows = 0. Используется как сигнальный маркер: маркер конца входных данных на стороне клиента и маркер границы потока на стороне сервера. См. варианты block.
Header block — Block с num_columns > 0 и num_rows = 0, отправляемый сервером как первый пакет Data в ответе на запрос. Объявляет схему результата. См. варианты block.
Inner type — тип, который оборачивает составной тип. Array(UInt32) имеет внутренний тип UInt32; для Nullable(T) внутренним типом является T.
Offsets stream — массив UInt64 с накопленными конечными позициями, который Array, Map и Nested используют для задания границ элементов в каждой строке. См. Array.
Placeholder value — байты, записываемые в позициях null в потоке значений столбца Nullable(T). Декодер считывает их, чтобы продвинуться по потоку, но игнорирует их содержимое. См. Nullable.
Result block — Block с num_rows > 0, содержащий фактические строки результата запроса. См. варианты block.
Schema block — синоним header block, используемый при описании фазы INSERT, где schema block сообщает клиенту ожидаемую структуру столбцов.
Serialization version — номер on-wire-версии для каждого типа, который версионируемые типы используют, чтобы указать, какой вариант кодирования следует далее. Отличается от версии протокола. См. версия сериализации: понятие.
State prefix — байты, предшествующие полезной нагрузке версионируемого типа в пределах блока. Содержат версию сериализации и (для LowCardinality) метаданные словаря на уровне блока. Записываются в начале каждого блока, где rows > 0; не сохраняются между блоками.
Stream — непрерывная последовательность байтов внутри тела столбца, кодирующая один логический подкомпонент (null-map, массив offsets, поток значений). Типы с несколькими потоками объединяют два или более потока на столбец.