- native TCP protocol 中
Data、Totals、Extremes、Log和ProfileEvents数据包的 body (TableColumns数据包不是 Native 块——它承载的是两个二进制字符串,因此其布局应归入 native protocol spec) ; - 通过 HTTP 执行
SELECT ... FORMAT Native时的输出; - 使用
INTO OUTFILE ... FORMAT Native写出的文件导出内容; - 服务器间复制载荷。
概述
data 布局取决于其类型所属的 家族。这些家族按解码复杂度从低到高依次为:
- 固定宽度类型将
data排布为bytes_per_value × num_rows个原始字节,不带任何按行分帧。 - 复合类型 (
Nullable、Array、Tuple、Map、Nested) 具有一种可由类型字符串完全递归推导出的结构形态,没有版本前缀,也不存在跨块状态。 - 带版本 / 有状态类型 (
LowCardinality、JSON、Variant、Dynamic) 会在每个非空块开头带有序列化版本/状态前缀。在Native传输格式中,这个前缀以及任何字典都仅限于当前块——该格式不会携带跨块状态 (写入器会为每个块创建全新的序列化状态,并将low_cardinality_max_dictionary_size = 0) 。跨块状态是 MergeTree 磁盘存储层面的问题,不属于 Native 传输布局。
线传输基本类型
VarUInt
1;最后一个字节的续位为 0。
对值
300 进行编码:
0xAC 0x02 进行解码:
定宽整数
例如,UInt32 类型的值
1 编码为 01 00 00 00,Int32 类型的值 -1 编码为 FF FF FF FF。
String
0x00 字节,字符串也可以包含任意字节值,包括嵌入的 NUL。字符串 "ab" 编码为 02 61 62;解码时,先读取 VarUInt 长度 (2) ,再读取对应数量的字节。
Bool
0x00 表示 false;任何非零值均表示 true (标准形式为 0x01) 。
块和列的结构
块的线传输布局
BlockInfo 前缀是否存在取决于通道,因为写入器是按 修订版本 参数化的:
-
在 native TCP protocol 上,server 会按 connection 协商出的 修订版本 写入块 (这是一个较大的值——在此 release 中,
DBMS_TCP_PROTOCOL_VERSION为54485) 。只要该 修订版本 大于零,就会写入BlockInfo;而对于真实 connection,这一条件始终成立。每一列中的has_custom_serialization字节 (参见列 wire 布局) 会在 修订版本 为54454及以上时写入。 -
Nativeoutput format——即通过 HTTP 执行的SELECT ... FORMAT Native、INTO OUTFILE ... FORMAT Native,以及clickhouse-client生成的Nativeformat——默认按 修订版本0进行序列化。在 修订版本0下,BlockInfo前缀和has_custom_serialization字节都会被省略,因此一个块只包含num_columns、num_rows和各列。 对于 HTTP,这个 修订版本 不是固定的:client 可以通过?client_protocol_version=<n>查询参数提高它,而 server 会将该值作为响应的序列化 修订版本。 当该值足够高时,HTTP 输出也会包含BlockInfo前缀 (只要 修订版本 大于0就会写入) 以及has_custom_serialization字节 (在 修订版本54454及以上时写入) ,与 TCP 路径上的情况完全一致。因此,client 不应假定每个 HTTPFORMAT Native载荷都是 修订版本0。
BlockInfo 前缀开头的字节示例描述的是 TCP Data-packet 载荷。同一个查询若通过 FORMAT Native 输出,则会生成旁边所示的较短形式。
BlockInfo
0 结束。其传输格式不是自描述的:字段 ID 本身不编码其值的长度或类型,因此读取方必须预先知道它可能遇到的每个字段 ID 对应的类型。ClickHouse 自身的读取器会将无法识别的字段 ID 视为数据损坏,并抛出异常 (UNKNOWN_BLOCK_INFO_FIELD) 。前向兼容性则通过协议修订版本实现:只有在协商出的修订版本不低于某个字段的最小修订版本时,发送方才会写入该字段,因此旧版接收器不会看到它不认识的字段。
字段
1 和 2 的最小修订版本为 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 条目——该组合会表现为两个连续条目 SPARSE、DETACHED。如果解码器仍然对这些嵌套字节使用紧凑表,就会把 0x03/0x04 错误映射,并导致失步。
count 是完整的栈长度,包括每个栈开头的 DEFAULT 前导条目。紧凑编码已经覆盖了所有一项和两项的栈,因此 COMBINATION 的 count 始终至少为三。
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 流中背靠背写入:
- 偏移流 —— 一串
VarUInt。每个值v都属于以下两种情况之一:- 如果
v在位置 62 的高位未置位:(v & 0x3FFFFFFFFFFFFFFF)= 下一个显式非默认值之前的默认位置数。该非默认位置为cursor + group_size,其中cursor是当前游标位置;之后cursor前进group_size + 1。 - 如果
v的第 62 位被置位 (END_OF_GRANULE_FLAG) :清除该标志后的值 = 最后一个非默认值之后尾随的默认位置数。这标志着该块的偏移流结束。
- 如果
- 值流 —— 使用内部类型对
count个非默认值进行稠密编码,其中count是上面读取到的非 EOGVarUInt数量。
num_rows 个条目的稠密列 (整数和浮点数为 0,String 为 "",Date 为 0 天,等等) 。
稀疏 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) 时,列会经过以下三个步骤:
- 每个符合条件的列 (非
const、非Tuple,且所在块包含多于一行) 都会被包装为一个ColumnBLOB,其中保存了已在主线程之外完成编组和压缩的列。 DETACHED会被追加到该包装列的 kind 栈中。- 列
data会被写为一个VarUIntblob 大小,后面紧跟恰好这么多的 blob 字节。
{DEFAULT, SPARSE, DETACHED},序列化后即为 DETACHED_OVER_SPARSE。客户端在解码这类列时,会先读取 blob 的长度和字节,然后对 blob 解压,以恢复内部列的载荷 (参见压缩部分下的 ColumnBLOB 说明) 。
块变体
字节级示例
BlockInfo 前缀和 has_custom_serialization 字节。使用 FORMAT Native 时,相同的块会更短——如有必要,会给出其对应的简短形式。
一个空块 (包含 BlockInfo) ,总计 8 字节:
SELECT 1 的头部块会声明一个名为 "1"、类型为 UInt8 且包含零行的列。在协议版本 ≥ 54454 时,还会包含 has_custom_serialization 字节:
FORMAT Native (修订版本 0) 时,相同的结果块不包含 BlockInfo,也没有 has_custom_serialization 字节——SELECT 1 FORMAT Native 的大小为 11 字节:
FORMAT Native 完全不会产生任何字节:该输出格式不会输出空块。)
数据类型
data 中承载的各类类型在 wire 上的编码方式,并按解码器复杂度递增归为四个家族。有两种类型——AggregateFunction(func, ...) 和 QBit(T, N)——虽然是有效的 Native 列类型,但其载荷依赖具体函数或类型,不在本文讨论范围内;下文会在原本可能被误认为别名的地方专门说明它们。
定宽类型
M 行的列在传输时恰好占用 bytes_per_row × M 字节,连续拼接,中间没有分隔符或填充。
整数类型
UInt8–UInt256 和 Int8–Int256 都是整数值的直接二进制编码。解码器会读取 bytes_per_row × num_rows 个字节,并按该类型对其进行解析。
一个包含 [1, 256, 65536] 的 UInt32 列:
[-1, 42] 的 Int32 列:
Float32 和 Float64
binary32) 和 8 字节双精度 (binary64) ,均采用小端序。NaN、±Infinity、±0.0 和次正规数在往返转换后都能保持原样,无需规范化。
Float32 值 1.5 (0x3FC00000) :
Float64 值 1.5 (0x3FF8000000000000) :
BFloat16
Float32 的高 16 位——1 个符号位、8 个指数位、7 个尾数位。每个值占 2 字节,采用小端序,保存原始的 16 位模式。要还原其数值,可将该模式放入高 16 位、将低 16 位清零 (把 bits << 16 重新解释为 Float32) ,从而扩展回 Float32;扩展后的值也沿用 Float32 的文本格式。
BFloat16 值 1.5 (模式为 0x3FC0,即 Float32 0x3FC00000 的高半部分) :
Bool
UInt8 在线路格式上兼容:每行 1 字节,0x00 = false,0x01 = true。在线路协议中,类型字符串就是 Bool (而不是 UInt8) ,因此按类型字符串进行分派的解码器必须将其单独识别。
一个 Bool 列 [true, false, true]:
Date 和 Date32
1970-01-01 的整数天数。两者都不包含时间部分。
Date 值 1970-01-02 (1 天) :
Date32 值 1900-01-01 (-25567 天) :
DateTime
UInt32 在线路格式上兼容:表示一个以秒为单位的 Unix 时间戳,占 4 字节,采用小端序。该类型可能显示为 DateTime 或 DateTime('Timezone');时区仅影响显示,不属于线路值的一部分。对于同一时刻,两个带有不同 timezone 参数的 DateTime 列会生成完全相同的字节。解码器会去掉 (...) 参数后缀,并将该列按 UInt32 处理。
DateTime('UTC') 的值 2024-03-15 14:30:00 UTC (时间戳 1710513000) :
DateTime64(scale[, timezone])
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 秒) :
Time 和 Time64(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 字节,因为它们本质上就是普通的有符号整数。
Time 值 45296 (12:34:56) :
Time64(3) 值 45296789 刻度 (12:34:56.789) :
Time 和 Time64 属于 Experimental 功能,且需要在 server 上设置 allow_experimental_time_time64_type = 1。Interval
Interval<Unit> — IntervalSecond、IntervalMinute、IntervalHour、IntervalDay、IntervalWeek、IntervalMonth、IntervalQuarter、IntervalYear、IntervalNanosecond 等。所有单位都使用同一种 wire 编码:将计数存储为有符号的 8 字节小端序 Int64。单位仅体现在类型字符串中——它既不会改变 wire 字节,也不会改变文本表示形式,后者就是裸整数。所有单位都由同一套解码逻辑处理。
IntervalDay 的值 5:
UUID
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,其中字节通常按大端序书写。线上传输模型会取这 16 个规范字节,将其拆分为两个 8 字节半段,并将每个半段按小端序写入:
- 线上的字节 0..7 = 规范字节 0..7 反转后。
- 线上的字节 8..15 = 规范字节 8..15 反转后。
550e8400-e29b-41d4-a716-446655440000:
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:
u32 形式存储,便于进行算术运算和紧凑的范围查询;而 IPv6 则保留了大多数网络 API 常用的网络字节序布局。
Enum8 and Enum16
Int8 和 Int16 在线路格式上兼容:每行占 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)
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) :
Nullable 封装格式 (参见 Nullable) ;内部的三个字节是 Nothing 载荷,解码器会跳过它。
变长类型
String
String。String 列由 num_rows 个带长度前缀的字节序列构成:
0x00 字节。ClickHouse String 面向字节而非文本:不会强制验证 UTF-8 的有效性,而且一个值可以包含任意字节,包括嵌入的 NUL。面向 UTF-8 字符串类型的解码器,要么在读取时进行校验,要么向调用方暴露原始字节。该列占用的总字节数为所有行的 Σ (varuint_size(len_i) + len_i)。
包含 3 个字符串 ["ab", "", "c"] 的一列 (总计 6 字节) :
FixedString(N)
FixedString(N),其中 N 为正整数 (例如 FixedString(16)) 。该列恰好包含 N × num_rows 个原始字节,没有长度前缀,也没有分隔符。解码器从类型字符串中解析出 N,并为每一行读取对应数量的字节。
当 SQL 插入的值短于 N 字节时 (例如 CAST('abc' AS FixedString(5))) ,server 会在右侧用 NUL 字节 (0x00) 填充到声明的长度。这些填充字节属于存储值本身的一部分,并会按原样通过 wire 发送;是否去除这些字节由 client 端处理。与 String 类似,FixedString(N) 更接近字节数组而非文本——通常用于定宽标识符、地址字节或哈希摘要。
两个 FixedString(3) 值 ["abc", "de\0"] (共 6 字节) :
复合类型
- 每个 schema 的形态固定。 结构在解码时完全由类型字符串决定。
Array(UInt32)的 stream 布局始终一致,不会因块而异。 - 自身没有版本前缀。 复合包装器本身不会添加版本字节;它的帧结构 (offsets、null-map、元素流) 在各个 ClickHouse 发行版之间保持稳定。这只适用于 wrapper 本身——内部带版本的类型请参见下文的前缀阶段说明。
- 自身没有跨块状态。 包装器的帧结构在每个块中都是完全自描述的;任何跨块状态相关的问题都来自内部带版本的类型,而不是包装器本身。
SerializationArray 会在写入数组 offsets 之前运行其内部类型的前缀阶段,Tuple、Map、Nested 和 Nullable 也会通过各自的元素序列化执行相同操作 (Nullable 会在其 null map 之前运行内部前缀) 。
因此,当复合类型封装了带版本/有状态类型 (LowCardinality、Variant、Dynamic、JSON) 时,该内部类型的版本/状态前缀会最先写出,位于包装器的 offsets 和元素载荷之前。例如,Array(LowCardinality(String)) 的布局是 [LowCardinality state prefix] → [array offsets] → [flattened LowCardinality element payload],而不是先写 offsets。
如果解码器在执行内部前缀阶段之前就先读取 offsets,那么在处理任何包含 LowCardinality、Variant、Dynamic 或 JSON 的复合类型时都会失步。当所有内部类型都是普通叶子类型或其他不带版本的复合类型时,前缀阶段不会输出任何字节,此时下文关于“先 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) 可以出现在 Array、Tuple、Map 和 Nested 内部——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))。
传输布局由内部 prefix 阶段 (除非内部类型带版本信息,否则为空) 以及随后拼接在一起的两个流组成,其中 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 字节) :
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]。
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):
[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)。传输格式对这两种类型均不作限制——K 和 V 都可以是任何受支持的类型,包括复合类型。 (ClickHouse 在 SQL 层面对可接受的键类型所设的规则在不同发行版之间有所变化;请查阅目标服务器版本对应的 SQL 文档。)
其传输布局与 Array(Tuple(K, V)) 在字节级别完全相同,因此开头是内部的前缀阶段 (除非 K 或 V 带有版本信息,否则为空) :
total_pairs = offsets[num_rows - 1] (当 num_rows == 0 时则为 0) 。offsets 流的语义与 Array 相同。键和值按位置一一对应:第 i 对为 (keys[i], values[i])。
ClickHouse 中 Map 列的内存表示是元组数组;类型系统将其作为一种独立类型暴露出来,以便更方便地在 SQL 中使用 (m['key'], mapKeys, mapValues) 。传输格式就是这种存储形式的直接序列化,因此 Map 和 Array(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.field1、outer.field2 等) 。对于 format 层来说,这里没有任何变化——每个点分列都只是一个普通的 Array:
flatten_nested = 0。 当表以 flatten_nested = 0 创建时,该列在线上传输中表现为单个列,类型字符串为 Nested(name1 T1, name2 T2, ...);而在类型字符串之后,其布局与 Array(Tuple(T1, T2, ..., Tn)) 在字节级别完全一致——包括内部的前缀阶段。因此,任何带版本的字段 T_i 都会先输出其状态前缀,然后才是偏移量。下面的示例使用的是非版本化字段,因此前缀阶段为空:
Nested 会保留字段名 (a、b) ,而 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 个字节) :
类型别名
因此,
Point 列的解码方式与 Tuple(Float64, Float64) 完全相同 (显示为 (1,2)) ,Ring 则与 Array(Tuple(Float64, Float64)) 完全相同 ([(0,0),(1,1)]) ,更高层级的类型也以此类推。
Geometry 也是别名,但它别名到的不是嵌套数组,而是 Variant:它的载荷是上述六种地理类型组成的 Variant。列头只携带类型字符串 Geometry——不会显式展开为该 Variant——因此解码器必须自行展开。与任何 Variant 一样,判别值遵循这些地理别名按规范名称排序后的顺序:0 = LineString,1 = MultiLineString,2 = MultiPolygon,3 = Point,4 = Polygon,5 = Ring。随后,每个选中的值都会按其对应的地理别名进行解码 (NULL 使用 Variant 的 NULL 判别值 255) 。
SimpleAggregateFunction(func, T) 是其值类型 T 的别名。它存储的是已完成最终计算的聚合值,因此其传输形式和显示结果与 T 完全一致 (SimpleAggregateFunction(sum, UInt64) 会按 UInt64 解码) 。只有这种单值类型形式才是以这种方式实现的别名;其底层类型本身也可以是复合类型。
有两种相关类型不是别名。它们都是有效的
Native 列类型——例如,客户端可以从 -State 组合器或分布式聚合中接收到 AggregateFunction 列——但它们各自都带有专用载荷,不在本页讨论范围内:AggregateFunction(func, ...)保存的是中间聚合状态 (而非最终值) ;其二进制布局取决于具体聚合函数和版本。QBit(T, N)存储的是一个向量,其 bit planes 经过转置,用于向量搜索 workload。
版本化类型
Native 线格式中,前缀和任何字典都按块划分——这些类型不维护跨块状态 (见下文的按块前缀说明) ;跨块序列化状态只存在于 MergeTree 的磁盘流中。
与固定形态的复合类型相比,这些类型复杂得多,因此面向简单分析查询的客户端可以暂不处理它们。
序列化版本:概念
大多数带版本的类型都会在任何其他状态前缀数据之前,先将该版本写为小端序 UInt64;少数则使用 VarUInt 或 UInt8。解码器会先读取版本,并拒绝未知值——更高的版本意味着发送方使用了更新的格式,而解码器并不理解;如果误解析它,后续的每一个字节都会被破坏。
状态前缀会在每个行数大于零的块开头输出,紧接在该块的载荷之前。
Native 写入器和读取器不会在块之间保留序列化状态:
NativeWriter 会为其写入的每个非空列块创建一个新的 serialize state,并写入状态前缀;NativeReader 会为其读取的每个非空块创建一个新的 deserialize state,并读取它 (当 rows == 0 时,两者都会完全跳过此前缀) 。
因此,头部块 (rows = 0) 和空块都不会输出任何内容,解码器必须在每个非空块开头重新读取状态前缀。如果解码器只读取一次前缀,并把后续块都当作仅包含载荷,那么它会把下一个块的前缀当作数据读取,从而导致失步:
序列化版本参考
关于这张表,有几点值得注意:
- 这些值并不是连续的。
Dynamic使用1、2、3、4,其中V3是4,FLATTENED是3。数值更大并不一定表示版本更新。 - 某些值仅用于原生格式。
Object::STRING、Object::FLATTENED和Dynamic::FLATTENED的存在,是为了与未实现完整 Object/Dynamic 的客户端保持原生协议兼容。它们不会出现在 MergeTree 的磁盘存储中。 V3主要用于磁盘存储。 使用原生 TCP 协议的客户端通常看到的是FLATTENED(值为3) ,而不是V3(值为4) 。
LowCardinality(T)
N 个内部值的数据替换为一个仅含唯一值的小型字典,以及 N 个指向该字典的索引。
类型字符串:LowCardinality(InnerType)。示例:LowCardinality(String)、LowCardinality(FixedString(4))、LowCardinality(Nullable(String))。
sharedDictionariesWithAdditionalKeys;其他值均为保留值。
每个块的元数据 UInt64 是一个位字段:
对于典型的查询响应,如果每列只有一个数据块,则元数据为
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_count 是 5,而不是 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] = NULL、dict[1] = ""、dict[3] = "b",即 ["a", NULL, "", "b"]。dict[0] 和 dict[1] 在线路表示中都是空字节;是否为 NULL 取决于键是否指向槽位 0,而不是这些字节本身。
JSON (Tier 1:String 回退)
JSON 类型有多种线传输编码 (请参阅序列化版本参考) 。Tier 1 是最简单的一种:当为每个查询设置 output_format_native_write_json_as_string = 1 时,服务器会将每个 JSON 值展平为其序列化后的文本,并将该列作为带有状态前缀标记的 String 输出。
类型字符串:JSON。
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}' (一行) :
{"a":1},其中整数仍保留为整数。该文本只是一个 String 值,因此客户端接收到的只是用于不透明传输的 JSON,不会还原各个路径及其 ClickHouse 类型;若要实现忠实的按路径类型化,则需要使用下文的第 2 层编码。
Variant(T1, T2, …)
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 元素本身也是有状态的 (
LowCardinality、Variant、Dynamic、JSON) ,则会在按元素的状态前缀阶段输出各自的状态前缀,该阶段位于模式 UInt64 之后。叶子类型以及简单复合类型 (由叶子类型组成的 Array、Tuple、Map) 的状态前缀为空,因此可以自由组合。Variant(String, UInt64) 的值为 [42, 'hi', NULL] (规范顺序会将 String 排在 UInt64 前面,因此判别值 0 = String,1 = UInt64) :
42;行 1 = String run[0] = "hi";行 2 = NULL。
判别值流就是索引;每个非 NULL 判别值都会从其类型对应的稠密 run 中取出下一个值,而 255 (NULL) 则不消耗任何值。沿用同样的遍历过程也可以重建 Dynamic,二者唯一的区别只在于 NULL 的编码方式:
Dynamic
Variant 不同,类型集合不会出现在列的类型字符串中——它保存在状态前缀里。
类型字符串:Dynamic 或 Dynamic(max_types=N)。max_types 参数限制该列可跟踪的不同类型数量,但不会影响下方的传输格式。
Dynamic 有四种编码——V1 = 1、V2 = 2、FLATTENED = 3、V3 = 4。server 输出哪一种取决于通道和查询设置:
- 通过
clickhouse-client和 HTTPFORMAT Native时,writer 的 revision 为0(除非通过client_protocol_version提高) ,因此默认是 V1。 - 通过 native TCP protocol 使用协商后的 revision 时,默认是 V2。
Nativewriter 会保持 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。num_types 个类型再加上 NULL 槽建立索引的最小无符号整数:当 num_types ≤ 255 时为 UInt8,之后依次为 UInt16、UInt32、UInt64。NULL 对应的判别值值就是 num_types 本身,这与 Variant 不同;在 Variant 中,NULL 的固定值是 255。重建过程与 Variant 相同,都是采用同样的稠密遍历:为每种类型维护一个计数器,第 r 行的判别值为 d (≠ num_types) 时,其值取自类型 d 的序列中的 counter[d]。
对于每个行数 > 0 的块,都会在开头读取状态前缀 (版本 + 类型列表) ;头块和空块不会输出任何内容。
序列化为有状态的运行时类型 (
LowCardinality、Variant、Dynamic、JSON) 会在类型名称列表后携带嵌套的状态前缀。Variant 的规范化形式——常规 variant slot 按 DataTypeVariant (类型名称) 顺序写入,因此 wire 顺序并不遵循插入顺序。不过,它并不总是全局有序的:溢出到共享 variant 中的类型 (例如在 Dynamic(max_types=N) 下) 会按首次出现的顺序追加在常规 slot 之后,因此列表尾部可能会打破按类型名称排序的顺序。因此,解码器必须将传输的类型列表视为分配判别值的权威依据,不得自行重新排序。对于行 [42::UInt64, "hi", NULL],两种类型是 String 和 UInt64,而且 "String" 排在 "UInt64" 之前,因此判别值分别为 0 = String、1 = UInt64、2 = NULL:
42;行 1 = String run[0] = "hi";行 2 = NULL。各类型的 run 在 wire 中的顺序与类型列表中的顺序相同 (String 在 UInt64 之前) 。
JSON (层级 2:FLATTENED Object)
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。
num_rows 个值的普通列。
Dynamic 前缀 (在前缀阶段) 会与其数据 (在数据阶段) 分开。状态前缀会在每个行数 > 0 的块开头读取,而每个路径列 (无论是类型化还是动态) 都恰好包含 num_rows 个值。行 r 的对象是通过读取每个路径中索引 r 处的值来组装的;如果某个动态路径在该行的 Dynamic 判别值为 NULL,则它不会提供任何键。
JSON 值 {"a": 42, "b": "hi"} (一行,两个路径都是动态的) 。JSON 整数会被推断为 Int64:
JSON 非扁平化 (V2/V3)
Object 编码 (V1/V2/V3) 用于 MergeTree 的磁盘存储;当 flattened 标志关闭时,这也是服务器在线上传输时输出的格式——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 布局。
压缩帧
Native stream 中的列数据。下面的帧布局与传输方式无关——无论是 native TCP protocol 还是 HTTP,使用的都是相同的帧——但压缩的请求方式以及帧外层的封装内容会因传输方式而异。
- Native TCP protocol。 可通过 Query packet 中的
compressionflag 按查询选择是否启用压缩。启用后,每个Data、Totals、Extremes、Log和ProfileEventspacket 的 body——即table_namestring 之后的字节——都会封装为这种帧格式。packet envelope 本身、packet-type code 以及table_namestring 不会被压缩;server 会将它们直接写入原始 stream。NativeWriter输出的所有内容都会进入压缩 stream,因此BlockInfo前缀会成为帧内的第一个内容,并与 dimensions 和 columns 一同包含在内。因此,client 必须先解压该帧,才能读取BlockInfo。 - HTTP。
SELECT ... FORMAT Native&compress=1会将整个FORMAT Native字节流封装到相同的帧中 (server 使用同一个内部CompressedWriteBuffer) ;而?decompress=1则要求Nativeinput 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 计算的是头部加数据体,但不包括校验和本身:
方法字节值
校验和
按块划分的边界
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 路径则没有这种封套:整个流都由分帧的块组成。
协商
compression: bool 字段用于为该次查询请求压缩。服务器会遵从该请求,并在该查询的整个生命周期内输出经过压缩的 Data/Totals/Extremes/Log/ProfileEvents 主体 (Log/ProfileEvents 仅在 v54481+ 中支持) 。它还要求客户端的出站 Data 块——外部表、表示数据结束的空标记以及 INSERT 行——也采用相同的分帧方式。同一连接上的后续查询可以不同。
在 HTTP 中,没有 Query 数据包:compress=1 查询参数为该请求选择分帧输出,而 decompress=1 则声明请求体采用分帧格式。compress=1 的输出使用服务器默认的 codec (LZ4) 写入,而不是使用 network_compression_method;decompress=1 读取器会从每个帧的 method 字节中获取 codec,因此输入时接受任意 codec。
启用压缩后,对于包含多于一行的块,服务器还可能通过并行块编组 /
ColumnBLOB 路径 (PARALLEL_BLOCK_MARSHALLING,v54478) 处理列。对 INSERT 数据进行压缩的实现必须能够处理该路径 (或显式选择不使用该路径) ,以避免流不同步。术语表
Native output format 会省略它,因为该格式按 revision 0 序列化。参见 BlockInfo。
Column body — Column 中承载实际值的字节部分,位于列头 (name、type、has_custom_serialization 字节) 之后。其布局因类型而异。参见 列传输布局。
Composite type — 由一个或多个内部类型构成的类型,每列编码为多个流。其传输格式稳定且无版本区分。参见 复合类型。
Dictionary (LowCardinality) — LowCardinality(T) 列通过整数索引引用的唯一值数组。参见 LowCardinality。
Empty block — num_columns = 0 且 num_rows = 0 的 Block。它用作哨兵:既是客户端的输入结束标记,也是服务端的流边界标记。参见 块变体。
Header block — num_columns > 0 且 num_rows = 0 的 Block,由服务端作为查询响应的第一个 Data packet 发送,用于声明结果 schema。参见 块变体。
Inner type — 复合类型所包装的类型。Array(UInt32) 的内部类型是 UInt32;Nullable(T) 的内部类型是 T。
Offsets stream — Array、Map 和 Nested 用于界定每行元素边界的累计结束位置 UInt64 数组。参见 Array。
Placeholder value — 在 Nullable(T) 列的 values 流中,写入空值位置的字节。解码器会读取这些字节以推进流,但会忽略其内容。参见 Nullable。
Result block — num_rows > 0、承载实际查询结果行的 Block。参见 块变体。
Schema block — header block 的同义词,用于描述 INSERT 阶段,此时 schema block 会告知客户端预期的列形态。
Serialization version — 版本化类型使用的按类型区分的传输版本号,用于声明后续编码采用哪种变体。它不同于协议版本。参见 序列化版本:概念。
State prefix — 位于版本化类型每个块载荷之前的字节。它携带 serialization version,以及 (对于 LowCardinality) 每个块的字典元数据。在每个 rows > 0 的块开头输出;不会跨块保留。
Stream — 列体中的一段连续字节,用于编码一个逻辑子组件 (null-map、offsets 数组、values 流) 。多流类型会在每列中串联两个或多个流。