Skip to main content
Native 格式是 ClickHouse 用于传输表格数据的列式传输格式。它会出现在以下几种场景中:
  • native TCP protocolDataTotalsExtremesLogProfileEvents 数据包的 body (TableColumns 数据包不是 Native 块——它承载的是两个二进制字符串,因此其布局应归入 native protocol spec) ;
  • 通过 HTTP 执行 SELECT ... FORMAT Native 时的输出;
  • 使用 INTO OUTFILE ... FORMAT Native 写出的文件导出内容;
  • 服务器间复制载荷。
本页介绍块内部的字节布局——也就是列式载荷——以及构成它的各列类型编码。数据包分帧、连接状态和版本协商则属于 native protocol specification 的内容。 所有多字节整数字段均采用小端字节序。带符号整数使用二进制补码。
如需查看面向用户的 Native 格式介绍 (包含 curl 示例) ,请参阅 Native format page。本规范是更底层的传输参考。

概述

凡是在传输过程中承载行数据的,都是一个 :即一个按列存储的、自描述的行数据块。列 1 的所有值先出现,然后是列 2 的所有值,依此类推。一个块只携带查询引用的列,绝不会携带整张表。 列的 data 布局取决于其类型所属的 家族。这些家族按解码复杂度从低到高依次为:
  • 定宽类型将 data 排布为 bytes_per_value × num_rows 个原始字节,不带任何按行分帧。
  • 复合类型 (NullableArrayTupleMapNested) 具有一种可由类型字符串完全递归推导出的结构形态,没有版本前缀,也不存在跨块状态。
  • 带版本 / 有状态类型 (LowCardinalityJSONVariantDynamic) 会在每个非空块开头带有序列化版本/状态前缀。在 Native 传输格式中,这个前缀以及任何字典都仅限于当前块——该格式不会携带块状态 (写入器会为每个块创建全新的序列化状态,并将 low_cardinality_max_dictionary_size = 0) 。跨块状态是 MergeTree 磁盘存储层面的问题,不属于 Native 传输布局。

传输基本类型

Native 格式建立在四种基本编码之上。

VarUInt

一种采用 LEB-128 编码的变长无符号整数。每个字节在第 0–6 位包含 7 个数据位,在第 7 位包含 1 个续位。如果后面还有更多字节,续位为 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 前缀是否存在取决于通道,因为写入器是按 修订版本 参数化的 (完整说明参见协议修订版本与 Native 格式,其中还包括 client_protocol_version 仅影响输出这一点) :
  • native TCP protocol 上,server 会按 connection 协商出的 修订版本 写入块 (这是一个较大的值——DBMS_TCP_PROTOCOL_VERSION,参见 src/Core/ProtocolDefines.h) 。只要该 修订版本 大于零,就会写入 BlockInfo;而对于真实 connection,这一条件始终成立。每一列中的 has_custom_serialization 字节 (参见列的传输布局) 会在 修订版本 为 54454 及以上时写入。
  • Native output format——即通过 HTTP 执行的 SELECT ... FORMAT NativeINTO OUTFILE ... FORMAT Native,以及 clickhouse-client 生成的 Native format——默认按 修订版本 0 进行序列化。在 修订版本 0 下,BlockInfo 前缀和 has_custom_serialization 字节都会被省略,因此一个块只包含 num_columnsnum_rows 和各列。 对于 HTTP,这个 修订版本 不是固定的:client 可以通过 ?client_protocol_version=<n> 查询参数提高它,而 server 会将该值作为响应的序列化 修订版本。 当该值足够高时,HTTP 输出也会包含 BlockInfo 前缀 (只要 修订版本 大于 0 就会写入) 以及 has_custom_serialization 字节 (在 修订版本 54454 及以上时写入) ,与 TCP 路径上的情况完全一致。因此,client 不应假定每个 HTTP FORMAT Native 载荷都是 修订版本 0
换句话说,本节中以 BlockInfo 前缀开头的字节示例描述的是 TCP Data-packet 载荷。同一个查询若通过 FORMAT Native 输出,则会生成旁边所示的较短形式。

BlockInfo

BlockInfo 是一组字段序列:每个字段前都有一个 VarUInt 类型的字段 ID,并以字段 ID 0 结束。其传输格式不是自描述的:字段 ID 本身不编码其值的长度或类型,因此读取方必须预先知道它可能遇到的每个字段 ID 对应的类型。ClickHouse 自身的读取器会将无法识别的字段 ID 视为数据损坏,并抛出异常 (UNKNOWN_BLOCK_INFO_FIELD) 。前向兼容性则通过协议修订版本实现:只有在协商出的修订版本不低于某个字段的最小修订版本时,发送方才会写入该字段,因此旧版接收器不会看到它不认识的字段。 字段 12 的最小修订版本为 0,因此只要写入了 BlockInfo,它们就一定会出现。字段 3 仅在修订版本为 54480 及以上时写入。常见情况 (修订版本低于 54480) 下的传输布局如下:

列的线协议布局

在一个 块 中,列 会出现 num_columns 次。 解码器会根据 type 字符串分派处理逻辑。类型字符串通常带有括号参数;解码器会去掉 (...) 后缀以找到基本类型,然后再解析这些参数,以确定大小、标度或内部类型。解析包含嵌套类型的参数列表时 (例如 Array 中嵌套一个 Tuple) ,需要使用能感知嵌套深度的逗号分割逻辑,跟踪括号嵌套层级,而不是简单地按 , 拆分。
二进制类型编码type 字段仅在默认模式下是文本 String。当查询设置 output_format_native_encode_types_in_binary_format = 1 时,该字段会改为二进制类型编码——也就是数据类型二进制编码文档中说明的同一种基于标签的编码——而扁平化后的 Dynamic 类型列表也会对各个类型名称使用相同的二进制编码。如果解码器始终将字段 2 读取为带长度前缀的字符串,就会把第一个二进制类型标签误当作字符串长度,从而失去同步,因此它必须知道该数据流使用的是哪种模式。

kind_stack 和稀疏编码

kind_stack 字节用于枚举每列的非默认序列化方式: COMBINATION 载荷使用的是另一套枚举。 上面的五行是紧凑的单字节编码。COMBINATION (0x05) 是适用于任何未被其覆盖的栈的通用转义形式:其后跟一个 VarUInt count,然后是 count 个单字节条目。这些条目不是表中的紧凑编码——它们是原始的 ISerialization::Kind 值: 这些字节值与紧凑编码不同:REPLICATED 在这套嵌套枚举中是 0x03,但作为紧凑编码时是 0x04;并且没有 DETACHED_OVER_SPARSE 条目——该组合会表现为两个连续条目 SPARSEDETACHED。如果解码器仍然对这些嵌套字节使用紧凑表,就会把 0x03/0x04 错误映射,并导致失步。 count 是完整的栈长度,包括每个栈开头的 DEFAULT 前导条目。紧凑编码已经覆盖了所有一项和两项的栈,因此 COMBINATIONcount 始终至少为三。 Tuple 列的递归 kind_stack 上述 kind_stack 载荷是某一列自身序列化信息对应的字节 (或 COMBINATION 序列) 。Tuple 携带一个 SerializationInfoTuple,它会先写入 tuple 自身 的 kind-stack 载荷,然后按顺序为每个元素写入一个完整的 kind-stack 载荷;解码器也会按相同的递归结构读回。因此,对于 Tuple(A, B, C),field-4 的字节为 [tuple_kind][A_kind][B_kind][C_kind];如果某个元素本身还是复合类型,则该元素载荷本身也会递归。只要 tuple 自身的信息或任一元素的信息为非默认,has_custom_serialization 字节 (field 3) 就会被设置,因此即使一个 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 是上面读取到的非 EOG VarUInt 数量。
解码器通过将每个未显式指定的位置填充为内部类型的默认值,来重建一个包含 num_rows 个条目的稠密列 (整数和浮点数为 0String""Date0 天,等等) 。 稀疏 Nullable(T) 列是一个特殊情况,因为 Nullable(T) 的默认值是 NULL。稀疏编码会完全省略常规的 Nullable null-map stream:offset stream 用于标识非默认值——也就是非 NULL——的位置,values stream 仅以稠密形式保存这些非 NULL 值 (类型为 T) ,而每个未显式指定的位置都会重建为 NULL。因此,解码器不应在 values stream 中查找 null map,也不应用有效的 0 来填充空缺;而应将其填充为 NULL。 Replicated 传输格式。kind_stack = 0x04 时,列 data 是一个字典:由一组不同元素值的列表,以及每一行指向该列表的索引组成 (其 lookup 形态与 LowCardinality 相同) 。当内部类型本身也带有版本信息——例如 LowCardinality(T)——其状态前缀会写入,即先于索引 stream:Replicated 序列化会先将前缀阶段委托给内部类型,再写入 num_rows。前缀为空的内部类型 (叶子类型和普通复合类型) 在这里不会写入任何字节。
解码器通过为每个输出行 i 选取 elements[indexes[i]] 来重建稠密列。复合内部类型会递归处理:元素列表先在内部类型中物化,再进行索引。支持的内部类型包括叶子类型、Nullable(T)Array(T)Tuple(...)Map(K, V)Nested(...) (其中每个字段都像 Array 一样展开) ,以及 LowCardinality(T) (共享字典会被保留;只有每个元素的 key 会被索引) 。 分离传输格式。 DETACHED (0x02) 和 DETACHED_OVER_SPARSE (0x03) 确实 会出现在传输中——它们并不只是内部表示。在 TCP 路径上,当启用压缩且协商后的修订版本至少为 DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING (v54478) 时,列会经过以下三个步骤:
  1. 每个符合条件的列 (非 const、非 Tuple,且所在块包含多于一行) 都会被包装为一个 ColumnBLOB,其中保存了已在主线程之外完成编组和压缩的列。
  2. DETACHED 会被追加到该包装列的 kind 栈中。
  3. data 会被写为一个 VarUInt blob 大小,后面紧跟恰好这么多的 blob 字节。
如果被包装的列是稀疏的,那么它的栈就是 {DEFAULT, SPARSE, DETACHED},序列化后即为 DETACHED_OVER_SPARSE。客户端在解码这类列时,会先读取 blob 的长度和字节,然后对 blob 解压,以恢复内部列的载荷 (参见压缩部分下的 ColumnBLOB 说明) 。

块变体

Data 家族的所有数据包都共享相同的 Block 传输格式。各个变体仅在列数和行数上有所不同:

字节级示例

本节中的所有示例均取自 TCP Data-packet path,因此都包含 BlockInfo 前缀和 has_custom_serialization 字节。使用 FORMAT Native 时,相同的块会更短——如有必要,会给出其对应的简短形式。 一个空块 (包含 BlockInfo) ,总计 8 字节:
SELECT 1 的头部块会声明一个名为 "1"、类型为 UInt8 且包含零行的列。在协议版本 ≥ 54454 时,还会包含 has_custom_serialization 字节:
同一查询返回的结果块,包含一行:
通过 FORMAT Native (修订版本 0) 时,相同的结果块不包含 BlockInfo,也没有 has_custom_serialization 字节——SELECT 1 FORMAT Native 的大小为 11 字节:
(零行结果 (例如仅含头部信息的块) 通过 FORMAT Native 完全不会产生任何字节:该输出格式不会输出空块。)

协议修订版本与 Native 格式

Native 字节流的形态首先取决于写入端和读取端所使用的协议修订版本。修订版本本身并不包含在这些字节中——线上并没有修订版本字段——但它仍然决定了某些特性是否会出现。因此,解码器必须先知道某个载荷是按哪个修订版本写入的,才能对其进行解析。由于修订版本不在 stream 中,读取端和写入端必须通过其他方式就此达成一致。 它是一个单独的 UInt64,并且 NativeWriterNativeReader 都将其作为构造函数参数传入。写入端将其称为 client_revision,读取端将其称为 server_revision``,但它们是同一个数值。此版本所知的最新修订版本是 DBMS_TCP_PROTOCOL_VERSION(参见 src/Core/ProtocolDefines.h`) 。

修订版本控制的内容

每项功能背后都有一个 DBMS_MIN_REVISION_WITH_* 阈值。只有当修订版本达到该阈值时,写入器才会写出该功能;读取器也会按完全相同的规则来判断是否读取,因此两端才能保持一致——只要任一侧的修订版本判断出错,就会失步。对于 Native 格式,关键的门槛如下: 这意味着,修订版本 0 对几乎所有情况来说都是最保守的编码方式:stream 中没有 BlockInfo、没有 has_custom_serialization 字节、使用 V1 Dynamic/JSON、没有聚合函数版本,并且 DateTime 会去掉时区参数。聚合函数的状态版本在修订版本 0 时是例外:不存在可用于推导版本的对端——stream 由生成它的一方写入和读回 (例如 StripeLog 数据文件、Set/Join 备份或 Native 格式文件) ——因此写入器会保留类型上固定的版本 (照常在类型字符串中明确写出) ,而不是将状态降级为版本 0;只有未固定任何版本的类型才会以版本 0 写入。 LowCardinality 是唯一的例外,而且这一点非常重要。写入器的判断条件是 remove_low_cardinality || (client_revision && client_revision < DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE)。关键就在前面的 client_revision &&:当修订版本恰好为 0 时,整个条件会短路为 false。 因此,在修订版本 0——FORMAT Native 的默认值——下,LowCardinality(T)不会被剥离。其类型字符串和每个块的状态前缀会保留在 stream 中,修订版本 0 的读取器会直接将它们读回。只有在非零且低于 54405 的修订版本下,或者不论修订版本为何都被强制剥离时,才会发生剥离。 这种强制由 remove_low_cardinality flag 控制。FORMAT Native 输出永远不会设置它,但原生 TCP 路径会在 low_cardinality_allow_in_native_format = 0 (默认值为 1) 时设置它。换言之,该设置会改变原生 TCP 输出,但对 FORMAT Native 没有影响。 实际要点是:默认的 FORMAT Native stream 可以合法包含 LowCardinality,因此不要将其视为修订版本 0 时不存在的功能。

修订版本的来源取决于数据的传输方式

相同的 Native 字节可以通过不同路径传输:native TCP 协议、HTTP 请求,或磁盘上的文件。每条路径都会以各自的方式确定修订版本。有一点需要注意:读取端和写入端是分别设置的,因此它们最终可能使用不同的修订版本。

原生 TCP 协议——通过协商确定,双向分别生效

原生 TCP 协议 中,修订版本来自 Hello 握手。客户端发送 DBMS_TCP_PROTOCOL_VERSION,服务端返回自己的版本,此后双方都会按对方声明的修订版本进行序列化:服务端根据 client_tcp_protocol_version 构建其 NativeReader/NativeWriter,客户端则使用收到的 server_revision。这里没有显式的 min,但双方都不可能发送自己尚未实现的特性,因此每个方向实际上都会受限于较旧的那一端。 当两端都是相同的较新构建时,两个方向会落到同一个修订版本 (DBMS_TCP_PROTOCOL_VERSION,见 src/Core/ProtocolDefines.h) ,所有门槛条件都会启用。这是最常见的情况,但并不绝对。对于混合版本或第三方对端,两个方向可能处于不同的修订版本,因此这些门槛条件必须按方向分别理解:只要修订版本非零,就会包含 BlockInfo;而其余内容——包括 has_custom_serialization——只有当该方向的有效修订版本达到各自阈值时才会出现。比如,某个对端如果声明的修订版本低于 54454,就既不会发送,也不会接收 has_custom_serialization 这个字节。

FORMAT Native 输出——默认修订版本为 0,但可通过 HTTP 提高

Native 输出格式默认采用修订版本 0。这包括通过 HTTP 执行的 SELECT ... FORMAT NativeINTO OUTFILE ... FORMAT Native,以及 ClickHouse 客户端写出的 Native 输出;在这几种情况下,输出工厂都会将 FormatSettings::client_protocol_version 直接传给 NativeWriter,它控制 BlockInfo / has_custom_serialization 的帧定界以及 offsets String 布局。因此,使用提高后的 client_protocol_version (≥ 54492) 通过 HTTP 执行 SELECT ... FORMAT Native 时,会写入 offsets 布局;而默认修订版本 0 则保留可移植的逐值 String 布局。 不过,对于 HTTP 来说,默认值并非全部。客户端可以通过 ?client_protocol_version=<n> 查询参数提高该值,HTTP handler 会将其视为保留参数,而不是 SQL 设置:它会进入查询上下文,然后由格式层复制到 FormatSettings 中。只要设得足够高,HTTP 的 FORMAT Native 输出就会像 TCP 路径一样,开始包含 BlockInfo 前缀和 has_custom_serialization 字节——因此,不要想当然地认为 HTTP 的 FORMAT Native 载荷始终都是修订版本 0 由于该参数会进入整个查询上下文,它会传递给为该请求构建的每个 NativeWriter,而不仅是结果流。尤其是,通过 HTTP 使用提高后的 ?client_protocol_version= 执行服务器端写入,例如 INSERT INTO FUNCTION file('x.native', 'Native', ...) SELECT ...,会以该修订版本写入文件。这是长期存在的行为 (早于 String offsets 序列化,并且同样适用于 BlockInfohas_custom_serialization) ,而且它是非对称的:读取端 (如下文所述) 不会以同样的方式提高版本,因此此类文件无法再通过 file(...) 读取。对于写入后还需再次读取的数据写入请求,请勿指定 client_protocol_versionINTO OUTFILE 和本地 ClickHouse 客户端输出没有这样的调节方式,因此会保持为 0

FORMAT Native 输入 — 始终使用修订版本 0

Native 输入格式始终以修订版本 0 创建其 NativeReader。它从不预期有 BlockInfo 前缀,从不读取 has_custom_serialization 字节,并始终假定使用默认序列化——无论是解析 INSERT ... FORMAT Native 的 body、读取 Native 文件,还是推断 schema。?client_protocol_version=<n> 不会提高输入修订版本 (只有输出端会读取该参数) 。 由于输入修订版本始终为 0,输入格式始终采用逐值 String 布局读取;使用 offsets 布局写入的 stream (通过原生 TCP 协议,或通过提高 client_protocol_versionSELECT ... FORMAT Native) 无法再通过 FORMAT Native 输入读回——这与 BlockInfo framing 的不对称性相同。

往返兼容性的影响

对于 FORMAT Native,默认情况下两端均使用修订版本 0。通过修订版本 0SELECT ... FORMAT Native 写出的数据,可以直接再读回 INSERT ... FORMAT Native,不会出现任何意外。 以提高后的输出修订版本生成的 stream (在 SELECT 上使用 ?client_protocol_version=<n>) 会携带 BlockInfohas_custom_serialization 帧信息,以及 offsets String 布局;而输入格式始终使用修订版本 0,无法读回这些内容。因此,提高 client_protocol_version 仅适用于使用原生 TCP 协议的消费者,而不适用于通过 HTTP 执行的 FORMAT Native 往返传输。 文件则属于不对称的情况。服务端 INSERT INTO FUNCTION file(...) (或 s3url) 中提高 ?client_protocol_version= 会以该修订版本写入文件,但 file(...) 读取始终按修订版本 0 解析——因此此类文件无法往返传输。要么不要在写入请求中设置 client_protocol_version,使文件保持为普通的修订版本 0,要么通过原生 TCP 协议传输数据——在这种情况下,每个方向都使用由握手协商出的修订版本。
协议修订版本与序列化版本不要将协议修订版本与序列化版本混为一谈。这里的修订版本是连接级或请求级的,永远不会出现在字节流中。序列化版本则是按列区分的,由带版本的类型携带,并会写入每个非空块。修订版本决定某个特性是否存在;而序列化版本则是在进入带版本的列后,决定该类型后续使用哪一种编码变体。

数据类型

本节介绍 Native 格式可在列的 data 中承载的各类类型在 wire 上的编码方式,并按解码器复杂度递增归为四个家族。有两种类型——AggregateFunction(func, ...)QBit(T, N[, stride])——虽然是有效的 Native 列类型,但其载荷依赖具体函数或类型,不在本文讨论范围内;下文会在原本可能被误认为别名的地方专门说明它们。

定宽类型

每个值都占用固定数量的字节。一个包含 M 行的列在传输时恰好占用 bytes_per_row × M 字节,连续拼接,中间没有分隔符或填充。

整数类型

UInt8UInt256Int8Int256 都是整数值的直接二进制编码。解码器会读取 bytes_per_row × num_rows 个字节,并按该类型对其进行解析。 一个包含 [1, 256, 65536]UInt32 列:
一个包含 [-1, 42]Int32 列:

Float32 和 Float64

标准 IEEE 754 二进制浮点数:4 字节单精度 (binary32) 和 8 字节双精度 (binary64) ,均采用小端序。NaN、±Infinity、±0.0 和次正规数在往返转换后都能保持原样,无需规范化。 Float321.5 (0x3FC00000) :
Float641.5 (0x3FF8000000000000) :

BFloat16

brain-float 格式:IEEE 754 Float32 的高 16 位——1 个符号位、8 个指数位、7 个尾数位。每个值占 2 字节,采用小端序,保存原始的 16 位模式。要还原其数值,可将该模式放入高 16 位、将低 16 位清零 (把 bits << 16 重新解释为 Float32) ,从而扩展回 Float32;扩展后的值也沿用 Float32 的文本格式。 BFloat161.5 (模式为 0x3FC0,即 Float32 0x3FC00000 的高半部分) :

Bool

UInt8 在线路格式上兼容:每行 1 字节,0x00 = false,0x01 = true。在线路协议中,类型字符串就是 Bool (而不是 UInt8) ,因此按类型字符串进行分派的解码器必须将其单独识别。 一个 Bool[true, false, true]

Date 和 Date32

两者都将日期编码为相对于 Unix 纪元 1970-01-01 的整数天数。两者都不包含时间部分。 Date1970-01-02 (1 天) :
Date321900-01-01 (-25567 天) :

DateTime

UInt32 在线路格式上兼容:表示一个以秒为单位的 Unix 时间戳,占 4 字节,采用小端序。该类型可能显示为 DateTimeDateTime('Timezone');时区仅影响显示,不属于线路值的一部分。对于同一时刻,两个带有不同 timezone 参数的 DateTime 列会生成完全相同的字节。解码器会去掉 (...) 参数后缀,并将该列按 UInt32 处理。 DateTime('UTC') 的值 2024-03-15 14:30:00 UTC (时间戳 1710513000) :

DateTime64(scale[, timezone])

8 字节,小端序 Int64,表示自 Unix 纪元以来、按 10^-scale 秒计的 tick。标度 参数 (0–9) 写在类型字符串中,用于设置时间单位: 该类型可写作 DateTime64(s) (隐式使用 server 默认时区) 或 DateTime64(s, 'TimezoneName') (显式指定时区,仅影响显示) 。负值表示纪元之前的 tick。 DateTime64(3, 'UTC') 的值 2024-01-15 12:30:45.123 UTC (1705321845123 ms) :
DateTime64(0)2024-01-15 12:30:45 UTC (1705321845 秒) :

TimeTime64(scale)

表示时钟时长,而不是时间点。Time 是有符号的秒计数,使用 4 字节小端序 Int32;Time64(scale) 是在给定十进制标度 (0–9) 下的有符号 tick 计数,使用 8 字节小端序 Int64——其 wire 形态与 DateTime64 相同。 其文本形式为 [-]HH:MM:SS[.fraction],但与 DateTime 不同,小时字段不会按 24 小时制折返:它表示的是总小时数,因此可能超过 23。显示值的上限为 999:59:59 (3599999 秒) ;更大的值会按该上限显示,且小数部分清零 (999:59:59.000) 。CAST 也会将存储值限制在这个范围内,不过算术运算可能产生超出范围的值,而这类值仅在显示时才会被限制。这些都不会影响 wire 字节,因为它们本质上就是普通的有符号整数。 Time45296 (12:34:56) :
Time64(3)45296789 刻度 (12:34:56.789) :
TimeTime64 属于 Experimental 功能,且需要在 server 上设置 allow_experimental_time_time64_type = 1

Interval

Interval<Unit>IntervalSecondIntervalMinuteIntervalHourIntervalDayIntervalWeekIntervalMonthIntervalQuarterIntervalYearIntervalNanosecond 等。所有单位都使用同一种 wire 编码:将计数存储为有符号的 8 字节小端序 Int64。单位体现在类型字符串中——它既不会改变 wire 字节,也不会改变文本表示形式,后者就是裸整数。所有单位都由同一套解码逻辑处理。 IntervalDay 的值 5

UUID

每个值占 16 个字节。其线上传输编码并非规范的 16 个大端序字节——而是将两个 8 字节的半段分别做字节反转。 逻辑模型是一个 128 位标识符,采用规范文本格式 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,其中字节通常按大端序书写。线上传输模型会取这 16 个规范字节,将其拆分为两个 8 字节半段,并将每个半段按小端序写入:
  • 线上的字节 0..7 = 规范字节 0..7 反转后。
  • 线上的字节 8..15 = 规范字节 8..15 反转后。
UUID 550e8400-e29b-41d4-a716-446655440000
nil UUID (全为零) 在两种表示形式中完全一致。

IPv4 和 IPv6

两种相关但编码方式不同的地址类型。 IPv4 占 4 字节,编码为一个小端序 UInt32,用于存储规范的 32 位地址 (即由 a.b.c.d 得到的值 (a << 24) | (b << 16) | (c << 8) | d) 。在线路上传输时,字节序列是将网络字节序的字节反转后得到的。 192.168.1.10 (规范 32 位值为 0xC0A8010A) :
IPv6 长度为 16 字节,按网络字节序原样写入,不做 swap——其字节序与 inet_pton(AF_INET6, ...) 相同。 2001:db8::1:
这种不对称性是有意设计的:IPv4 以 u32 形式存储,便于进行算术运算和紧凑的范围查询;而 IPv6 则保留了大多数网络 API 常用的网络字节序布局。

Enum8 and Enum16

分别与 Int8Int16 在线路格式上兼容:每行占 1 或 2 个字节,其中 16 位变体采用二进制补码小端序。完整的变体映射位于类型字符串中:
解码器可能会去掉 (...) 参数后缀,并按 Int8 / Int16 处理——在线上传输的字节其实只是整数索引。会暴露标签的客户端会从类型字符串中解析出 'name' = value 映射,并将其与该列一同保留:仅凭整数本身无法还原标签。面向文本的输出显示的是标签 (active) 而不是索引;当枚举嵌套在复合类型中时,则会使用单引号 ('active') 。由于无法从整数列中恢复该映射,因此对于 Array(Enum8(...))Map(Enum16(...), V) 这类嵌套枚举,必须保留该映射。 一个 Enum8('active' = 1, 'inactive' = 2)[active, inactive, active]
Enum16(...) 的一个值 30000

Decimal(P, S)

按 10 的幂进行缩放的有符号整数。该整数的字节宽度由 精度 P 决定;标度 S 是负指数 (即小数点后的位数) 。这两个参数都包含在类型字符串中。 线上的编码是采用小端序二进制补码表示的底层整数,逻辑上的十进制值则为 wire_integer × 10^(-S) 无论类型最初是如何声明的,ClickHouse 始终都会输出 Decimal(P, S)Decimal32(S)Decimal64(S) 等写法在线上传输时都会规范化为 Decimal(P, S) (其中 P 会设置为该宽度下的自然最大值:9、18、38、76) 。因此,只要解码器能识别 Decimal(P, S),就能覆盖服务器输出的所有写法。 Decimal(9, 4) 的值 123.4567 → 底层整数 1234567
Decimal(18, 1)-1.5 → 底层整数 -15
Decimal(38, 4)123.4567 (总计 16 字节) :

Nothing

Nothing 类型不包含任何值。实际中,它只会作为 Nullable(Nothing) 的内部类型出现——也就是服务器对 SELECT NULL 这类表达式返回的类型,其唯一可能的有效值就是“不存在值”。从概念上说,它是一种 unit type。 在线路格式中,它每行恰好占用一个占位字节。服务器会输出 ASCII 字符 '0' (0x30) ,但反序列化器会忽略这些字节——其内容未定义,解码器不得依赖任何特定值。写入的字节数为 num_rows × 1,因此列头中的 num_rows 就完全决定了需要读取多少内容。 这种每行一个字节的设计保持了 Block 不变式:每一列都占据一个可由 num_rows 推导出的长度,因此解码器可以向前扫描,而不需要每个单元的长度前缀。外层的 Nullable 始终将每个位置都标记为 NULL,因此这些占位符永远不会被检查。 一个包含 3 行的 Nullable(Nothing) 列 (全部为 NULL) :
null-map 前缀采用标准的 Nullable 封装格式 (参见 Nullable) ;内部的三个字节是 Nothing 载荷,解码器会跳过它。

变长类型

每个值在传输时都会携带其自身的长度。

String

类型字符串:StringString 列有两种传输布局,由协议修订版本决定 (请参阅修订版本控制的内容) 。该修订版本是 native TCP protocol 上协商的修订版本;对于 FORMAT Native 输出,则是 client_protocol_version (默认值为 0) ;FORMAT Native 输入始终使用修订版本 0,而 Buffers 格式始终使用逐值布局。 逐值布局 — 当修订版本低于 DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION (54492) 时使用,包括 FORMAT Native 默认的修订版本 0:由 num_rows 个带长度前缀的字节序列组成的序列:
除长度前缀外,行之间没有其他分隔符,也没有行级状态。空字符串由单个 0x00 字节表示。该列占用的总字节数为所有行的 Σ (varuint_size(len_i) + len_i) 由 3 个字符串 ["ab", "", "c"] 组成的列 (总计 6 个字节) :
偏移量布局 — 修订版本 54492 及以上:两个串联的流,先是累计字节偏移量,随后是所有数据:
offsets 按原样发送,与 Array 通过 native protocol 发送 offsets 的方式相同。Offset i 表示数据 blob 中值 i 的结束位置,因此值 i 的大小为 offset[i] - offset[i-1] (其中 offset[-1] = 0) ,最后一个 offset 即为数据 blob 的总大小。decoder 会先读取 8 × num_rows 字节的 offsets,再一次性读取整个数据 blob,并以最后一个 offset 作为其精确长度——因此可以精确预分配 buffer 并进行批量复制,无需逐行 parsing,也不需要将大小转换为 offset。num_rows = 0 的列在两个 stream 中均不占用任何字节。 同样的 3 个字符串 ["ab", "", "c"] (共 27 字节) :
当 offsets 布局生效时,它适用于块中的每个 String,包括复合类型 (Array(String)Nullable(String)MapTupleVariantDynamicJSON) 中嵌套的 String——但有一个例外:LowCardinality 列的字典始终采用默认的嵌套序列化方式写入,因此其中的 String 值在所有修订版本中都保持逐值布局。这包括 LowCardinality(String)LowCardinality(Nullable(String)),以及其他任何包含 String 的 LowCardinality 字典。 无论采用哪种布局,ClickHouse String 都是面向字节而非文本的:不会强制验证 UTF-8 的有效性,且值可包含任意字节,包括嵌入的 NUL。以 UTF-8 字符串类型为目标的解码器应在读取时验证,或向调用方提供原始字节。

FixedString(N)

类型字符串:FixedString(N),其中 N 为正整数 (例如 FixedString(16)) 。该列恰好包含 N × num_rows 个原始字节,没有长度前缀,也没有分隔符。解码器从类型字符串中解析出 N,并为每一行读取对应数量的字节。 当 SQL 插入的值短于 N 字节时 (例如 CAST('abc' AS FixedString(5))) ,server 会在右侧用 NUL 字节 (0x00) 填充到声明的长度。这些填充字节属于存储值本身的一部分,并会按原样通过 wire 发送;是否去除这些字节由 client 端处理。与 String 类似,FixedString(N) 更接近字节数组而非文本——通常用于定宽标识符、地址字节或哈希摘要。 两个 FixedString(3)["abc", "de\0"] (共 6 字节) :
对比这两种字符串类型:

复合类型

复合类型会封装一个或多个内部类型,并共享一种通用的 wire 模型:每列对应多个流。单个逻辑列会被编码为两个或多个可独立读取的字节序列,并将其拼接在一起。 它们具有三个共同的结构特性:
  • 每个 schema 的形态固定。 结构在解码时完全由类型字符串决定。Array(UInt32) 的 stream 布局始终一致,不会因块而异。
  • 自身没有版本前缀。 复合包装器本身不会添加版本字节;它的帧结构 (offsets、null-map、元素流) 在各个 ClickHouse 发行版之间保持稳定。这只适用于 wrapper 本身——内部带版本的类型请参见下文的前缀阶段说明。
  • 自身没有跨块状态。 包装器的帧结构在每个块中都是完全自描述的;任何跨块状态相关的问题都来自内部带版本的类型,而不是包装器本身。
复合类型是递归的——内部类型本身也可以是复合类型。 数据流之前的前缀阶段。 读取一列分为两个阶段,顺序如下:先是状态前缀阶段,然后是数据流阶段。复合包装器自身没有前缀字节,但在写入自身任何数据流之前,它会将前缀阶段委托给内部序列化来执行:SerializationArray 会在写入数组 offsets 之前运行其内部类型的前缀阶段,TupleMapNestedNullable 也会通过各自的元素序列化执行相同操作 (Nullable 会在其 null map 之前运行内部前缀) 。 因此,当复合类型封装了带版本/有状态类型 (LowCardinalityVariantDynamicJSON) 时,该内部类型的版本/状态前缀会最先写出,位于包装器的 offsets 和元素载荷之前。例如,Array(LowCardinality(String)) 的布局是 [LowCardinality state prefix][array offsets][flattened LowCardinality element payload],而不是先写 offsets。 如果解码器在执行内部前缀阶段之前就先读取 offsets,那么在处理任何包含 LowCardinalityVariantDynamicJSON 的复合类型时都会失步。当所有内部类型都是普通叶子类型或其他不带版本的复合类型时,前缀阶段不会输出任何字节,此时下文关于“先 offsets”的描述可原样适用。

Nullable(T)

类型字符串:Nullable(InnerType)。示例:Nullable(UInt32)Nullable(String)Nullable(FixedString(16))Nullable(DateTime('UTC')) 与其他复合类型一样,Nullable 会先将前缀阶段委托给其内部序列化处理,然后再写入 null map:当内部类型带版本时,会输出内部的状态前缀。因此,Nullable(Tuple(LowCardinality(String))) 会以 LowCardinality 的状态前缀开头,而不是 null map。当内部类型是叶子类型或其他不带版本的类型时,前缀阶段不会输出任何字节。 传输布局由内部前缀阶段 (除非内部类型带版本,否则为空) 以及随后拼接的两个流组成,其中 null-map 在前:
空值映射的大小恰好为 num_rows 字节,每行一个字节: values stream 包含内部类型对全部 num_rows 行的标准编码,包括 NULL 所在位置。解码器仍必须读取 NULL 位置处的占位符字节以推进 stream,但在解释任何单个值之前,必须先检查空值映射。发送方可以在 NULL 位置写入任意字节,因此解码器不能依赖某个特定的占位符值。 按内部类型家族划分的占位符值: Nullable(T) 可以出现在 ArrayTupleMapNested 内部——Array(Nullable(T))Tuple(Nullable(T1), T2) 很常见。可空性不能自行嵌套:Nullable(Nullable(T)) 会被 server 拒绝。 一个有三行 [5, NULL, 9]Nullable(UInt8) (总共 6 字节) :
一个具有三行 ["hello", NULL, "world"]Nullable(String) (共 15 字节) :

Array(T)

类型字符串:Array(InnerType)。示例:Array(UInt32)Array(String)Array(Nullable(UInt32))Array(Array(UInt8)) 传输布局由内部 前缀阶段 (除非内部类型带版本信息,否则为空) 以及随后拼接在一起的两个流组成,其中 offsets 在前:
offsets stream 恰好由 num_rows 个小端序 UInt64 值组成,每个值都是该行元素之后 values stream 中的累计结束位置
  • N 的元素起始索引 = offsets[N - 1] (或在 N == 0 时为 0) 。
  • N 的元素结束索引 (exclusive) = offsets[N]
  • N 的元素个数 = offsets[N] - offsets[N - 1]
因此,offsets[num_rows - 1] 就是所有行的元素总数,而 values stream 则按顺序首尾相接地存放这么多个内部值。 Offsets 单调非递减;连续相等的 offsets 表示空行,解码器应将非单调的 offsets 视为损坏并拒绝。空列 (num_rows == 0) 会写入零字节——既没有 offsets stream,也没有 values stream。内部类型可以是任意类型,包括其他复合类型:Array(Array(T))Array(Tuple(...))Array(Nullable(T)) 都是合法的。 行值为 [[10, 20, 30], [], [40, 50]]Array(UInt32) (总计 44 字节) :
每个偏移量都是共享 values 流中某一行切片的累计结束位置;其起始位置是前一个偏移量 (第 0 行则为 0) 。连续相等的偏移量表示空行: Array(String),对应的行 [["a", "bb"], []] (共 20 字节) :
具有行 [[[1,2]], [], [[3], [4,5]]]Array(Array(UInt32)) 按相同的形态嵌套:
  • 外层偏移量:[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(a UInt32, b String) 定义命名元组;这些名称仅作为元数据,不会影响传输格式。 其传输布局为:先是各元素的前缀阶段 (每个带版本的元素都会按声明顺序贡献其状态前缀;非版本化元素则为空) ,随后是按声明顺序拼接的 N 个流,每种元素类型对应一个:
每个流恰好编码 num_rows 个值。没有长度前缀,没有 offsets 流,流与流之间也没有分隔符。空列 (num_rows == 0) 在每个流中写入零字节。元素类型可以是任意类型,包括其他复合类型——Tuple(Tuple(...), ...)Tuple(Array(...), ...)Tuple(Nullable(T1), T2) 都是合法的。 零元素元组 Tuple() 也是合法的——它会由 SELECT tuple()CAST(x AS Tuple()) 这类表达式产生。由于没有元素流,它会像 Nothing 一样被序列化:每行一个占位字节 (0x30,ASCII '0') ,反序列化器会将其丢弃。行数来自块头,与 Nothing 完全相同。 具有 3 行 (1,4)、(2,5)、(3,6)Tuple(UInt8, UInt8)
其布局不是按行主序排列:将原始字节读回后,元素 0 得到 [1, 2, 3],元素 1 得到 [4, 5, 6] 包含 2 行 (10, "a")(20, "bb")Tuple(UInt32, String) (总计 13 字节) :

Map(K, V)

类型字符串:Map(KeyType, ValueType)。示例:Map(String, UInt32)Map(String, Array(UInt32))Map(UInt8, Tuple(Int32, String))Map(Array(String), Int8)。传输格式对这两种类型均不作限制——KV 都可以是任何受支持的类型,包括复合类型。 (ClickHouse 在 SQL 层面对可接受的键类型所设的规则在不同发行版之间有所变化;请查阅目标服务器版本对应的 SQL 文档。) 其传输布局与 Array(Tuple(K, V)) 在字节级别完全相同,因此开头是内部的前缀阶段 (除非 KV 带有版本信息,否则为空) :
其中 total_pairs = offsets[num_rows - 1] (当 num_rows == 0 时则为 0) 。offsets 流的语义与 Array 相同。键和值按位置一一对应:第 i 对为 (keys[i], values[i]) ClickHouse 中 Map 列的内存表示是元组数组;类型系统将其作为一种独立类型暴露出来,以便更方便地在 SQL 中使用 (m['key'], mapKeys, mapValues) 。传输格式就是这种存储形式的直接序列化,因此 MapArray(Tuple(K, V)) 在字节级别完全可以互换。 offsets 单调不减,并且 keys 流和值流都恰好包含 total_pairs 个值。空列会写入 0 字节。在同一行内,键通常是唯一的,但这是一条语义规则,而不是传输格式强制规定的:传输格式允许重复键被原样往返保留,而 server-side 语义只有在某个支持 Map 的函数消费该行时才会处理重复键。 Map(UInt8, UInt8) 包含 2 行 {1:10, 2:20}{3:30} (总计 22 字节) :
键和值分别存储在不同的流中,而不是交错存储——通过同时读取 keys[i]values[i],即可重建第 i 个键值对。 含 1 行 {'a':1, 'b':2}Map(String, UInt32) (共 20 字节) :

Nested(name1 T1, name2 T2, …)

Nested 在线上传输中的表示形式取决于服务端的 flatten_nested 设置,因此分为两种情况。 情况 A:flatten_nested = 1 (server 默认值) 。 当表在默认设置下创建时,Nested 不是一种 wire 类型。server 会将该列存储并显示为 N 个并行的 Array(T_i) 列,并使用点分名称 (outer.field1outer.field2 等) 。对于 format 层来说,这里没有任何变化——每个点分列都只是一个普通的 Array
情况 B:flatten_nested = 0 当表以 flatten_nested = 0 创建时,该列在线上传输中表现为单个列,类型字符串为 Nested(name1 T1, name2 T2, ...);而在类型字符串之后,其布局与 Array(Tuple(T1, T2, ..., Tn)) 在字节级别完全一致——包括内部的前缀阶段。因此,任何带版本的字段 T_i 都会先输出其状态前缀,然后才是偏移量。下面的示例使用的是非版本化字段,因此前缀阶段为空:
唯一的区别在于类型字符串:Nested 会保留字段名 (ab) ,而 Array(Tuple) 不会将其作为具名槽位保留。 Case B 的类型字符串是由逗号分隔的 (name, type) 对列表。第一个空白字符用于分隔名称和类型;类型本身还可能包含更多空白、逗号和括号,因此解析时需要使用与 Tuple 相同的、能够感知嵌套深度的分割器。其 wire 布局如下:
其中 total_elements = offsets[num_rows - 1] (当 num_rows == 0 时则为 0) 。偏移量单调不减,并且每个字段流都恰好包含 total_elements 个值。server 会在 INSERT 时强制确保:在同一行内,所有字段包含的元素数量都相同。空列会写入零字节。 Nested(a UInt8, b String) 包含 2 行 [(10,'x'),(20,'y')][(30,'z')] (类型字符串之后有 25 个字节) :

类型别名

有些类型只是纯别名:服务器会在列头中发送别名名称,但后续字节仍是其底层类型的字节。解码器会将别名映射到底层类型,并复用其 codec——不会引入新的传输格式。 地理类型实际上是对嵌套数组和 Tuple 的别名: 因此,Point 列的解码方式与 Tuple(Float64, Float64) 完全相同 (显示为 (1,2)) ,Ring 则与 Array(Tuple(Float64, Float64)) 完全相同 ([(0,0),(1,1)]) ,更高层级的类型也以此类推。 Geometry 也是别名,但它别名到的不是嵌套数组,而是 Variant:它的载荷是上述七种地理类型组成的 Variant。列头只携带类型字符串 Geometry——不会显式展开为该 Variant——因此解码器必须自行展开。与用户显式写出的 Variant 不同,Geometry 的判别值遵循按名称排序的顺序:为了兼容性,它们是固定的,新的地理类型只会追加到末尾。映射关系为:0 = LineString1 = MultiLineString2 = MultiPolygon3 = Point4 = Polygon5 = Ring6 = MultiPoint (在 26.7 中添加——来自旧版服务器的 stream 不会包含判别值 6) 。随后,每个选中的值都会按其对应的地理别名进行解码 (NULL 使用 VariantNULL 判别值 255) 。 SimpleAggregateFunction(func, T) 是其值类型 T 的别名。它存储的是已完成最终计算的聚合值,因此其传输形式和显示结果与 T 完全一致 (SimpleAggregateFunction(sum, UInt64) 会按 UInt64 解码) 。只有这种单值类型形式才是以这种方式实现的别名;其底层类型本身也可以是复合类型。
以下两种相关类型不是别名。它们都是有效的 Native 列类型——例如,客户端可以从 -State 组合器或分布式聚合中接收 AggregateFunction 列——但各自携带专用载荷,具体细节不在本页讨论范围内:
  • AggregateFunction(func, ...) 保存中间聚合状态 (而非最终值) ;其二进制布局取决于聚合函数及其版本。版本化聚合函数会将非零状态版本显式写为类型字符串的第一个参数——AggregateFunction(1, quantileDeterministic, UInt64, UInt64)——而版本 0 不会输出,因此类型字符串仍为 AggregateFunction(quantileDeterministic, UInt64, UInt64)。在已协商的连接上 (修订版本高于 0) ,写入器仅根据协议修订版本选择版本——绝不会采用其本地类型中恰好固定的版本,因为在线上传输中,版本 0 与“未通知版本”无法区分——因此,同一函数可在不同连接上通知不同版本,读取器必须从类型字符串中获取版本,而不能假定使用最新版本。在修订版本 0 时,没有可用于推导版本的 peer——该流是自描述的,且由写入者自行读回——因此,类型中固定的版本会保留在流中 (以及通知的类型字符串中) ,而不会降级为 0quantileDeterministic (包括 quantilesDeterministicmedianDeterministic 这两种写法) 从修订版本 54491 起支持版本化:版本 1 会在状态末尾追加 UInt8 skip_degree,其前缀与版本 0 完全字节一致。低于该修订版本时,写入器会输出版本 0 状态,因此不会影响旧版 peer。这同样适用于嵌套在容器类型中的状态——ArrayTupleMapNullableVariant (其中,候选类型中显式写出的版本会改变类型字符串,但不会改变判别值顺序) ,以及 Nested / SimpleAggregateFunction 包装器。包装器的自定义名称中包含其参数类型的第二个副本,因此写入器必须使用相同的协商聚合状态版本重建该副本;否则其标头可能会为版本 0 的载荷通知版本 1Variant 有一种边界情况完全没有线上传输表示:当两个候选类型仅在显式写出的状态版本上不同时 (由于类型字符串不同,作为列类型是合法的) ,若按协商的修订版本对二者重新确定版本,它们将合并为同一个候选类型,因此发送方必须使传输失败,而不能通知与载荷实际写入版本不一致的版本。Dynamic 是基于修订版本确定版本这一规则的有意例外:Dynamic 列会自行通知其嵌套类型——通过自身的类型列表以及二进制类型编码,其显式版本字段携带明确写出的状态版本 (其共享 Variant 的值即使在静态存储时也会以这种方式嵌入其类型) ——这些通知是列自身数据的一部分,对所有 peer 和所有介质均相同,因此绝不会针对协商的修订版本重写。因此,嵌套在 Dynamic 值中的状态会按其类型中明确写出的版本写入 (新创建的状态会携带该版本,因为 -State 查询结果会明确写出其版本) ;类型未写出版本时,则按版本 0 写入,所有连接和介质均是如此。
  • QBit(T, N[, stride]) 为向量搜索工作负载存储位平面转置后的向量;其线上传输流布局 (按组排列的 FixedString 位平面流,共 element_size * (N / stride) 个,带显式 stride) 及其二进制类型编码 (标签为 0x36,或当 stride != N 时为 0x37 QBitWithStride) 已在 QBit 数据类型页面二进制类型编码参考中说明,因此 Native 读取器无需从 C++ 源代码中还原这些信息。

版本化类型

版本化类型会携带一个在线上传输中的序列化版本前缀,用于声明后续采用的是哪一种编码 Variant。它们也可能使用多个流 (类似复合类型) 。在 Native 线格式中,前缀和任何字典都按块划分——这些类型不维护跨块状态 (见下文的按块前缀说明) ;跨块序列化状态只存在于 MergeTree 的磁盘流中。 与固定形态的复合类型相比,这些类型复杂得多,因此面向简单分析查询的客户端可以暂不处理它们。

序列化版本:概念

序列化版本是按类型、按列划分的线上传输版本号,用于声明发送方当前使用的是某种类型编码的哪个 Variant。它位于该列状态前缀的最前面,因此解码器会先读取它,再据此选择合适的解析器来解析该列的其余内容。 它不同于协议版本: 大多数带版本的类型都会在任何其他状态前缀数据之前,先将该版本写为小端序 UInt64;少数则使用 VarUInt 或 UInt8。解码器会先读取版本,并拒绝未知值——更高的版本意味着发送方使用了更新的格式,而解码器并不理解;如果误解析它,后续的每一个字节都会被破坏。 状态前缀会在每个行数大于零的块开头输出,紧接在该块的载荷之前。 Native 写入器和读取器不会在块之间保留序列化状态:NativeWriter 会为其写入的每个非空列块创建一个新的 serialize state,并写入状态前缀;NativeReader 会为其读取的每个非空块创建一个新的 deserialize state,并读取它 (当 rows == 0 时,两者都会完全跳过此前缀) 。 因此,头部块 (rows = 0) 和空块都不会输出任何内容,解码器必须在每个非空块开头重新读取状态前缀。如果解码器只读取一次前缀,并把后续块都当作仅包含载荷,那么它会把下一个块的前缀当作数据读取,从而导致失步:

序列化版本参考

关于这张表,有几点值得注意:
  • 这些值并不是连续的。 Dynamic 使用 1234,其中 V34FLATTENED3。数值更大并不一定表示版本更新。
  • 某些值仅用于原生格式。 Object::STRINGObject::FLATTENEDDynamic::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) 是唯一已定义的版本,即 sharedDictionariesWithAdditionalKeys;其他值均为保留值。 每个块的元数据 UInt64 是一个位字段: 对于典型的查询响应,如果每列只有一个数据块,则元数据为 0x600 (HasAdditionalKeys + NeedUpdateDictionary) 。 dict values 是用内部类型 T 编码的 dict_size 个值。字典会为特殊值预留前面的槽位:非 Nullable 列会保留一个 (dict[0] 保存内部类型的默认值,例如 String"") ,真正的不同值从 dict[1] 开始。 对于 LowCardinality(Nullable(T)),dict 仍按普通 T 编码 (没有 null-map stream) ,但会预留两个槽位: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_count5,而不是 3;对于 Map(K, LowCardinality(V)),它是键值对总数,依此类推。解码器必须从这个字段读取 keys_count,而不能假定它等于块的行数。当这个扁平化计数为零时——例如某个块中的数组全部为空——LowCardinality 数据阶段将完全不会写入任何内容:只会有状态前缀 (在 composite prefix phase 中输出) ,后面不会再跟随元数据、字典或 keys_count 状态前缀会在每个行数大于零的块开头读取——头部块 (rows = 0) 和空块都不会输出任何内容。在一个块内,keys_count 等于行数,dict_size 等于 dict stream 中的值数量,并且每个键都占用 1 << key_type_code 字节。
Native format 中,每个块都会携带一个自包含的块内局部字典——不存在跨块的字典状态。Native writer 会将 low_cardinality_max_dictionary_size = 0,因此 SerializationLowCardinality 永远不会构建共享字典:每个非空块都会把它的键作为块内局部附加键写出,且 NeedGlobalDictionaryBit 不会被设置 (metadata 0x600) ;而当 native_format 为 true 时,Native reader 会拒绝 NeedGlobalDictionaryBit。因此,解码器必须在每个块开始时重置字典,并读取该块中存在的 dict_size 个条目;如果沿用前一个块的字典,就会误读下一个块的键。 (跨块持久化 LC 字典是 MergeTree 的磁盘存储问题,不属于 Native wire layout。)
值为 ['a', 'b', 'a', 'c', 'b']LowCardinality(String)
重建后:dict[1], dict[2], dict[1], dict[3], dict[2] = ["a", "b", "a", "c", "b"] 取值为 ['a', NULL, '', 'b']LowCardinality(Nullable(String)) 会显示两个保留槽位——dict[0] 用于 NULL,dict[1] 用于空字符串默认值:
重建后:dict[2] = "a"dict[0] = NULLdict[1] = ""dict[3] = "b",即 ["a", NULL, "", "b"]dict[0]dict[1] 在线路表示中都是空字节;是否为 NULL 取决于键是否指向槽位 0,而不是这些字节本身。

JSON (Tier 1:String 回退)

ClickHouse 的 JSON 类型有多种线传输编码 (请参阅序列化版本参考) 。Tier 1 是最简单的一种:当为每个查询设置 output_format_native_write_json_as_string = 1 时,服务器会将每个 JSON 值展平为其序列化后的文本,并将该列作为带有状态前缀标记的 String 输出。 类型字符串:JSON
对于这种 String fallback,状态前缀值为 1。其他值表示不同的 JSON/Object 编码:0 = V1,2 = V2 (native TCP protocol 上的默认值) ,3 = FLATTENED,4 = V3 (参见serialization version reference) 。如果解码器在这里看到的值不是 1,说明它看到的不是 String fallback。此前缀会在每个行数 > 0 的块开头读取,而 values stream 则是一个包含 num_rows 行的标准 String 列。 JSON'{"a":1}' (一行) :
该值会以紧凑的 JSON 文本形式输出——{"a":1},其中整数仍保留为整数。该文本只是一个 String 值,因此客户端接收到的只是用于不透明传输的 JSON,不会还原各个路径及其 ClickHouse 类型;若要实现忠实的按路径类型化,则需要使用下文的第 2 层编码。

Variant(T1, T2, …)

一种带判别值的联合类型:每一行恰好保存某一种 Variant 类型的值,或者为 NULL。每一行都带有一个 1 字节的全局判别值来指示其类型,随后各类型的值会以紧凑方式存储,每种 Variant 类型各自对应一段连续区域。 类型字符串:Variant(T1, T2, ...)。服务器会将顺序规范化 (Variant 类型按名称排序) ,因此接收到的类型字符串已经按全局判别值顺序列出各类型:判别值 0 选择列出的第一个类型,1 选择第二个,以此类推。255 (NULL_DISCRIMINATOR) 表示该行为 NULL。Variant 元素永远不会是 Nullable——NULL 由判别值表示。示例:Variant(String, UInt64)Variant(Array(UInt8), String) 状态前缀携带一个 UInt64 LE 判别值模式:0 = BASIC (直接写出每一行的判别值) ,1 = COMPACT (按游程长度进行粒度编码) 。默认情况下,服务器通过 native protocol 使用 BASIC (use_compact_variant_discriminators_serialization = false) ;这里只规定 BASIC。
要重建数据,需要从左到右遍历判别值,并为每种类型维护一个各自递增的计数器。判别值为 d (≠ 255) 的行 r,会从 Variant 类型 d 的值序列中取出索引为 counter[d] 的值,然后将 counter[d] 加 1。判别值为 255 的行是 NULL,不会从任何值序列中取值,因此各类型计数器之和等于非 NULL 行的数量。 状态前缀 (即模式 UInt64) 会在每个行数 > 0 的块开头读取;块头和空块都不会输出任何内容。每个非 NULL 判别值都小于 Variant 类型的数量,并且 Variant 类型 i 会恰好为 count[i] 行解码。
如果 Variant 元素本身也是有状态的 (LowCardinalityVariantDynamicJSON) ,则会在按元素的状态前缀阶段输出各自的状态前缀,该阶段位于模式 UInt64 之后。叶子类型以及简单复合类型 (由叶子类型组成的 ArrayTupleMap) 的状态前缀为空,因此可以自由组合。
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 判别值都会从其类型对应的稠密 run 中取出下一个值,而 255 (NULL) 则不消耗任何值。沿用同样的遍历过程也可以重建 Dynamic,二者唯一的区别只在于 NULL 的编码方式:

Dynamic

一种值类型在运行时确定的列:每一行保存的值要么属于某个运行时确定的类型集合中的一种类型,要么为 NULL。与 Variant 不同,类型集合会出现在列的类型字符串中——它保存在状态前缀里。 类型字符串:DynamicDynamic(max_types=N)max_types 参数限制该列可跟踪的不同类型数量,但不会影响下方的传输格式。 Dynamic 有四种编码——V1 = 1V2 = 2FLATTENED = 3V3 = 4。server 输出哪一种取决于通道和查询设置:
  • 通过 clickhouse-client 和 HTTP FORMAT Native 时,writer 的 revision 为 0 (除非通过 client_protocol_version 提高) ,因此默认是 V1
  • 通过 native TCP protocol 使用协商后的 revision 时,默认是 V2Native writer 会保持 statistics 禁用,因此默认的 V2 载荷不包含按 variant 划分的 statistics——类型列表之后直接就是嵌套的 Variant 前缀和数据。 (按 variant 划分的 statistics 属于 MergeTree 磁盘存储层面的内容,不属于 Native 传输格式的一部分。)
  • 查询设置 output_format_native_use_flattened_dynamic_and_json_serialization = 1 会覆盖前两者,无论 revision 如何都输出 FLATTENED (version 3)
范围本页仅规定 FLATTENED 布局。非扁平的 V1/V2/V3 二进制布局属于内部/磁盘表示形式 (二进制编码的类型列表、按 variant 划分的 statistics) ,此处作说明。想要根据本页解码 Dynamic 的 client,必须通过设置 output_format_native_use_flattened_dynamic_and_json_serialization = 1 来请求 FLATTENED;下方布局以该设置为前提。由于 version 字节位于前缀开头,解码器可以检测实际收到的编码;如果它只实现了 FLATTENED,则可拒绝 V1/V2/V3
该设置选用的 FLATTENED (version 3) 布局如下:
判别值宽度是能为 num_types 个类型再加上 NULL 槽建立索引的最小无符号整数:当 num_types ≤ 255 时为 UInt8,之后依次为 UInt16UInt32UInt64。NULL 对应的判别值值就是 num_types 本身,这与 Variant 不同;在 Variant 中,NULL 的固定值是 255。重建过程与 Variant 相同,都是采用同样的稠密遍历:为每种类型维护一个计数器,第 r 行的判别值为 d (≠ num_types) 时,其值取自类型 d 的序列中的 counter[d] 对于每个行数 > 0 的块,都会在开头读取状态前缀 (版本 + 类型列表) ;头块和空块不会输出任何内容。
计数值损坏num_types 会在读取任何类型名称之前先从 stream 中读出。解码器必须将其视为不可信,且不得直接用它来确定分配大小——无论是接近 SIZE_MAX 的计数值 (这可能导致中间算术溢出,或抛出非 DB::Exception) ,还是像 100000000 这样数值很大但仍在可表示范围内的计数值 (它远低于 vector 的 max_size(),却会在尚未读取任何一个类型名称之前就分配数 GB 内存) 。ClickHouse 会逐个读取类型列表条目,并且只使用带上限的预分配 hint,因此损坏的 num_types 要么会作为 INCORRECT_DATA 被拒绝 (“Dynamic column has too many types”,即计数超过容器可容纳的范围时) ,要么会在 stream 中的类型条目耗尽时作为普通读取错误被拒绝——绝不会表现为内存不足故障。不过,不要ColumnDynamic::MAX_DYNAMIC_TYPES_LIMIT (254 in ClickHouse) 来限制扁平化的 num_types:扁平化类型列表包含所有不同的运行时类型,包括那些已溢出到共享 variant 中的类型,因此一个有效的扁平化块完全可能合法地列出远多于该限制的类型。MAX_DYNAMIC_TYPES_LIMIT 这一限制只适用于非扁平 V1/V2/V3 前缀中的 num_dynamic_types 计数;该计数统计的是常规 variant slot,并受此限制约束 (ClickHouse 会在这里、为共享 variant 执行 + 1 之前对其进行验证) 。
序列化为有状态的运行时类型 (LowCardinalityVariantDynamicJSON) 会在类型名称列表之后携带嵌套的 state prefix。
运行时类型列表通常遵循 Variant 的规范化形式——常规 variant slot 按 DataTypeVariant (类型名称) 顺序写入,因此 wire 顺序并不遵循插入顺序。不过,它并不总是全局有序的:溢出到共享 variant 中的类型 (例如在 Dynamic(max_types=N) 下) 会按首次出现的顺序追加在常规 slot 之后,因此列表尾部可能会打破按类型名称排序的顺序。因此,解码器必须将传输的类型列表视为分配判别值的权威依据,不得自行重新排序。对于行 [42::UInt64, "hi", NULL],两种类型是 StringUInt64,而且 "String" 排在 "UInt64" 之前,因此判别值分别为 0 = String、1 = UInt64、2 = NULL:
重建后:行 0 = UInt64 run[0] = 42;行 1 = String run[0] = "hi";行 2 = NULL。各类型的 run 在 wire 中的顺序与类型列表中的顺序相同 (StringUInt64 之前) 。

JSON (层级 2:FLATTENED Object)

这种 JSON 编码更丰富:它不像 Tier 1 那样将每个值都扁平化为文本,而是针对每个 JSON 路径将该列拆分为一个子列。要选择这种编码,需要在启用 flattened-serialization 标志 (output_format_native_use_flattened_dynamic_and_json_serialization = 1) 的同时,请求 Tier 1 回退 (output_format_native_write_json_as_string = 0) ;随后服务器会输出序列化版本 3 路径分为两种:
  • 类型化路径在类型字符串中声明,例如 JSON(a UInt32, b String),并按声明的类型解码。如果路径名称中包含点号,则会在类型字符串中用反引号括起。
  • 动态路径在运行时发现,并且每个都会被解码为一列 Dynamic
在 FLATTENED 模式下,没有共享数据列 (该溢出存储属于非扁平的 V2/V3 Object 编码) 。每条路径都是一个包含 num_rows 个值的普通列。
请注意这种两阶段形态:所有路径状态前缀都会先出现,然后才是所有路径数据。因此,动态路径的 Dynamic 前缀 (在前缀阶段) 会与其数据 (在数据阶段) 分开。状态前缀会在每个行数 > 0 的块开头读取,而每个路径列 (无论是类型化还是动态) 都恰好包含 num_rows 个值。行 r 的对象是通过读取每个路径中索引 r 处的值来组装的;如果某个动态路径在该行的 Dynamic 判别值为 NULL,则它不会提供任何键。
Malformed counts此处记录的 FLATTENED 布局中的 num_dynamic_paths —— 以及非扁平 V1/V2/V3 编码中的动态路径计数 —— 都是在路径名称之前从 stream 中读取的。(非扁平前缀中没有单独的 flattened-paths 字段:V1/V2/V3 只携带动态路径计数,另外 V1 还包含一个会被读取后丢弃的 max_dynamic_paths 值,以及下文所述的 V3 共享数据元数据。) 与 Dynamic 一样,decoder 必须将这些计数视为不可信,绝不能直接据此确定分配大小——无论是 SIZE_MAX 这一类计数,还是虽大但仍可表示的计数。ClickHouse 会逐个读取路径名称,并且只使用一个带上限的预分配 hint,因此损坏的计数会在两种情况下被拒绝:要么是在超过容器可容纳数量时以 INCORRECT_DATA (“JSON/Object column has too many paths”) 拒绝,要么是在 stream 中路径名称耗尽后作为普通读取错误拒绝。非扁平 V3 前缀还会额外携带一个 shared_data_buckets 计数 (当共享数据 serialization version 为 MAP_WITH_BUCKETSADVANCED 时存在) 。它会直接确定每个桶的 reader 状态和列向量的大小 (而不是通过按需增长循环) ,因此 decoder 必须预先拒绝不合理的 bucket 计数。与路径计数和类型计数不同,这个计数在 writer 端有严格的不变量:桶数取自较小的 MergeTree settings (object_shared_data_buckets_for_compact_part / object_shared_data_buckets_for_wide_part) ,这些配置值非零且上限为 256,因此唯一有效的线上传输范围是 1 … 256。ClickHouse 会将任何超出该范围的值——包括像 100000 这样虽可表示但很大的计数,它远低于容器的 max_size()——都以 INCORRECT_DATA 拒绝 (“JSON/Object column has an invalid number of shared data buckets”) 。
JSON{"a": 42, "b": "hi"} (一行,两个路径都是动态的) 。JSON 整数会被推断为 Int64

JSON 非扁平化 (V2/V3)

非扁平化的 Object 编码 (V1/V2/V3) 用于 MergeTree 的磁盘存储;当 flattened 标志关闭时,这也是服务器在线上传输时输出的格式——clickhouse-client / HTTP FORMAT Native (修订版 0) 使用 V1,原生 TCP 协议使用 V2。它们都带有一个 shared-data 列,但在本页的说明范围内。请注意,它们不会通过 Native 传输携带按路径划分的统计信息:NativeWriter 会保持 statistics 关闭,因此 Object 结构前缀中没有 statistics 部分,其后的字节直接就是 typed/dynamic/shared-data 的前缀和数据。Statistics 仅出现在启用了该功能的 MergeTree 磁盘路径上。若要使用本页内容解码 JSON 列,客户端必须选择本文档中说明的某个层级:将 output_format_native_write_json_as_string = 1 用于 String 回退,或将 output_format_native_use_flattened_dynamic_and_json_serialization = 1 (同时将 output_format_native_write_json_as_string = 0) 用于 FLATTENED Object 布局。

压缩帧

ClickHouse 可以使用内部帧格式压缩 Native stream 中的列数据。下面的帧布局传输方式无关——无论是 native TCP protocol 还是 HTTP,使用的都是相同的帧——但压缩的请求方式以及帧外层的封装内容会因传输方式而异。
  • Native TCP protocol。 可通过 Query packet 中的 compression flag 按查询选择是否启用压缩。启用后,每个 DataTotalsExtremesLogProfileEvents packet 的 body——即 table_name string 之后的字节——都会封装为这种帧格式。packet envelope 本身、packet-type code 以及 table_name string 不会被压缩;server 会将它们直接写入原始 stream。NativeWriter 输出的所有内容都会进入压缩 stream,因此 BlockInfo 前缀会成为帧内的第一个内容,并与 dimensions 和 columns 一同包含在内。因此,client 必须先解压该帧,才能读取 BlockInfo
  • HTTP。 SELECT ... FORMAT Native&compress=1 会将整个 FORMAT Native 字节流封装到相同的帧中 (server 使用同一个内部 CompressedWriteBuffer) ;而 ?decompress=1 则要求 Native input body 使用相同的帧,并通过对应的 CompressedReadBuffer 进行解码。在这一路径上,没有 TCP packet type、table_name 或 packet envelope:整个压缩载荷只是带帧的 Native 块 (只有在协商的 revision 大于 0 时,才会包含 BlockInfo 前缀,这一点与上文未压缩布局中的情况完全一致) 。这种内部 compress/decompress 帧机制不同于 HTTP 传输压缩 (Content-Encoding: gzip/zstd,由 enable_http_compression 启用) ;后者是在 HTTP 层对响应进行封装,并不是下文所述的帧格式。
因此,仅实现了未压缩 FORMAT Native 布局的 client,如要读取压缩后的 HTTP Native 响应,或发送 decompress=1 request body,仍然必须增加这一帧层。

帧格式

总帧大小为 16 + compressed_size = 16 + 9 + body_size = 25 + body_size。注意这里涉及两个范围:校验和覆盖 9 字节的头部加上数据体,而 compressed_size 计算的是头部加数据体,但包括校验和本身:

method 字节值

这三种编解码器是服务器在整个流的 Native 分帧中使用的:HTTP compress=1 输出始终使用 LZ4,而原生 TCP 协议则根据 network_compression_method 使用 LZ4ZSTDNONE。通用的 Native client 只需要生成和处理这几种编解码器。 method 字节还会编码 column-level codecs。这些编解码器是按列应用在 MergeTree 的 磁盘路径上,而不是用于整个流的分帧;但 decompress=1 的 HTTP 输入路径会从每个帧的method 字节中读取编解码器,因此这些字节都可能合法地出现在输入中。因此,符合规范的 解码器 必须识别整个已分配的取值空间,并在遇到尚未实现的字节时拒绝处理,而不是误读主体。它们的主体内容取决于具体编解码器,不属于这个通用帧约定的范围: 0x9c (ALP) 是一种用于 Float32Float64beta 无损编解码器 (自适应无损浮点压缩) 。只有在设置了 enable_alp_codec 设置时,才能使用 CODEC(ALP) 创建表;但在解压时始终接受该method 字节,以确保之前写入的数据仍然可读。0x9d (SZ3) 是一种 Experimental 的、有误差边界的有损编解码器,用于 Float32Float64 以及这些类型的 Array。只有在设置了 enable_sz3_codec 设置时,才能使用 CODEC(SZ3) 创建表;但在解压时始终接受该method 字节,以确保之前写入的数据仍然可读。0x9f (ZXC) 是一种 Experimental 的非对称 LZ 编解码器:压缩较慢,但解压非常快,压缩比介于 LZ4ZSTD 之间。只有在设置了 enable_zxc_codec 设置时,才能使用 CODEC(ZXC) 创建表;但在解压时始终接受该method 字节,以确保之前写入的数据仍然可读。字节 0x99 (DeflateQpl) 和 0x9b (ZSTD_QPL) 曾分配给现已移除的编解码器;它们现已保留,不会复用。 0x9e (Quantized) 是一种用于稠密向量列 (Array(Float32) 及类似类型) 的 Experimental 列编解码器。和 NONE 一样,它是直通的——全精度主体会原样存储——但它的存在会附加一种 serialization,用于写入一个紧凑的量化伴随 stream,以加速向量搜索。只有在设置了 enable_quantized_codec 设置时,才能使用 CODEC(Quantized(...)) 创建表,并且在解压时始终接受该method 字节。

校验和

ClickHouse 使用 CityHash v1.0.2 (历史版本) ,而不是现代的 Google CityHash;两者产生的输出不同。 校验和是对 9 个头部字节 (method + compressed_size + uncompressed_size) 以及 N 个数据体字节计算得出的——也就是校验和之后直到帧末尾之间的全部内容。16 字节的 CityHash128 输出中,前 8 个字节为低半部分 (LE) ,接下来的 8 个字节为高半部分 (LE) 。解码器会根据接收到的头部和数据体重新计算 CityHash128,并与开头的 16 个字节进行比对;如果不匹配,则说明数据已损坏,解码器会报错。

按块划分的边界

一个 Block 的压缩载荷是由一个或多个帧组成的流,不一定只有一个帧。发送方通过 CompressedWriteBuffer 写入序列化后的块;每当其内部缓冲区写满 (约 1 MB,即 DBMS_DEFAULT_BUFFER_SIZE) 时,就会输出一个帧,并在块刷写时输出最后一个帧。因此,小块对应一个帧;大块则对应多个连续帧。 这个不变式只单向成立:因为发送方会在每个块结束时刷写压缩缓冲区,所以每个块的结束都恰好落在某个帧边界上——但反过来并不成立。缓冲区如果在块的中间写满,产生的中间帧边界就位于块的中间,并不是块边界。因此,解码器必须使用块自身的维度 (num_columns/num_rows) 来判断块在哪里结束;不能假定每个帧都是一个完整的块。 接收方会以流式方式处理这些帧:先读取 16 + 9 字节,再精确读取 compressed_size - 9 字节的 body,将其解压为精确的 uncompressed_size 字节,并将这些字节交给块解码器;当解码器需要的数据超出当前帧所包含的内容时,再读取下一个帧。由于发送方是按块刷写的,因此一个块完全解码后,帧缓冲区就是空的,下一个块会从一个新的帧开始。 在原生 TCP protocol 中,数据包封套——packet-type VarUInt 和 table_name 字符串——写入的是原始流,位于压缩载荷之外;只有块体 (BlockInfo + 列) 会被分帧。HTTP compress/decompress 路径则没有这种封套:整个流都由分帧的块组成。

协商

在原生 TCP 协议中,压缩是按查询生效的,而不是按连接生效。Query 数据包中的 compression: bool 字段用于为该次查询请求压缩。服务器会遵从该请求,并在该查询的整个生命周期内输出经过压缩的 Data/Totals/Extremes/Log/ProfileEvents 主体 (Log/ProfileEvents 仅在 v54481+ 中支持) 。它还要求客户端的出站 Data 块——外部表、表示数据结束的空标记以及 INSERT 行——也采用相同的分帧方式。同一连接上的后续查询可以不同。 在 HTTP 中,没有 Query 数据包:compress=1 查询参数为该请求选择分帧输出,而 decompress=1 则声明请求体采用分帧格式。compress=1 的输出使用服务器默认的 codec (LZ4) 写入,而不是使用 network_compression_methoddecompress=1 读取器会从每个帧的 method 字节中获取 codec,因此输入时接受任意 codec。
启用压缩后,对于包含多于一行的块,服务器还可能通过并行块编组 / ColumnBLOB 路径 (PARALLEL_BLOCK_MARSHALLING,v54478) 处理列。对 INSERT 数据进行压缩的实现必须能够处理该路径 (或显式选择不使用该路径) ,以避免流不同步。

术语表

Block — Native format 中的数据交换单位。它是一个自描述的行块,采用列式存储。参见 块和列结构 BlockInfo — 在 TCP Data-packet 路径上位于 Block 之前的元数据头 (当 connection revision 大于 0 时写入) 。它是一组受 revision 控制、带有 field ID 标签的字段序列。Native output format 会省略它,因为该格式按 revision 0 序列化。参见 BlockInfo Column body — Column 中承载实际值的字节部分,位于列头 (name、type、has_custom_serialization 字节) 之后。其布局因类型而异。参见 列传输布局 Composite type — 由一个或多个内部类型构成的类型,每列编码为多个流。其传输格式稳定且无版本区分。参见 复合类型 Dictionary (LowCardinality)LowCardinality(T) 列通过整数索引引用的唯一值数组。参见 LowCardinality Empty blocknum_columns = 0num_rows = 0 的 Block。它用作哨兵:既是客户端的输入结束标记,也是服务端的流边界标记。参见 块变体 Header blocknum_columns > 0num_rows = 0 的 Block,由服务端作为查询响应的第一个 Data packet 发送,用于声明结果 schema。参见 块变体 Inner type — 复合类型所包装的类型。Array(UInt32) 的内部类型是 UInt32Nullable(T) 的内部类型是 T Offsets streamArrayMapNested 用于界定每行元素边界的累计结束位置 UInt64 数组。参见 Array Placeholder value — 在 Nullable(T) 列的 values 流中,写入空值位置的字节。解码器会读取这些字节以推进流,但会忽略其内容。参见 Nullable Result blocknum_rows > 0、承载实际查询结果行的 Block。参见 块变体 Schema block — header block 的同义词,用于描述 INSERT 阶段,此时 schema block 会告知客户端预期的列形态。 Serialization version — 版本化类型使用的按类型区分的传输版本号,用于声明后续编码采用哪种变体。它不同于协议版本。参见 序列化版本:概念 State prefix — 位于版本化类型每个块载荷之前的字节。它携带 serialization version,以及 (对于 LowCardinality) 每个块的字典元数据。在每个 rows > 0 的块开头输出;不会跨块保留。 Stream — 列体中的一段连续字节,用于编码一个逻辑子组件 (null-map、offsets 数组、values 流) 。多流类型会在每列中串联两个或多个流。
最后修改于 2026年8月28日