Skip to main content
Формат Native — это столбцовый формат передачи данных, который ClickHouse использует для передачи табличных данных. Он встречается в нескольких местах:
  • в теле пакетов Data, Totals, Extremes, Log и ProfileEvents в собственном TCP-протоколе (пакет TableColumns не является блоком Native — он содержит две двоичные строки, поэтому его структура относится к спецификации собственного протокола);
  • в выводе SELECT ... FORMAT Native по HTTP;
  • в файлах, экспортируемых с помощью INTO OUTFILE ... FORMAT Native;
  • в полезных нагрузках межсерверной репликации.
На этой странице описываются байты внутри Block — столбцовой полезной нагрузки — и кодирование типов отдельных столбцов, из которых он состоит. Формирование пакетов, состояние соединения и согласование версий относятся к спецификации собственного протокола. Все целочисленные поля длиной более одного байта используют порядок little-endian. Знаковые целые числа используют дополнительный код.
Чтобы ознакомиться с форматом Native с точки зрения пользователя (с примерами curl), см. страницу Формат Native. Эта спецификация представляет собой низкоуровневое справочное описание формата передачи данных.

Обзор

Всё, что передаёт строки по сети, представляет собой Block — самоописывающийся фрагмент строк, хранящийся по столбцам. Сначала идут все значения столбца 1, затем все значения столбца 2 и так далее. Block содержит только те столбцы, к которым обращается запрос, а не всю таблицу. 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-примитивы

Формат Native основан на четырёх примитивных кодировках.

VarUInt

Беззнаковое целое переменной длины в кодировке LEB-128. Каждый байт содержит 7 бит данных в позициях 0–6 и 1 бит продолжения в позиции 7. Бит продолжения равен 1, если за ним следуют дополнительные байты, и 0 в последнем байте. Кодирование значения 300:
Декодирование байтов 0xAC 0x02:

Целые числа фиксированной разрядности

Например, значение UInt32 1 кодируется как 01 00 00 00, а значение Int32 -1 — как FF FF FF FF.

String

Байтовая последовательность с префиксом длины:
Последовательность байтов не обязательно должна быть корректной UTF-8. Пустая строка кодируется одним байтом 0x00, а строки могут содержать любые значения байтов, включая встроенный NUL. Строка "ab" кодируется как 02 61 62; чтобы декодировать её, сначала прочитайте длину VarUInt (2), затем — указанное число байтов.

Bool

Один байт. 0x00 означает false; любое ненулевое значение — true (канонически 0x01).

Структура блока и столбцов

Структура блока в формате передачи данных

Наличие префикса BlockInfo зависит от канала, поскольку модуль записи параметризуется значением ревизии:
  • В собственном TCP-протоколе сервер записывает блоки с ревизией, согласованной для соединения (это большое значение — в этом release DBMS_TCP_PROTOCOL_VERSION равен 54485). BlockInfo записывается всегда, когда это значение ревизии больше нуля, а для реального соединения это верно всегда. Байт has_custom_serialization в каждом столбце (см. структуру столбца в формате передачи данных) записывается начиная с ревизии 54454.
  • Native output formatSELECT ... 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. Поэтому клиенты не должны предполагать, что любая полезная нагрузка HTTP FORMAT Native имеет ревизию 0.
Иными словами, примеры байтов в этом разделе, начинающиеся с префикса BlockInfo, описывают полезную нагрузку TCP-пакета Data. Тот же запрос, выполненный через FORMAT Native, даёт более короткую форму, показанную рядом.

BlockInfo

BlockInfo — это последовательность полей, каждому из которых предшествует VarUInt с ID поля, а заканчивается она ID поля 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-потоке:
  1. Поток смещений — последовательность VarUInt. Каждое значение v — это либо:
    • v, у которого старший бит в позиции 62 не установлен: (v & 0x3FFFFFFFFFFFFFFF) = число позиций со значением по умолчанию перед следующим явно заданным значением, отличным от значения по умолчанию. Позиция этого значения — cursor + group_size, где cursor — текущая позиция; после этого cursor увеличивается на group_size + 1.
    • v с установленным битом 62 (END_OF_GRANULE_FLAG): значение со снятым флагом = число завершающих позиций со значением по умолчанию после последнего нестандартного значения. Это отмечает конец потока смещений для блока.
  2. Поток значений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), столбец проходит три этапа:
  1. Каждый подходящий столбец (не const, не Tuple, в блоке более чем с одной строкой) оборачивается в ColumnBLOB, который содержит столбец, уже маршалированный и сжатый вне основного потока.
  2. DETACHED добавляется в стек kind обёрнутого столбца.
  3. data столбца записываются как размер blob в формате VarUInt, за которым следуют ровно столько байтов blob.
Если обёрнутый столбец был разреженным, его стек имеет вид {DEFAULT, SPARSE, DETACHED}, что сериализуется как DETACHED_OVER_SPARSE. Клиент, декодирующий такой столбец, считывает длину blob и сами байты, затем распаковывает blob, чтобы восстановить полезную нагрузку внутреннего столбца (см. примечание о ColumnBLOB в разделе о сжатии).

Варианты блока

Во всех пакетах семейства Data используется один и тот же формат передачи данных Block. Варианты различаются только количеством столбцов и строк:

Примеры на уровне байт

Все примеры в этом разделе взяты из пути Data-пакета TCP, поэтому они включают префикс BlockInfo и байт has_custom_serialization. В FORMAT Native те же блоки короче — там, где это уместно, приведена эквивалентная короткая форма. Пустой блок (с BlockInfo), всего 8 байт:
Блок заголовка для SELECT 1 объявляет один столбец с именем "1" типа UInt8 и нулевое число строк. В протоколе ≥ 54454 включается байт has_custom_serialization:
Результирующий блок для того же запроса с одной строкой:
В формате FORMAT Native (ревизия 0) у того же блока результата нет ни BlockInfo, ни байта has_custom_serializationSELECT 1 FORMAT Native занимает 11 байт:
(Результат с нулевым числом строк, например блок, содержащий только заголовок, вообще не выдаёт байтов в FORMAT Native: формат вывода не выводит пустые блоки.)

Типы данных

В этом разделе описывается представление в протоколе для типов, которые формат Native может передавать в data столбца; они сгруппированы в четыре семейства по возрастанию сложности декодирования. Два типа — AggregateFunction(func, ...) и QBit(T, N) — являются допустимыми типами столбцов Native, но имеют полезную нагрузку, зависящую от функции или типа, и здесь не рассматриваются; ниже они отдельно отмечены там, где их иначе можно было бы принять за псевдонимы.

Типы фиксированной ширины

Каждое значение занимает фиксированное число байтов. столбец из M строк занимает в wire-формате ровно bytes_per_row × M байт; данные идут подряд, без разделителей и заполнения.

Целочисленные типы

UInt8UInt256 и Int8Int256 — это прямое двоичное кодирование целочисленных значений. Декодер считывает bytes_per_row × num_rows байт и интерпретирует их в соответствии с типом. Столбец UInt32, содержащий [1, 256, 65536]:
Столбец Int32 со значениями [-1, 42]:

Float32 и Float64

Стандартные двоичные числа с плавающей точкой IEEE 754: 4-байтовые одинарной точности (binary32) и 8-байтовые двойной точности (binary64), оба в порядке little-endian. NaN, ±Infinity, ±0.0 и субнормальные числа сохраняются без нормализации при преобразовании туда и обратно. Значение 1.5 типа Float32 (0x3FC00000):
Float64, значение 1.5 (0x3FF8000000000000):

BFloat16

Формат brain floating point: старшие 16 бит числа IEEE 754 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

Оба типа кодируют даты как целочисленное число дней относительно эпохи Unix 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])

8 байт, 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

16 байт на значение. Представление не соответствует каноническим 16 байтам в порядке big-endian — каждая 8-байтная половина записывается с независимым обращением порядка байтов. Логическая модель — это 128-битный идентификатор в канонической текстовой форме xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, где байты по соглашению записываются в порядке big-endian. В представлении по wire эти 16 канонических байтов делятся на две 8-байтные половины, и каждая половина записывается в порядке little-endian:
  • Байты wire 0..7 = канонические байты 0..7 в обратном порядке.
  • Байты wire 8..15 = канонические байты 8..15 в обратном порядке.
UUID 550e8400-e29b-41d4-a716-446655440000:
UUID со значением nil (все нули) выглядит одинаково в обоих представлениях.

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:
Эта асимметрия сделана намеренно: IPv4 хранится как 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)

Знаковое целое число, масштабированное на степень 10. Байтовая ширина целого числа определяется precision 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):
Префикс null-map — это стандартный формат 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 байтов):
Сравнение двух строковых типов:

Составные типы

Составные типы оборачивают один или несколько внутренних типов и используют общую wire-модель: несколько потоков на столбец. Один логический столбец кодируется как две или более независимо читаемые последовательности байтов, соединённые друг с другом. Для них характерны три структурных свойства:
  • Фиксированная форма для каждой схемы. Структура полностью определяется строкой типа на этапе декодирования. 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:
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].
Итого: 24 (внешние смещения) + 24 (внутренние смещения) + 20 (значения) = 68 байт.

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:
Случай B: 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) хранит вектор, у которого битовые плоскости транспонированы для рабочих нагрузок векторного поиска.

Версионируемые типы

Версионируемые типы содержат префикс версии сериализации в wire-представлении, который указывает, какой вариант кодирования следует далее. Они также могут использовать несколько потоков (как и составные типы). В wire-представлении Native префикс и словарь задаются для каждого блока отдельно — эти типы не хранят состояние между блоками (см. примечание о префиксе для каждого блока ниже); межблочное состояние сериализации существует только в дисковом потоке MergeTree. Эти типы существенно сложнее, чем составные типы с фиксированной структурой, поэтому клиенту, ориентированному на простые аналитические запросы, их поддержку можно отложить.

Версия сериализации: концепция

Версия сериализации — это номер версии представления в передаваемом формате для каждого типа и каждого столбца, который указывает, какой вариант кодирования типа использует отправитель. Это первое значение в префиксе состояния столбца, поэтому декодер сначала считывает его, а затем выбирает подходящий парсер для остальной части столбца. Она отличается от версии протокола: Большинство типов с версией записывают её как UInt64 в формате little-endian непосредственно перед любыми другими данными префикса состояния; некоторые используют VarUInt или UInt8. Декодер сначала считывает версию и отклоняет неизвестные значения — более высокая версия означает более новый формат отправителя, который декодер не понимает, а неправильный разбор повреждает каждый последующий байт. Префикс состояния выводится в начале каждого блока, количество строк в котором больше нуля, непосредственно перед полезной нагрузкой этого блока. Средства записи и чтения Native не сохраняют состояние сериализации между блоками: NativeWriter создаёт новое состояние serialize и записывает префикс состояния для каждого записываемого непустого блока столбца, а NativeReader создаёт новое состояние deserialize и считывает его для каждого читаемого непустого блока (оба полностью пропускают префикс, когда rows == 0). Поэтому блоки заголовка (rows = 0) и пустые блоки ничего не выводят, и декодер должен заново считывать префикс состояния в начале каждого непустого блока. Если декодер считывает префикс только один раз и считает последующие блоки содержащими только полезную нагрузку, он прочитает префикс следующего блока как данные и потеряет синхронизацию:

Справочник версий сериализации

Несколько моментов, на которые стоит обратить внимание в таблице:
  • Значения не идут подряд. Dynamic использует 1, 2, 3, 4, где V3 имеет значение 4, а FLATTENED3. Большее число не обязательно означает более новую версию.
  • Некоторые значения существуют только для нативного формата. 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)).
Префикс состояния (Int64 LE = 1) — единственная определённая version, 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}' (одна строка):
Значение выводится как компактный текст JSON — '{\"a\":1}', при этом целое число остаётся целым числом. Текст — это просто значение String, поэтому клиент получает JSON для непрозрачной передачи, но не восстанавливает отдельные пути и их типы ClickHouse; для точной типизации по каждому пути требуется кодирование уровня 2, описанное ниже.

Variant(T1, T2, …)

Дискриминируемое объединение: каждая строка содержит значение ровно одного из типов варианта или NULL. Каждая строка несёт однобайтовый глобальный дискриминатор, который определяет её тип, а значения для каждого типа затем хранятся плотно, одним непрерывным блоком для каждого типа варианта. Строка типа: 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):
Восстановлено: строка 0 = UInt64 run[0] = 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 и HTTP FORMAT 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.
Структура FLATTENED (version 3), выбираемая этой настройкой:
Ширина дискриминатора — это наименьшее беззнаковое целое число, которого достаточно для индексации 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); заголовок и пустые блоки ничего не выводят.
Типы времени выполнения, сериализация которых выполняется с сохранением состояния (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:
Реконструировано: строка 0 = UInt64 run[0] = 42; строка 1 = String run[0] = "hi"; строка 2 = NULL. Последовательности для каждого типа следуют тому же порядку в wire-представлении, что и список типов (String перед UInt64).

JSON (уровень 2: FLATTENED Object)

Более богатое кодирование JSON: вместо преобразования каждого значения в текст (уровень 1) столбец разбивается на один подстолбец для каждого JSON-пути. Этот вариант выбирается, если не запрашивать резервный вариант уровня 1 (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.
В режиме FLATTENED нет общего столбца данных (это хранилище переполнения относится к неуплощённым кодировкам Object V2/V3). Каждый путь представляет собой полный столбец из num_rows значений.
Обратите внимание на двухфазную структуру: сначала идут все префиксы состояния путей, затем — все данные путей. Поэтому префикс Dynamic динамического пути (в фазе префиксов) отделён от его данных (в фазе данных). Префикс состояния считывается в начале каждого блока, где число строк > 0, и каждый столбец пути (типизированный или динамический) содержит ровно num_rows значений. Объект строки r собирается чтением значения каждого пути по индексу r; динамический путь, у которого дискриминатор Dynamic для этой строки равен NULL, не добавляет ключ. Значение 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 = 1output_format_native_write_json_as_string = 0) для структуры FLATTENED Object.

Фрейм сжатия

ClickHouse может сжимать данные столбцов потока 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 включает заголовок и тело, но не саму контрольную сумму:

Байтовые значения метода

Контрольная сумма

ClickHouse использует CityHash v1.0.2 (историческую версию), не современный Google CityHash; они дают разные результаты. Контрольная сумма вычисляется по 9 байтам заголовка (method + compressed_size + uncompressed_size) и N байтам тела — то есть по всем данным между самой контрольной суммой и концом фрейма. Первые 8 байт 16-байтного вывода CityHash128 — это младшая половина (LE), следующие 8 байт — старшая половина (LE). Декодер заново вычисляет CityHash128 по полученным заголовку и телу и сравнивает результат с начальными 16 байтами; несовпадение означает повреждение данных, и декодер завершает работу с ошибкой.

Границы отдельных блоков

Сжатая полезная нагрузка Block — это поток из одного или нескольких фреймов, а не обязательно один фрейм. Отправитель записывает сериализованный блок через 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 такой внешней части нет: весь поток состоит из фреймированных блоков.

Согласование

В нативном TCP-протоколе сжатие задаётся для каждого запроса, а не для соединения в целом. Поле 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, должна быть готова обработать этот путь (или явно отказаться от него), чтобы избежать рассинхронизации потока.

Глоссарий

Block — единица обмена данными в формате Native. Самоописывающийся фрагмент строк, хранящийся в столбцовом формате. См. структура block и столбца. BlockInfo — заголовок метаданных, предшествующий Block в TCP-пакете Data (записывается всегда, если revision соединения больше нуля). Последовательность полей с идентификаторами полей, наличие которых зависит от revision. Отсутствует в выходном формате 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, поток значений). Типы с несколькими потоками объединяют два или более потока на столбец.
Last modified on June 25, 2026