> ## Documentation Index
> Fetch the complete documentation index at: https://clickhouse.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ClickHouse Native 列式格式规范：传输基本类型、Block 与 Column 结构、各数据类型的编码，以及压缩帧

# Native 格式

Native 格式是 ClickHouse 用于传输表格数据的列式传输格式，主要用于以下场景：

* [原生 TCP 协议](https://clickhouse.com/docs/zh/resources/develop-contribute/native-protocol/protocol.md)中 `Data`、`Totals`、`Extremes`、`Log` 和 `ProfileEvents` 数据包的主体 (`TableColumns` 数据包**不是** Native 块，它携带的是两个二进制字符串，因此其布局在[原生协议规范](https://clickhouse.com/docs/zh/resources/develop-contribute/native-protocol/protocol.md)中说明) ；
* 通过 HTTP 执行 `SELECT ... FORMAT Native` 时的输出；
* 通过 `INTO OUTFILE ... FORMAT Native` 导出的文件；
* 服务器间的复制载荷。

本页介绍 Block (即列式载荷) 内部的字节结构，以及构成 Block 的各列类型编码。数据包帧信息、连接状态和版本协商请参阅[原生协议规范](https://clickhouse.com/docs/zh/resources/develop-contribute/native-protocol/protocol.md)。

所有多字节整数字段均采用小端序，有符号整数采用二进制补码表示。

> [!TIP]
>
> 如需了解面向用户的 `Native` 格式介绍 (含 `curl` 示例) ，请参阅 [Native 格式页面](https://clickhouse.com/docs/zh/reference/formats/Native.md)。本规范则是更底层的传输格式参考文档。

## 概述

线上传输中所有承载行数据的单元都是 **Block**：一种按列存储行的自描述数据块。第 1 列的所有值排在最前，随后是第 2 列的所有值，依此类推。Block 只包含查询所引用的列，绝不会包含整张表。

列的 `data` 按其类型所属的*家族*进行布局。按解码器复杂度从低到高，这些家族依次为：

![列式传输块的结构及其数据布局家族](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/block-structure.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=e4e067d9d428c13b9fce7fa7fe812828)

* **定宽**类型将 `data` 布局为 `bytes_per_value × num_rows` 个原始字节，不含逐行的帧信息。
* **复合**类型 (`Nullable`、`Array`、`Tuple`、`Map`、`Nested`) 的递归形态可完全由类型字符串推导得出，既没有版本前缀，也没有跨块状态。
* **版本化 / 有状态**类型 (`LowCardinality`、`JSON`、`Variant`、`Dynamic`) 在每个非空块的开头都带有序列化版本/状态前缀。在 `Native` 传输格式中，该前缀以及字典 (如有) 都是**按块**存在的——该格式不会*跨*块保留状态 (写入器会为每个块创建全新的序列化状态，并设置 `low_cardinality_max_dictionary_size = 0`) 。跨块状态属于 MergeTree 磁盘存储层面的问题，与 Native 传输布局无关。

## 传输基本类型

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

| 基本类型 | 大小 | 描述 |
| - | - | - |
| VarUInt | 1–10 B | LEB-128 变长无符号整数 |
| 定宽整数 | 1、2、4、8、16、32 B | 小端序；有符号整数采用二进制补码 |
| String | 可变 | 每个值由 VarUInt 长度前缀 + 原始字节组成；自修订版本 `54492` 起使用单独的 [offsets 流](#string-type) |
| Bool | 1 B | `0x00` = false，非零 = true |

### VarUInt

一种采用 LEB-128 编码的变长无符号整数。每个字节的第 0–6 位存放 7 个数据位，第 7 位为 1 个延续位。若后面还有字节，延续位为 `1`；最后一个字节的延续位为 `0`。

| 值范围 | 字节数 |
| - | - |
| 0 – 127 | 1 |
| 128 – 16383 | 2 |
| 16384 – 2097151 | 3 |
| 最大可达 UInt64 全范围 | 最多 10 |

对值 `300` 进行编码：

```text theme={null}
300 = 0b100101100

Byte 0: 0xAC = 0b10101100   (data: 0101100, continuation: 1)
Byte 1: 0x02 = 0b00000010   (data: 0000010, continuation: 0)
```

解码字节 `0xAC 0x02`：

```text theme={null}
Byte 0: data = 0x2C, continuation = 1 → accumulator = 0x2C, shift = 7
Byte 1: data = 0x02, continuation = 0 → accumulator = (0x02 << 7) | 0x2C = 300
```

### 定宽整数

| 类型 | 字节数 | 编码 |
| - | - | - |
| UInt8 | 1 | 原始字节 |
| UInt16 | 2 | 小端序 |
| UInt32 | 4 | 小端序 |
| UInt64 | 8 | 小端序 |
| UInt128 | 16 | 小端序 |
| UInt256 | 32 | 小端序 |
| Int8 | 1 | 原始字节，二进制补码 |
| Int16 | 2 | 小端序，二进制补码 |
| Int32 | 4 | 小端序，二进制补码 |
| Int64 | 8 | 小端序，二进制补码 |
| Int128 | 16 | 小端序，二进制补码 |
| Int256 | 32 | 小端序，二进制补码 |
| Float32 | 4 | IEEE 754 单精度，小端序 |
| Float64 | 8 | IEEE 754 双精度，小端序 |

例如，UInt32 值 `1` 编码为 `01 00 00 00`，Int32 值 `-1` 则编码为 `FF FF FF FF`。

### String

以长度为前缀的字节序列：

```text theme={null}
[VarUInt: byte_length] [byte_length bytes: raw value]
```

该字节序列不必是有效的 UTF-8。空字符串编码为单个 `0x00` 字节，且字符串可以包含任意字节值，包括内嵌的 NUL。例如，字符串 `"ab"` 编码为 `02 61 62`；解码时，先读取 VarUInt 长度 (`2`) ，再读取相应数量的字节。

### Bool

占用单个字节。`0x00` 表示 false；任何非零值均表示 true (规范值为 `0x01`) 。

## 块与列结构

### 块传输布局

```text theme={null}
[BlockInfo]               metadata (only on the TCP Data-packet path; see below)
[VarUInt: num_columns]    number of columns in this block
[VarUInt: num_rows]       number of rows in this block
[Column × num_columns]    column entries, omitted when num_columns = 0
```

是否存在 `BlockInfo` 前缀取决于所用的通道，因为写入器以*修订版本*作为参数 (完整说明请参见 [Protocol revision and the Native format](#protocol-revision)，其中也介绍了 `client_protocol_version` 仅作用于输出这一特性) ：

* 在**原生 TCP 协议**上，服务器按照连接协商所得的修订版本写入块 (这是一个较大的值——`DBMS_TCP_PROTOCOL_VERSION`，参见 `src/Core/ProtocolDefines.h`) 。只要该修订版本大于零，就会写入 `BlockInfo`；对于真实连接，这一条件始终成立。每个列中的 `has_custom_serialization` 字节 (参见[列的线协议布局](#column-wire-layout)) 仅在修订版本为 `54454` 及以上时写入。
* `Native` *输出格式*——即通过 HTTP 执行的 `SELECT ... FORMAT Native`、`INTO OUTFILE ... FORMAT Native`，以及 ClickHouse 客户端 (`clickhouse-client`) 生成的 `Native` 格式——*默认*以修订版本 `0` 进行序列化。在修订版本 `0` 下，`BlockInfo` 前缀和 `has_custom_serialization` 字节均会省略，因此一个块仅由 `num_columns`、`num_rows` 及各列组成。

  通过 HTTP 时，该修订版本并不固定：客户端可以通过 `?client_protocol_version=<n>` 查询参数调高该值，服务器会将其用作响应的序列化修订版本。

  当该值足够大时，HTTP 输出将包含 `BlockInfo` 前缀 (修订版本大于 `0` 时写入) 和 `has_custom_serialization` 字节 (修订版本为 `54454` 及以上时写入) ，与 TCP 路径完全相同。因此，客户端不应假定所有 HTTP `FORMAT Native` 载荷的修订版本都是 `0`。

换言之，本节中以 `BlockInfo` 前缀开头的字节示例描述的是 TCP Data 数据包的载荷；同一查询若通过 `FORMAT Native` 执行，则会生成与之并列展示的较短形式。

### BlockInfo

BlockInfo 由一系列字段组成，每个字段前面都有一个 VarUInt 类型的字段 ID，并以字段 ID `0` 结尾。该传输格式**不是**自描述的：字段 ID 不包含其值的长度或类型信息，因此读取器必须预先知道它可能遇到的每个字段 ID 对应的类型。ClickHouse 自身的读取器会将无法识别的字段 ID 视为数据损坏，并引发异常 (`UNKNOWN_BLOCK_INFO_FIELD`) 。前向兼容性则由协议修订版本来保证：只有当协商出的修订版本不低于某字段的最低修订版本时，发送方才会写入该字段，因此较旧的接收方永远不会遇到它无法识别的字段。

| 字段 ID | 字段 | 类型 | 最低修订版本 | 描述 |
| - | - | - | - | - |
| 1 | is\_overflows | UInt8 | 0 | 由 GROUP BY 产生的溢出块。非溢出块为 `0`。 |
| 2 | bucket\_number | Int32 | 0 | 聚合桶编号。未分桶的块为 `-1`。 |
| 3 | out\_of\_order\_buckets | Int32 列表 | 54480 | 分布式聚合过程中被延迟的桶。编码方式为一个 VarUInt 计数，后跟相应数量的 `Int32` 值。 |
| 0 | (终止符) | — | — | 标志 BlockInfo 结束。始终必需。 |

字段 `1` 和 `2` 的最低修订版本为 `0`，因此只要写入了 `BlockInfo`，这两个字段就一定存在。字段 `3` 仅在修订版本为 `54480` 及以上时才会写入。常见情况 (修订版本低于 `54480`) 下的传输布局如下：

```text theme={null}
[VarUInt: 1] [UInt8: is_overflows]
[VarUInt: 2] [Int32: bucket_number]
[VarUInt: 0]
```

### 列的传输格式布局

在一个 数据块 中，Column 会出现 `num_columns` 次。

| # | 字段 | 类型 | 条件 | 描述 |
| - | - | - | - | - |
| 1 | name | String | 始终 | 列名 |
| 2 | type | String *或* 二进制类型编码 | 始终 | 默认情况下为 ClickHouse 类型字符串 (例如 `"UInt64"`、`"Array(String)"`) ；当 `output_format_native_encode_types_in_binary_format = 1` 时为二进制类型编码，但次级查询除外 (参见下方说明) |
| 3 | has\_custom\_serialization | UInt8 | 特性 `CUSTOM_SERIALIZATION` (v54454) | `0` = 默认，`1` = 自定义 (其后紧跟 kind\_stack) |
| 4 | kind\_stack | bytes | 当字段 3 = `1` 时 | 一个 UInt8 枚举字节 (见下文) ，用于描述非默认序列化方式 (如稀疏序列化等) 。若取值为 `COMBINATION`，其后还跟随一个 VarUInt 计数，以及与该计数相同数量的附加 kind 字节。对于 `Tuple` (以及其他带有元素级序列化信息的复合类型) ，其载荷为递归结构——见下文。 |
| 5 | data | bytes | 始终 | 全部 `num_rows` 行的列值。各类型的布局参见[数据类型](#data-types)。稀疏列见下文。 |

解码器根据 `type` 字符串进行分派。类型字符串通常会在括号中携带参数；解码器先去掉 `(...)` 后缀以确定基本类型，再解析其中的参数，以确定大小、精度或内部类型。解析包含嵌套类型的参数列表 (例如 `Array` 中嵌套 `Tuple`) 时，需要使用能感知嵌套深度、跟踪括号层级的逗号拆分器，而不能简单地按 `,` 拆分。

> [!NOTE]
>
> **二进制类型编码**
>
> `type` 字段仅在默认模式下才是文本形式的 `String`。若启用查询设置 `output_format_native_encode_types_in_binary_format = 1`，该字段将改为**二进制类型编码**——即[数据类型二进制编码](https://clickhouse.com/docs/zh/reference/data-types/data-types-binary-encoding.md)中所述的基于标签的编码——同时，展平后的 `Dynamic` 类型列表中的各类型名称也采用同样的二进制编码。如果解码器总是将字段 2 按带长度前缀的字符串读取，就会把第一个二进制类型标签误当作字符串长度，导致数据流失去同步，因此解码器必须知道数据流采用的是哪种模式。
>
> 对于 `query_kind = SECONDARY_QUERY` 的查询 (由另一台服务器针对 `remote`、`Distributed` 或并行副本发送，或由 `clickhouse-client --query_kind secondary_query` 发送) ，通信双方在处理该查询的数据块时都会忽略 `output_format_native_encode_types_in_binary_format` 和 `input_format_native_decode_types_in_binary_format`，双向传输的均为文本形式的类型名称。早于该规则的服务器版本则会像对待其他查询一样，对次级查询应用这些设置。

![解码器根据类型描述路由到相应的编码家族](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/decoder-dispatch.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=b922e9bfee45e9c3f8d7e090bc5a31ea)

#### kind\_stack 与稀疏编码

`kind_stack` 字节以枚举值表示某列的非默认序列化方式：

| Byte | Name | 含义 | 对 `data` 传输格式的影响 |
| - | - | - | - |
| `0x00` | DEFAULT | 默认序列化 | 与 `has_custom = 0` 相同 |
| `0x01` | SPARSE | 稀疏序列化 (v54465+) | 偏移流 + 非默认值；见下文 |
| `0x02` | DETACHED | 由并行块封送 (parallel block marshalling) 包装在 `ColumnBLOB` 中的列 (v54478+) | 预先封送的 blob：`VarUInt size` + 相应数量的字节；见下文 |
| `0x03` | DETACHED\_OVER\_SPARSE | 包装在 `ColumnBLOB` 中的稀疏列 | 与 `DETACHED` 相同的 blob 载荷；见下文 |
| `0x04` | REPLICATED | 针对重复值的字典形式 (v54482+) | 索引流 + 稠密元素值；见下文 |
| `0x05` | COMBINATION | 多 kind 栈 | 后跟 VarUInt `count` 以及 `count` 个后续 kind 字节——见下方说明 |

**`COMBINATION` 载荷使用另一套枚举。** 上表前五行是*紧凑*的单字节编码。`COMBINATION` (`0x05`) 是一种通用转义，用于表示上述编码未涵盖的任意栈：其后跟随一个 `VarUInt` `count`，然后是 `count` 个单字节条目。这些条目**并非**上表中的紧凑编码，而是原始的 `ISerialization::Kind` 值：

| Byte | 嵌套 `Kind` |
| - | - |
| `0x00` | DEFAULT |
| `0x01` | SPARSE |
| `0x02` | DETACHED |
| `0x03` | REPLICATED |

这些字节值与紧凑编码不同：`REPLICATED` 在该嵌套枚举中为 `0x03`，而作为紧凑编码时为 `0x04`；此外也没有 `DETACHED_OVER_SPARSE` 条目——该组合以 `SPARSE`、`DETACHED` 两个连续条目表示。如果解码器在解析嵌套字节时仍沿用紧凑编码表，就会把 `0x03`/`0x04` 映射错误，导致解析失去同步。

`count` 是栈的完整长度，**包括每个栈开头的 `DEFAULT` 条目**。由于紧凑编码已涵盖所有包含一个或两个条目的栈，`COMBINATION` 的 `count` 始终不小于 3。

**Kind 栈的有效性。** 对于格式错误的栈，解码器会以 `INCORRECT_DATA` 拒绝，而不会按其描述构建序列化：

* 栈必须以 `DEFAULT` 开头；
* 栈必须是 `DEFAULT`、`SPARSE`、`REPLICATED`、`DETACHED` 的子序列——这也是各 kind 逐层包装的顺序。`SPARSE` 可以位于 `REPLICATED` 内部，反之则不行；`DETACHED` 始终位于最后，因为 `ColumnBLOB` 保存的是其下层所有内容的序列化形式。这也意味着同一 kind 不会出现两次，因此 `count` 不会超过 kind 的总数。

请注意，此顺序与上述 kind 字节值的顺序并不一致。其他任何栈所描述的列布局，既不会有写入器构建，也无法通过任何物化步骤解包还原为声明类型的普通列——例如，值列本身为稀疏或副本形式的 `ColumnSparse` 将始终保持包装状态。

**`Tuple` 列的递归 `kind_stack`。** 上述 `kind_stack` 载荷是单个列自身序列化信息对应的字节 (或 `COMBINATION` 序列) 。`Tuple` 携带一个 `SerializationInfoTuple`，它会先写入该 tuple *自身*的 kind 栈载荷，再按顺序为*每个*元素各写入一个完整的 kind 栈载荷；解码器按同样的递归结构读回。因此，对于 `Tuple(A, B, C)`，字段 4 的字节为 `[tuple_kind][A_kind][B_kind][C_kind]`；若某个元素本身又是复合类型，其元素载荷同样是递归的。只要 tuple 自身的信息*或任一元素*的信息为非默认，就会设置 `has_custom_serialization` 字节 (字段 3) ，因此即使 `Tuple` 中只有一个元素是稀疏或副本形式，也会触发 kind 栈载荷。如果解码器对 `Tuple` 只读取开头的单个枚举字节，就会过早停止，并把剩余的元素 kind 字节误读为列数据。

**稀疏传输格式。** 当 `kind_stack = 0x01` 时，列 `data` 由两个流组成，二者在同一个共享 TCP 流中依次连续写入：

1. **偏移流** — 由一系列 `VarUInt` 组成。每个值 `v` 有以下两种情况：
   * 第 62 位 (高位) 未置位的 `v`：`(v & 0x3FFFFFFFFFFFFFFF)` = 下一个显式非默认值之前的默认位置数量。该非默认位置为 `cursor + group_size`，其中 `cursor` 为当前累计位置；之后 `cursor` 前进 `group_size + 1`。
   * 第 62 位已置位 (`END_OF_GRANULE_FLAG`) 的 `v`：清除该标志后的值 = 最后一个非默认值之后的尾随默认位置数量。该值标志着当前数据块偏移流的结束。
2. **值流** — `count` 个以内部类型紧凑编码的非默认值，其中 `count` 为上面读取到的非 EOG VarUInt 的数量。

解码器在重建包含 `num_rows` 个条目的稠密列时，会用内部类型的默认值填充所有非显式位置 (整数和浮点数为 `0`，`String` 为 `""`，`Date` 为 `0` 天，依此类推) 。

稀疏 `Nullable(T)` 列属于特殊情况，因为 `Nullable(T)` 的默认值是 **NULL**。稀疏编码会完全省略常规的 `Nullable` 空值映射流：偏移流标识非默认 (即非 NULL) 位置，值流仅以 `T` 类型紧凑存储这些非 NULL 值，所有非显式位置则重建为 NULL。因此，解码器*不得*在值流中查找空值映射，也*不得*用实际存在的 `0` 填充空缺，而应填充 NULL。

**Replicated 传输格式。** 当 `kind_stack = 0x04` 时，列的 `data` 是一个字典：由一组互不相同的元素值，以及每行指向该列表的索引组成 (与 `LowCardinality` 的查找结构相同) 。如果内部类型本身是版本化类型 (例如 `LowCardinality(T)`) ，则其状态前缀会**先于**索引流写入：Replicated 序列化在写入 `num_rows` 之前，会将前缀阶段交由内部类型处理。前缀为空的内部类型 (叶子类型和普通复合类型) 在此处不写入任何字节。

```text theme={null}
[inner type's state prefix]              empty for leaf inners; e.g. LowCardinality version (Int64 = 1)
[VarUInt num_rows]
[UInt8  size_of_indexes_type]            width of each index: 1, 2, 4, or 8 bytes
[indexes: num_rows × size_of_indexes_type bytes]
[VarUInt num_elements]
[elements: num_elements dense inner-type values]
```

解码器为每个输出行 `i` 选取 `elements[indexes[i]]`，以此重建稠密列。复合内部类型会递归处理：先按内部类型物化元素列表，再按索引取值。已支持的内部类型包括各叶元素类型、`Nullable(T)`、`Array(T)`、`Tuple(...)`、`Map(K, V)`、`Nested(...)` (每个字段均按 `Array` 方式展开) 以及 `LowCardinality(T)` (保留共享字典，仅对每个元素的键进行索引) 。

**Detached 传输格式。** `DETACHED` (`0x02`) 和 `DETACHED_OVER_SPARSE` (`0x03`) *确实*会出现在网络传输中，并非仅供内部使用。在 TCP 路径上，如果启用了压缩，且协商的修订版本不低于 `DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING` (v54478) ，列将经过以下三个步骤：

1. 每个符合条件的列 (非 `const`、非 `Tuple`，且所在块的行数大于 1) 会被包装到一个 `ColumnBLOB` 中，其中保存的是已在主线程之外完成编组和压缩的列。
2. 将 `DETACHED` 追加到被包装列的 kind stack 中。
3. 列的 `data` 先写入一个 `VarUInt` 类型的 blob 大小，随后紧跟相同字节数的 blob 数据。

如果被包装的列是稀疏列，则其 stack 为 `{DEFAULT, SPARSE, DETACHED}`，序列化后即为 `DETACHED_OVER_SPARSE`。客户端解码此类列时，先读取 blob 长度和字节，再解压该 blob，以恢复内部列载荷 (参见压缩部分中的 [`ColumnBLOB` 说明](#compression-negotiation)) 。

`DETACHED` 具有方向性：它只能出现在服务器发送的结果块中，且只能作用于整个顶层列——绝不能作为 `Tuple` 的某个元素 kind 出现。与其他 kind 不同，它会使运行时列变为 `ColumnBLOB`，而非所声明类型的列，因此只有当接收方自身的管道能够将该 blob 还原时，接收方才会接受它。正因如此，对于客户端发送的任何块 (无论是外部表数据、标量还是插入数据) ，只要其中包含 `DETACHED`，服务器都会以 `INCORRECT_DATA` 拒绝。

### Block 变体

所有 Data 家族的数据包都使用相同的 Block 传输格式。各变体之间的区别仅在于列数和行数：

| 变体 | num\_columns | num\_rows | 用途 |
| - | - | - | - |
| 头部块 | N > 0 | 0 | 声明结果 schema (列名 + 类型) 。 |
| 结果块 | N > 0 | M > 0 | 实际的结果行。 |
| 空块 | 0 | 0 | 哨兵标记：在客户端表示输入结束，在服务器端用作边界标记。 |

### 字节级示例

本节中的所有示例均取自 **TCP Data 数据包路径**，因此都包含 `BlockInfo` 前缀和 `has_custom_serialization` 字节。若通过 `FORMAT Native` 传输，同样的块会更短——必要时会一并给出对应的等效简短形式。

一个空块 (含 BlockInfo) ，共 8 字节：

```text theme={null}
01 00                   BlockInfo: field_id=1, is_overflows=0
02 FF FF FF FF          BlockInfo: field_id=2, bucket_number=-1
00                      BlockInfo terminator
00                      num_columns = 0
00                      num_rows = 0
```

`SELECT 1` 的头部块声明了一个名为 `"1"`、类型为 `UInt8` 的列，行数为零。当协议版本 ≥ 54454 时，还会包含 `has_custom_serialization` 字节：

```text theme={null}
01 00                   BlockInfo: is_overflows = 0
02 FF FF FF FF          BlockInfo: bucket_number = -1
00                      BlockInfo terminator
01                      num_columns = 1
00                      num_rows = 0
01 "1"                  Column[0].name = "1"
05 "UInt8"              Column[0].type = "UInt8"
00                      Column[0].has_custom_serialization = 0
                        Column[0].data: no bytes (num_rows = 0)
```

同一查询的结果块 (包含一行) ：

```text theme={null}
01 00                   BlockInfo: is_overflows = 0
02 FF FF FF FF          BlockInfo: bucket_number = -1
00                      BlockInfo terminator
01                      num_columns = 1
01                      num_rows = 1
01 "1"                  Column[0].name = "1"
05 "UInt8"              Column[0].type = "UInt8"
00                      Column[0].has_custom_serialization = 0
01                      Column[0].data: one UInt8 byte = 1
```

通过 `FORMAT Native` (修订版本 `0`) 输出时，同一个结果块既没有 `BlockInfo`，也没有 `has_custom_serialization` 字节——`SELECT 1 FORMAT Native` 共 11 个字节：

```text theme={null}
01                      num_columns = 1
01                      num_rows = 1
01 "1"                  Column[0].name = "1"
05 "UInt8"              Column[0].type = "UInt8"
01                      Column[0].data: one UInt8 byte = 1
```

(零行结果 (例如仅含表头的块) 通过 `FORMAT Native` 输出时不会产生任何字节：该输出格式不会输出空块。)

## 协议修订版本与 Native 格式

决定 Native 字节流结构的首要因素，是其写入器和读取器所使用的**协议修订版本**。修订版本本身并不出现在字节中——线上传输的数据里没有修订版本字段——但它仍决定着若干特性是否出现。因此，解码器必须先知道载荷是以哪个修订版本写入的，才能对其进行解析。既然修订版本不在流中，读取器和写入器就必须通过其他方式约定好它。

它是一个 `UInt64`，`NativeWriter` 和 `NativeReader` 都通过构造函数参数接收它。写入器将其称为 `client_revision`，读取器将其称为 `server_revision`，但二者是同一个数值。当前版本所支持的最新修订版本为 `DBMS_TCP_PROTOCOL_VERSION` (参见 `src/Core/ProtocolDefines.h`) 。

### 修订版本控制哪些特性

每个特性都受一个 `DBMS_MIN_REVISION_WITH_*` 阈值控制。写入器仅在其修订版本达到该阈值时才输出该特性，读取器也按完全相同的规则查找该特性，因此两者始终保持同步——只要任何一方的修订版本设置有误，两者就会失去同步。与 Native 格式 相关的控制项如下：

| 特性 | 阈值常量 | 修订版本 | 低于阈值时的效果 |
| - | - | - | - |
| `BlockInfo` 前缀 | (任意 `> 0` 的值) | `1` | 完全省略 [`BlockInfo`](#blockinfo) 前缀；块仅由 `num_columns`、`num_rows` 和各列组成。 |
| `has_custom_serialization` 字节 | `DBMS_MIN_REVISION_WITH_CUSTOM_SERIALIZATION` | `54454` | 省略每列的 [`has_custom_serialization`](#column-wire-layout) 字节；所有列均使用默认序列化 (不使用稀疏、副本形式或分离形式) 。 |
| 线上传输中的 `LowCardinality` | `DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE` | `54405` | 特殊情况——**不**遵循简单的“低于阈值即生效”规则。仅当修订版本*非零*且低于 `54405`，或另行强制剥离时，`LowCardinality(T)` 才会被剥离为基本类型 `T`。修订版本为 `0` 时会保留该类型。参见下方说明。 |
| V2 `Dynamic` / `JSON` 序列化 | `DBMS_MIN_REVISION_WITH_V2_DYNAMIC_AND_JSON_SERIALIZATION` | `54473` | `Dynamic` 和 `JSON`/`Object` 使用 V1 序列化 (带 `max_dynamic_*` 参数) ，而非 V2。 |
| 偏移量 `String` 序列化 | `DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION` | `54492` | 低于阈值时，`String` 列数据使用逐值布局 (每行一个 `VarUInt` 长度前缀 + 原始字节) ，而非[偏移量布局](#string-type) (先是以 `UInt64` 表示的累计字节偏移量，然后是拼接在一起的全部数据) 。该控制完全取决于修订版本，没有逐列的线上传输标记。达到或高于阈值时，偏移量布局同样适用于嵌套在复合类型 (`Array`、`Nullable`、`Map`、`Tuple`、`Variant`、`Dynamic`、`JSON`) 中的 `String`，但**不**适用于 `LowCardinality` 列的字典：`LowCardinality` 字典始终以默认嵌套序列化写入，因此其 `String` 值仍采用逐值布局 (包括 `LowCardinality(String)` 和 `LowCardinality(Nullable(String))`) 。`Buffers` 格式不属于协议的一部分，始终使用逐值布局。 |
| 聚合函数版本控制 | `DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING` | `54452` | 写入 `AggregateFunction` 状态时不内嵌版本号。 |
| `quantileDeterministic` 状态中的 `skip_degree` | `DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE` | `54491` | `AggregateFunction(quantileDeterministic, ...)` 状态 (以及同一函数的 `quantiles`/`median` 写法) 以状态版本 `0` 写入，不含末尾的 `UInt8 skip_degree`。参见[类型别名](#type-aliases)下的说明。 |
| `uniq` 状态中的 64 位哈希样本 | `DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1` | `54493` | `AggregateFunction(uniq, ...)` 状态以状态版本 `0` 写入：既没有开头的标志字节，也没有末尾的 64 位哈希样本。参见[类型别名](#type-aliases)下的说明。 |
| `BlockInfo` 中的 `out_of_order_buckets` | `DBMS_MIN_REVISION_WITH_OUT_OF_ORDER_BUCKETS_IN_AGGREGATION` | `54480` | 不写入 `BlockInfo` 字段 ID `3` (参见 [BlockInfo](#blockinfo)) 。 |
| 并行块封送 (`DETACHED`) | `DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING` | `54478` | 列不会被包装在 `ColumnBLOB` 中；也不会出现 `DETACHED` / `DETACHED_OVER_SPARSE` 类别 (参见 [kind\_stack](#kind-stack-and-sparse-encoding)) 。 |
| `DateTime(tz)` 类型参数 | `DBMS_MIN_REVISION_WITH_TIME_ZONE_PARAMETER_IN_DATETIME_DATA_TYPE` | `54337` | 时区参数会从 `type` 字符串中去除——`DateTime('UTC')` 仅声明为 `DateTime`。 |

因此，对几乎所有内容而言，修订版本 `0` 都是最保守的编码方式：流中没有 `BlockInfo`，没有 `has_custom_serialization` 字节，`Dynamic`/`JSON` 使用 V1，没有聚合函数版本，`DateTime` 以裸类型出现且去掉了时区参数。聚合函数*状态*版本是修订版本 `0` 下的例外：此时没有可据以推导版本的对端——流由其生成方自行写入并读回 (例如 `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)` **不会**被剥离。其类型字符串和每个块的状态前缀都会保留在流中，修订版本 `0` 的读取器可直接将其读回。只有当修订版本非零且低于 `54405`，或者无论修订版本如何都强制剥离时，才会执行剥离。

强制剥离由 `remove_low_cardinality` 标志控制。`FORMAT Native` 输出从不设置该标志，而原生 TCP 路径会在 `low_cardinality_allow_in_native_format = 0` (默认值为 `1`) 时设置它。也就是说，该设置会影响原生 TCP 输出，但对 `FORMAT Native` 不起作用。

实际结论：默认的 `FORMAT Native` 流完全可能包含 `LowCardinality`，因此不要认为该特性在修订版本 `0` 下不存在。

### 修订版本从何而来：取决于数据的传输方式

同样的 Native 字节可以经由不同途径传输：原生 TCP 协议、HTTP 请求，或磁盘上的文件。每种途径设置修订版本的方式各不相同。需要注意的是：读取端和写入端是分别配置的，因此二者最终可能使用不同的修订版本。

#### 原生 TCP 协议：协商确定，双向生效

在[原生 TCP 协议](https://clickhouse.com/docs/zh/resources/develop-contribute/native-protocol/protocol.md)中，修订版本通过 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 Native`、`INTO OUTFILE ... FORMAT Native`，以及 `clickhouse-client` 写出的 `Native` 输出；在这些情况下，输出工厂都会将 `FormatSettings::client_protocol_version` 直接传给 `NativeWriter`，由它决定 `BlockInfo` / `has_custom_serialization` 帧信息*以及* offsets `String` 布局。因此，如果通过 HTTP 执行 `SELECT ... FORMAT Native` 时提高了 `client_protocol_version` (≥ `54492`) ，就会写出 offsets 布局；而默认修订版本 `0` 则保留可移植的逐值布局。

不过在 HTTP 上，默认值并不是最终结果。客户端可以通过 `?client_protocol_version=<n>` 查询参数提高该值，HTTP handler 会将其视为保留参数而非 SQL 设置：该参数会写入查询上下文，再由格式层复制到 `FormatSettings` 中。只要该值足够高，HTTP `FORMAT Native` 输出就会开始包含 `BlockInfo` 前缀和 `has_custom_serialization` 字节，与 TCP 路径完全相同——因此不要假定 HTTP `FORMAT Native` 载荷一定是修订版本 `0`。

由于该参数作用于整个查询上下文，它会影响为该请求构建的所有 `NativeWriter`，而不仅仅是结果流。特别是，如果通过 HTTP 执行服务器端写入 (例如 `INSERT INTO FUNCTION file('x.native', 'Native', ...) SELECT ...`) 时提高了 `?client_protocol_version=`，文件就会以该修订版本写出。这一行为由来已久 (早于 String offsets 序列化的引入，同样适用于 `BlockInfo` 和 `has_custom_serialization`) ，而且是不对称的：读取端 (见下文) 不会以同样的方式提高修订版本，因此这样写出的文件无法通过 `file(...)` 读回。如果请求写入的数据之后还需要再次读取，请不要在该请求中设置 `client_protocol_version`。`INTO OUTFILE` 和本地 `clickhouse-client` 输出没有此类开关，始终保持为 `0`。

#### `FORMAT Native` 输入 — 始终为修订版本 0

`Native` *输入*格式始终以修订版本 **`0`** 构建其 `NativeReader`。无论是解析 `INSERT ... FORMAT Native` 的请求正文、读取 `Native` 文件，还是推断 schema，它都不会预期存在 `BlockInfo` 前缀，也不会读取 `has_custom_serialization` 字节，并且始终采用默认序列化。`?client_protocol_version=<n>` 不会提高输入端的修订版本 (只有输出端会读取该参数) 。

由于输入修订版本始终为 `0`，输入格式始终按逐值 `String` 布局读取；以 offsets 布局写入的 stream (无论是通过原生 TCP 协议写入，还是通过提高了 `client_protocol_version` 的 `SELECT ... FORMAT Native` 写入) 都无法通过 `FORMAT Native` 输入读回。这与 `BlockInfo` 帧信息的不对称情况相同。

### 往返读写的影响

对于 `FORMAT Native`，两端的默认修订版本均为 `0`。由 `SELECT ... FORMAT Native` 以修订版本 `0` 写出的数据，可以直接通过 `INSERT ... FORMAT Native` 读回，不会出现任何意外。

如果以提高后的输出修订版本 (在 `SELECT` 上指定 `?client_protocol_version=<n>`) 生成数据流，该数据流会携带 `BlockInfo` 和 `has_custom_serialization` 帧信息，并采用 offsets `String` 布局；而输入格式始终处于修订版本 `0`，这些内容都无法读回。因此，只有当消费者使用原生 TCP 协议时，提高 `client_protocol_version` 才有意义；通过 HTTP 进行 `FORMAT Native` 往返读写时则不应这样做。

文件属于非对称情况。在服务器端的 `INSERT INTO FUNCTION file(...)` (或 `s3`、`url`) 上提高 `?client_protocol_version=`，会以该修订版本*写入*文件，但 `file(...)` *读取*时始终以修订版本 `0` 解析——因此这样的文件无法往返读写。解决办法有两种：一是在写入请求中不指定 `client_protocol_version`，使文件保持为普通的修订版本 `0`；二是改用原生 TCP 协议传输数据，此时每个方向都使用握手协商得到的修订版本。

| 通道 | 写入修订版本 | 读取修订版本 | `BlockInfo` / 自定义序列化 |
| - | - | - | - |
| 原生 TCP Data 数据包 | 对端声明的修订版本 (按方向) | 对端声明的修订版本 (按方向) | 修订版本 `> 0` 时包含 `BlockInfo`；`≥ 54454` 时包含 `has_custom_serialization` |
| 通过 HTTP 的 `SELECT ... FORMAT Native` | `client_protocol_version` (默认 `0`) | 不适用 | 仅在提高 `client_protocol_version` 时包含 |
| 通过 HTTP 的 `INSERT ... FORMAT Native` (请求正文) | 不适用 | `0` (始终) | 无帧信息；`String` 始终逐值编码 |
| 通过 HTTP 的 `INSERT INTO FUNCTION file`/`s3`/`url(..., 'Native')` | `client_protocol_version` (默认 `0`) | `0` (读取时始终如此) | 仅在提高 `client_protocol_version` 时于写入中包含——此类文件无法读回 |
| `INTO OUTFILE` / 本地 `clickhouse-client` `FORMAT Native` | `0` | `0` | 无 (但保留 `LowCardinality`——参见上文说明) |

> [!NOTE]
>
> **协议修订版本与序列化版本**
>
> 请勿将协议修订版本与[序列化版本](#serialization-version-concept)混淆。此处的修订版本作用于整个连接或请求，且从不出现在字节数据中。序列化版本则按列区分，由[版本化类型](#versioned-types)携带，并写入每个非空块中。修订版本决定某项特性是否存在；而在版本化列内部，序列化版本决定该类型后续采用哪种编码变体。

## 数据类型

本节介绍 Native 格式可在列的 `data` 中承载的各类型的传输编码，并按解码器复杂度由低到高将其分为四个家族。其中 `AggregateFunction(func, ...)` 和 `QBit(T, N[, stride])` 这两种类型虽然是有效的 `Native` 列类型，但其载荷取决于具体的函数或类型，不在本文讨论范围内；下文会在它们容易被误认为别名的地方专门加以说明。

| 家族 | 章节 | 每列的流数量 | 跨块状态 |
| - | - | - | - |
| 定长 | [定长类型](#fixed-width-types) | 一个 | 无 |
| 变长 | [变长类型](#variable-length-types) | 一个 | 无 |
| 复合 (固定形态) | [复合类型](#composite-types) | 多个 | 无 |
| 版本化 / 有状态 | [版本化类型](#versioned-types) | 多个 | 在 Native 传输层面无跨块状态——每个块各自携带状态前缀，且逐块重新开始 |

### 定长类型

每个值占用固定数量的字节。包含 `M` 行的列在线上传输时恰好占用 `bytes_per_row × M` 字节，各值依次紧密拼接，中间没有分隔符，也没有填充。

| 类型字符串 | 每个值的字节数 | 逻辑值 | 传输编码 |
| - | - | - | - |
| `UInt8` | 1 | 无符号 8 位整数 | 原始字节 |
| `UInt16` | 2 | 无符号 16 位整数 | 小端序 |
| `UInt32` | 4 | 无符号 32 位整数 | 小端序 |
| `UInt64` | 8 | 无符号 64 位整数 | 小端序 |
| `UInt128` | 16 | 无符号 128 位整数 | 小端序 |
| `UInt256` | 32 | 无符号 256 位整数 | 小端序 |
| `Int8` | 1 | 有符号 8 位整数，二进制补码 | 原始字节 |
| `Int16` | 2 | 有符号 16 位整数，二进制补码 | 小端序 |
| `Int32` | 4 | 有符号 32 位整数，二进制补码 | 小端序 |
| `Int64` | 8 | 有符号 64 位整数，二进制补码 | 小端序 |
| `Int128` | 16 | 有符号 128 位整数，二进制补码 | 小端序 |
| `Int256` | 32 | 有符号 256 位整数，二进制补码 | 小端序 |
| `Float32` | 4 | IEEE 754 单精度 | 小端序 |
| `Float64` | 8 | IEEE 754 双精度 | 小端序 |
| `BFloat16` | 2 | IEEE 754 `Float32` 的高 16 位 | 小端序 |
| `Bool` | 1 | `0x00` = false，`0x01` = true | 原始字节 |
| `Date` | 2 | 自 `1970-01-01` 起的天数 | 小端序 UInt16 |
| `Date32` | 4 | 自 `1970-01-01` 起的天数 (有符号，可表示 1970 年之前的日期) | 小端序 Int32 |
| `DateTime` | 4 | 以秒为单位的 Unix 时间戳 | 小端序 UInt32 |
| `DateTime(tz)` | 4 | 与 `DateTime` 相同；时区仅作为元数据 | 小端序 UInt32 |
| `DateTime64(s)` | 8 | 标度为 `s` 的 tick 数 (自纪元起经过的 10^-s 秒数) | 小端序 Int64 |
| `DateTime64(s, tz)` | 8 | 与 `DateTime64(s)` 相同；时区仅作为元数据 | 小端序 Int64 |
| `Time` | 4 | 以秒为单位的有符号时钟时长 | 小端序 Int32 |
| `Time64(s)` | 8 | 以标度 `s` 的 tick 为单位的有符号时钟时长 | 小端序 Int64 |
| `Interval<Unit>` | 8 | 有符号计数；单位记录在类型字符串中 | 小端序 Int64 |
| `UUID` | 16 | 128 位标识符 | 拆分为两个小端序 UInt64 半部分，分别进行字节交换 (参见 [UUID](#uuid)) |
| `IPv4` | 4 | IPv4 地址 | 小端序 UInt32 |
| `IPv6` | 16 | IPv6 地址 | 网络字节序，不做字节交换 |
| `Enum8` | 1 | 有符号 8 位整数 (枚举值索引) | 原始字节 |
| `Enum16` | 2 | 有符号 16 位整数 (枚举值索引) | 小端序 |
| `Decimal(P, S)` | 4 / 8 / 16 / 32 | 以有符号整数存储的 `value × 10^S`；宽度取决于 P (≤9 → 4 B，≤18 → 8 B，≤38 → 16 B，≤76 → 32 B) | 小端序有符号整数 |

#### 整数类型

`UInt8`–`UInt256` 和 `Int8`–`Int256` 直接以二进制形式编码整数值。解码器读取 `bytes_per_row × num_rows` 个字节，并按照对应类型进行解析。

例如，一个存储 `[1, 256, 65536]` 的 `UInt32` 列：

```text theme={null}
01 00 00 00              row 0: 1
00 01 00 00              row 1: 256
00 00 01 00              row 2: 65536
```

一个存储 `[-1, 42]` 的 `Int32` 列：

```text theme={null}
FF FF FF FF              row 0: -1
2A 00 00 00              row 1: 42
```

#### Float32 和 Float64

标准 IEEE 754 二进制浮点数：4 字节单精度 (`binary32`) 和 8 字节双精度 (`binary64`) ，均采用小端序。NaN、±Infinity、±0.0 以及非规格化数在写入后再读回时都能原样保留，不会被规范化。

`Float32` 值 `1.5` (`0x3FC00000`) ：

```text theme={null}
00 00 C0 3F              little-endian IEEE 754
```

`Float64` 值 `1.5` (`0x3FF8000000000000`) ：

```text theme={null}
00 00 00 00 00 00 F8 3F  little-endian IEEE 754
```

#### BFloat16

brain-float 浮点格式：即 IEEE 754 `Float32` 的高 16 位，包含 1 个符号位、8 个指数位和 7 个尾数位。每个值占 2 字节，采用小端序，存储原始的 16 位位模式。要还原数值，需将其扩展回 `Float32`：把该位模式放入高半部分，并将低半部分清零 (即将 `bits << 16` 重新解释为 `Float32`) ；扩展后的值与 `Float32` 采用相同的文本格式化方式。

`BFloat16` 值 `1.5` (位模式为 `0x3FC0`，即 `Float32` `0x3FC00000` 的高半部分) ：

```text theme={null}
C0 3F                    little-endian, widens to Float32 1.5
```

#### Bool

与 `UInt8` 在线路格式上兼容：每行 1 字节，`0x00` = false，`0x01` = true。线上传输的类型字符串就是 `Bool` 本身 (而非 `UInt8`) ，因此根据类型字符串进行分派的解码器必须单独识别该类型。

一个 `Bool` 列 `[true, false, true]`：

```text theme={null}
01 00 01
```

#### Date 和 Date32

两者都将日期编码为相对于 Unix 纪元 `1970-01-01` 的整数天数，且均不包含时间部分。

| 类型 | 字节数 | 编码 | 范围 |
| - | - | - | - |
| `Date` | 2 | 小端序 UInt16 | `1970-01-01` 至 `2149-06-06` |
| `Date32` | 4 | 小端序 Int32 | 范围较宽的有符号值，可表示 1970 年之前的日期 |

`Date` 值 `1970-01-02` (1 天) ：

```text theme={null}
01 00                    UInt16 LE = 1
```

`Date32` 值 `1900-01-01` (-25567 天) ：

```text theme={null}
21 9C FF FF              Int32 LE = -25567
```

#### DateTime

与 `UInt32` 在线路格式上兼容：以秒为单位的 Unix 时间戳，采用 4 字节小端序存储。该类型可能写作 `DateTime` 或 `DateTime('Timezone')`；时区仅影响显示，并不包含在线路值中。对于同一时刻，两个时区参数不同的 `DateTime` 列会生成完全相同的字节。解码器会去掉 `(...)` 参数后缀，并将该列按 `UInt32` 处理。

`DateTime('UTC')` 值 `2024-03-15 14:30:00 UTC` (时间戳 `1710513000`) ：

```text theme={null}
68 5B F4 65              UInt32 LE = 1710513000
```

#### DateTime64(scale\[, timezone])

8 字节，小端序 Int64，表示自 Unix 纪元起经过的 tick 数，每个 tick 为 `10^-scale` 秒。`scale` 参数 (0–9) 包含在类型字符串中，用于确定时间单位：

| 标度 | 时间粒度 | 常用名称 |
| - | - | - |
| 0 | 1 秒 | 秒 |
| 3 | 1 毫秒 | ms |
| 6 | 1 微秒 | µs |
| 9 | 1 纳秒 | ns |

该类型的形式为 `DateTime64(s)` (隐式使用服务器默认时区) 或 `DateTime64(s, 'TimezoneName')` (显式指定时区，仅影响显示) 。负值表示纪元之前的 tick 数。

`DateTime64(3, 'UTC')` 类型的值 `2024-01-15 12:30:45.123 UTC` (1705321845123 ms) ：

```text theme={null}
83 51 1A 0D 8D 01 00 00  Int64 LE = 1705321845123
```

`DateTime64(0)` 值 `2024-01-15 12:30:45 UTC` (1705321845 秒) ：

```text theme={null}
75 25 A5 65 00 00 00 00  Int64 LE = 1705321845
```

#### Time 和 Time64(scale)

表示时钟时长，而非某个时间点。`Time` 是有符号的秒数，以 4 字节小端序 Int32 编码；`Time64(scale)` 是给定十进制标度 (0–9) 下的有符号 tick 数，以 8 字节小端序 Int64 编码，与 `DateTime64` 的 wire 形态相同。

其文本形式为 `[-]HH:MM:SS[.fraction]`，但与 `DateTime` 不同，小时字段**不会**按一天 24 小时回绕：它表示总小时数，可以超过 23。显示的数值上限为 `999:59:59` (`3599999` 秒) ；超出上限的值在渲染时，秒部分取上限值，而存储的小数部分保持原样，因此 tick 数 `3700000.25` 会渲染为 `999:59:59.250000`。`CAST` 会将存储值饱和截断为目标标度下可表示的最大 tick (对于 `Time64(6)` 为 `999:59:59.999999`) ，而非截断到整秒；但算术运算仍可能在列中留下超出范围的 tick 数，这类值仅在显示时才被截断到上限。上述行为均不影响 wire 字节，wire 字节始终是普通的有符号整数。

`Time` 值 `45296` (`12:34:56`) ：

```text theme={null}
F0 B0 00 00              Int32 LE = 45296
```

`Time64(3)` 值 `45296789` 个 tick (`12:34:56.789`) ：

```text theme={null}
95 2C B3 02 00 00 00 00  Int64 LE = 45296789
```

> [!NOTE]
>
> `Time` 和 `Time64` 为实验性类型，需要在服务器上启用 `allow_experimental_time_time64_type = 1`。

#### Interval

`Interval<Unit>` — `IntervalSecond`、`IntervalMinute`、`IntervalHour`、`IntervalDay`、`IntervalWeek`、`IntervalMonth`、`IntervalQuarter`、`IntervalYear`、`IntervalNanosecond` 等。所有单位共用同一种传输编码：计数值以有符号 8 字节小端序 Int64 表示。单位信息**仅**存在于类型字符串中——它既不影响 wire 字节，也不影响文本形式 (即一个裸整数) 。所有单位都由同一条解码器路径处理。

`IntervalDay` 值 `5`：

```text theme={null}
05 00 00 00 00 00 00 00  Int64 LE = 5
```

#### UUID

每个值占 16 字节。其传输编码**并非**规范形式的 16 个大端序字节，而是将前后两个 8 字节分别独立进行字节反转。

从逻辑上看，UUID 是一个 128 位标识符，其规范文本形式为 `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`，按惯例其字节以大端序书写。而在传输层面，则是将这 16 个规范字节拆分为前后两个 8 字节，并分别以小端序写入：

* wire 字节 0..7 = 规范字节 0..7 反转后的结果。
* wire 字节 8..15 = 规范字节 8..15 反转后的结果。

UUID `550e8400-e29b-41d4-a716-446655440000`：

```text theme={null}
Canonical bytes (16):    55 0E 84 00 E2 9B 41 D4  A7 16 44 66 55 44 00 00

Wire bytes:
D4 41 9B E2 00 84 0E 55  high half byte-reversed
00 00 44 55 66 44 16 A7  low half byte-reversed
```

nil UUID (全零) 在两种表示形式中完全一致。

#### IPv4 和 IPv6

这是两种相互关联但编码方式不同的地址类型。

`IPv4` 占 4 个字节，以小端序 UInt32 编码，存储规范的 32 位地址 (即由 `a.b.c.d` 计算得出的值 `(a << 24) | (b << 16) | (c << 8) | d`) 。其 wire 字节即网络字节序字节的逆序。

`192.168.1.10` (规范的 32 位值为 `0xC0A8010A`) ：

```text theme={null}
0A 01 A8 C0              Little-endian UInt32
```

`IPv6` 占 16 字节，**按网络字节序原样**写入，不做字节交换——与 `inet_pton(AF_INET6, ...)` 的字节序一致。

`2001:db8::1`：

```text theme={null}
20 01 0D B8 00 00 00 00  network bytes 0..7
00 00 00 00 00 00 00 01  network bytes 8..15
```

这种不对称是有意为之：IPv4 以 `u32` 形式存储，便于算术运算和紧凑的范围查询；而 IPv6 则沿用了大多数网络 API 通用的网络字节序布局。

#### Enum8 和 Enum16

分别与 `Int8` 和 `Int16` 在线路格式上兼容：每行占 1 或 2 个字节，其中 16 位版本采用小端序二进制补码表示。完整的枚举值映射记录在类型字符串中：

```text theme={null}
Enum8('active' = 1, 'inactive' = 2, 'banned' = -1)
Enum16('a' = 1, 'b' = 30000)
```

解码器可以去掉 `(...)` 参数后缀，直接按 `Int8` / `Int16` 分派——wire 字节只是整数索引。若客户端需要展示标签，则需从类型字符串中解析出 `'name' = value` 映射，并将其与该列一并保存：仅凭整数无法还原标签。面向文本的输出会渲染标签 (`active`) 而非索引；若枚举嵌套在复合类型中，标签还会加上单引号 (`'active'`) 。由于无法从整数列还原该映射，对于 `Array(Enum8(...))` 或 `Map(Enum16(...), V)` 等嵌套枚举，必须保留该映射。

一个 `Enum8('active' = 1, 'inactive' = 2)` 列 `[active, inactive, active]`：

```text theme={null}
01 02 01
```

`Enum16(...)` 类型的值 `30000`：

```text theme={null}
30 75                    Int16 LE = 30000
```

#### Decimal(P, S)

按 10 的幂缩放的有符号整数。整数的字节宽度由**精度** `P` 隐式决定；**标度** `S` 即负指数 (小数点后的位数) 。两者均包含在类型字符串中。

| 精度 (P) | 底层整数 | 字节数 |
| - | - | - |
| 1 ≤ P ≤ 9 | Int32 | 4 |
| 10 ≤ P ≤ 18 | Int64 | 8 |
| 19 ≤ P ≤ 38 | Int128 | 16 |
| 39 ≤ P ≤ 76 | Int256 | 32 |

传输编码为底层整数的小端序二进制补码表示，对应的逻辑十进制值为 `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`：

```text theme={null}
87 D6 12 00              Int32 LE = 1234567
```

`Decimal(18, 1)` 类型的值 `-1.5` → 底层整数 `-15`：

```text theme={null}
F1 FF FF FF FF FF FF FF  Int64 LE = -15
```

`Decimal(38, 4)` 类型的值 `123.4567` (共 16 字节) ：

```text theme={null}
87 D6 12 00 00 00 00 00 00 00 00 00 00 00 00 00
```

#### Nothing

`Nothing` 类型不携带任何值。实际上，它只会作为 `Nullable(Nothing)` 的内部类型出现——对于 `SELECT NULL` 这类表达式，服务器返回的就是该类型，其唯一有效的值就是“值的缺失”。从概念上讲，它是一种单元类型 (unit type) 。

在线上传输中，它**每行恰好占用一个占位字节**。服务器写出的是 ASCII 字符 `'0'` (`0x30`) ，但反序列化器会忽略这些字节——其内容是未定义的，解码器不得依赖任何特定值。写入的字节数为 `num_rows × 1`，因此列标题中的 `num_rows` 即可完全确定需要读取的字节数。

每行一个字节的设计维持了 Block 的不变式：每一列所占的长度都可以由 `num_rows` 推导得出，因此解码器无需逐单元的长度前缀即可顺序向前扫描。外层的 `Nullable` 始终将每个位置标记为 NULL，因此这些占位字节永远不会被读取检查。

一个包含 3 行 (全部为 NULL) 的 `Nullable(Nothing)` 列：

```text theme={null}
01 01 01                 null map: 1, 1, 1 (three NULLs)
30 30 30                 Nothing placeholder bytes (one per row)
```

空值映射前缀采用标准的 `Nullable` 帧信息 (参见 [Nullable](#nullable)) ；其后的三个字节是 `Nothing` 载荷，解码器会直接跳过。

### 变长类型

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

#### String

类型字符串：`String`。`String` 列有两种传输布局，具体使用哪种由协议修订版本决定 (参见 [修订版本控制的内容](#what-the-revision-gates)) 。在原生 TCP 协议上，修订版本即协商得出的修订版本；对于 `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` 个带长度前缀的字节序列依次组成：

```text theme={null}
[VarUInt: byte_length] [byte_length bytes: raw value]
[VarUInt: byte_length] [byte_length bytes: raw value]
...
```

各行之间除长度前缀外没有任何分隔符，也没有行级状态。空字符串表示为单个 `0x00` 字节。该列占用的总字节数为对所有行计算 `Σ (varuint_size(len_i) + len_i)`。

由 3 个字符串 `["ab", "", "c"]` 组成的列 (共 6 字节) ：

```text theme={null}
02 61 62                 row 0: length 2, "ab"
00                       row 1: length 0, empty
01 63                    row 2: length 1, "c"
```

**Offsets 布局** — 修订版本 `54492` 及以上：由两个流拼接而成，先是累计字节偏移量，然后是全部数据：

```text theme={null}
[UInt64 × num_rows: cumulative byte offset (end of each value), little-endian]
[last offset bytes: all values concatenated, no separators]
```

偏移量按原样发送，方式与 `Array` 通过原生协议发送其偏移量相同。偏移量 `i` 是值 `i` 在数据 blob 中的结束位置，因此值 `i` 的大小为 `offset[i] - offset[i-1]` (其中 `offset[-1] = 0`) ，最后一个偏移量即为数据 blob 的总大小。解码器先读取 `8 × num_rows` 字节的偏移量，再以最后一个偏移量作为确切长度，一次性读取整个数据 blob。这样既能精确预分配缓冲区、以批量复制代替逐行解析，也无需将大小转换为偏移量。`num_rows = 0` 的列在两个流中均不产生任何字节。

同样的 3 个字符串 `["ab", "", "c"]` (共 27 字节) ：

```text theme={null}
02 00 00 00 00 00 00 00  row 0 offset: 2  (end of "ab")
02 00 00 00 00 00 00 00  row 1 offset: 2  (end of "", so unchanged)
03 00 00 00 00 00 00 00  row 2 offset: 3  (end of "c")
61 62 63                 "ab" + "" + "c"
```

启用偏移量布局后，该布局适用于块中的每个 `String`，包括嵌套在复合类型 (`Array(String)`、`Nullable(String)`、`Map`、`Tuple`、`Variant`、`Dynamic`、`JSON`) 中的 `String`。但有一个例外：[`LowCardinality`](#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`，然后每行读取 `N` 个字节。

当 SQL 插入的值短于 `N` 字节时 (例如 `CAST('abc' AS FixedString(5))`) ，服务器会在右侧用 NUL 字节 (`0x00`) 补齐至声明的长度。这些填充字节属于所存储值的一部分，会按原样进行线上传输；是否去除填充字节由客户端自行处理。与 `String` 一样，`FixedString(N)` 更接近字节数组而非文本，通常用于定长标识符、地址字节或哈希摘要。

两个 `FixedString(3)` 值 `["abc", "de\0"]` (共 6 字节) ：

```text theme={null}
61 62 63                 row 0: 3 bytes, "abc"
64 65 00                 row 1: 3 bytes, "de" + NUL padding
```

两种字符串类型对比：

| 属性 | `String` | `FixedString(N)` |
| - | - | - |
| 每行长度前缀 | 是 (VarUInt) ，修订版本 `54492` 及以上则改用单独的 offsets stream | 否 |
| 行大小 | 可变 | 固定为 `N` 字节 |
| 列总字节数 | 可变 | `N × num_rows` |
| NUL 字节填充 | 不适用 | 由服务器在右侧填充 |
| 预期为 UTF-8 | 通常是 (不强制) | 否 (按原始字节处理) |
| 类型参数 | 无 | 必需，整数 `N` |

### 复合类型

复合类型封装一个或多个内部类型，并共享同一种传输模型：**每列多个流**。一个逻辑列会被编码为两个或更多可独立读取的字节序列，再依次拼接。

它们具有三个共同的结构特性：

* **每个 schema 对应固定的形态。** 其结构在解码时完全由类型字符串决定。`Array(UInt32)` 在每个块中的流布局始终相同。
* **自身没有版本前缀。** 复合封装层本身不添加版本字节；其帧信息 (偏移量、空值映射、元素流) 在各 ClickHouse 发行版之间保持稳定。这一点仅适用于*封装层*本身——内部版本化类型的情况，请参阅下文关于前缀阶段的说明。
* **自身没有跨块状态。** 封装层的帧信息在每个块内完全自描述；任何跨块状态问题都源自内部的版本化类型，而非封装层。

复合类型可以递归嵌套——内部类型本身也可以是复合类型。

**数据流之前的前缀阶段。** 读取一列依次分为两个阶段：先是**状态前缀阶段**，再是**数据流阶段**。复合封装层自身没有前缀字节，但在写入自身的任何数据流之前，会把前缀阶段*委托*给内部序列化：`SerializationArray` 会先运行内部类型的前缀阶段，再写入 array 偏移量；`Tuple`、`Map`、`Nested` 和 `Nullable` 也通过各自的元素序列化执行同样的操作 (`Nullable` 会在写入空值映射之前运行内部前缀) 。

因此，当复合类型封装的是[版本化/有状态类型](#versioned-types) (`LowCardinality`、`Variant`、`Dynamic`、`JSON`) 时，该内部类型的版本/状态前缀会*最先*输出，排在封装层的偏移量和元素载荷之前。例如，`Array(LowCardinality(String))` 的布局为 `[LowCardinality state prefix]` → `[array offsets]` → `[flattened LowCardinality element payload]`，而不是先输出偏移量。

如果解码器在运行内部前缀阶段之前就读取偏移量，那么在处理任何包含 `LowCardinality`、`Variant`、`Dynamic` 或 `JSON` 的复合类型时都会失步。如果所有内部类型都是普通叶类型或其他非版本化复合类型，前缀阶段不会输出任何字节，下文中先读偏移量的描述可原样适用。

#### Nullable(T)

类型字符串：`Nullable(InnerType)`。示例：`Nullable(UInt32)`、`Nullable(String)`、`Nullable(FixedString(16))`、`Nullable(DateTime('UTC'))`。

与其他复合类型一样，`Nullable` 在写入空值映射之前，会先将[前缀阶段](#composite-types)交由其内部类型的序列化处理：若内部类型为版本化类型，则会**先**输出内部类型的状态前缀。因此，`Nullable(Tuple(LowCardinality(String)))` 以 `LowCardinality` 的状态前缀开头，而非空值映射。若内部类型为叶子类型或其他非版本化类型，则前缀阶段不输出任何字节。

传输布局依次为内部前缀阶段 (仅当内部类型为版本化类型时非空) 和两个首尾相接的流，其中空值映射在前：

```text theme={null}
[inner type's state prefix]   empty for leaf/non-versioned inners; emitted first when the inner is versioned
[null-map stream]             num_rows × UInt8
[values stream]               inner type's encoding for num_rows values
```

空值映射恰好为 `num_rows` 字节，每行一个字节：

| 字节值 | 含义 |
| - | - |
| `0x00` | 该行有值。 |
| 非零 (规范值为 `0x01`) | 值为 NULL。值流中对应的字节为占位符。 |

值流以内部类型的标准编码存储**全部** `num_rows` 行的数据，NULL 位置也包含在内。解码器仍需读取 NULL 位置上的占位字节，以推进流的读取位置，但在解析任何单个值之前，必须先查阅空值映射。发送方可以在 NULL 位置写入任意字节，因此解码器不得依赖特定的占位值。

各内部类型家族对应的占位值：

| 内部类型家族 | NULL 位置上的占位值 |
| - | - |
| 定长类型 (UInt/Int/Float/DateTime/UUID 等) | 与该类型宽度相同的全零字节 |
| `String` | 空字符串 — 单个 `0x00` 字节 |
| `FixedString(N)` | `N` 个零字节 |
| `Array(T)` | 空数组 — 偏移量增量为 0 |
| `Tuple(T1, T2, ...)` | 每个元素各自使用其对应的占位值 |

`Nullable(T)` 可以出现在 `Array`、`Tuple`、`Map` 和 `Nested` 内部，其中 `Array(Nullable(T))` 和 `Tuple(Nullable(T1), T2)` 较为常见。Nullable 不能自身嵌套：服务器会拒绝 `Nullable(Nullable(T))`。

以包含三行数据 `[5, NULL, 9]` 的 `Nullable(UInt8)` 为例 (共 6 字节) ：

```text theme={null}
00 01 00                 null-map: present, null, present
05 00 09                 values:   5, placeholder, 9
```

一个包含三行数据 `["hello", NULL, "world"]` 的 `Nullable(String)` 列 (共 15 字节) ：

```text theme={null}
00 01 00                 null-map
05 'h' 'e' 'l' 'l' 'o'   row 0: "hello"
00                       row 1: placeholder (empty string)
05 'w' 'o' 'r' 'l' 'd'   row 2: "world"
```

#### Array(T)

类型字符串：`Array(InnerType)`。示例：`Array(UInt32)`、`Array(String)`、`Array(Nullable(UInt32))`、`Array(Array(UInt8))`。

其传输布局依次为内部类型的[前缀阶段](#composite-types) (仅当内部类型为版本化类型时才非空) ，以及两个拼接在一起的流，其中偏移量流在前：

```text theme={null}
[inner type's state prefix]   empty for leaf/non-versioned inners; emitted first when the inner is versioned
[offsets stream]              num_rows × UInt64 LE
[values stream]               inner type's encoding for offsets[num_rows - 1] values
```

偏移量 stream 恰好由 `num_rows` 个小端序 UInt64 值组成，每个值表示写入该行元素后在 values stream 中的**累计结束位置**：

* 第 `N` 行的元素起始索引 = `offsets[N - 1]` (当 `N == 0` 时为 `0`) 。
* 第 `N` 行的元素结束索引 (不含) = `offsets[N]`。
* 第 `N` 行的元素数量 = `offsets[N] - offsets[N - 1]`。

因此，`offsets[num_rows - 1]` 即为所有行的元素总数，values stream 中包含同样数量的内部值，依次首尾相接。

偏移量 是**单调非递减**的；相邻两个 偏移量 相等表示该行为空，解码器应将非单调的 偏移量 视为数据损坏并予以拒绝。空列 (`num_rows == 0`) 不写入任何字节——既没有 偏移量 stream，也没有 values stream。内部类型可以是任意类型，包括其他复合类型：`Array(Array(T))`、`Array(Tuple(...))` 和 `Array(Nullable(T))` 均合法。

以行数据为 `[[10, 20, 30], [], [40, 50]]` 的 `Array(UInt32)` 为例 (共 44 字节) ：

```text theme={null}
Offsets (3 × UInt64 LE = 24 bytes):
03 00 00 00 00 00 00 00      offsets[0] = 3
03 00 00 00 00 00 00 00      offsets[1] = 3 (empty row)
05 00 00 00 00 00 00 00      offsets[2] = 5

Values (5 × UInt32 LE = 20 bytes):
0A 00 00 00                  10
14 00 00 00                  20
1E 00 00 00                  30
28 00 00 00                  40
32 00 00 00                  50
```

每个偏移量表示某一行在共享值流中所占片段的累计*结束*位置；起始位置为前一个偏移量 (第 0 行则为 `0`) 。若相邻两个偏移量相等，则表示该行为空：

![偏移量将各行映射到共享值流中](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/array-offsets.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=caba90e4578ba0cd297f97007d747b98)

包含行 `[["a", "bb"], []]` 的 `Array(String)` (共 20 字节) ：

```text theme={null}
Offsets (2 × UInt64 LE = 16 bytes):
02 00 00 00 00 00 00 00      offsets[0] = 2
02 00 00 00 00 00 00 00      offsets[1] = 2 (empty row)

Values (2 strings, 4 bytes total):
01 'a'                       row's first string: "a"
02 'b' 'b'                   row's second string: "bb"
```

对于 `Array(Array(UInt32))`，若其各行为 `[[[1,2]], [], [[3], [4,5]]]`，则会按相同的形态逐层嵌套：

* 外层偏移量：`[1, 1, 3]` —— 第 0 行包含 1 个内层数组，第 1 行包含 0 个，第 2 行包含 2 个。
* 中间层的 `Array(UInt32)` 解码 3 行，偏移量为 `[2, 3, 5]`。
* 最内层的 `UInt32` 解码 5 个值：`[1, 2, 3, 4, 5]`。

总计 24 (外层偏移量) + 24 (中间层偏移量) + 20 (值) = 68 字节。

#### Tuple(T1, T2, ...)

类型字符串：`Tuple(T1, T2, ..., Tn)`。示例：`Tuple(UInt32, String)`、`Tuple(Int32)`、`Tuple(Array(UInt32), String)`、`Tuple(UInt8, Tuple(Int32, String))`。ClickHouse 还支持通过 `Tuple(a UInt32, b String)` 定义**命名元组**；名称仅作为元数据，不影响传输格式。

其传输布局依次为：各元素的[前缀阶段](#composite-types) (每个版本化元素按声明顺序写入各自的状态前缀；非版本化元素则无前缀) ，随后是按声明顺序拼接的 *N* 个流，每个元素类型对应一个流：

```text theme={null}
[element state prefixes]   in declaration order; empty unless an element type is versioned
[stream for T1]    inner T1's encoding for num_rows values
[stream for T2]    inner T2's encoding for num_rows values
 ...
[stream for Tn]    inner Tn's encoding for num_rows values
```

每个流恰好编码 `num_rows` 个值。没有长度前缀，没有 偏移量 流，流与流之间也没有分隔符。空列 (`num_rows == 0`) 在每个流中写入零字节。元素类型可以是任意类型，包括其他复合类型——`Tuple(Tuple(...), ...)`、`Tuple(Array(...), ...)` 和 `Tuple(Nullable(T1), T2)` 都是合法的。

零元素的 Tuple `Tuple()` 同样合法——它由 `SELECT tuple()` 或 `CAST(x AS Tuple())` 等表达式产生。由于不存在元素流，它转而采用与 [Nothing](#nothing) 相同的序列化方式：**每行写入一个占位字节 (`0x30`，即 ASCII `'0'`) **，反序列化器会将其丢弃。行数取自块的头部，与 `Nothing` 的处理方式完全相同。

包含 3 行 `(1,4), (2,5), (3,6)` 的 `Tuple(UInt8, UInt8)`：

```text theme={null}
Element 0 stream (3 × UInt8 = 3 bytes):
01 02 03

Element 1 stream (3 × UInt8 = 3 bytes):
04 05 06
```

该布局**并非**按行存储：回读原始字节时，元素 0 对应 `[1, 2, 3]`，元素 1 对应 `[4, 5, 6]`。

包含 `(10, "a")` 和 `(20, "bb")` 这 2 行的 `Tuple(UInt32, String)` (共 13 字节) ：

```text theme={null}
Element 0 stream (2 × UInt32 LE = 8 bytes):
0A 00 00 00                  10
14 00 00 00                  20

Element 1 stream (2 strings, 5 bytes total):
01 'a'                       "a"
02 'b' 'b'                   "bb"
```

#### 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))` 逐字节相同，因此开头是内部的[前缀阶段](#composite-types) (除非 `K` 或 `V` 为版本化类型，否则该阶段为空) ：

```text theme={null}
[K/V state prefixes]   from the inner Tuple's prefix phase; empty unless K or V is versioned
[offsets stream]    num_rows × UInt64 LE                   ← from Array
[keys stream]       K's encoding for total_pairs values    ┐ from Tuple's
[values stream]     V's encoding for total_pairs values    ┘ per-element streams
```

其中 `total_pairs = offsets[num_rows - 1]` (当 `num_rows == 0` 时为 `0`) 。偏移量流的语义与 [Array](#array) 相同。键与值按位置对齐：第 `i` 对为 `(keys[i], values[i])`。

在 ClickHouse 内存中，Map 列表示为由 Tuple 组成的 array；类型系统将其作为一种独立类型对外呈现，以便在 SQL 中使用 (`m['key']`、`mapKeys`、`mapValues`) 。传输格式直接序列化这一存储结构，因此 `Map` 与 `Array(Tuple(K, V))` 在字节层面完全可以互换。

偏移量单调不减，键流和值流均恰好包含 `total_pairs` 个值。空列写入零字节。单行内的键通常是唯一的，但这只是语义层面的规则，而非由传输格式强制约束：传输格式允许重复键在写出后原样读回，只有当支持 Map 的函数处理该行时，服务器端才会按语义处理重复键。

`Map(UInt8, UInt8)`，包含 2 行 `{1:10, 2:20}`、`{3:30}` (共 22 字节) ：

```text theme={null}
Offsets (2 × UInt64 LE = 16 bytes):
02 00 00 00 00 00 00 00      offsets[0] = 2
03 00 00 00 00 00 00 00      offsets[1] = 3

Keys (3 × UInt8 = 3 bytes):
01 02 03                     keys: 1, 2, 3

Values (3 × UInt8 = 3 bytes):
0A 14 1E                     values: 10, 20, 30
```

键和值分别存储在各自独立的流中，而非交错存放——读取第 `i` 个键值对时，会同时读取 `keys[i]` 和 `values[i]` 来进行重建。

包含 1 行 `{'a':1, 'b':2}` 的 `Map(String, UInt32)` (共 20 字节) ：

```text theme={null}
Offsets (1 × UInt64 LE = 8 bytes):
02 00 00 00 00 00 00 00      offsets[0] = 2

Keys (2 strings, 4 bytes total):
01 'a'                       "a"
01 'b'                       "b"

Values (2 × UInt32 LE = 8 bytes):
01 00 00 00                  1
02 00 00 00                  2
```

#### Nested(name1 T1, name2 T2, ...)

`Nested` 的线上传输表示形式取决于服务器端的 `flatten_nested` 设置，因此分为两种不同的情况。

![启用与未启用扁平化时的复合列布局](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/nested-layout.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=786631c14f4e8b40cbd3ad9d47eb3c56)

**情况 A：`flatten_nested = 1` (服务器默认值) 。** 如果表是在默认设置下创建的，`Nested` **并不是一种 wire type**。服务器会将该列存储并呈现为 N 个并行的 `Array(T_i)` 列，列名**以点号分隔** (`outer.field1`、`outer.field2` 等) 。对格式层而言，这并没有引入任何新东西——每个带点号的列都只是普通的 [Array](#array)：

```text theme={null}
DESCRIBE TABLE t   -- t has column n Nested(a UInt8, b String)
id     UInt8
n.a    Array(UInt8)
n.b    Array(String)
```

**情况 B：`flatten_nested = 0`。** 如果表在创建时设置了 `flatten_nested = 0`，则该列在线上传输时表现为单个列，其类型字符串为 `Nested(name1 T1, name2 T2, ...)`，且类型字符串之后的布局**与 `Array(Tuple(T1, T2, ..., Tn))` 逐字节相同**——包括内部的[前缀阶段](#composite-types)。因此，任何版本化字段 `T_i` 都会先输出其状态前缀，再输出偏移量。下面的示例使用的是非版本化字段，因此前缀阶段为空：

```text theme={null}
Nested(a UInt8, b String) bytes (after type string):
  02 00 00 00 00 00 00 00       offsets[0] = 2
  03 00 00 00 00 00 00 00       offsets[1] = 3
  0A 14 1E                       UInt8 stream
  01 'x' 01 'y' 01 'z'           String stream

Array(Tuple(a UInt8, b String)) bytes (after type string):
  02 00 00 00 00 00 00 00       offsets[0] = 2
  03 00 00 00 00 00 00 00       offsets[1] = 3
  0A 14 1E                       UInt8 stream
  01 'x' 01 'y' 01 'z'           String stream
```

唯一的区别在于类型字符串的文本：`Nested` 会保留字段名 (`a`、`b`) ，而 `Array(Tuple)` 不会以具名槽位的形式保存这些名称。

Case B 的类型字符串是一个以逗号分隔的 (名称, 类型) 对列表。名称与类型之间以第一个空白字符分隔；类型本身还可能包含空白字符、逗号和括号，因此解析时需要使用与 `Tuple` 相同的、能识别嵌套深度的拆分器。传输布局如下：

```text theme={null}
[offsets stream]    num_rows × UInt64 LE                       ← from Array
[field1 stream]     T1's encoding for total_elements values    ┐ from Tuple's
[field2 stream]     T2's encoding for total_elements values    │ per-element
 ...                                                            │ streams
[fieldn stream]     Tn's encoding for total_elements values    ┘
```

其中 `total_elements = offsets[num_rows - 1]` (当 `num_rows == 0` 时为 `0`) 。偏移量单调非递减，且每个字段的数据流恰好包含 `total_elements` 个值。服务器会在 INSERT 时强制校验：同一行内所有字段的元素数量必须一致。空列不写入任何字节。

以包含 2 行 `[(10,'x'),(20,'y')]` 和 `[(30,'z')]` 的 `Nested(a UInt8, b String)` 为例 (类型字符串之后共 25 字节) ：

```text theme={null}
Offsets (2 × UInt64 LE = 16 bytes):
02 00 00 00 00 00 00 00      offsets[0] = 2
03 00 00 00 00 00 00 00      offsets[1] = 3

Field 'a' stream (3 × UInt8 = 3 bytes):
0A 14 1E                     10, 20, 30

Field 'b' stream (3 strings, 6 bytes):
01 'x' 01 'y' 01 'z'         "x", "y", "z"
```

### 类型别名

有几种类型是纯粹的别名：服务器在列标题中发送的是别名，但随后的字节属于某个底层类型。解码器只需将该别名映射到对应的底层类型，并复用其编解码器即可，不涉及任何新的传输格式。

地理类型是嵌套数组和元组的别名：

| 类型字符串 | 底层传输类型 |
| - | - |
| `Point` | `Tuple(Float64, Float64)` |
| `Ring`, `LineString`, `MultiPoint` | `Array(Point)` |
| `Polygon`, `MultiLineString` | `Array(Ring)` |
| `MultiPolygon` | `Array(Polygon)` |

因此，`Point` 列的解码方式与 `Tuple(Float64, Float64)` 完全相同 (渲染为 `(1,2)`) ，`Ring` 则按 `Array(Tuple(Float64, Float64))` 解码 (`[(0,0),(1,1)]`) ，更高层级的类型依此类推。

`Geometry` 同样是别名，但它对应的是 [`Variant`](#variant) 而非嵌套数组：其载荷是由上述七种地理类型构成的 Variant。列标题中只携带类型字符串 `Geometry`，**不会**展开写出该 Variant，因此解码器必须自行展开。与用户显式声明的 `Variant` 不同，`Geometry` 的判别值**不**按名称排序：出于兼容性考虑，这些判别值是固定的，新的地理类型只会追加到末尾。映射关系为 `0` = `LineString`、`1` = `MultiLineString`、`2` = `MultiPolygon`、`3` = `Point`、`4` = `Polygon`、`5` = `Ring`、`6` = `MultiPoint` (26.7 版本新增；旧版本服务器发送的数据流中不会出现判别值 `6`) 。随后，每个选中的值再按上述对应的地理别名进行解码 (`NULL` 使用 `Variant` 的 `NULL` 判别值 `255`) 。

`SimpleAggregateFunction(func, T)` 是其值类型 `T` 的别名。它存储的是已完成最终计算的聚合值，因此其传输形式和渲染方式与 `T` 完全相同 (`SimpleAggregateFunction(sum, UInt64)` 按 `UInt64` 解码) 。只有带单一值类型的形式才属于这种别名；其底层类型本身可以是复合类型。

> [!NOTE]
>
> 有两种相关类型**并非**别名。它们都是有效的 `Native` 列类型——例如，客户端可能会从 `-State` 组合器或分布式聚合中接收到 `AggregateFunction` 列——但它们各自携带专门的载荷，不在本页讨论范围之内：
>
> * `AggregateFunction(func, ...)` 保存的是*中间*聚合状态 (而非最终值) ；其二进制布局取决于具体的聚合函数及其版本。函数名可以带有参数，每个参数均以 SQL 字面量形式输出，例如 `AggregateFunction(topK(3), UInt64)`。某些聚合函数会在参数后输出显式的 `::Type` 后缀，例如 `AggregateFunction(topK('3'::Decimal32(0)), UInt64)`，这是因为 `Decimal` 或宽整数值没有可直接输出的裸字面量形式；是否这样做取决于具体函数，而非格式本身，因此需要检查参数的解码器必须同时接受这两种写法。仅需要参数类型的解码器则不受影响。版本化的聚合函数会将非零状态版本作为类型字符串的第一个参数写出——`AggregateFunction(1, quantileDeterministic, UInt64, UInt64)`——而版本 `0` 不会输出，因此类型字符串仍为 `AggregateFunction(quantileDeterministic, UInt64, UInt64)`。在已协商的连接上 (修订版本大于 `0`) ，写入器**仅**根据协议修订版本选择版本——绝不会采用其本地类型上恰好固定的版本，因为在传输层面上，版本 `0` 与“未通知版本”无法区分——因此同一函数在不同连接上可能通知不同的版本，读取器必须从类型字符串中获取版本，而不能假定其为最新版本。在修订版本 `0` 下，不存在需要为其推导版本的 peer——该 stream 是自描述的，并由写入方自行读回——因此类型上固定的版本会保留在 stream 中 (以及所通知的类型字符串中) ，而不会降级为 `0`。`quantileDeterministic` (包括 `quantilesDeterministic` 和 `medianDeterministic` 写法) 自修订版本 `54491` 起版本化：版本 `1` 会在状态末尾追加一个 `UInt8 skip_degree`，其前缀与版本 `0` 逐字节相同。低于该修订版本时，写入器输出版本 `0` 的状态，因此较旧的 peer 不受影响。`uniq` 自修订版本 `54493` 起版本化：版本 `1` 会在版本 `0` 布局之前添加一个标志字节；当标志的第 `0` 位被置位时，还会追加一个由完整 64 位哈希组成的小型附加样本，每个哈希均与常量 `0x9E3779B97F4A0000` 进行异或 (依次为 `UInt8 skip_degree`、`VarUInt count`，然后是 8 字节的值) ，从而在基数超过二十亿时仍能保持估算的准确性；版本 `0` 的状态仅携带 32 位主样本。标志的第 `1` 位表示 64 位样本*不完整*——即该状态已合并了一个版本 `0` 的状态，而后者的元素从未进入过 64 位样本——此时不会写入 64 位样本，读取器必须仅根据 32 位主样本进行估算 (并在其后续写出的状态中继续标明这一点) ；因此这两个位互斥，读取器必须拒绝同时设置这两个位的状态。其他位均未定义，读取器必须拒绝设置了任何其他位的状态。上述规则同样适用于嵌套在容器类型中的状态——`Array`、`Tuple`、`Map`、`Nullable`、`Variant` (其中备选类型写出的版本会改变类型字符串，但不会改变判别值顺序) ，以及 `Nested` / `SimpleAggregateFunction` 包装器。这些规则也适用于聚合函数组合器：作用于版本化函数的透传组合器——`uniqIf`、`uniqArray`、`uniqForEach`、`uniqMap`、`uniqArgMin` / `uniqArgMax`、`uniqOrNull` / `uniqOrDefault`、`uniqResample`，以及隐式的 `Nullable` 参数适配器——会将版本传递给其载荷中包含的嵌套状态，在新生成状态的类型字符串中以与普通函数完全相同的方式写出嵌套函数的当前版本 (`AggregateFunction(1, uniqIf, UInt64, UInt8)`) ，并在已协商的连接上以同样方式根据协议修订版本推导版本。包装器的自定义名称中还包含一份其参数类型的副本，因此写入器必须使用相同的已协商聚合状态版本重建该副本；否则其头部可能会为版本 `0` 的载荷通知版本 `1`。`Variant` 有一种边界情况根本无法在传输层面表示：当两个备选类型仅在写出的状态版本上不同时 (这作为列类型是合法的，因为类型字符串不同) ，按已协商修订版本对两者重新设定版本会使它们合并为同一个备选类型，因此发送方必须令传输失败，而不能通知与载荷实际写入版本不符的版本。`Dynamic` 是按修订版本推导版本这一规则的有意例外：`Dynamic` 列会自行通知其嵌套类型——通过其自身的类型列表以及[二进制类型编码](https://clickhouse.com/docs/zh/reference/data-types/data-types-binary-encoding.md)，后者的显式版本字段携带写出的状态版本 (其共享 variant 中的值即使在静态存储时也以这种方式嵌入其类型) ——这些通知属于该列自身的数据，对任何 peer 和任何介质都完全相同，因此绝不会针对已协商修订版本进行重写。因此，嵌套在 `Dynamic` 值中的状态会以其类型中写出的版本写入 (新生成的状态会携带该版本，因为 `-State` 查询结果会写出其版本) ；若类型中未写出版本，则以版本 `0` 写入，在所有连接和介质上均是如此。
> * `QBit(T, N[, stride])` 以位平面转置的方式存储向量，适用于向量搜索工作负载；其传输层 stream 布局 (按组优先排列的 `FixedString` 位平面 stream，显式指定 `stride` 时共有 `element_size * (N / stride)` 个) 及其二进制类型编码 (标签 `0x36`，当 `stride != N` 时为 `0x37` `QBitWithStride`) 已在 [`QBit` 数据类型页面](https://clickhouse.com/docs/zh/reference/data-types/qbit)和[二进制类型编码](https://clickhouse.com/docs/zh/reference/data-types/data-types-binary-encoding.md)参考文档中说明，因此 `Native` 读取器无需再从 C++ 源代码中推断这些细节。

### 版本化类型

版本化类型在线上传输时会带有一个序列化版本前缀，用于声明后续数据采用哪一种编码变体。它们也可能使用多个流 (与复合类型类似) 。在 `Native` 线上传输格式中，前缀和字典 (如有) 均以块为单位——这些类型不维护跨块状态 (参见下文的[按块前缀说明](#serialization-version-concept)) ；跨块序列化状态仅存在于 MergeTree 的磁盘存储流中。

这些类型比固定形态的复合类型复杂得多，仅面向简单分析查询的客户端可以暂缓对其提供支持。

#### 序列化版本：概念

**序列化版本**是一个按类型、按列设置的线上传输版本号，用于声明发送方采用的是该类型编码的哪种变体。它位于列状态前缀的最开头，因此解码器会先读取它，再据此将该列的其余部分交给相应的解析器处理。

它与协议版本不同：

| 维度 | 协议版本 | 序列化版本 (本节) |
| - | - | - |
| 作用范围 | 整个连接 | 按类型、按列 |
| 是否协商 | 是，在握手时协商 | 否 — 由发送方写入，接收方读取 |
| 控制内容 | 启用哪些数据包级功能 | 某一类型使用哪种线上传输变体 |
| 是否必须读取 | 是 | 是，每个版本化列都必须读取 |

大多数版本化类型会在其他所有状态前缀数据之前，以小端序 UInt64 写入版本号；少数类型使用 VarUInt 或 UInt8。解码器应首先读取版本号，并拒绝未知值 — 更高的版本意味着发送方使用了解码器无法理解的较新格式，一旦解析出错，后续的每一个字节都会被破坏。

状态前缀会在**每个行数大于零的块**的开头输出，紧接在该块的载荷之前。

Native 写入器和读取器**不会**跨块保留序列化状态：`NativeWriter` 每写入一个非空列块，都会创建新的序列化状态并写入状态前缀；`NativeReader` 每读取一个非空块，也会创建新的反序列化状态并读取该前缀 (当 `rows == 0` 时，两者都会完全跳过前缀) 。

因此，头部块 (行数 = 0) 和空块不会输出任何内容，解码器则必须在每个非空块的开头重新读取状态前缀。如果解码器只读取一次前缀，并将后续块视为仅包含载荷，就会把下一个块的前缀误当作数据读取，从而导致失步：

![每个非空块都会重复的状态前缀](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/state-prefix-per-block.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=753ca07b93f3b88feea72d9584eca779)

#### 序列化版本参考

| 类型 | 字段宽度 | 值 | 名称 | 含义 |
| - | - | - | - | - |
| **Object** (JSON 的基础类型) | UInt64 LE | `0` | `V1` | 原始编码。包含 `max_dynamic_paths` 参数以及动态路径列表。 |
| | | `1` | `STRING` | Native 格式兼容模式 —— Object 以单个 `String` 列的形式传输，列中包含 JSON 文本。 |
| | | `2` | `V2` | 在 V1 布局的基础上去掉了 `max_dynamic_paths` 参数。 |
| | | `3` | `FLATTENED` | Native 格式兼容模式 —— 采用扁平化的路径表示。 |
| | | `4` | `V3` | 仅写入 MergeTree parts，从不在网络上传输；本文不说明其布局。 |
| **Dynamic** | UInt64 LE | `1` | `V1` | 原始编码。包含 `max_dynamic_types` 以及运行时 Variant 类型列表。 |
| | | `2` | `V2` | 在 V1 的基础上去掉了 `max_dynamic_types` 参数。 |
| | | `3` | `FLATTENED` | Native 格式兼容模式。 |
| | | `4` | `V3` | 仅写入 MergeTree parts，从不在网络上传输；本文不说明其布局。 |
| **Variant** 判别值模式 | UInt64 LE | `0` | `BASIC` | 逐行原样写入判别值。 |
| | | `1` | `COMPACT` | 如果一个粒度中的所有行共用同一个判别值，则只写入一个值和一个粒度标记。 |
| **Variant** 粒度格式 (模式为 `COMPACT` 时) | UInt8 | `0` | `PLAIN` | 粒度中包含多种不同的判别值。 |
| | | `1` | `COMPACT` | 粒度中所有行共用一个判别值。 |
| **LowCardinality** 键序列化 | Int64 | `1` | `sharedDictionariesWithAdditionalKeys` | 目前唯一已定义的版本。 |
| **JSON-as-String** 回退 (启用 `output_format_native_write_json_as_string` 时) | UInt64 LE | `1` | `JSONStringSerializationVersion` | JSON 列以 `String` 列的形式传输，并以该前缀开头。 |

关于上表，有几点值得注意：

* **这些值并不连续。** `Dynamic` 使用 `1`、`2`、`3`、`4`，其中 `V3` 对应 `4`，`FLATTENED` 对应 `3`。数值越大并不一定代表版本越新。
* **部分值仅用于 Native 格式。** `Object::STRING`、`Object::FLATTENED` 和 `Dynamic::FLATTENED` 的存在，是为了在原生协议层面兼容那些未完整实现 Object/Dynamic 的客户端。它们不会出现在 MergeTree 的磁盘存储中。
* **`V3` 仅用于磁盘存储。** 之所以列出 `Object::V3` 和 `Dynamic::V3`，是因为这些值已被分配、不得复用，而非解码器可能遇到它们：ClickHouse 从不通过 `Native` 发送它们，若在该格式中出现，会以 `INCORRECT_DATA` 拒绝。它们所承载的全部内容 —— `Object::V3` 的 shared-data 子版本及其分桶和粒度布局 —— 描述的都是 part 而非数据块，因此不在本页讨论范围内。使用原生 TCP 协议的客户端只会遇到 `V1`、`V2`、`STRING` 或 `FLATTENED`。

#### LowCardinality(T)

最简单的版本化类型。它将包含 `N` 个内部值的列替换为一个由去重值构成的小型字典，以及 `N` 个指向该字典的索引。

类型字符串：`LowCardinality(InnerType)`。示例：`LowCardinality(String)`、`LowCardinality(FixedString(4))`、`LowCardinality(Nullable(String))`。

```text theme={null}
[per block with rows > 0]:
  [8 bytes:  Int64 LE state prefix = 1]             ← repeated at the start of every non-empty block
  [8 bytes:  UInt64 LE metadata]                    ← key type code (low byte) + flag bits
  [8 bytes:  UInt64 LE dict_size]                   ← number of dict entries (incl. placeholder slot)
  [N bytes:  dict values]                           ← inner type's encoding for dict_size values
  [8 bytes:  UInt64 LE keys_count]                  ← number of values at this recursive level (see below)
  [K bytes:  keys]                                  ← (1 << key_type_code) bytes per key
```

状态前缀 (Int64 LE = 1) 是唯一已定义的版本，即 `sharedDictionariesWithAdditionalKeys`；其他值均为保留值。

每个块的元数据 UInt64 是一个位字段：

| 比特范围 | 含义 |
| - | - |
| 0..7 | 键类型代码：`0` = UInt8，`1` = UInt16，`2` = UInt32，`3` = UInt64。选用足以索引 `dict_size` 个条目的最小类型。 |
| 8 (`0x100`) | `NeedGlobalDictionaryBit` —— 跨块共享的单一字典。**`Native` 格式中从不设置此位**：写入器使用 `low_cardinality_max_dictionary_size = 0`，读取器会拒绝该比特 (`native_format` 抛出 `INCORRECT_DATA` —— "cannot use global dictionary") 。该位属于 MergeTree 的磁盘存储流，而非线上传输格式。 |
| 9 (`0x200`) | `HasAdditionalKeysBit` —— 当块携带额外的字典键 (写在索引之前) 时设置。非空的 `Native` 块始终设置此位。 |
| 10 (`0x400`) | `NeedUpdateDictionary` —— 当块携带字典更新时设置。非空的 `Native` 块始终设置此位，因为每个块都自带一份自包含的字典。 |

对于每列仅含单个数据块的典型查询响应，元数据为 `0x600` (HasAdditionalKeys + NeedUpdateDictionary) 。

dict 值为 `dict_size` 个以内部类型 T 编码的值。字典会在开头为特殊值预留槽位：非 Nullable 列预留一个 (`dict[0]` 存放内部类型的默认值，例如 `String` 的 `""`) ，实际的不同值从 `dict[1]` 开始。

对于 `LowCardinality(Nullable(T))`，dict 仍按普通 T 编码 (没有空值映射流) ，但会预留**两个**槽位：`dict[0]` 为 NULL 标记，`dict[1]` 为内部类型的默认值 (例如 `String` 的 `""`) ；实际的不同值从 `dict[2]` 开始。NULL 行的键指向 `dict[0]`，该槽位在线上传输时写为内部类型默认值的字节。

键是指向 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-types)中输出) ，其后不跟元数据、字典或 `keys_count`。

每个行数大于零的块在开头都会读取状态前缀——请求头块 (rows = 0) 和空块不输出任何内容。在块内，`keys_count` 等于行数，`dict_size` 等于 dict stream 中值的数量，每个键占用 `1 << key_type_code` 字节。

> [!NOTE]
>
> 在 `Native` 格式中，每个块都携带一个**自包含的块本地字典**——不存在跨块的字典状态。写入器将 `low_cardinality_max_dictionary_size` 设为 `0`，因此 `SerializationLowCardinality` 从不构建共享字典：每个非空块都将其键作为块本地的附加键写入，且不设置 `NeedGlobalDictionaryBit` (元数据 `0x600`) ；当 `native_format` 为 true 时，读取器会拒绝 `NeedGlobalDictionaryBit`。因此，解码器必须在每个块开始时重置字典，并读取该块中的 `dict_size` 个条目；若沿用前一个块的字典，就会错误解析下一个块的键。 (跨块持久化 LC 字典属于 MergeTree 磁盘存储层面的问题，与 Native 传输布局无关。)

值为 `['a', 'b', 'a', 'c', 'b']` 的 `LowCardinality(String)`：

```text theme={null}
01 00 00 00 00 00 00 00      state prefix Int64 = 1
00 06 00 00 00 00 00 00      metadata UInt64 = 0x600
04 00 00 00 00 00 00 00      dict_size = 4
00                           dict[0] = "" (placeholder)
01 'a'                       dict[1] = "a"
01 'b'                       dict[2] = "b"
01 'c'                       dict[3] = "c"
05 00 00 00 00 00 00 00      keys_count = 5
01 02 01 03 02               keys (UInt8): 1, 2, 1, 3, 2
```

还原结果：`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]` 用于空字符串默认值：

```text theme={null}
01 00 00 00 00 00 00 00      state prefix Int64 = 1
00 06 00 00 00 00 00 00      metadata UInt64 = 0x600
04 00 00 00 00 00 00 00      dict_size = 4
00                           dict[0] = "" → NULL marker
00                           dict[1] = "" → inner default value
01 'a'                       dict[2] = "a"
01 'b'                       dict[3] = "b"
04 00 00 00 00 00 00 00      keys_count = 4
02 00 01 03                  keys (UInt8): 2, 0, 1, 3
```

还原结果：`dict[2]` = `"a"`，`dict[0]` = `NULL`，`dict[1]` = `""`，`dict[3]` = `"b"`，即 `["a", NULL, "", "b"]`。`dict[0]` 和 `dict[1]` 在线上传输时都是空字节；值是否为 NULL 取决于键是否指向槽位 `0`，而不是由字节本身决定。

#### JSON (Tier 1：String 回退)

ClickHouse 的 `JSON` 类型有多种传输编码 (参见[序列化版本参考](#serialization-version-reference)) 。Tier 1 是其中最简单的一种：当某个查询设置了 `output_format_native_write_json_as_string = 1` 时，服务器会将每个 JSON 值展平为序列化后的文本，并将该列以带状态前缀标记的 `String` 类型输出。

类型字符串：`JSON`。

```text theme={null}
[8 bytes:  Int64 LE state prefix = 1]        ← JSONStringSerializationVersion
[per block with rows > 0]:
  [N bytes: String column encoding for num_rows JSON text values]
```

对于这种 String 回退，状态前缀的值为 `1`。其他值表示不同的 `JSON`/`Object` 编码：`0` = V1，`2` = V2 (原生 TCP 协议下的默认编码) ，`3` = FLATTENED，`4` = V3 (参见[序列化版本参考](#serialization-version-reference)) 。如果解码器在此处读到的值不是 `1`，则说明当前数据并非 String 回退。在每个行数 > 0 的块开头都会读取该前缀，其后的值流是一个包含 `num_rows` 行的标准 [String](#string-type) 列。

`JSON` 值 `'{"a":1}'` (一行) ：

```text theme={null}
01 00 00 00 00 00 00 00      state prefix Int64 = 1
07 7B 22 61 22 3A 31 7D      String: 7 bytes {"a":1}
```

该值以紧凑的 JSON 文本形式输出 (如 `{"a":1}`) ，其中的整数仍保留为整数。该文本只是一个 `String` 值，因此客户端收到的 JSON 仅可作为不透明数据传递，无法还原出各个路径及其 ClickHouse 类型；若要按路径准确保留类型，需使用下文所述的第 2 层编码。

#### Variant(T1, T2, ...)

一种可辨识联合 (discriminated union) ：每一行恰好保存其中某一个 Variant 类型的值，或者为 NULL。每一行都带有一个单字节的**全局判别值**，用于指明该行的类型；各类型的值随后紧凑存储，每个 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 (基于粒度的游程编码) 。服务器通过原生协议传输时默认使用 BASIC (`use_compact_variant_discriminators_serialization = false`) ；本文仅介绍 BASIC 模式。

```text theme={null}
[per block with rows > 0]:
  [8 bytes:  UInt64 LE discriminators mode = 0]    ← state prefix, repeated at the start of every non-empty block;
                                                     followed by each variant element's own state prefix
                                                     (empty for leaf types)
  [num_rows bytes: UInt8 discriminators]           ← one global discriminator per row; 255 = NULL
  [for each variant type i, in declared order]:
    [values for the rows whose discriminator == i] ← dense encoding in type i; count = #rows selecting i
```

重建时，从左到右遍历判别值，同时为每种类型维护一个累计计数器。判别值为 `d` (≠ 255) 的行 `r` 从 Variant 类型 `d` 的值序列中取出索引为 `counter[d]` 的值，然后将 `counter[d]` 加一。判别值为 `255` 的行为 NULL，不消耗任何值序列中的值，因此各类型计数器之和等于非 NULL 行的数量。

状态前缀 (即 mode `UInt64`) 会在每个行数 > 0 的块开头读取；请求头 块和空块不输出任何内容。每个非 NULL 判别值都小于 Variant 类型的数量，且 Variant 类型 `i` 恰好解码 `count[i]` 行。

> [!NOTE]
>
> 本身有状态的 Variant 元素 (`LowCardinality`、`Variant`、`Dynamic`、`JSON`) 会在逐元素状态前缀阶段输出各自的状态前缀，位于 mode `UInt64` 之后。叶类型以及普通复合类型 (由叶类型构成的 `Array`、`Tuple`、`Map`) 的状态前缀为空，可自由组合。

以值为 `[42, 'hi', NULL]` 的 `Variant(String, UInt64)` 为例 (按规范顺序，`String` 排在 `UInt64` 之前，因此判别值 0 = String，1 = UInt64) ：

```text theme={null}
00 00 00 00 00 00 00 00      state prefix: UInt64 discriminators mode = 0 (BASIC)
01 00 FF                     discriminators (3 rows): 1 (UInt64), 0 (String), 255 (NULL)
02 68 69                     String run (1 value): len=2 "hi"
2A 00 00 00 00 00 00 00      UInt64 run (1 value): 42
```

重建结果：第 0 行 = UInt64 run\[0] = `42`；第 1 行 = String run\[0] = `"hi"`；第 2 行 = NULL。

判别值流本身就是索引：每个非 NULL 判别值都会从其所属类型的紧凑序列中取出下一个值，而 `255` (NULL) 不消耗任何值。[Dynamic](#dynamic) 也采用同样的遍历方式进行重建，唯一的区别在于 NULL 的编码方式：

![判别值从各类型的紧凑序列中选取值](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/variant-discriminators.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=b722e6e431e0afbfd7a719d4b9711ea2)

#### Dynamic

一种在运行时才确定值类型的列：每一行保存的值属于某个运行时确定的类型集合中的一种类型，或者为 NULL。与 `Variant` 不同，该类型集合**不**包含在列的类型字符串中，而是由状态前缀携带。

类型字符串：`Dynamic` 或 `Dynamic(max_types=N)`。`max_types` 参数限定该列最多跟踪多少种不同类型，但不影响下文所述的传输格式。

`Dynamic` 有四种编码：`V1 = 1`、`V2 = 2`、`FLATTENED = 3`、`V3 = 4`。服务器输出哪一种编码，取决于所用通道以及查询设置：

* 通过 `clickhouse-client` 和 HTTP `FORMAT Native` 时，写入器的修订版本为 `0` (除非通过 `client_protocol_version` 调高) ，因此默认使用 **V1**。
* 通过原生 TCP 协议并使用协商后的修订版本时，默认使用 **V2**。`Native` 写入器不启用统计信息，因此默认的 `V2` 载荷不包含按变体划分的统计信息：类型列表之后紧跟嵌套的 `Variant` 前缀和数据。 (按变体划分的统计信息属于 MergeTree 磁盘存储层面的内容，不属于 Native 传输格式。)
* 查询设置 `output_format_native_use_flattened_dynamic_and_json_serialization = 1` 会覆盖上述两种情况，无论修订版本为何，均输出 **FLATTENED (版本 3) **。

> [!NOTE]
>
> **适用范围**
>
> 本页仅规定 **`FLATTENED`** 布局。非扁平的 `V1`/`V2`/`V3` 二进制布局属于内部/磁盘表示形式 (二进制编码的类型列表、按变体划分的统计信息) ，本页**不**作规定。客户端若要依据本页解码 `Dynamic`，必须设置 `output_format_native_use_flattened_dynamic_and_json_serialization = 1` 以请求 `FLATTENED`；下文的布局均以该设置为前提。由于版本字节位于前缀开头，解码器可以据此识别实际收到的编码；如果解码器只实现了 `FLATTENED`，则可拒绝 `V1`/`V2`/`V3`。

启用该设置后所选用的 **FLATTENED (版本 3) ** 布局如下：

```text theme={null}
[per block with rows > 0]:
  [8 bytes:  UInt64 LE version = 3]                ← state prefix, repeated at the start of every non-empty block
  [VarUInt num_types]                              ← number of runtime types
  [num_types × type]                               ← type names, in wire order; each a String, or a binary
                                                     type encoding when output_format_native_encode_types_in_binary_format = 1,
                                                     except for a secondary query (see the type field note)
  [per type: its own state prefix]                 ← empty for leaf types; + indexes-type prefix (empty, integer)
  [num_rows × discriminator]                       ← width by num_types (UInt8 if ≤ 255, else UInt16/32/64);
                                                     NULL discriminator = num_types (one past the last type)
  [for each type i, in wire order]:
    [values for the rows whose discriminator == i] ← dense encoding in type i
```

判别值的宽度取能够索引 `num_types` 种类型外加一个 NULL 槽位的最小无符号整数类型：当 `num_types ≤ 255` 时为 `UInt8`，更大时依次为 `UInt16`、`UInt32`、`UInt64`。NULL 对应的判别值就是 `num_types` 本身，这一点与 `Variant` 不同，后者的 NULL 固定为 `255`。重建方式与 `Variant` 相同，同样采用紧凑遍历：为每种类型维护一个计数器，对于判别值为 `d` (≠ `num_types`) 的行 `r`，从类型 `d` 的连续序列中取第 `counter[d]` 个值。

状态前缀 (版本号 + 类型列表) 在每个行数 > 0 的块开头读取；请求头 块和空块不输出任何内容。

> [!NOTE]
>
> **计数值损坏**
>
> `num_types` 在所有类型名称之前从流中读取。解码器必须将其视为不可信数据，不得直接据此确定内存分配的大小——无论是接近 `SIZE_MAX` 的计数 (可能导致中间运算溢出，或抛出非 `DB::Exception` 的异常) ，还是诸如 `100000000` 这样很大但仍可表示的计数 (它远低于 vector 的 `max_size()`，却会在读取任何类型名称之前就分配数 GB 内存) 。ClickHouse 逐条读取类型列表，仅使用有上限的预分配提示，因此损坏的 `num_types` 要么以 `INCORRECT_DATA` 被拒绝 ("`Dynamic` column has too many types"，发生在计数超出容器可容纳范围时) ，要么在流中的类型条目耗尽时以普通读取错误被拒绝——绝不会导致内存不足故障。
>
> 但是，**不要**用 `ColumnDynamic::MAX_DYNAMIC_TYPES_LIMIT` (在 ClickHouse 中为 `254`) 来限制**扁平化**的 `num_types`：扁平化类型列表包含*所有*不同的运行时类型，包括那些溢出到共享 variant 中的类型，因此一个有效的扁平化块完全可以合法地列出远多于该数量的类型。`MAX_DYNAMIC_TYPES_LIMIT` 上限仅适用于非扁平 `V1`/`V2`/`V3` 前缀中的 `num_dynamic_types` 计数，该计数统计的是常规 variant 槽位，并受此上限约束 (ClickHouse 会在为共享 variant 执行 `+ 1` 之前，在该处对其进行校验) 。

> [!NOTE]
>
> **截断的 stream**
>
> 判别值序列恰好包含 `num_rows` 个条目，解码器必须验证确实读取了这么多条目，而不能流中有多少就接受多少。较短的序列并不会与其后的内容产生矛盾：每种类型的值计数是根据实际读取的判别值推导出来的，因此与值序列保持一致；而完全由 NULL 标记组成的序列根本不需要任何值字节。ClickHouse 会在执行任何按类型检查之前，以 `INCORRECT_DATA` ("Mismatch in flattened `Dynamic` column: N rows are expected, but the indexes stream contains only M rows") 拒绝过短的判别值序列，因为无论重建列的实际大小如何，它随后都会被当作 `num_rows` 行来使用。

> [!NOTE]
>
> 序列化具有状态的运行时类型 (`LowCardinality`、`Variant`、`Dynamic`、`JSON`) 会在类型名称列表之后携带嵌套的状态前缀。

运行时类型列表通常遵循 `Variant` 的规范化规则——常规 variant 槽位按 `DataTypeVariant` (类型名称) 顺序写入，因此传输顺序与插入顺序无关。不过，该列表**并不总是**全局有序的：溢出到共享 variant 中的类型 (例如在 `Dynamic(max_types=N)` 下) 会按首次出现的顺序追加在常规槽位之后，因此列表末尾可能不符合类型名称顺序。因此，解码器必须以传输的类型列表为准来分配判别值，不得自行重新排序。列表中不允许出现重复类型或 `Nothing`：两个相同的条目会映射到同一个判别值，而 `Nothing` 根本不是 variant，因此这两种情况都会导致列表中有一部分内容无处重建。ClickHouse 对这两种情况均以 `INCORRECT_DATA` 拒绝。对于行 `[42::UInt64, "hi", NULL]`，两种类型分别为 `String` 和 `UInt64`，且 `"String"` 排在 `"UInt64"` 之前，因此判别值为 `0` = String、`1` = UInt64、`2` = NULL：

```text theme={null}
03 00 00 00 00 00 00 00      state prefix: UInt64 version = 3 (FLATTENED)
02                           VarUInt num_types = 2
06 53 74 72 69 6E 67         type[0] = "String"
06 55 49 6E 74 36 34         type[1] = "UInt64"
01 00 02                     discriminators (3 rows): 1 (UInt64), 0 (String), 2 (NULL)
02 68 69                     String run (type[0], 1 value): len=2 "hi"
2A 00 00 00 00 00 00 00      UInt64 run (type[1], 1 value): 42
```

还原结果：第 0 行 = UInt64 run\[0] = `42`；第 1 行 = String run\[0] = `"hi"`；第 2 行 = NULL。各类型的 run 与类型列表采用相同的传输顺序 (`String` 位于 `UInt64` 之前) 。

#### JSON (第 2 层：FLATTENED Object)

一种更丰富的 JSON 编码：它不像第 1 层那样将每个值都扁平化为文本，而是将该列拆分为多个子列，每个 JSON 路径对应一个子列。要选用此编码，需在启用扁平化序列化标志 (`output_format_native_use_flattened_dynamic_and_json_serialization = 1`) 的同时，**不**请求第 1 层回退 (`output_format_native_write_json_as_string = 0`) ；此时服务器会输出序列化**版本 3**。

路径分为两类：

* **类型化路径**在类型字符串中声明，例如 `JSON(a UInt32, b String)`，并按声明的类型解码。若路径名包含点号，在类型字符串中需用反引号括起。
* **动态路径**在运行时发现，每个路径都解码为一个 [Dynamic](#dynamic) 列。

在 FLATTENED 模式下，**不存在 shared-data 列** (这种溢出存储仅用于非扁平的 V2/V3 Object 编码) 。每个路径都是一个包含 `num_rows` 个值的普通列。

```text theme={null}
[per block with rows > 0]:
  -- prefix phase (repeated at the start of every non-empty block):
  [8 bytes:  UInt64 LE version = 3]                ← state prefix
  [VarUInt num_dynamic_paths]
  [num_dynamic_paths × String]                     ← dynamic path names, in wire order
  [per typed path: its column's state prefix]      ← empty for leaf types
  [per dynamic path: a Dynamic state prefix]       ← version + type list (see Dynamic)
  -- data phase:
  [for each typed path:   its column's data]       ← num_rows values in the declared type
  [for each dynamic path: its Dynamic data]        ← num_rows values (discriminators + runs)
```

请注意这种两阶段的形态：**所有**路径的状态前缀在前，**所有**路径的数据在后。因此，动态路径的 `Dynamic` 前缀 (位于前缀阶段) 与其数据 (位于数据阶段) 是分开的。在每个行数 > 0 的块开头都会读取状态前缀，并且每个路径列 (无论是类型化路径还是动态路径) 都恰好包含 `num_rows` 个值。第 `r` 行的对象通过读取每个路径在索引 `r` 处的值组装而成；若某个动态路径在该行的 `Dynamic` 判别值为 NULL，则该路径不会为对象提供任何键。动态路径名称必须互不相同，且不得与类型字符串中已声明为类型化路径的任何路径重复——这两种情况都会导致组装出的对象在同一个键下出现两个值，因此 ClickHouse 会以 `INCORRECT_DATA` 拒绝这两种情况。非扁平编码中的动态路径列表同样遵循此规则。

> [!NOTE]
>
> **计数值损坏**
>
> 此处所述 FLATTENED 布局中的 `num_dynamic_paths`，以及非扁平 `V1`/`V2` 编码中的动态路径计数，都是在路径名称之前从流中读取的。 (非扁平前缀中没有单独的扁平化路径字段：它们只包含动态路径计数，另外 `V1` 中还有一个读取后即被丢弃的 `max_dynamic_paths` 值。) 与 [`Dynamic`](#dynamic) 一样，解码器必须将这些计数视为不可信数据，不得直接据此确定内存分配的大小——无论是 `SIZE_MAX` 量级的计数，还是数值很大但仍可表示的计数。ClickHouse 逐个条目读取路径名称，且只使用有上限的预分配提示，因此损坏的计数会被拒绝：要么以 `INCORRECT_DATA` 拒绝 ("JSON/Object column has too many paths"，即超出容器可容纳的数量时) ，要么在流中的路径名称耗尽时以普通读取错误拒绝。

> [!NOTE]
>
> **截断的 stream**
>
> 每个路径列包含 `num_rows` 个值是硬性要求，而非上界：解码器必须将每个路径列 (包括类型化路径和动态路径) 与块的行数进行核对，而不能指望流恰好在正确位置结束。组装出的对象没有可供比对的独立大小——其行数完全取决于反扁平化的结果——因此长度不足的路径列事后无法被检测出来，而读取其第 `r` 行时就会越界。ClickHouse 会以 `INCORRECT_DATA` ("Unexpected size of typed path / flattened path *p* in flattened Object column") 拒绝这种情况。对于动态路径，该检查是在其 [`Dynamic`](#dynamic) 列内部的等效检查之外额外进行的。

`JSON` 值 `{"a": 42, "b": "hi"}` (一行，两个路径均为动态路径) 。JSON 整数会被推断为 `Int64`：

```text theme={null}
03 00 00 00 00 00 00 00      version = 3 (Object)
02                           num_dynamic_paths = 2
01 61                        path "a"
01 62                        path "b"
03 00 00 00 00 00 00 00 01 05 49 6E 74 36 34      "a" Dynamic prefix: version 3, 1 type, "Int64"
03 00 00 00 00 00 00 00 01 06 53 74 72 69 6E 67   "b" Dynamic prefix: version 3, 1 type, "String"
00 2A 00 00 00 00 00 00 00   "a" data: discriminator 0, Int64 42
00 02 68 69                  "b" data: discriminator 0, String "hi"
```

#### JSON 非扁平编码 (V1/V2)

非扁平化的 `Object` 编码是扁平化标志关闭时服务器在线上传输中输出的编码：通过 `clickhouse-client` / HTTP `FORMAT Native` (修订版本 `0`) 传输时为 `V1`，通过原生 TCP 协议传输时为 `V2`。 (第三种非扁平编码 `V3` 仅写入 MergeTree parts，在线上传输中会被拒绝。) 这些编码包含一个 shared-data 列，本页**不**对其进行说明。请注意，它们在 Native 线上传输中**不**携带按路径划分的统计信息：`NativeWriter` 会禁用统计信息，因此 `Object` 结构前缀中没有统计信息部分，紧随其后的字节直接就是 typed/dynamic/shared-data 前缀及数据。统计信息仅出现在启用了统计信息的 MergeTree 磁盘路径上。若要按照本页解码 `JSON` 列，客户端必须选用本页所述的某一层级：设置 `output_format_native_write_json_as_string = 1` 以使用 [String 回退](#json-tier-1-string-fallback)，或设置 `output_format_native_use_flattened_dynamic_and_json_serialization = 1` (同时设置 `output_format_native_write_json_as_string = 0`) 以使用 [FLATTENED Object](#json-tier-2-flattened-object) 布局。

## 压缩帧

ClickHouse 可以使用一种内部帧格式压缩 `Native` 流中的列数据。下文所述的[帧布局](#frame-format)**与传输方式无关**——原生 TCP 协议和 HTTP 中使用的是相同的帧——但请求压缩的方式以及帧外围的内容因传输方式而异。

* **原生 TCP 协议。** 压缩需按查询显式启用，方法是在 [Query 数据包](https://clickhouse.com/docs/zh/resources/develop-contribute/native-protocol/protocol.md#query)中设置 `compression` 标志。启用后，每个 `Data`、`Totals`、`Extremes`、`Log` 和 `ProfileEvents` 数据包的正文 (即 `table_name` 字符串之后的字节) 都会以帧格式封装。数据包封装本身 (即数据包类型代码和 `table_name` 字符串) **不会**被压缩，服务器会将其直接写入原始流。`NativeWriter` 输出的所有内容都会进入压缩流，因此帧内首先是 `BlockInfo` 前缀，随后是维度和列。也就是说，客户端必须先解压帧，才能读取 `BlockInfo`。
* **HTTP。** `SELECT ... FORMAT Native&compress=1` 会将整个 `FORMAT Native` 字节流封装在相同的帧中 (服务器使用相同的内部 `CompressedWriteBuffer`) ，而 `?decompress=1` 则要求 `Native` *输入*请求正文采用相同的帧，并通过对应的 `CompressedReadBuffer` 进行解码。该路径中没有 TCP 数据包类型、`table_name` 或数据包封装：整个压缩载荷仅由经过帧封装的 `Native` 块组成 (与上文的未压缩布局完全一致，仅当协商的修订版本大于 `0` 时才包含 `BlockInfo` 前缀) 。这种内部的 `compress`/`decompress` 帧信息不同于 HTTP 传输压缩 (`Content-Encoding: gzip`/`zstd`，通过 `enable_http_compression` 启用) ；后者在 HTTP 层封装响应，并不属于下文所述的帧格式。

因此，仅实现了未压缩 `FORMAT Native` 布局的客户端，仍需额外支持这一帧层，才能读取压缩的 HTTP `Native` 响应，或发送 `decompress=1` 请求正文。

### 帧格式

```text theme={null}
[16 bytes: CityHash128 checksum over the 9-byte header + compressed body]
[1 byte:   method]                 ← 0x82 = LZ4, 0x90 = ZSTD, 0x02 = NONE
[4 bytes:  compressed_size LE u32] ← INCLUDES the 9-byte header, EXCLUDES the 16-byte checksum
[4 bytes:  uncompressed_size LE u32]
[N bytes:  compressed body]        ← N = compressed_size - 9
```

帧的总大小为 `16 + compressed_size` = `16 + 9 + body_size` = `25 + body_size`。请注意这两个范围的区别：校验和覆盖 9 字节的头部和请求正文，而 `compressed_size` 计入头部和请求正文，但**不**包含校验和本身：

![压缩帧各字段及 compressed-size 的覆盖边界](https://mintcdn.com/private-7c7dfe99/kA9td9fgr8TRWXKS/images/interfaces/native-format/compression-frame.webp?fit=max\&auto=format\&n=kA9td9fgr8TRWXKS\&q=85\&s=1d0d4d1a7615bd7bf38eee8504fc7296)

### 方法字节值

服务器为整流 `Native` 帧信息生成的正是以下三种编解码器：HTTP `compress=1` 输出使用服务器的默认编解码器 (`ZSTD(3)`) ，而原生 TCP 协议则根据 `network_compression_method` 使用 `LZ4`、`ZSTD` 或 `NONE`。通用的 `Native` 客户端只需生成和解析这几种即可。

| 字节 | 方法 | 请求正文编码 |
| - | - | - |
| `0x02` | NONE | 请求正文为原始字节 (未压缩) 。帧仍会照常发出；接收方会校验 校验和。 |
| `0x82` | LZ4 | 请求正文为 **LZ4 块格式**，*而非* LZ4 帧格式。不含魔数。 |
| `0x90` | ZSTD | 请求正文为原始 zstd 单帧流 (标准 zstd 魔数包含在请求正文中) 。 |

方法字节还用于标识[列级编解码器](https://clickhouse.com/docs/zh/reference/statements/create/table/codec.md)。这些编解码器在 MergeTree 磁盘路径上按列应用，而不是作用于整流帧信息；但 `decompress=1` HTTP 输入路径会从每个帧的方法字节中读取编解码器，因此这些字节中的任何一个都可能合法地出现在输入中。所以，符合规范的解码器必须能够识别整个已分配的取值空间，遇到未实现的字节时应直接拒绝，而不是错误地解析请求正文。这些编解码器的请求正文格式各不相同，不在本通用帧约定的范围内：

| 字节 | 方法 |
| - | - |
| `0x91` | `Multiple` (封装一系列嵌套编解码器的组合编解码器) |
| `0x92` | `Delta` |
| `0x93` | `T64` |
| `0x94` | `DoubleDelta` |
| `0x95` | `Gorilla` |
| `0x96` | `AES_128_GCM_SIV` (加密) |
| `0x97` | `AES_256_GCM_SIV` (加密) |
| `0x98` | `FPC` |
| `0x9a` | `GCD` |
| `0x9c` | `ALP` |
| `0x9d` | `SZ3` |
| `0x9e` | `Quantized` |
| `0x9f` | `ZXC` |

`DoubleDelta` (`0x94`) 以 MSB 优先的前缀/符号/幅值位存储二阶差分。基于字的解码会保留这种编码方式、元素数量以及末字节填充。

`0x9c` (`ALP`) 是一种适用于 `Float32` 和 `Float64` 的 **beta** 无损编解码器 (自适应无损浮点压缩) 。只有启用 `enable_alp_codec` 设置后，才能使用 `CODEC(ALP)` 创建表；但解压时始终接受该方法字节，以确保先前写入的数据仍可读取。`0x9d` (`SZ3`) 是一种 **Experimental** 的误差有界*有损*编解码器，适用于 `Float32`、`Float64` 以及由这些类型构成的 `Array`。只有启用 `enable_sz3_codec` 设置后，才能使用 `CODEC(SZ3)` 创建表；但解压时始终接受该方法字节，以确保先前写入的数据仍可读取。`0x9f` (`ZXC`) 是一种 **Experimental** 的非对称 LZ 编解码器：压缩较慢，但解压极快，压缩率介于 `LZ4` 和 `ZSTD` 之间。只有启用 `enable_zxc_codec` 设置后，才能使用 `CODEC(ZXC)` 创建表；但解压时始终接受该方法字节，以确保先前写入的数据仍可读取。字节 `0x99` (`DeflateQpl`) 和 `0x9b` (`ZSTD_QPL`) 曾分配给现已移除的编解码器，目前作为保留值，不会被复用。

`0x9e` (`Quantized`) 是一种 **Experimental** 的列编解码器，适用于稠密向量列 (`Array(Float32)` 及类似类型) 。与 `NONE` 一样，它采用直通方式，全精度请求正文按原样存储；但启用它后会附加一种序列化方式，额外写入一个紧凑的量化伴随流，用于加速向量搜索。只有启用 `enable_quantized_codec` 设置后，才能使用 `CODEC(Quantized(...))` 创建表，而解压时始终接受该方法字节。

### 校验和

ClickHouse 使用的是 CityHash v1.0.2 (历史版本) ，**而非**现代的 Google CityHash；两者的输出结果并不相同。

校验和基于 9 个头部字节 (method + compressed\_size + uncompressed\_size) 以及 N 个请求正文字节计算得出，即覆盖从校验和之后到帧末尾的全部内容。在 16 字节的 CityHash128 输出中，前 8 个字节为低半部分 (LE) ，后 8 个字节为高半部分 (LE) 。解码器会基于接收到的头部和请求正文重新计算 CityHash128，并与开头的 16 个字节进行比对；若两者不一致，则表明数据已损坏，解码器会直接报错。

### 按块划分的边界

块的压缩载荷是**由一个或多个帧组成的流**，不一定只有单个帧。发送方通过 `CompressedWriteBuffer` 写入序列化后的块：每当其内部缓冲区写满 (约 1 MB，`DBMS_DEFAULT_BUFFER_SIZE`) 时就输出一个帧，并在块刷写时输出最后一个帧。因此，小块对应一个帧，大块则对应若干个连续的帧。

这一不变量只在单一方向上成立：由于发送方在每个块结束时都会刷写压缩缓冲区，**每个块的结尾都与帧边界重合**——但反之则不成立。若缓冲区在块写入中途写满，由此产生的中间帧边界位于块的*中间*，并不是块边界。因此，解码器必须依据块自身的维度 (`num_columns`/`num_rows`) 来判断块在何处结束，而不能假定每个帧就是一个完整的块。

接收方以流式方式处理帧：先读取 16 + 9 字节，再精确读取 `compressed_size - 9` 字节的请求正文数据，将其解压为恰好 `uncompressed_size` 字节，然后交给块解码器；当解码器所需的数据超出当前帧的内容时，再拉取下一个帧。由于发送方按块刷写，一个块完全解码后，帧缓冲区即为空，下一个块会从一个新帧开始。

在原生 TCP 协议中，数据包封装——即数据包类型 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` 的输出使用服务器的默认编解码器 (`ZSTD(3)`) 写入，而不是 `network_compression_method`；`decompress=1` 读取器根据每个帧的方法字节确定编解码器，因此输入端可接受任意编解码器。

> [!NOTE]
>
> 启用压缩后，对于包含多行的块，服务器还可能通过并行块编组 / `ColumnBLOB` 路径 (`PARALLEL_BLOCK_MARSHALLING`，v54478) 传输列。对 INSERT 数据进行压缩的实现必须能够处理该路径 (或显式选择不使用该路径) ，以免数据流失去同步。

## 术语表

**块** — Native 格式中数据交换的基本单位。一种自描述、按列存储的行数据块。参见[块与列结构](#block-and-column-structure)。

**BlockInfo** — 在 TCP Data 数据包路径上位于 块 之前的元数据头 (只要连接修订版本大于零就会写入) 。它由一系列受修订版本控制、以字段 ID 标记的字段组成。`Native` 输出格式以修订版本 `0` 进行序列化，因此不包含该部分。参见 [BlockInfo](#blockinfo)。

**列数据体 (Column body) ** — 列中位于列头 (名称、类型、has\_custom\_serialization 字节) 之后、承载实际值的字节。其布局因类型而异。参见[列的线协议布局](#column-wire-layout)。

**复合类型 (Composite type) ** — 由一个或多个内部类型构成的类型，每列编码为多个流。其线协议格式稳定，且不带版本。参见[复合类型](#composite-types)。

**字典 (LowCardinality) ** — `LowCardinality(T)` 列通过整数索引引用的唯一值数组。参见 [LowCardinality](#lowcardinality)。

**空块 (Empty block) ** — `num_columns = 0` 且 `num_rows = 0` 的 块。用作哨兵：在客户端表示输入结束，在服务器端表示流边界。参见[块的变体](#block-variants)。

**头部块 (Header block) ** — `num_columns > 0` 且 `num_rows = 0` 的 块，由服务器作为查询响应的第一个 Data 数据包发送，用于声明结果 schema。参见[块的变体](#block-variants)。

**内部类型 (Inner type) ** — 复合类型所包装的类型。`Array(UInt32)` 的内部类型为 `UInt32`；`Nullable(T)` 的内部类型为 `T`。

**偏移量流 (Offsets stream) ** — 由累计结束位置构成的 UInt64 数组，`Array`、`Map` 和 `Nested` 借此划定每行元素的边界。参见 [Array](#array)。

**占位值 (Placeholder value) ** — `Nullable(T)` 列的值流中写在 null 位置上的字节。解码器会读取这些字节以推进流，但忽略其内容。参见 [Nullable](#nullable)。

**结果块 (Result block) ** — `num_rows > 0`、承载实际查询结果行的 块。参见[块的变体](#block-variants)。

**Schema 块 (Schema block) ** — 头部块的同义词，用于描述 INSERT 阶段，此时由 schema 块告知客户端预期的列结构。

**序列化版本 (Serialization version) ** — 按类型定义、在线上传输的版本号，版本化类型用它来声明后续采用哪种编码变体。它不同于协议版本。参见[序列化版本：概念](#serialization-version-concept)。

**状态前缀 (State prefix) ** — 版本化类型中位于每个块载荷之前的字节，携带序列化版本以及 (对于 LowCardinality) 每个块的字典元数据。在每个行数 > 0 的块开头写出，不会跨块保留。

**流 (Stream) ** — 列数据体中一段连续的字节，用于编码一个逻辑子组件 (null-map、偏移量数组、值流) 。多流类型会在每列中依次拼接两个或更多流。
