- no corpo dos pacotes
Data,Totals,Extremes,LogeProfileEventsno protocolo TCP nativo (o pacoteTableColumnsnão é um bloco Native — ele carrega duas strings binárias, portanto seu layout pertence à especificação do protocolo nativo); - na saída de
SELECT ... FORMAT Nativevia HTTP; - em exportações para arquivo gravadas com
INTO OUTFILE ... FORMAT Native; - em payloads de replicação entre servidores.
Visão geral
data de uma coluna é organizado de acordo com a família à qual seu tipo pertence. As famílias, em ordem crescente de complexidade do decodificador, são:
- Tipos de largura fixa organizam
datacomobytes_per_value × num_rowsbytes brutos, sem delimitação por linha. - Tipos compostos (
Nullable,Array,Tuple,Map,Nested) têm uma estrutura recursiva totalmente derivável da string do tipo, sem prefixo de versão e sem estado entre blocks. - Tipos versionados / com estado (
LowCardinality,JSON,Variant,Dynamic) iniciam cada block não vazio com um prefixo de versão/estado de serialização. No wireNative, esse prefixo e qualquer dicionário são por block — o format não carrega estado entre blocks (o componente de escrita cria um novo estado de serialização para cada block e definelow_cardinality_max_dictionary_size = 0). O estado entre blocks é uma questão do MergeTree em disco, não do layout do wire Native.
Primitivos wire
VarUInt
1 quando há mais bytes a seguir e 0 no byte final.
Codificação do valor
300:
0xAC 0x02:
Inteiros de largura fixa
Por exemplo, o valor
1 do tipo UInt32 é codificado como 01 00 00 00, e o valor -1 do tipo Int32 como FF FF FF FF.
String
0x00, e strings podem conter qualquer valor de byte, incluindo NUL embutido. A string "ab" é codificada como 02 61 62; para decodificar, leia o comprimento em VarUInt (2) e, em seguida, leia essa quantidade de bytes.
Bool
0x00 é false; qualquer valor diferente de zero é true (na forma canônica, 0x01).
Estrutura de Block e colunas
Layout em wire do Block
BlockInfo depende do canal, porque o gravador é parametrizado por uma revisão:
-
No protocolo TCP nativo, o servidor grava blocos na revisão negociada da conexão (um valor alto —
DBMS_TCP_PROTOCOL_VERSIONé54485neste lançamento).BlockInfoé gravado sempre que essa revisão é maior que zero, o que sempre acontece em uma conexão real. O bytehas_custom_serializationem cada coluna (veja wire layout da coluna) é gravado na revisão54454e acima. -
O formato de saída
Native—SELECT ... FORMAT Nativepor HTTP,INTO OUTFILE ... FORMAT Nativee o formatoNativeproduzido porclickhouse-client— serializa na revisão0por padrão. Na revisão0, tanto o prefixoBlockInfoquanto o bytehas_custom_serializationsão omitidos, então um bloco é apenasnum_columns,num_rowse as colunas. Em HTTP, essa revisão não é fixa: um cliente pode aumentá-la com o parâmetro de consulta?client_protocol_version=<n>, e o servidor usa esse valor como a revisão de serialização da resposta. Com um valor alto o bastante, a saída HTTP inclui o prefixoBlockInfo(gravado sempre que a revisão é maior que0) e o bytehas_custom_serialization(gravado na revisão54454e acima), exatamente como no caminho TCP. Portanto, os clientes não devem presumir que todo payload HTTPFORMAT Nativeesteja na revisão0.
BlockInfo descrevem o payload do pacote Data no TCP. A mesma consulta feita por FORMAT Native produz a forma mais curta mostrada ao lado deles.
BlockInfo
0. O formato wire não é autodescritivo: um ID de campo não codifica o comprimento nem o tipo do seu valor, portanto o leitor já deve conhecer o tipo de cada ID de campo que possa encontrar. O próprio leitor do ClickHouse trata um ID de campo não reconhecido como corrupção e lança uma exceção (UNKNOWN_BLOCK_INFO_FIELD). A compatibilidade com versões futuras é tratada pela revisão do protocolo: o remetente só escreve um campo se a revisão negociada for pelo menos a revisão mínima desse campo, de modo que um receiver mais antigo nunca veja um campo que não conhece.
Os campos
1 e 2 têm revisão mínima 0, portanto estão presentes sempre que um BlockInfo é escrito. O campo 3 é escrito apenas na revisão 54480 e posteriores. Layout wire para o caso comum (revisão abaixo de 54480):
Layout wire da coluna
num_columns vezes dentro de um Block.
Um decodificador decide com base na string
type. As strings de tipo frequentemente trazem parâmetros entre parênteses; o decodificador remove o sufixo (...) para encontrar o tipo básico e, em seguida, analisa os parâmetros para decidir tamanho, escala ou tipo interno. Analisar uma lista de parâmetros com tipos aninhados (um Tuple dentro de um Array, por exemplo) exige um separador de vírgulas sensível à profundidade, que acompanhe o aninhamento de parênteses em vez de uma divisão ingênua em ,.
Codificação binária de tipoO campo
type é um String textual apenas no modo padrão. Quando a configuração da consulta output_format_native_encode_types_in_binary_format = 1 está definida, esse campo passa a ser uma codificação binária de tipo — a mesma codificação baseada em tags documentada em codificação binária de tipos de dados — e listas achatadas do tipo Dynamic usam a mesma codificação binária para seus nomes por tipo. Um decodificador que sempre ler o campo 2 como uma string prefixada por comprimento trataria a primeira tag binária de tipo como um comprimento de string e perderia a sincronização, portanto precisa saber qual modo o fluxo usa.kind_stack e codificação esparsa
kind_stack enumera uma serialização não padrão por coluna:
O payload de
COMBINATION usa um enum diferente. As cinco linhas acima são códigos compactos de um byte. COMBINATION (0x05) é o escape geral para qualquer pilha não coberta por eles: ele é seguido por um VarUInt count e depois por count entradas de um byte. Essas entradas não são os códigos compactos da tabela — são os valores brutos de ISerialization::Kind:
Os valores de byte diferem dos códigos compactos:
REPLICATED é 0x03 neste enum aninhado, mas 0x04 como código compacto, e não há entrada DETACHED_OVER_SPARSE — essa combinação aparece como duas entradas consecutivas, SPARSE e DETACHED. Um decodificador que continuar usando a tabela compacta para os bytes aninhados fará o mapeamento incorreto de 0x03/0x04 e perderá a sincronização.
O count é o comprimento total da pilha incluindo a entrada DEFAULT inicial que começa toda pilha. Os códigos compactos já cobrem toda pilha com uma ou duas entradas, portanto COMBINATION sempre tem count de pelo menos três.
kind_stack recursivo para colunas Tuple. O payload de kind_stack acima é o byte (ou a sequência COMBINATION) das informações de serialização da própria coluna. Um Tuple carrega um SerializationInfoTuple, que primeiro grava o payload da pilha de tipos do próprio tuple e depois grava um payload completo de pilha de tipos para cada elemento, em ordem; um decodificador lê de volta a mesma estrutura recursiva. Assim, para Tuple(A, B, C), os bytes do campo 4 são [tuple_kind][A_kind][B_kind][C_kind], e o payload de cada elemento é ele próprio recursivo se esse elemento também for composto. O byte has_custom_serialization (campo 3) é definido sempre que as informações do próprio tuple ou de qualquer elemento forem não padrão, de modo que um Tuple cujo único elemento especial seja esparso, replicado ou detached ainda aciona o payload de pilha de tipos. Um decodificador que ler apenas o único byte inicial do enum para um Tuple vai parar cedo demais e interpretar incorretamente os bytes restantes de tipo dos elementos como dados da coluna.
Formato wire esparso. Quando kind_stack = 0x01, o data da coluna é composto por dois fluxos gravados em sequência no único fluxo TCP compartilhado:
- Fluxo de offsets — uma sequência de
VarUInts. Cada valorvé:vcom o bit mais alto na posição 62 desativado:(v & 0x3FFFFFFFFFFFFFFF)= o número de posições padrão antes do próximo valor explícito não padrão. Essa posição não padrão écursor + group_size, em quecursoré a posição corrente; depois disso,cursoravança emgroup_size + 1.vcom o bit 62 ativado (END_OF_GRANULE_FLAG): o valor com a flag limpa = o número de posições padrão finais após o último valor não padrão. Isso marca o fim do fluxo de offsets para o block.
- Fluxo de valores —
countvalores não padrão codificados densamente no tipo interno, em quecounté o número deVarUInts não EOG lidos acima.
num_rows entradas, preenchendo cada posição não explícita com o valor padrão do tipo interno (0 para inteiros e números de ponto flutuante, "" para String, 0 dias para Date e assim por diante).
Uma coluna Nullable(T) esparsa é um caso especial, porque o valor padrão de Nullable(T) é NULL. A codificação esparsa elimina por completo o fluxo usual de mapa de nulos de Nullable: o fluxo de offset identifica as posições não padrão — isto é, não-NULL —, o fluxo de valores contém apenas esses valores não-NULL de forma densa em T, e cada posição não explícita é reconstruída como NULL. Portanto, um decodificador não deve procurar um mapa de nulos no fluxo de valores e não deve preencher as lacunas com um 0 presente; ele deve preenchê-las com NULL.
Formato wire replicado. Quando kind_stack = 0x04, a coluna data é um dicionário: uma lista de valores de elementos distintos mais um índice por linha nessa lista (a mesma estrutura de lookup de LowCardinality). Quando o tipo interno é, ele próprio, versionado — por exemplo, LowCardinality(T) —, seu prefixo de estado é escrito primeiro, antes do fluxo de índice: a serialização replicada delega a fase de prefixo ao tipo interno antes de escrever num_rows. Tipos internos com prefixo vazio (os tipos folha e os compostos simples) não acrescentam bytes aqui.
elements[indexes[i]] para cada linha de saída i. Tipos internos compostos são processados recursivamente: a lista de elementos é materializada no tipo interno e, em seguida, indexada. Os tipos internos compatíveis incluem os tipos folha, Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...) (cada campo expandido como um Array) e LowCardinality(T) (o Dicionário compartilhado é mantido; apenas as chaves de cada elemento são indexadas).
Formato wire desanexado. DETACHED (0x02) e DETACHED_OVER_SPARSE (0x03) de fato aparecem no wire — não são puramente internos. No caminho TCP, quando a compressão está habilitada e a revisão negociada é no mínimo DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING (v54478), a coluna passa por três etapas:
- Cada coluna elegível (não
const, nãoTuple, em um bloco com mais de uma linha) é encapsulada em umColumnBLOBque armazena a coluna já serializada e comprimida fora da thread principal. DETACHEDé acrescentado à pilha de kind da coluna encapsulada.- O
datada coluna é escrito como um tamanho de blobVarUInt, seguido por exatamente essa quantidade de bytes do blob.
{DEFAULT, SPARSE, DETACHED}, que é serializada como DETACHED_OVER_SPARSE. Um cliente que decodifica essa coluna lê o comprimento e os bytes do blob e, em seguida, descomprime o blob para recuperar o payload da coluna interna (veja a nota sobre ColumnBLOB na seção sobre compressão).
Variantes de Block
Exemplos no nível de byte
BlockInfo e o byte has_custom_serialization. Em FORMAT Native, os mesmos blocos são mais curtos — a forma curta equivalente é mostrada quando isso for útil.
Um bloco vazio (com BlockInfo), 8 bytes no total:
SELECT 1 indica uma coluna chamada "1" do tipo UInt8, com zero linhas. No protocolo ≥ 54454, o byte has_custom_serialization é incluído:
FORMAT Native (revisão 0), o mesmo bloco de resultado não contém BlockInfo nem o byte has_custom_serialization — SELECT 1 FORMAT Native tem 11 bytes:
FORMAT Native: o formato de saída não emite blocos vazios.)
Tipos de dados
data de uma coluna, agrupados em quatro famílias com complexidade de decodificação crescente. Dois tipos — AggregateFunction(func, ...) e QBit(T, N) — são tipos de coluna Native válidos, mas têm payloads específicos de função ou de tipo que estão fora do escopo aqui; eles são destacados abaixo onde, de outra forma, poderiam ser confundidos com aliases.
Tipos de largura fixa
M linhas ocupa exatamente bytes_per_row × M bytes no wire, concatenados sem separadores nem preenchimento.
Tipos inteiros
UInt8–UInt256 e Int8–Int256 são codificações binárias diretas de valores inteiros. Um decodificador lê bytes_per_row × num_rows bytes e os interpreta de acordo com o tipo.
Uma coluna UInt32 contendo [1, 256, 65536]:
Int32 com [-1, 42]:
Float32 e Float64
binary32) e 8 bytes de precisão dupla (binary64), ambos em little-endian. NaN, ±Infinity, ±0.0 e subnormais preservam-se sem normalização na ida e volta.
Valor Float32 1.5 (0x3FC00000):
1.5 do tipo Float64 (0x3FF8000000000000):
BFloat16
Float32 — 1 bit de sinal, 8 bits de expoente, 7 bits de mantissa. Cada valor ocupa 2 bytes, em little-endian, armazenando o padrão bruto de 16 bits. Para recuperar o valor numérico, expanda-o novamente para Float32, colocando o padrão na metade alta e zerando a metade baixa (bits << 16 reinterpretado como Float32); o valor expandido então usa a mesma formatação de texto de Float32.
Valor BFloat16 1.5 (padrão 0x3FC0, a metade superior de Float32 0x3FC00000):
Bool
UInt8 no wire: 1 byte por linha, 0x00 = false, 0x01 = true. A string do tipo no wire é literalmente Bool (não UInt8), portanto um decodificador que faz o roteamento com base na string do tipo deve reconhecê-lo separadamente.
Uma coluna Bool [true, false, true]:
Date e Date32
1970-01-01. Nenhum deles inclui um componente de tempo.
Valor
Date 1970-01-02 (1 dia):
Date32 com valor 1900-01-01 (-25567 dias):
DateTime
UInt32 no wire: um timestamp Unix em segundos, com 4 bytes em little-endian. O tipo pode aparecer como DateTime ou DateTime('Timezone'); o fuso horário afeta apenas a exibição e não faz parte do valor no wire. Duas colunas DateTime com parâmetros de fuso horário diferentes produzem bytes idênticos para o mesmo instante. Um decodificador remove o sufixo de parâmetro (...) e processa a coluna como UInt32.
Valor DateTime('UTC') 2024-03-15 14:30:00 UTC (timestamp 1710513000):
DateTime64(scale[, timezone])
10^-scale segundos desde a epoch Unix. O parâmetro scale (0–9) fica na string do tipo e define a unidade de tempo:
O tipo aparece como
DateTime64(s) (fuso horário padrão implícito do servidor) ou DateTime64(s, 'TimezoneName') (fuso horário explícito, apenas para exibição). Valores negativos representam ticks anteriores à epoch.
Valor DateTime64(3, 'UTC') 2024-01-15 12:30:45.123 UTC (1705321845123 ms):
DateTime64(0) valor 2024-01-15 12:30:45 UTC (1705321845 s):
Time e Time64(scale)
Time é uma contagem de segundos com sinal, em Int32 little-endian de 4 bytes; Time64(scale) é uma contagem de ticks com sinal na escala decimal especificada (0–9), em Int64 little-endian de 8 bytes — a mesma forma wire de DateTime64.
A forma textual é [-]HH:MM:SS[.fraction], mas, diferentemente de DateTime, o campo de hora não é ajustado para um dia de 24 horas: ele representa a contagem total de horas e pode exceder 23. A magnitude exibida é limitada a 999:59:59 (3599999 segundos); uma magnitude maior é exibida nesse limite, com a fração zerada (999:59:59.000). CAST também restringe o valor armazenado a esse intervalo, embora operações aritméticas possam produzir valores fora dele, que são limitados apenas na exibição. Nada disso afeta os bytes wire, que são simplesmente o inteiro com sinal.
Valor Time 45296 (12:34:56):
Time64(3) com valor 45296789 ticks (12:34:56.789):
Time e Time64 são experimentais e exigem allow_experimental_time_time64_type = 1 no servidor.Interval
Interval<Unit> — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond e assim por diante. Todas as unidades compartilham uma única codificação wire: a contagem como um Int64 little-endian com sinal de 8 bytes. A unidade existe apenas na string do tipo — ela não altera nem os bytes wire nem a forma textual, que é o inteiro simples. Um único caminho de decodificação lida com todas as unidades.
Valor 5 de IntervalDay:
UUID
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, em que os bytes são convencionalmente escritos em big-endian. O modelo wire pega esses 16 bytes canônicos, divide-os em duas metades de 8 bytes e grava cada metade em little-endian:
- Bytes wire 0..7 = bytes canônicos 0..7 invertidos.
- Bytes wire 8..15 = bytes canônicos 8..15 invertidos.
550e8400-e29b-41d4-a716-446655440000:
IPv4 e IPv6
IPv4 tem 4 bytes, codificados como um UInt32 little-endian que armazena o endereço canônico de 32 bits (o valor (a << 24) | (b << 16) | (c << 8) | d de a.b.c.d). Os bytes na wire são os bytes em ordem de rede invertidos.
192.168.1.10 (valor canônico de 32 bits 0xC0A8010A):
IPv6 tem 16 bytes, escritos exatamente na ordem de bytes de rede, sem troca de bytes — a mesma ordem de bytes de inet_pton(AF_INET6, ...).
2001:db8::1:
u32 para aritmética e consultas por intervalo mais compactas, enquanto o IPv6 mantém a estrutura em ordem de rede comum à maioria das APIs de rede.
Enum8 and Enum16
Int8 e Int16 no wire, respectivamente: 1 ou 2 bytes por linha, em complemento de dois little-endian para a variante de 16 bits. O mapeamento completo da variante fica na string do tipo:
(...) e tratá-lo como Int8 / Int16 — os bytes no wire são apenas o índice inteiro. Um client que expõe o rótulo analisa o map 'name' = value da string de tipo e o mantém junto com a coluna: o inteiro, por si só, não recupera o rótulo. A saída orientada a texto exibe o rótulo (active) em vez do índice, entre aspas simples ('active') quando o enum está aninhado dentro de um tipo composto. Como o map não pode ser recuperado da coluna inteira, ele deve ser mantido para enums aninhados, como Array(Enum8(...)) ou Map(Enum16(...), V).
Uma coluna Enum8('active' = 1, 'inactive' = 2) [active, inactive, active]:
30000 de Enum16(...):
Decimal(P, S)
P; a escala S é o expoente negativo (o número de dígitos após o separador decimal). Ambos ficam na string do tipo.
A codificação wire é o inteiro subjacente em complemento de dois little-endian, e o valor decimal lógico é
wire_integer × 10^(-S).
O ClickHouse sempre emite Decimal(P, S), independentemente de como o tipo foi declarado. Decimal32(S), Decimal64(S) e assim por diante são todos normalizados para Decimal(P, S) no wire (com P definido como o máximo natural para a largura: 9, 18, 38, 76). Um decodificador que reconhece apenas Decimal(P, S) cobre todas as formas que o servidor emite.
Valor 123.4567 de Decimal(9, 4) → inteiro subjacente 1234567:
Decimal(18, 1) valor -1.5 → inteiro subjacente -15:
Decimal(38, 4) com valor 123.4567 (16 bytes no total):
Nothing
Nothing não contém nenhum valor. Na prática, ele aparece apenas como o tipo interno de Nullable(Nothing) — o que o servidor retorna para uma expressão como SELECT NULL, cujo único valor válido é a ausência de valor. Conceitualmente, é um tipo unitário.
No wire, ele ocupa exatamente um byte de placeholder por linha. O servidor emite o caractere ASCII '0' (0x30), mas o desserializador ignora esses bytes — o conteúdo é indefinido, e os decodificadores não devem presumir nenhum valor específico. O número de bytes gravados é num_rows × 1, portanto o num_rows no cabeçalho da coluna determina completamente quanto deve ser consumido.
Esse byte por linha mantém intacta a invariante do Block: cada coluna tem um comprimento derivável de num_rows, de modo que os decodificadores avançam sem prefixos de comprimento por célula. O Nullable externo sempre informa todas as posições como NULL, portanto os placeholders nunca são inspecionados.
Uma coluna Nullable(Nothing) com 3 linhas (todas NULL):
Nullable (consulte Nullable); os três bytes internos são o payload Nothing, que o decodificador ignora.
Tipos de comprimento variável
String
String. Uma coluna String é uma sequência de num_rows sequências de bytes com prefixo de comprimento:
0x00. O String do ClickHouse é orientado a bytes, e não a texto: a validade de UTF-8 não é imposta, e um valor pode conter quaisquer bytes, inclusive NUL embutido. Um decodificador voltado para um tipo de string UTF-8 valida na leitura ou expõe bytes brutos ao chamador. O total de bytes consumidos pela coluna é Σ (varuint_size(len_i) + len_i) em todas as linhas.
Uma coluna com 3 strings ["ab", "", "c"] (6 bytes no total):
FixedString(N)
FixedString(N), em que N é um inteiro positivo (por exemplo, FixedString(16)). A coluna tem exatamente N × num_rows raw bytes, sem prefixos de comprimento nem separadores. Um decodificador analisa N a partir da string de tipo e consome essa quantidade de bytes por linha.
Quando o SQL insere um valor com menos de N bytes (por exemplo, CAST('abc' AS FixedString(5))), o servidor preenche à direita com bytes NUL (0x00) até o comprimento declarado. Esses bytes de preenchimento fazem parte do valor armazenado e são enviados no wire exatamente como estão; removê-los é uma questão do lado do cliente. Assim como String, FixedString(N) se parece mais com um array de bytes do que com texto — normalmente usado para identificadores de largura fixa, bytes de endereço ou digests de hash.
Dois valores FixedString(3) ["abc", "de\0"] (6 bytes no total):
Tipos compostos
- Forma fixa por schema. A estrutura é determinada inteiramente pela string do tipo no momento da decodificação.
Array(UInt32)sempre tem o mesmo layout de fluxos, de bloco para bloco. - Sem prefixo de versão próprio. O encapsulamento composto em si não adiciona nenhum byte de versão; sua estrutura (offsets, null-map, fluxos de elementos) é estável entre lançamentos do ClickHouse. Isso se aplica apenas ao wrapper — veja a observação sobre a fase de prefixo abaixo para tipos internos versionados.
- Sem estado entre blocos próprio. A estrutura do wrapper é totalmente autodescritiva em cada bloco; qualquer preocupação com estado entre blocos vem de um tipo interno versionado, não do wrapper.
SerializationArray executa a fase de prefixo do tipo interno antes de os offsets do array serem gravados, e Tuple, Map, Nested e Nullable fazem o mesmo por meio das serializações de seus elementos (Nullable executa o prefixo interno antes do seu mapa de nulos).
Assim, quando um composto encapsula um tipo versionado/com estado (LowCardinality, Variant, Dynamic, JSON), o prefixo de versão/estado desse tipo interno é emitido primeiro, antes dos offsets do wrapper e do payload dos elementos. Por exemplo, Array(LowCardinality(String)) é organizado como [prefixo de estado de LowCardinality] → [offsets do array] → [payload achatado dos elementos de LowCardinality], e não com offsets primeiro.
Um decodificador que lê os offsets antes de executar a fase de prefixo interno ficará dessincronizado em qualquer composto que contenha LowCardinality, Variant, Dynamic ou JSON. Quando todo tipo interno é uma folha simples ou outro composto não versionado, a fase de prefixo não emite bytes, e a descrição com offsets primeiro abaixo se aplica literalmente.
Nullable(T)
Nullable(InnerType). Exemplos: Nullable(UInt32), Nullable(String), Nullable(FixedString(16)), Nullable(DateTime('UTC')).
Assim como os outros tipos compostos, Nullable delega a fase de prefixo à sua serialização interna antes de gravar o mapa de nulos: quando o tipo interno é versionado, o prefixo de estado do tipo interno é emitido primeiro. Portanto, Nullable(Tuple(LowCardinality(String))) começa com o prefixo de estado de LowCardinality, e não com o mapa de nulos. Quando o tipo interno é um tipo folha ou outro tipo não versionado, a fase de prefixo não emite nenhum byte.
O layout wire consiste na fase de prefixo interna (vazia, a menos que o tipo interno seja versionado), seguida de dois fluxos concatenados, com o mapa de nulos primeiro:
num_rows bytes, um por linha:
O fluxo de valores contém a codificação padrão do tipo interno para todas as
num_rows linhas, incluindo as posições nulas. Um decodificador ainda precisa ler os bytes de placeholder nas posições nulas para avançar no fluxo, mas deve consultar o mapa de nulos antes de interpretar qualquer valor individual. Quem envia os dados pode gravar quaisquer bytes nas posições nulas, portanto os decodificadores não devem depender de um valor de placeholder específico.
Valores de placeholder por família de tipo interno:
Nullable(T) pode aparecer dentro de Array, Tuple, Map e Nested — Array(Nullable(T)) e Tuple(Nullable(T1), T2) são comuns. A nulabilidade não se compõe consigo mesma: Nullable(Nullable(T)) é rejeitado pelo servidor.
Um Nullable(UInt8) com três linhas [5, NULL, 9] (6 bytes no total):
Nullable(String) com três linhas ["hello", NULL, "world"] (15 bytes ao todo):
Array(T)
Array(InnerType). Exemplos: Array(UInt32), Array(String), Array(Nullable(UInt32)), Array(Array(UInt8)).
O layout no wire consiste na fase de prefixo interna (vazia, a menos que o tipo interno tenha versionamento), seguida por dois fluxos concatenados, com os offsets primeiro:
num_rows valores UInt64 em little-endian, cada um representando a posição final acumulada no fluxo de valores após os elementos daquela linha:
- Índice inicial dos elementos da linha
N=offsets[N - 1](ou0quandoN == 0). - Índice final dos elementos (exclusivo) da linha
N=offsets[N]. - Quantidade de elementos da linha
N=offsets[N] - offsets[N - 1].
offsets[num_rows - 1] é, portanto, a quantidade total de elementos em todas as linhas, e o fluxo de valores contém essa mesma quantidade de valores internos concatenados em sequência.
Os offsets são monotonicamente não decrescentes; offsets consecutivos iguais indicam uma linha vazia, e um decodificador deve rejeitar offsets não monotônicos por indicar corrupção. Uma coluna vazia (num_rows == 0) grava zero bytes — sem fluxo de offsets e sem fluxo de valores. Os tipos internos podem ser de qualquer tipo, inclusive outros tipos compostos: Array(Array(T)), Array(Tuple(...)) e Array(Nullable(T)) são todos válidos.
Array(UInt32) com linhas [[10, 20, 30], [], [40, 50]] (44 bytes no total):
0 para a linha 0). Offsets consecutivos iguais indicam uma linha vazia:
Array(String) com linhas [["a", "bb"], []] (20 bytes no total):
Array(Array(UInt32)) com linhas [[[1,2]], [], [[3], [4,5]]] aninha o mesmo formato:
- Offsets externos:
[1, 1, 3]— a linha 0 tem 1 array interno, a linha 1 tem 0 e a linha 2 tem 2. - O
Array(UInt32)intermediário decodifica 3 linhas com offsets[2, 3, 5]. - O
UInt32mais interno decodifica 5 valores:[1, 2, 3, 4, 5].
Tuple(T1, T2, …)
Tuple(T1, T2, ..., Tn). Exemplos: Tuple(UInt32, String), Tuple(Int32), Tuple(Array(UInt32), String), Tuple(UInt8, Tuple(Int32, String)). O ClickHouse também oferece suporte a tuplas nomeadas por meio de Tuple(a UInt32, b String); os nomes são apenas metadados e não afetam o formato wire.
O layout wire é a fase de prefixo dos elementos (cada elemento versionado contribui com seu prefixo de estado, na ordem de declaração; vazio para elementos não versionados), seguida por N fluxos concatenados, um por tipo de elemento, na ordem de declaração:
num_rows valores. Não há prefixo de comprimento, fluxo de offsets nem separadores entre os fluxos. Uma coluna vazia (num_rows == 0) escreve zero bytes por fluxo. Os tipos de elemento podem ser quaisquer tipos, incluindo outros compostos — Tuple(Tuple(...), ...), Tuple(Array(...), ...) e Tuple(Nullable(T1), T2) são todos válidos.
A tupla de zero elementos Tuple() também é válida — ela surge de expressões como SELECT tuple() ou CAST(x AS Tuple()). Como não tem fluxos de elementos, em vez disso ela é serializada como Nothing: um byte placeholder (0x30, ASCII '0') por linha, que o desserializador descarta. A contagem de linhas vem do cabeçalho do bloco, exatamente como em Nothing.
Tuple(UInt8, UInt8) com 3 linhas (1,4), (2,5), (3,6):
[1, 2, 3] para o elemento 0 e [4, 5, 6] para o elemento 1.
Tuple(UInt32, String) com 2 linhas (10, "a"), (20, "bb") (13 bytes no total):
Map(K, V)
Map(KeyType, ValueType). Exemplos: Map(String, UInt32), Map(String, Array(UInt32)), Map(UInt8, Tuple(Int32, String)), Map(Array(String), Int8). O formato wire não impõe restrições a nenhum dos tipos — tanto K quanto V podem ser qualquer tipo suportado, incluindo tipos compostos. (As regras do ClickHouse, em nível de SQL, sobre os tipos de chave aceitos variaram entre lançamentos; consulte a documentação SQL da versão do servidor desejada.)
O layout wire é idêntico, byte a byte, a Array(Tuple(K, V)); portanto, ele começa com a fase de prefixo interna (vazia, a menos que K ou V seja versionado):
total_pairs = offsets[num_rows - 1] (ou 0 quando num_rows == 0). O fluxo de offsets tem a mesma semântica de Array. As chaves ficam alinhadas posicionalmente aos valores: o par i é (keys[i], values[i]).
A representação em memória, no ClickHouse, de uma coluna Map é um array de tuplas; o sistema de tipos o expõe como um tipo distinto por questões de ergonomia em SQL (m['key'], mapKeys, mapValues). O wire format é uma serialização direta desse armazenamento, portanto Map e Array(Tuple(K, V)) são intercambiáveis byte a byte.
Os offsets são monotônicos não decrescentes, e os fluxos de chaves e de valores contêm exatamente total_pairs valores. Uma coluna vazia grava zero bytes. Em uma mesma linha, as chaves normalmente são únicas, mas isso é uma regra semântica, não algo imposto pelo wire: o wire format permite que chaves duplicadas façam round-trip, e a semântica do lado do servidor resolve duplicatas apenas quando uma função compatível com Map consome a linha.
Map(UInt8, UInt8) com 2 linhas {1:10, 2:20}, {3:30} (22 bytes no total):
i é reconstruído lendo keys[i] e values[i] em conjunto.
Map(String, UInt32) com 1 linha {'a':1, 'b':2} (20 bytes no total):
Nested(name1 T1, name2 T2, …)
Nested depende da configuração flatten_nested no servidor, o que resulta em dois casos distintos.
Caso A: flatten_nested = 1 (padrão do servidor). Quando a tabela é criada com as configurações padrão, Nested não é um tipo wire. O servidor armazena e apresenta a coluna como N colunas paralelas Array(T_i) com nomes pontilhados (outer.field1, outer.field2 e assim por diante). Na camada de formato, não há nada de novo — cada coluna pontilhada é um Array comum:
flatten_nested = 0. Quando a tabela é criada com flatten_nested = 0, a coluna aparece no wire como uma única coluna com a string de tipo Nested(name1 T1, name2 T2, ...), e seu layout após a string de tipo é idêntico byte a byte a Array(Tuple(T1, T2, ..., Tn)) — incluindo a fase de prefixo interna; portanto, qualquer campo versionado T_i emite primeiro seu prefixo de estado, antes dos offsets. O exemplo abaixo usa campos não versionados, então a fase de prefixo fica vazia:
Nested preserva os nomes dos campos (a, b), que Array(Tuple) não mantém como slots nomeados.
A string de tipo do Caso B é uma lista de pares (nome, tipo) separados por vírgulas. O primeiro espaço em branco separa um nome do seu tipo; o próprio tipo pode conter outros espaços em branco, vírgulas e parênteses, portanto o parsing precisa do mesmo separador sensível à profundidade usado para Tuple. O layout wire:
total_elements = offsets[num_rows - 1] (ou 0 quando num_rows == 0). Os offsets são monotônicos não decrescentes, e cada fluxo de campo contém exatamente total_elements valores. O servidor garante, no momento do INSERT, que, dentro de uma única linha, todos os campos tenham o mesmo número de elementos. Uma coluna vazia grava zero bytes.
Nested(a UInt8, b String) com 2 linhas [(10,'x'),(20,'y')] e [(30,'z')] (25 bytes após a string de tipo):
Aliases de tipo
Assim, uma coluna
Point é decodificada exatamente como Tuple(Float64, Float64) (apresentada como (1,2)), um Ring como Array(Tuple(Float64, Float64)) ([(0,0),(1,1)]) e assim por diante ao longo da hierarquia.
Geometry também é um alias, mas de um Variant em vez de um array aninhado: seu payload é a variante dos seis tipos geo acima. O cabeçalho da coluna carrega apenas a string de tipo Geometry — ele não explicita a variante — portanto, um decodificador precisa expandi-la por conta própria. Como em qualquer Variant, os discriminadores seguem a ordem canônica dos aliases geo, ordenados por nome: 0 = LineString, 1 = MultiLineString, 2 = MultiPolygon, 3 = Point, 4 = Polygon, 5 = Ring. Cada valor selecionado é então decodificado por meio do alias geo correspondente acima (NULL usa o discriminador NULL 255 de Variant).
SimpleAggregateFunction(func, T) é um alias para seu tipo de valor T. Ele armazena um valor agregado já finalizado, portanto sua forma wire e sua representação são exatamente as de T (SimpleAggregateFunction(sum, UInt64) é decodificado como UInt64). Apenas a forma com um único tipo de valor é um alias dessa maneira; o tipo subjacente pode, por si só, ser composto.
Dois tipos relacionados não são aliases. Eles são tipos de coluna
Native válidos — um cliente pode receber uma coluna AggregateFunction de um combinador -State ou de agregação distribuída, por exemplo — mas cada um carrega seu próprio payload especializado, que está fora do escopo desta página:AggregateFunction(func, ...)contém um estado de agregação intermediário (não um valor finalizado); seu layout binário é específico da função de agregação e da versão.QBit(T, N)armazena um vetor com seus bit planes transpostos para cargas de trabalho de busca vetorial.
Tipos versionados
Native, o prefixo e qualquer Dicionário são por bloco — esses tipos não mantêm nenhum estado entre blocos (veja a observação sobre prefixo por bloco abaixo); o estado de serialização entre blocos existe apenas no stream em disco do MergeTree.
Esses tipos são consideravelmente mais complexos do que os compostos de estrutura fixa, e um cliente voltado para consultas analíticas simples pode deixar o suporte a eles para depois.
Versão de serialização: conceito
A maioria dos tipos versionados grava a versão como um UInt64 little-endian imediatamente antes de qualquer outro dado do prefixo de estado; alguns usam VarUInt ou UInt8. Um decodificador lê primeiro a versão e rejeita valores desconhecidos — uma versão mais alta implica um formato mais recente do remetente que o decodificador não entende, e interpretá-lo incorretamente corrompe todos os bytes subsequentes.
O prefixo de estado é emitido no início de todo bloco cuja contagem de linhas seja maior que zero, imediatamente antes do payload desse bloco.
O writer e o reader Native não mantêm o estado de serialização entre blocos:
NativeWriter cria um novo estado de serialização e grava um prefixo de estado para cada bloco de coluna não vazio que escreve, e NativeReader cria um novo estado de desserialização e o lê para cada bloco não vazio que lê (ambos ignoram totalmente o prefixo quando rows == 0).
Blocos de cabeçalho (rows = 0) e blocos vazios, portanto, não emitem nada, e um decodificador deve ler novamente o prefixo de estado no início de cada bloco não vazio. Um decodificador que lê o prefixo apenas uma vez e trata os blocos posteriores como se contivessem apenas payload lerá o prefixo do próximo bloco como dados e perderá a sincronização:
Referência da versão de serialização
Alguns pontos que vale a pena observar sobre a tabela:
- Os valores não são contíguos.
Dynamicusa1,2,3,4, comV3em4eFLATTENEDem3. Um número maior não é necessariamente mais recente. - Alguns valores são exclusivos do formato nativo.
Object::STRING,Object::FLATTENEDeDynamic::FLATTENEDexistem para compatibilidade com o protocolo nativo em clientes que não implementam Object/Dynamic completo. Eles não aparecem no armazenamento em disco do MergeTree. V3é usado principalmente em disco. Clientes que consomem o protocolo TCP nativo normalmente veemFLATTENED(valor3) em vez deV3(valor4).
LowCardinality(T)
N valores internos por um pequeno Dicionário de valores únicos, além de N índices para esse dicionário.
String do tipo: LowCardinality(InnerType). Exemplos: LowCardinality(String), LowCardinality(FixedString(4)), LowCardinality(Nullable(String)).
sharedDictionariesWithAdditionalKeys; os demais valores são reservados.
Os metadados UInt64 por bloco são um campo de bits:
Em uma resposta de consulta típica com um único bloco de dados por coluna, os metadados são
0x600 (HasAdditionalKeys + NeedUpdateDictionary).
Os valores do dict são dict_size valores codificados usando o tipo interno T. O dicionário reserva slots iniciais para valores especiais: uma coluna não anulável reserva um (dict[0] contém o valor padrão do tipo interno, por exemplo "" para String), e os valores distintos reais começam em dict[1].
Para LowCardinality(Nullable(T)), o dict ainda é codificado como T puro (sem stream de mapa de nulos), mas dois slots são reservados: dict[0] é o marcador NULL e dict[1] é o valor padrão do tipo interno (por exemplo "" para String); os valores distintos reais começam em dict[2]. A chave de uma linha NULL aponta para dict[0], e esse slot é gravado no wire como os bytes padrão do tipo interno.
As chaves são índices no dict; cada índice ocupa 1 << key_type_code bytes (1, 2, 4 ou 8), e o valor N é reconstruído como dict[keys[N]].
keys_count é o número de valores LowCardinality no nível recursivo atual, não necessariamente a contagem de linhas do bloco. Para uma coluna LowCardinality de nível superior, os dois coincidem. Mas, quando LowCardinality está dentro de um tipo composto, a contagem é a quantidade de valores achatados que o tipo composto repassa: para Array(LowCardinality(String)) com três linhas contendo cinco elementos no total, keys_count é 5, não 3; para Map(K, LowCardinality(V)), é a contagem total de pares, e assim por diante. Um decodificador deve obter keys_count desse campo, em vez de presumir a contagem de linhas do bloco. Quando essa contagem achatada é zero — por exemplo, em um bloco cujos arrays estão todos vazios — a fase de dados de LowCardinality não grava absolutamente nada: apenas o prefixo de estado (emitido na fase de prefixo de tipos compostos) está presente, sem metadados, dicionário nem keys_count em seguida.
O prefixo de estado é lido no início de cada bloco cuja contagem de linhas seja maior que zero — blocos de cabeçalho (rows = 0) e blocos vazios não emitem nada. Dentro de um bloco, keys_count é igual à contagem de linhas, dict_size é igual ao número de valores no fluxo do dicionário, e cada chave cabe em 1 << key_type_code bytes.
No formato
Native, cada bloco envia um dicionário autocontido, local ao bloco — não há estado de dicionário entre blocos. O writer do Native define low_cardinality_max_dictionary_size = 0, portanto SerializationLowCardinality nunca cria um dicionário compartilhado: cada bloco não vazio grava suas chaves como chaves adicionais locais ao bloco com NeedGlobalDictionaryBit desativado (metadados 0x600), e o reader do Native rejeita NeedGlobalDictionaryBit quando native_format é true. Portanto, um decodificador deve redefinir o dicionário a cada bloco e ler as entradas dict_size presentes nele; carregar um dicionário de um bloco anterior faria com que as chaves do bloco seguinte fossem lidas incorretamente. (Persistir um dicionário LC entre blocos é uma questão de armazenamento em disco do MergeTree, não do layout Native no wire.)LowCardinality(String) com valores ['a', 'b', 'a', 'c', 'b']:
dict[1], dict[2], dict[1], dict[3], dict[2] = ["a", "b", "a", "c", "b"].
LowCardinality(Nullable(String)) com os valores ['a', NULL, '', 'b'] mostra ambos os slots reservados — dict[0] para NULL e dict[1] para o valor padrão de string vazia:
dict[2] = "a", dict[0] = NULL, dict[1] = "", dict[3] = "b", ou seja, ["a", NULL, "", "b"]. Tanto dict[0] quanto dict[1] são bytes vazios no wire; o valor nulo vem da chave apontar para a posição 0, não dos bytes.
JSON (Tier 1: fallback String)
JSON do ClickHouse tem várias codificações wire (consulte a referência da versão de serialização). O Tier 1 é o mais simples: quando a configuração por consulta output_format_native_write_json_as_string = 1 é definida, o servidor achata cada valor JSON em seu texto serializado e emite a coluna como uma String com um marcador com prefixo de estado.
string do tipo: JSON.
1 para este fallback String. Os outros valores indicam diferentes codificações de JSON/Object: 0 = V1, 2 = V2 (o padrão no protocolo TCP nativo), 3 = FLATTENED, 4 = V3 (consulte a referência da versão de serialização). Um decodificador que encontra aqui um valor diferente de 1 não está lendo o fallback String. O prefixo é lido no início de cada bloco com linhas > 0, e o fluxo de valores é uma coluna String padrão para num_rows linhas.
Valor JSON '{"a":1}' (uma linha):
{"a":1} —, com o inteiro mantido como inteiro. O texto é apenas um valor String, portanto o cliente recebe o JSON para transporte opaco, mas não recupera os caminhos individuais nem seus tipos no ClickHouse; a tipagem fiel por caminho exige a codificação de Nível 2 abaixo.
Variant(T1, T2, …)
Variant(T1, T2, ...). O servidor canoniciza a ordem (os tipos variantes são ordenados por nome), portanto a string do tipo, como recebida, já lista os tipos na ordem do discriminante global: o discriminante 0 seleciona o primeiro tipo listado, 1 o segundo, e assim por diante. 255 (NULL_DISCRIMINATOR) significa que a linha é NULL. Elementos de Variant nunca são Nullable — o NULL é responsabilidade do discriminante. Exemplos: Variant(String, UInt64), Variant(Array(UInt8), String).
O prefixo de estado carrega um modo de discriminante UInt64 LE: 0 = BASIC (o discriminante de cada linha é gravado literalmente), 1 = COMPACT (codificação de grânulo com run-length). O servidor usa BASIC pelo protocolo nativo por padrão (use_compact_variant_discriminators_serialization = false); somente BASIC é especificado aqui.
r com discriminador d (≠ 255) recebe o valor no índice counter[d] da sequência de valores do tipo Variant d; em seguida, counter[d] é incrementado. As linhas com discriminador 255 são NULL e não consomem nenhum valor de nenhuma sequência, portanto a soma dos contadores por tipo é igual ao número de linhas não NULL.
O prefixo de estado (o modo UInt64) é lido no início de cada bloco com linhas > 0; o cabeçalho e os blocos vazios não emitem nada. Cada discriminador não NULL é menor que o número de tipos Variant, e o tipo Variant i é decodificado para exatamente count[i] linhas.
Elementos de Variant que também são com estado (
LowCardinality, Variant, Dynamic, JSON) emitem seu próprio prefixo de estado na fase de prefixo de estado por elemento, após o modo UInt64. Tipos folha e os compostos simples (Array, Tuple, Map de tipos folha) têm prefixos de estado vazios e podem ser compostos livremente.Variant(String, UInt64) com valores [42, 'hi', NULL] (a ordem canônica classifica String antes de UInt64, então discriminador 0 = String, 1 = UInt64):
42; linha 1 = String run[0] = "hi"; linha 2 = NULL.
A sequência de discriminadores é o índice; cada discriminador não NULL extrai o próximo valor da sequência densa do seu tipo, enquanto 255 (NULL) não consome nada. Esse mesmo percurso reconstrói Dynamic, que difere apenas na forma como NULL é codificado:
Dynamic
Variant, o conjunto de tipos não aparece na string de tipo da coluna — ele é armazenado no prefixo de estado.
string do tipo: Dynamic ou Dynamic(max_types=N). O parâmetro max_types limita quantos tipos distintos a coluna rastreia, mas não afeta o wire format abaixo.
Dynamic tem quatro codificações — V1 = 1, V2 = 2, FLATTENED = 3, V3 = 4. Qual delas o servidor emite depende do canal e das configurações da consulta:
- Em
clickhouse-cliente HTTPFORMAT Native, a revisão do writer é0(a menos que seja aumentada comclient_protocol_version), então o padrão é V1. - No protocolo TCP nativo, na revisão negociada, o padrão é V2. O writer
Nativemantém as estatísticas desabilitadas, então um payloadV2padrão não traz estatísticas por variante — após a lista de tipos, vêm diretamente o prefixo aninhado deVariante os dados. (As estatísticas por variante são uma questão de armazenamento em disco do MergeTree, não fazem parte do wire Native.) - A configuração da consulta
output_format_native_use_flattened_dynamic_and_json_serialization = 1substitui ambas e emite FLATTENED (versão 3) independentemente da revisão.
EscopoEsta página especifica apenas o layout
FLATTENED. Os layouts binários não achatados V1/V2/V3 são a representação interna/em disco (listas de tipos codificadas em binário, estatísticas por variante) e não são especificados aqui. Um client que queira decodificar Dynamic usando esta página deve solicitar FLATTENED definindo output_format_native_use_flattened_dynamic_and_json_serialization = 1; o layout abaixo pressupõe essa configuração. Como o byte de versão inicia o prefixo, um decodificador pode detectar a codificação real recebida e rejeitar V1/V2/V3 se implementar apenas FLATTENED.num_types tipos mais o slot NULL — UInt8 para num_types ≤ 255, depois UInt16, UInt32, UInt64. NULL é o próprio valor do discriminador num_types, o que difere de Variant, em que NULL é o valor fixo 255. A reconstrução segue o mesmo percurso denso de Variant: mantenha um contador por tipo, e a linha r com discriminador d (≠ num_types) assume o valor counter[d] da sequência do tipo d.
O prefixo de estado (versão + lista de tipos) é lido no início de cada bloco com linhas > 0; cabeçalho e blocos vazios não emitem nada.
Tipos de runtime cuja serialização tem estado (
LowCardinality, Variant, Dynamic, JSON) carregam prefixos de estado aninhados após a lista de nomes dos tipos.Variant — os slots regulares do variant são gravados na ordem de DataTypeVariant (nome do tipo), portanto a ordem no wire não segue a ordem de inserção. No entanto, ela nem sempre é ordenada globalmente: os tipos que tiveram overflow para o variant compartilhado (por exemplo, em Dynamic(max_types=N)) são acrescentados após os slots regulares na ordem em que apareceram pela primeira vez, de modo que o final da lista pode quebrar a ordem por nome de tipo. Portanto, um decodificador deve tratar a lista de tipos transmitida como a fonte autoritativa para a atribuição de discriminadores e não deve reordená-la por conta própria. Para as linhas [42::UInt64, "hi", NULL], os dois tipos são String e UInt64, e "String" vem antes de "UInt64" na ordenação, portanto os discriminadores são 0 = String, 1 = UInt64, 2 = NULL:
42; linha 1 = String run[0] = "hi"; linha 2 = NULL. Os runs por tipo seguem, no wire, a mesma ordem da lista de tipos (String antes de UInt64).
JSON (Tier 2: FLATTENED Object)
output_format_native_write_json_as_string = 0) enquanto a flag de serialização flattened está ativada (output_format_native_use_flattened_dynamic_and_json_serialization = 1); o servidor então emite a versão 3 da serialização.
Há dois tipos de caminho:
- Caminhos tipados são declarados na string de tipo, por exemplo
JSON(a UInt32, b String), e decodificados no tipo declarado. Um nome de caminho que contém pontos é colocado entre crases na string de tipo. - Caminhos dinâmicos são descobertos em tempo de execução, e cada um é decodificado como uma coluna Dynamic.
num_rows.
Dynamic de um caminho dinâmico (na fase de prefixo) fica separado de seus dados (na fase de dados). O prefixo de estado é lido no início de cada bloco com linhas > 0, e cada coluna de caminho (tipada ou dinâmica) contém exatamente num_rows valores. O objeto da linha r é montado lendo o valor de cada caminho no índice r; um caminho dinâmico cujo discriminador Dynamic é NULL nessa linha não contribui com nenhuma chave.
Valor JSON {"a": 42, "b": "hi"} (uma linha, ambos os caminhos dinâmicos). Um inteiro em JSON é inferido como Int64:
JSON não achatado (V2/V3)
Object não achatadas (V1/V2/V3) são usadas pelo armazenamento em disco do MergeTree e são o que o servidor emite pelo wire quando a flag flattened está desativada — V1 via clickhouse-client / HTTP FORMAT Native (revisão 0), V2 pelo protocolo TCP nativo. Elas carregam uma coluna de dados compartilhados e não são especificadas nesta página. Observe que elas não carregam estatísticas por path pelo wire Native: NativeWriter mantém as estatísticas desativadas, portanto o prefixo de estrutura de Object não tem seção de estatísticas, e os bytes após ele são diretamente os prefixos e dados tipados/dinâmicos/de dados compartilhados. As estatísticas aparecem apenas nos paths em disco do MergeTree que as habilitam. Para decodificar uma coluna JSON com esta página, um cliente deve selecionar um dos níveis documentados: defina output_format_native_write_json_as_string = 1 para o fallback String, ou output_format_native_use_flattened_dynamic_and_json_serialization = 1 (com output_format_native_write_json_as_string = 0) para o layout FLATTENED Object.
Frame de compressão
Native com um formato interno de frame. O layout do frame abaixo é independente do transporte — os mesmos frames aparecem tanto no protocolo TCP nativo quanto em HTTP —, mas a forma de solicitar a compressão e o que envolve os frames varia conforme o transporte.
- Protocolo TCP nativo. A compressão é opcional por consulta, por meio da flag
compressionno pacote Query. Quando ativada, o corpo de cada pacoteData,Totals,Extremes,LogeProfileEvents— os bytes após a stringtable_name— é encapsulado no formato de frame. O próprio envelope do pacote, o código do tipo de pacote e a stringtable_namenão são comprimidos; o servidor os grava no fluxo bruto. Tudo o que oNativeWriteremite vai para o fluxo comprimido, portanto o prefixoBlockInfoé a primeira coisa dentro do frame, junto com as dimensões e colunas. Portanto, um cliente precisa descomprimir o frame antes de conseguir lerBlockInfo. - HTTP.
SELECT ... FORMAT Native&compress=1encapsula todo o fluxo de bytes deFORMAT Nativenos mesmos frames (o servidor usa o mesmoCompressedWriteBufferinterno), e?decompress=1espera os mesmos frames em um corpo de entradaNative, decodificando-os por meio doCompressedReadBuffercorrespondente. Não há tipo de pacote TCP,table_namenem envelope de pacote nesse caminho: toda a carga útil comprimida consiste apenas em blocosNativeem frames (um prefixoBlockInfoestá presente somente se a revisão negociada for maior que0, exatamente como no layout não comprimido acima). Esse encapsulamento interno decompress/decompressé diferente da compressão de transporte HTTP (Content-Encoding: gzip/zstd, habilitada porenable_http_compression), que encapsula a resposta na camada HTTP e não corresponde ao formato de frame abaixo.
FORMAT Native ainda precisa adicionar essa camada de frame para ler uma resposta HTTP Native comprimida ou para enviar um corpo da requisição decompress=1.
Formato do frame
16 + compressed_size = 16 + 9 + body_size = 25 + body_size. Observe os dois alcances: o checksum cobre o cabeçalho de 9 bytes mais o corpo, enquanto compressed_size conta o cabeçalho mais o corpo, mas não o próprio checksum:
Valores de byte do método
Checksum
method + compressed_size + uncompressed_size) mais os N bytes do corpo — tudo entre o checksum e o fim do frame. Os primeiros 8 bytes da saída de 16 bytes do CityHash128 correspondem à metade inferior (LE), e os 8 bytes seguintes, à metade superior (LE). Um decodificador recalcula o CityHash128 sobre o cabeçalho e o corpo recebidos e compara o resultado com os 16 bytes iniciais; se houver divergência, isso indica corrupção, e o decodificador falha.
Limites por bloco
CompressedWriteBuffer, que emite um frame sempre que seu buffer interno enche (≈1 MB, DBMS_DEFAULT_BUFFER_SIZE) e um frame final quando o bloco passa por flush. Assim, um bloco pequeno corresponde a um frame; um bloco grande, a vários frames consecutivos.
Essa invariável só vale em um sentido: como o remetente faz flush do buffer comprimido ao final de cada bloco, todo fim de bloco coincide com um limite de frame — mas o inverso não é verdadeiro. Um limite intermediário de frame, emitido quando o buffer enche no meio do bloco, cai no meio de um bloco e não é um limite de bloco. Portanto, um decodificador deve usar as dimensões do próprio bloco (num_columns/num_rows) para determinar onde ele termina; não deve presumir que cada frame seja um bloco completo.
Um receptor processa os frames em fluxo: lê 16 + 9 bytes, lê exatamente compressed_size - 9 bytes de corpo, descomprime para exatamente uncompressed_size bytes e entrega esses bytes ao decodificador de blocos; quando o decodificador precisa de mais bytes do que o frame atual contém, ele lê o próximo frame. Como o remetente faz flush por bloco, depois que um bloco é totalmente decodificado o buffer de frames fica vazio, e o próximo bloco começa em um novo frame.
No protocolo TCP nativo, o envelope do pacote — o VarUInt do tipo de pacote e a string table_name — é gravado no fluxo bruto, fora do payload comprimido; apenas o corpo do bloco (BlockInfo + colunas) é dividido em frames. O caminho HTTP compress/decompress não tem esse envelope: todo o fluxo é composto por blocos em frames.
Negociação
compression: bool do pacote Query a solicita para essa consulta específica. O servidor atende à solicitação e emite corpos Data/Totals/Extremes/Log/ProfileEvents comprimidos durante todo o ciclo de vida da consulta (Log/ProfileEvents apenas na v54481+). Ele também espera que os blocos Data enviados pelo cliente — tabelas externas, o marcador vazio de fim dos dados e linhas de INSERT — usem o mesmo enquadramento. Consultas subsequentes na mesma conexão podem ser diferentes.
Em HTTP, não há pacote Query: o parâmetro de consulta compress=1 seleciona saída com enquadramento para essa requisição, e decompress=1 indica que o corpo da requisição usa enquadramento. A saída de compress=1 é gravada com o codec padrão do servidor (LZ4), em vez de network_compression_method; o leitor de decompress=1 obtém o codec do byte de método de cada frame, portanto qualquer codec é aceito na entrada.
Com a compressão ativada, o servidor também pode encaminhar colunas pelo caminho paralelo de serialização de blocos /
ColumnBLOB (PARALLEL_BLOCK_MARSHALLING, v54478) para blocos com mais de uma linha. Uma implementação que comprime dados de INSERT deve estar preparada para lidar com esse caminho (ou desativá-lo explicitamente) para evitar um fluxo desincronizado.Glossário
Native, que serializa na revisão 0. Veja BlockInfo.
Corpo da coluna — os bytes de uma coluna que contêm os valores reais, após o cabeçalho da coluna (nome, tipo, byte has_custom_serialization). O layout depende do tipo. Veja layout wire da coluna.
Tipo composto — um tipo construído a partir de um ou mais tipos internos, codificado como vários streams por coluna. O wire format é estável e sem versionamento. Veja tipos compostos.
Dicionário (LowCardinality) — o array de valores únicos ao qual uma coluna LowCardinality(T) faz referência por meio de índices inteiros. Veja LowCardinality.
Bloco vazio — um Bloco com num_columns = 0 e num_rows = 0. Usado como sentinela: um marcador de fim de entrada no lado do cliente e um marcador de limite de stream no lado do servidor. Veja variantes de bloco.
Bloco de cabeçalho — um Bloco com num_columns > 0 e num_rows = 0, enviado pelo servidor como o primeiro pacote Data de uma resposta de consulta. Anuncia o esquema do resultado. Veja variantes de bloco.
Tipo interno — o tipo que um tipo composto encapsula. Array(UInt32) tem tipo interno UInt32; o tipo interno de Nullable(T) é T.
Stream de offsets — o array UInt64 de posições finais cumulativas que Array, Map e Nested usam para delimitar os limites dos elementos por linha. Veja Array.
Valor placeholder — os bytes gravados nas posições nulas no stream de valores de uma coluna Nullable(T). O decodificador os lê para avançar o stream, mas ignora seu conteúdo. Veja Nullable.
Bloco de resultado — um Bloco com num_rows > 0 que carrega as linhas reais do resultado da consulta. Veja variantes de bloco.
Bloco de esquema — um sinônimo de bloco de cabeçalho, usado ao descrever a fase INSERT, em que o bloco de esquema informa ao cliente os formatos de coluna esperados.
Versão de serialização — um número de versão por tipo no wire que os tipos versionados usam para declarar qual variante da codificação vem em seguida. É diferente da versão do protocolo. Veja versão de serialização: conceito.
Prefixo de estado — os bytes que precedem o payload por bloco de um tipo versionado. Carrega a versão de serialização e (para LowCardinality) os metadados de dicionário por bloco. É emitido no início de cada bloco com rows > 0; não é mantido entre blocos.
Stream — uma sequência contínua de bytes dentro do corpo de uma coluna, codificando um subcomponente lógico (um mapa de nulos, um array de offsets, um stream de valores). Tipos com múltiplos streams concatenam dois ou mais streams por coluna.