Nativeフォーマットは、ClickHouse が表形式データをやり取りするために使用する列指向のワイヤ形式です。これは次のような箇所で使われます。
- ネイティブ TCP プロトコル における
Data、Totals、Extremes、Log、ProfileEventsパケットのボディ (TableColumnsパケットは Native ブロックではありません。2 つのバイナリ文字列を持つため、そのレイアウトは ネイティブプロトコル仕様 に属します) ; - HTTP 経由の
SELECT ... FORMAT Nativeの出力; INTO OUTFILE ... FORMAT Nativeで書き出されるファイルエクスポート;- サーバー間レプリケーションのペイロード。
このページでは、Block 内のバイト列、つまり列指向ペイロードと、それを構成する各カラムの型エンコーディングについて説明します。パケットのフレーミング、接続状態、バージョンネゴシエーションについては、ネイティブプロトコル仕様 を参照してください。
複数バイトの整数フィールドはすべてリトルエンディアンです。符号付き整数には 2 の補数を使用します。
概要
ワイヤ上で行を運ぶものはすべて Block です。これは、行をカラム単位で格納した、自己記述的な chunk です。最初にカラム 1 のすべての値が並び、次にカラム 2 のすべての値が続きます。Block に含まれるのは、完全なテーブルではなく、クエリが参照するカラムだけです。
カラムの data は、その型が属するファミリーに応じて配置されます。デコーダの複雑さが低いものから高いものへ順に、次のファミリーがあります。
- 固定幅型では、
dataはbytes_per_value × num_rowsの生バイトとして配置され、行ごとのフレーミングはありません。 - 複合型 (
Nullable,Array,Tuple,Map,Nested) は、型文字列から完全に導き出せる再帰的な構造を持ち、バージョンプレフィックスもブロック間の状態もありません。 - バージョン付き / ステートフル 型 (
LowCardinality,JSON,Variant,Dynamic) は、空でない各ブロックの先頭にシリアライゼーションのバージョン/state prefixを付加します。Nativewire では、このプレフィックスと任意の Dictionary はブロックごとです。つまり、このフォーマットはブロック間で状態を持ちません (writer はブロックごとに新しいシリアライゼーション状態を作成し、low_cardinality_max_dictionary_size = 0を設定します) 。ブロック間の状態は MergeTree のオンディスク上の話であり、Native wire layout の話ではありません。
ワイヤプリミティブ
Nativeフォーマットは、4つの基本的なエンコーディングを基盤としています。
| Primitive | Size | Description |
|---|---|---|
| VarUInt | 1–10 B | LEB-128 可変長符号なし整数 |
| 固定幅 int | 1, 2, 4, 8, 16, 32 B | リトルエンディアン、符号付き整数は 2 の補数 |
| String | variable | VarUInt の長さプレフィックス + 値ごとの生のバイト列。リビジョン 54492+ では別個のオフセットストリーム |
| Bool | 1 B | 0x00 = false、非ゼロ = true |
VarUInt
LEB-128 エンコーディングを使用する可変長の符号なし整数です。各バイトは、0~6 ビット目に 7 ビットのデータ、7 ビット目に 1 ビットの継続ビットを持ちます。継続ビットは、後続のバイトがある場合は 1、最後のバイトでは 0 になります。
| Value range | Bytes |
|---|---|
| 0 – 127 | 1 |
| 128 – 16383 | 2 |
| 16384 – 2097151 | 3 |
| UInt64 の最大値まで | 最大 10 |
値 300 のエンコード:
300 = 0b100101100
Byte 0: 0xAC = 0b10101100 (data: 0101100, continuation: 1)
Byte 1: 0x02 = 0b00000010 (data: 0000010, continuation: 0)バイト列 0xAC 0x02 をデコードすると:
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 | 生のバイト、2 の補数 |
| Int16 | 2 | リトルエンディアン、2 の補数 |
| Int32 | 4 | リトルエンディアン、2 の補数 |
| Int64 | 8 | リトルエンディアン、2 の補数 |
| Int128 | 16 | リトルエンディアン、2 の補数 |
| Int256 | 32 | リトルエンディアン、2 の補数 |
| Float32 | 4 | IEEE 754 単精度、リトルエンディアン |
| Float64 | 8 | IEEE 754 倍精度、リトルエンディアン |
たとえば、UInt32 の値 1 は 01 00 00 00 にエンコードされ、Int32 の値 -1 は FF FF FF FF にエンコードされます。
String
長さプレフィックス付きのバイト列:
[VarUInt: byte_length] [byte_length bytes: raw value]バイト列は、有効な UTF-8 である必要はありません。空文字列は 1 バイトの 0x00 としてエンコードされ、文字列には埋め込み NUL を含む任意のバイト値を含めることができます。文字列 "ab" は 02 61 62 としてエンコードされます。デコードするには、まず VarUInt の長さ (2) を読み取り、次にその長さ分のバイトを読み取ります。
Bool
1 バイトです。0x00 は false、0x00 以外の値は true (標準的には 0x01) です。
ブロックとカラム構造
Block のワイヤレイアウト
[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 = 0BlockInfo プレフィックスの有無はチャネルによって異なります。これは、writer が リビジョン によってパラメータ化されているためです (完全な説明については プロトコルのリビジョンとNativeフォーマット を参照してください。これには client_protocol_version が出力専用であることも含まれます)。
-
native TCP プロトコル では、server は接続時にネゴシエートされた リビジョン で block を書き込みます (大きな値であり、
DBMS_TCP_PROTOCOL_VERSION、src/Core/ProtocolDefines.hを参照) 。BlockInfoは、その リビジョン が 0 より大きい場合に書き込まれます。実際の接続では、これは常に当てはまります。各 カラム のhas_custom_serializationバイト (カラム wire layout を参照) は、リビジョン54454以上で書き込まれます。 -
Native出力フォーマット — HTTP 経由のSELECT ... FORMAT Native、INTO OUTFILE ... FORMAT Native、およびclickhouse-clientが生成するNativeフォーマット — は、デフォルトでは リビジョン0でシリアライズされます。リビジョン0では、BlockInfoプレフィックスとhas_custom_serializationバイトはどちらも省略されるため、block は単にnum_columns、num_rows、および カラム だけになります。HTTP では、この リビジョン は固定ではありません。client は
?client_protocol_version=<n>クエリパラメータでこれを引き上げることができ、server はその値をレスポンスのシリアライゼーション リビジョン として使用します。十分に大きな値を指定すると、HTTP 出力には
BlockInfoプレフィックス (リビジョン が0より大きい場合に書き込まれます) とhas_custom_serializationバイト (リビジョン54454以上で書き込まれます) が含まれ、TCP 経路とまったく同じになります。したがって、clients はすべての HTTPFORMAT Nativeペイロード が リビジョン0だと想定してはなりません。
つまり、この節で BlockInfo プレフィックスで始まるバイト例は、TCP Data-packet ペイロード を示しています。同じクエリを 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 | List of Int32 | 54480 | 分散集約中に遅延したバケット。VarUInt の個数に続けて、その個数分の Int32 値としてエンコードされます。 |
| 0 | (終端) | — | — | BlockInfo の終端。常に必須です。 |
フィールド 1 と 2 の最小リビジョンは 0 であるため、BlockInfo が書き込まれる場合は常に含まれます。フィールド 3 はリビジョン 54480 以上でのみ書き込まれます。一般的なケース (リビジョンが 54480 未満) でのワイヤレイアウトは次のとおりです。
[VarUInt: 1] [UInt8: is_overflows]
[VarUInt: 2] [Int32: bucket_number]
[VarUInt: 0]Column のワイヤレイアウト
1 つの Block 内には、Column が num_columns 回現れます。
| # | Field | Type | Condition | Description |
|---|---|---|---|---|
| 1 | name | String | 常に | カラム名 |
| 2 | type | String または binary type encoding | 常に | 既定では ClickHouse の型文字列 (例: "UInt64"、"Array(String)"")です。output_format_native_encode_types_in_binary_format = 1` の場合は、binary type encoding になります (下の注記を参照) 。 |
| 3 | has_custom_serialization | UInt8 | feature CUSTOM_SERIALIZATION (v54454) |
0 = 既定、1 = カスタム (この後に kind_stack が続く) |
| 4 | kind_stack | bytes | field 3 = 1 の場合 |
既定以外のシリアライゼーション (スパースなど) を示す UInt8 の enum バイト 1 つです (下記参照) 。値が COMBINATION の場合は、その後に VarUInt の個数と、その個数分の追加 kind バイトが続きます。Tuple (および要素レベルのシリアライゼーション情報を持つその他の複合型) の場合、ペイロードは再帰的になります。下記を参照してください。 |
| 5 | data | bytes | 常に | num_rows の全行に対するカラム値です。レイアウトは型ごとに異なります。data typesを参照してください。スパースカラムについては下記を参照してください。 |
デコーダは type 文字列に基づいて処理を分岐します。型文字列には括弧付きのパラメーターが含まれることが多いため、デコーダはまず (...) の接尾辞を取り除いて基本型を特定し、その後でサイズ、scale、または内部型の判定に必要なパラメーターをパースします。ネストした型を含むパラメーターリスト (たとえば Array の中に Tuple がある場合) をパースするには、単純に , で分割するのではなく、括弧のネストを追跡する深さ対応のカンマ分割が必要です。
kind_stack とスパースエンコーディング
kind_stack バイトは、カラムごとの非デフォルトなシリアライゼーションを表します。
| Byte | Name | Meaning | Wire impact on data |
|---|---|---|---|
0x00 |
DEFAULT | デフォルトのシリアライゼーション | has_custom = 0 と同一 |
0x01 |
SPARSE | スパースシリアライゼーション (v54465+) | オフセットストリーム + 非デフォルト値。詳細は下記 |
0x02 |
DETACHED | 並列ブロックマーシャリング (v54478+) により ColumnBLOB でラップされたカラム |
事前にマーシャリングされたブロブ: VarUInt size + そのサイズ分のバイト列。詳細は下記 |
0x03 |
DETACHED_OVER_SPARSE | ColumnBLOB でラップされたスパースカラム |
DETACHED と同じブロブペイロード。詳細は下記 |
0x04 |
REPLICATED | 繰り返し値用の Dictionary 形式 (v54482+) | 索引ストリーム + 密にエンコードされた要素値。詳細は下記 |
0x05 |
COMBINATION | 複数 kind のスタック | この後に VarUInt count と、さらに count 個の kind バイトが続く — 詳細は下の注記を参照 |
COMBINATION ペイロードでは別の enum を使います。 上の 5 行は compact な 1 バイトコードです。COMBINATION (0x05) は、それらで表せない任意のスタックに対する汎用エスケープで、後ろに VarUInt count、さらに count 個の 1 バイトエントリが続きます。これらのエントリは、表にある compact コードではなく、生の ISerialization::Kind 値です。
| Byte | Nested Kind |
|---|---|
0x00 |
DEFAULT |
0x01 |
SPARSE |
0x02 |
DETACHED |
0x03 |
REPLICATED |
このネストされた enum のバイト値は compact コードとは異なります。たとえば REPLICATED はこの nested enum では 0x03 ですが、compact コードでは 0x04 です。また、DETACHED_OVER_SPARSE に対応するエントリはなく、この組み合わせは SPARSE, DETACHED という 2 つの連続したエントリとして表されます。ネストされたバイトに対しても compact テーブルを使い続けるデコーダーは、0x03/0x04 の対応付けを誤り、同期がずれます。
count は、すべてのスタックの先頭にある DEFAULT エントリを含むスタック全体の長さです。compact コードはすでに 1 エントリおよび 2 エントリのスタックをすべてカバーしているため、COMBINATION の count は常に 3 以上になります。
Tuple カラムにおける再帰的な kind_stack。 上記の kind_stack ペイロードは、1 つのカラム自身のシリアライゼーション情報に対応するバイト (または COMBINATION シーケンス) です。Tuple は SerializationInfoTuple を持ち、まず tuple 自身の kind-stack ペイロードを書き出し、その後に各要素について完全な kind-stack ペイロードを順番に書き出します。デコーダーも同じ再帰構造でこれを読み戻します。したがって Tuple(A, B, C) では、field 4 のバイト列は [tuple_kind][A_kind][B_kind][C_kind] となり、ある要素がさらに複合型であれば、その要素のペイロード自体も再帰的になります。has_custom_serialization バイト (field 3) は、tuple 自身の情報またはいずれかの要素の情報が非デフォルトである場合に設定されるため、特殊な要素が sparse、replicated、detached のいずれか 1 つだけである Tuple でも kind-stack ペイロードが出力されます。Tuple に対して先頭の単一 enum バイトだけを読むデコーダーは、途中で早く止まり、残りの要素 kind バイトをカラムデータとして誤読します。
スパースのワイヤ形式。 kind_stack = 0x01 の場合、カラム data は 2 つのストリームからなり、1 本の共有 TCP ストリーム上に連続して書き込まれます。
- オフセットストリーム —
VarUIntの数列。各値vは次のいずれかです。- 62 ビット目がクリアされた
v:(v & 0x3FFFFFFFFFFFFFFF)= 次の明示的な非デフォルト値の前にあるデフォルト位置の数。その非デフォルト位置はcursor + group_sizeで、ここでcursorは現在位置です。その後cursorはgroup_size + 1だけ進みます。 - 62 ビット目がセットされた
v(END_OF_GRANULE_FLAG): フラグをクリアした値 = 最後の非デフォルト値の後ろにある、末尾のデフォルト位置の数。これが、そのブロックにおけるオフセットストリームの終端を示します。
- 62 ビット目がクリアされた
- 値ストリーム — 内部型で密にエンコードされた
count個の非デフォルト値。ここでcountは、上で読み取った非 EOG のVarUIntの個数です。
デコーダーは、num_rows 個のエントリを持つ密なカラムを再構築する際、明示されていないすべての位置を内部型のデフォルト値 (整数と浮動小数点数では 0、String では ""、Date では 0 日、など) で埋めます。
スパースな Nullable(T) カラムは特別なケースです。というのも、Nullable(T) のデフォルト値は NULL だからです。スパースエンコーディングでは、通常の Nullable の null-map ストリームは完全に省略されます。オフセットストリームがデフォルト値以外、つまり非 NULL の位置を示し、値ストリームにはそれらの非 NULL 値だけが T として密に格納され、明示されていないすべての位置は NULL として再構築されます。したがって、デコーダーは値ストリーム内に null map を探しては ならず、またギャップを存在する 0 で埋めて ならず、NULL で埋めなければなりません。
レプリケーションされたワイヤ形式。 kind_stack = 0x04 の場合、カラム data は辞書です。つまり、重複のない要素値のリストと、そのリストへの各行の索引から構成されます (LowCardinality と同じルックアップ構造です) 。内部型自体がバージョン化されている場合、たとえば LowCardinality(T) では、その状態プレフィックスがインデックスストリームより先に書き込まれます。つまり、このレプリケーションされたシリアライゼーションでは、num_rows を書き込む前に、プレフィックスのフェーズを内部型に委譲します。プレフィックスが空の内部型 (リーフ型および通常の複合型) は、ここでは何のバイト列も追加しません。
[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) (共有 Dictionary は保持され、要素ごとのキーだけがインデックス参照される) が含まれます。
デタッチされたワイヤ形式。 DETACHED (0x02) と DETACHED_OVER_SPARSE (0x03) は、ワイヤ上に実際に現れます。つまり、純粋な内部表現ではありません。TCP パスでは、圧縮が有効で、ネゴシエートされたリビジョンが少なくとも DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING (v54478) である場合、カラムは次の 3 段階を経ます。
- 対象となる各カラム (
constでもTupleでもなく、かつ複数行を含むブロック内にあるもの) は、メインスレッドとは別で、すでにマーシャリングおよび圧縮済みのカラムを保持するColumnBLOBでラップされます。 DETACHEDが、ラップされたカラムの kind スタックに追加されます。- カラムの
dataは、VarUIntのブロブサイズに続けて、そのサイズちょうどのブロブのバイト列として書き込まれます。
ラップされたカラムがスパースだった場合、そのスタックは {DEFAULT, SPARSE, DETACHED} となり、DETACHED_OVER_SPARSE としてシリアライズされます。そのようなカラムをデコードするクライアントは、ブロブの長さとバイト列を読み取った後、ブロブを解凍して内側のカラムのペイロードを復元します (圧縮の節にある ColumnBLOB の注記 を参照) 。
Block のバリアント
Data ファミリーのすべてのパケットは、同じ Block ワイヤ形式を共有します。バリアントごとの差異は、カラム数と行数のみです。
| Variant | num_columns | num_rows | Purpose |
|---|---|---|---|
| Header block | N > 0 | 0 | 結果スキーマ (カラム名 + 型) を通知します。 |
| Result block | N > 0 | M > 0 | 実際の結果の行です。 |
| Empty block | 0 | 0 | センチネル — クライアント側では入力の終端、サーバー側では境界マーカーです。 |
バイトレベルの例
このセクションのすべての例は TCP Data-packet path から取っているため、BlockInfo プレフィックスと has_custom_serialization バイトが含まれています。FORMAT Native では同じブロックはより短くなり、参考になる場合は対応する短縮形式も示します。
空のブロック (BlockInfo あり) 、合計 8 バイト:
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 = 0SELECT 1 のヘッダーブロックは、型 UInt8 の "1" という名前のカラムが 1 つあり、行数は 0 であることを示します。プロトコル ≥ 54454 では、has_custom_serialization バイトが含まれます。
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)同じクエリの結果ブロック (1行) :
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 = 1FORMAT Native (リビジョン 0) では、同じ結果ブロックに BlockInfo も has_custom_serialization バイトも含まれず、SELECT 1 FORMAT Native は 11 バイトです:
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(ヘッダーのみのブロックのような0行の結果は、FORMAT Native では一切バイトを出力しません。出力フォーマットは空のブロックを出力しないためです。)
プロトコルのリビジョンとNativeフォーマット
Nativeのバイトストリームは、何よりもまず、そのwriterとreaderが使用するプロトコルのリビジョンによって決まります。リビジョンはバイト列そのもののどこにも含まれておらず、つまりワイヤ上にリビジョン フィールドは存在しません。それでも、いくつかの機能が現れるかどうかはこの値で決まります。そのため、デコーダがペイロードをparseするには、そのペイロードがどのリビジョンで書き込まれたかを事前に把握している必要があります。リビジョンはストリームに含まれないため、readerとwriterは別の方法でその値を取り決める必要があります。
これは単一のUInt64値で、NativeWriterとNativeReaderはいずれもこれをコンストラクタ引数として受け取ります。writerはこれをclient_revision、readerはserver_revisionと呼びますが、どちらも同じ数値です。このリリースが認識している最新のリビジョンはDBMS_TCP_PROTOCOL_VERSIONです (src/Core/ProtocolDefines.hを参照) 。
リビジョンが制御する内容
各機能には DBMS_MIN_REVISION_WITH_* のしきい値があります。writer は自身のリビジョンがそのしきい値に達して初めてその機能を出力し、reader もまったく同じ条件でそれを期待します。こうして両者の動作はそろいますが、どちらか一方でもリビジョンを誤ると読み書きの前提がずれてしまいます。Nativeフォーマット で重要になるゲートは次のとおりです。
| Feature | Threshold constant | Revision | Effect when below threshold |
|---|---|---|---|
BlockInfo prefix |
(any value > 0) |
1 |
BlockInfo プレフィックスは完全に省略され、block は num_columns、num_rows、columns だけになります。 |
has_custom_serialization byte |
DBMS_MIN_REVISION_WITH_CUSTOM_SERIALIZATION |
54454 |
カラムごとの has_custom_serialization バイトは省略され、すべてのカラムでデフォルトのシリアライゼーションが使われます (spare、replicated、detached 形式は使われません) 。 |
LowCardinality on the wire |
DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE |
54405 |
特殊ケースで、単純な「しきい値未満なら無効」という規則には従いません。LowCardinality(T) が基本型 T に落とされるのは、リビジョンが 0 ではなく 54405 未満の場合、または別途強制的に落とす場合だけです。リビジョン 0 では保持されます。詳しくは以下の注記を参照してください。 |
V2 Dynamic / JSON serialization |
DBMS_MIN_REVISION_WITH_V2_DYNAMIC_AND_JSON_SERIALIZATION |
54473 |
Dynamic と JSON/Object は V2 ではなく V1 のシリアライゼーション (max_dynamic_* パラメーター付き) を使用します。 |
Offsets String serialization |
DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION |
54492 |
しきい値未満では、String カラムデータは offsets レイアウト (UInt64 としての累積バイトオフセット、続いてすべてのデータを連結) ではなく、値ごとのレイアウト (行ごとの VarUInt 長さプレフィックス + raw bytes) を使用します。カラムごとの wire marker はなく、リビジョンのみで制御されます。しきい値以上では、offsets レイアウトは composite types (Array、Nullable、Map、Tuple、Variant、Dynamic、JSON) 内にネストされた String にも適用されますが、LowCardinality カラムの Dictionary には適用されません。LowCardinality Dictionary は常にデフォルトのネストされたシリアライゼーションで書き込まれるため、その String 値は値ごとのレイアウトを維持します (LowCardinality(String) および LowCardinality(Nullable(String)) を含みます)。Buffers フォーマットはプロトコルの一部ではなく、常に値ごとのレイアウトを使用します。 |
| Aggregate-function versioning | DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING |
54452 |
AggregateFunction state は埋め込みバージョンなしで書き込まれます。 |
skip_degree in quantileDeterministic state |
DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE |
54491 |
AggregateFunction(quantileDeterministic, ...) state (および同じ関数の quantiles/median 表記) は、末尾の UInt8 skip_degree なしで state バージョン 0 として書き込まれます。type aliases の注記を参照してください。 |
out_of_order_buckets in BlockInfo |
DBMS_MIN_REVISION_WITH_OUT_OF_ORDER_BUCKETS_IN_AGGREGATION |
54480 |
BlockInfo のフィールド ID 3 は書き込まれません (BlockInfo を参照) 。 |
Parallel block marshalling (DETACHED) |
DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING |
54478 |
カラムが ColumnBLOB でラップされることはなく、DETACHED / DETACHED_OVER_SPARSE kind も現れません (kind_stack を参照) 。 |
DateTime(tz) type parameter |
DBMS_MIN_REVISION_WITH_TIME_ZONE_PARAMETER_IN_DATETIME_DATA_TYPE |
54337 |
timezone パラメーターは type string から落とされ、DateTime('UTC') は単なる DateTime として通知されます。 |
つまり、リビジョン 0 はほぼすべてにおいて最も保守的なエンコーディングになります。stream には BlockInfo も has_custom_serialization バイトも含まれず、Dynamic/JSON は V1、aggregate-function のバージョンもなく、timezone パラメーターが省かれた素の DateTime になります。リビジョン 0 では aggregate-function の state バージョンが例外です。バージョンを導出する相手が存在しないためです。stream はそれを生成したもの (StripeLog データファイル、Set/Join backup、Native フォーマットファイル) によって書き込まれ、読み戻されます。そのため writer は state をバージョン 0 にダウングレードするのではなく、型に固定されたバージョンを保持します (通常どおり 型文字列 で明示します) 。バージョンを固定しない型だけがバージョン 0 で書き込まれます。
唯一の例外が LowCardinality で、しかも重要な例外です。writer の判定は remove_low_cardinality || (client_revision && client_revision < DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE) です。ポイントは先頭の client_revision && です。リビジョンがちょうど 0 の場合、この条件全体は短絡評価で false になります。
したがって、FORMAT Native のデフォルトであるリビジョン 0 では、LowCardinality(T) は削除されません。その型文字列と block ごとの state prefix は stream 内に保持され、リビジョン 0 の reader はそれらをそのまま読み取ります。削除されるのは、54405 未満のゼロ以外のリビジョンの場合、またはリビジョンに関係なく強制された場合だけです。
この強制を行うのが remove_low_cardinality フラグです。FORMAT Native の出力ではこのフラグは設定されませんが、native TCP path では low_cardinality_allow_in_native_format = 0 (デフォルトは 1) の場合に設定されます。つまり、この設定は native TCP 出力を変更しますが、FORMAT Native には影響しません。
実務上の要点として、デフォルトの FORMAT Native stream には LowCardinality が正当に含まれる場合があります。したがって、リビジョン 0 では存在しない機能として扱わないでください。
データの流れ方によって、リビジョンがどこで決まるか
同じ Native のバイト列でも、native TCP プロトコル、HTTPリクエスト、あるいはディスク上のファイルなど、異なる経路でやり取りされることがあります。経路ごとに、リビジョンの設定方法は異なります。注意点として、読み取り側と書き込み側は別々に設定されるため、結果として異なるリビジョンになることがあります。
Native TCP プロトコル — ネゴシエートされ、双方向
native TCP protocol では、リビジョンは Hello ハンドシェイクで決まります。クライアントは DBMS_TCP_PROTOCOL_VERSION を送信し、サーバーは自身の値を返します。その後は、各側が 相手側が通知したリビジョン でシリアライズします。つまり、サーバーは client_tcp_protocol_version を基に NativeReader/NativeWriter を構築し、クライアントは受け取った server_revision を使用します。明示的な min はありませんが、どちらの側も未実装の機能を出力することはできないため、各方向の上限は実質的に 2 つのピアのうち古い側で決まります。
両方のピアが同じ最新の build であれば、双方向とも同じリビジョン (DBMS_TCP_PROTOCOL_VERSION、src/Core/ProtocolDefines.h を参照) になり、すべてのゲートが有効になります。これは一般的なケースですが、保証されているわけではありません。バージョンが混在している場合や third-party のピアでは、2 つの方向で異なるリビジョンになることがあるため、ゲートは方向ごとに見る必要があります。BlockInfo はリビジョンが 0 でなければ常に存在しますが、それ以外、つまり 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 のフレーミングとオフセット String レイアウトを制御します。したがって、引き上げた client_protocol_version (≥ 54492) を指定した HTTP 経由の SELECT ... FORMAT Native はオフセットレイアウトで書き出されますが、既定のリビジョン 0 では移植可能な値ごとのレイアウトが維持されます。
ただし、HTTP 経由ではこれで終わりではありません。クライアントは ?client_protocol_version=<n> クエリパラメータで、この値を引き上げられます。HTTP ハンドラーはこれを SQL の設定ではなく予約済みパラメータとして扱うため、値はクエリコンテキストに入り、フォーマット層がそれを FormatSettings にコピーします。十分に高い値を設定すると、HTTP の FORMAT Native 出力にも TCP 経路と同様に BlockInfo プレフィックスと has_custom_serialization バイトが含まれるようになります。したがって、HTTP の FORMAT Native ペイロードが常にリビジョン 0 だと考えてはいけません。
パラメータはクエリ全体のコンテキストに入るため、結果ストリームだけでなく、そのリクエスト用に構築されるすべての NativeWriter に届きます。特に、引き上げた ?client_protocol_version= を指定した HTTP 経由の INSERT INTO FUNCTION file('x.native', 'Native', ...) SELECT ... のようなサーバー側書き込みでは、そのリビジョンでファイルが書き出されます。これは以前からある動作であり (String のオフセットシリアライゼーションより前から存在し、BlockInfo と has_custom_serialization にも適用されます) 、非対称です。読み取り側 (後述) は同じ方法で引き上げられないため、そのようなファイルは file(...) では読み戻せません。再度読み取ることを意図したデータを書き込むリクエストでは、client_protocol_version を指定しないでください。INTO OUTFILE やローカルの clickhouse-client 出力にはこのような切り替え手段はなく、0 のままです。
FORMAT Native 入力 — 常にリビジョン 0
Native 入力フォーマットでは、常にリビジョン 0 で NativeReader が構築されます。BlockInfo プレフィックスを想定せず、has_custom_serialization バイトも読み取らず、INSERT ... FORMAT Native のボディをパースする場合、Native ファイルを読み取る場合、スキーマを推論する場合のいずれでも、常にデフォルトのシリアライゼーションを前提とします。?client_protocol_version=<n> を指定しても入力リビジョンは上がりません (このパラメータを読み取るのは出力側のみです) 。
入力リビジョンは常に 0 のため、入力フォーマットは常に値ごとの String レイアウトを読み取ります。オフセットレイアウトで書き込まれたストリーム (native TCP プロトコル経由、または client_protocol_version を上げた SELECT ... FORMAT Native 経由) は、FORMAT Native 入力では読み戻せません。これは BlockInfo フレーミングと同じ非対称性です。
往復変換時の影響
FORMAT Native では、両端のデフォルトはリビジョン 0 です。リビジョン 0 の SELECT ... FORMAT Native で書き出したデータは、そのまま INSERT ... FORMAT Native に問題なく読み戻せます。
引き上げた出力リビジョンで生成されたストリーム (SELECT に付けた ?client_protocol_version=<n>) には、BlockInfo と has_custom_serialization のフレーミング、および オフセット String レイアウトが含まれますが、常にリビジョン 0 である入力フォーマットでは、これらはいずれも読み戻されません。したがって、client_protocol_version を引き上げるのは、HTTP 経由の FORMAT Native 往復変換ではなく、native TCP プロトコルを使用するコンシューマーに対してのみ意味があります。
ファイルは非対称なケースです。サーバー側の INSERT INTO FUNCTION file(...) (または s3、url) で引き上げた ?client_protocol_version= を指定すると、そのリビジョンでファイルが書き込まれますが、file(...) の読み取りは常にリビジョン 0 で解析されます。そのため、このようなファイルは往復変換できません。書き込みリクエストから client_protocol_version を外してプレーンなリビジョン 0 のファイルにするか、各方向でハンドシェイク時にネゴシエートされたリビジョンを使用する native TCP プロトコル経由でデータを転送してください。
| チャネル | 書き込みリビジョン | 読み取りリビジョン | BlockInfo / カスタムシリアライゼーション |
|---|---|---|---|
| Native TCP Data パケット | ピアが通知したリビジョン (方向ごと) | ピアが通知したリビジョン (方向ごと) | リビジョン > 0 なら常に BlockInfo、≥ 54454 なら has_custom_serialization |
SELECT ... FORMAT Native over HTTP |
client_protocol_version (デフォルトは 0) |
n/a | client_protocol_version を引き上げた場合のみ |
INSERT ... FORMAT Native over HTTP (ボディ) |
n/a | 0 (常に) |
フレーミングなし。String は常に値ごと |
INSERT INTO FUNCTION file/s3/url(..., 'Native') over HTTP |
client_protocol_version (デフォルトは 0) |
0 (常に読み取り) |
書き込み時のみ、client_protocol_version を引き上げた場合。このようなファイルは読み戻せません |
INTO OUTFILE / local clickhouse-client FORMAT Native |
0 |
0 |
なし (ただし LowCardinality は保持されます。上記の注記を参照) |
データ型
このセクションでは、Nativeフォーマットでカラムの data に格納できる型のワイヤエンコーディングについて説明します。型は、デコーダの複雑さが増す順に 4 つのファミリーに分類されています。AggregateFunction(func, ...) と QBit(T, N[, stride]) の 2 つは有効な Native のカラム型ですが、関数固有または型固有のペイロードを持つため、ここでの対象外です。これらについては、別名と誤解される可能性がある箇所で、その旨を以下に示しています。
| ファミリー | セクション | 1 カラムあたりのストリーム数 | ブロック間の状態 |
|---|---|---|---|
| 固定幅 | 固定幅型 | 1 | なし |
| 可変長 | 可変長型 | 1 | なし |
| 複合 (固定構造) | 複合型 | 複数 | なし |
| バージョン付き / ステートフル | バージョン付き型 | 複数 | Native wire 上ではなし — ブロックごとの状態プレフィックスで、各ブロックごとに新規 |
固定幅型
各値は固定のバイト数を占有します。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 ビット整数、2 の補数 | 生バイト |
Int16 |
2 | 符号付き 16 ビット整数、2 の補数 | リトルエンディアン |
Int32 |
4 | 符号付き 32 ビット整数、2 の補数 | リトルエンディアン |
Int64 |
8 | 符号付き 64 ビット整数、2 の補数 | リトルエンディアン |
Int128 |
16 | 符号付き 128 ビット整数、2 の補数 | リトルエンディアン |
Int256 |
32 | 符号付き 256 ビット整数、2 の補数 | リトルエンディアン |
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 と同じ。timezone はメタデータ |
リトルエンディアン UInt32 |
DateTime64(s) |
8 | スケール s のティック (epoch からの 10^-s 秒) |
リトルエンディアン Int64 |
DateTime64(s, tz) |
8 | DateTime64(s) と同じ。timezone はメタデータ |
リトルエンディアン Int64 |
Time |
4 | 秒単位の符号付き経過時間 | リトルエンディアン Int32 |
Time64(s) |
8 | スケール s のティック単位の符号付き経過時間 |
リトルエンディアン Int64 |
Interval<Unit> |
8 | 符号付きの個数。単位は型文字列で表されます | リトルエンディアン Int64 |
UUID |
16 | 128 ビット識別子 | バイトスワップした LE UInt64 を 2 つ並べたもの (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 カラム:
01 00 00 00 row 0: 1
00 01 00 00 row 1: 256
00 00 01 00 row 2: 65536[-1, 42] の値を持つ Int32 カラム:
FF FF FF FF row 0: -1
2A 00 00 00 row 1: 42Float32 and Float64
標準的な IEEE 754 バイナリ浮動小数点数です。4 バイトの単精度 (binary32) と 8 バイトの倍精度 (binary64) があり、いずれもリトルエンディアンです。NaN、±Infinity、±0.0、非正規化数はいずれも、正規化されることなくそのまま往復変換されます。
Float32 の値 1.5 (0x3FC00000):
00 00 C0 3F little-endian IEEE 754Float64 の値 1.5 (0x3FF8000000000000):
00 00 00 00 00 00 F8 3F little-endian IEEE 754BFloat16
brain-floating-point 形式です。IEEE 754 Float32 の上位 16 ビットで、1 ビットの符号、8 ビットの指数、7 ビットの仮数から構成されます。各値は 2 バイトで、リトルエンディアンの生の 16 ビットパターンを保持します。数値として復元するには、そのパターンを上位半分に配置し、下位半分を 0 にして Float32 に拡張します (bits << 16 を Float32 として reinterpret したもの) 。このように拡張した値のテキストフォーマットは、Float32 と同じです。
BFloat16 の値 1.5 (パターン 0x3FC0、Float32 0x3FC00000 の上位半分) :
C0 3F little-endian, widens to Float32 1.5Bool
UInt8 とワイヤ形式で互換性があるで、1行あたり 1 バイトです。0x00 = false、0x01 = true。wire 上の型文字列は文字どおり Bool (UInt8 ではなく) なので、型文字列に基づいて振り分けるデコーダーでは、これを別個の型として認識する必要があります。
Bool カラム [true, false, true]:
01 00 01Date と Date32
どちらも、Unix エポック 1970-01-01 からの経過日数を整数としてエンコードします。どちらにも時刻の部分は含まれません。
| 型 | バイト | エンコーディング | 範囲 |
|---|---|---|---|
Date |
2 | リトルエンディアン UInt16 | 1970-01-01 から 2149-06-06 |
Date32 |
4 | リトルエンディアン Int32 | 広い符号付き範囲、1970年以前も可 |
Date の値 1970-01-02 (1日) :
01 00 UInt16 LE = 1Date32 の値 1900-01-01 (-25567日) :
21 9C FF FF Int32 LE = -25567DateTime
ワイヤ形式で UInt32 と互換性があります。秒単位の Unixタイムスタンプで、4 バイトのリトルエンディアンです。この型は DateTime または DateTime('Timezone') として現れることがあり、タイムゾーンは表示にのみ影響し、ワイヤ上の値には含まれません。タイムゾーンのパラメータが異なる 2 つの DateTime カラムでも、同じ時点に対しては同一のバイト列を生成します。デコーダーは (...) のパラメータ接尾辞を取り除き、そのカラムを UInt32 として処理します。
DateTime('UTC') の値 2024-03-15 14:30:00 UTC (タイムスタンプ 1710513000) :
68 5B F4 65 UInt32 LE = 1710513000DateTime64(scale[, timezone])
8 バイトのリトルエンディアン Int64 で、Unix epoch からの 10^-scale 秒単位のティックを表します。scale パラメーター (0~9) は型文字列に含まれ、時間の単位を設定します。
| Scale | Tick size | Common name |
|---|---|---|
| 0 | 1 second | seconds |
| 3 | 1 millisecond | ms |
| 6 | 1 microsecond | µs |
| 9 | 1 nanosecond | ns |
この型は、DateTime64(s) (暗黙的に server のデフォルトタイムゾーンを使用) または DateTime64(s, 'TimezoneName') (明示的なタイムゾーン。表示のみに使用) として表されます。負の値は、epoch より前のティックを表します。
DateTime64(3, 'UTC') の値 2024-01-15 12:30:45.123 UTC (1705321845123 ms) :
83 51 1A 0D 8D 01 00 00 Int64 LE = 1705321845123DateTime64(0) の値 2024-01-15 12:30:45 UTC (1705321845 秒) :
75 25 A5 65 00 00 00 00 Int64 LE = 1705321845Time と 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 seconds) で、これを超える値は小数部を 0 にした上限値 (999:59:59.000) として表示されます。CAST でも格納値はこの範囲にクランプされますが、演算によって範囲外の値が生成されることがあり、その場合にクランプされるのは表示時だけです。これらはいずれも wire bytes には影響せず、そこには単なる符号付き整数が入ります。
Time の値 45296 (12:34:56):
F0 B0 00 00 Int32 LE = 45296Time64(3) の値 45296789 ティック (12:34:56.789) :
95 2C B3 02 00 00 00 00 Int64 LE = 45296789Interval
Interval<Unit> — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond など。すべての単位で wire エンコーディングは共通で、値は符号付き 8 バイトのリトルエンディアン Int64 として表現されます。単位は型文字列にのみ含まれ、wire バイト列にも、単なる整数で表されるテキスト形式にも影響しません。すべての単位は 1 つのデコーダーパスで処理できます。
IntervalDay の値 5:
05 00 00 00 00 00 00 00 Int64 LE = 5UUID
値ごとに16バイトです。wire形式のエンコーディングは、標準的な16バイトのビッグエンディアン表現ではありません。8バイトごとの各半分について、それぞれ独立にバイト順が反転されます。
論理モデルは、標準的なテキスト形式 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx で表される128ビットの識別子で、バイトは慣例的にビッグエンディアンで記述されます。wireモデルでは、この標準的な16バイトを2つの8バイト単位に分割し、それぞれをリトルエンディアンで書き込みます。
- wireバイト 0..7 = 標準バイト 0..7 を逆順にしたもの。
- wireバイト 8..15 = 標準バイト 8..15 を逆順にしたもの。
UUID 550e8400-e29b-41d4-a716-446655440000:
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-reversednil UUID (すべて 0) は、どちらの表現でも同じです。
IPv4 と IPv6
相互に関連していますが、エンコード方式が異なる 2 つのアドレス型です。
IPv4 は 4 バイトで、正規の 32 ビットアドレス (a.b.c.d から得られる値 (a << 24) | (b << 16) | (c << 8) | d) を保持するリトルエンディアンの UInt32 としてエンコードされます。wire 上のバイト列は、ネットワークバイトオーダーのバイト列を逆順にしたものです。
192.168.1.10 (正規の 32 ビット値 0xC0A8010A) :
0A 01 A8 C0 Little-endian UInt32IPv6 は 16 バイトで、ネットワークバイトオーダーでそのまま、スワップせずに書き込まれます。バイト順は inet_pton(AF_INET6, ...) と同じです。
2001:db8::1:
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 行あたり 1 バイトまたは 2 バイトで、16 ビットのバリアントは 2 の補数のリトルエンディアンです。各バリアントの完全な対応関係は型文字列に含まれます:
Enum8('active' = 1, 'inactive' = 2, 'banned' = -1)
Enum16('a' = 1, 'b' = 30000)デコーダーは (...) のパラメータ接尾辞を取り除き、Int8 / Int16 として処理することがあります。wire上のバイト列は単なる整数インデックスです。ラベルを扱うクライアントは、型文字列から 'name' = value の map を解析して取り出し、カラムと一緒に保持します。整数だけではラベルを復元できません。テキスト指向の出力では、インデックスではなくラベル (active) が表示され、enum が複合型の中にネストされている場合はシングルクォート付き ('active') で表示されます。map は整数カラムからは復元できないため、Array(Enum8(...)) や Map(Enum16(...), V) のようなネストされた enum では保持しておく必要があります。
Enum8('active' = 1, 'inactive' = 2) のカラム [active, inactive, active]:
01 02 01Enum16(...) の値 30000:
30 75 Int16 LE = 30000Decimal(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 エンコーディングでは、基になる整数がリトルエンディアンの 2 の補数で表現され、論理的な 10 進値は wire_integer × 10^(-S) になります。
ClickHouse は、型の宣言方法にかかわらず、常に Decimal(P, S) を出力します。Decimal32(S)、Decimal64(S) なども、wire 上ではすべて Decimal(P, S) に正規化されます (P には、その幅で自然な最大値である 9、18、38、76 が設定されます) 。Decimal(P, S) のみを認識するデコーダーであれば、サーバーが出力するあらゆる表記を扱えます。
Decimal(9, 4) の値 123.4567 → 基になる整数 1234567:
87 D6 12 00 Int32 LE = 1234567Decimal(18, 1) の値 -1.5 → 内部整数 -15:
F1 FF FF FF FF FF FF FF Int64 LE = -15Decimal(38, 4)の値 123.4567 (合計16バイト) :
87 D6 12 00 00 00 00 00 00 00 00 00 00 00 00 00Nothing
Nothing 型は値を一切持ちません。実際には、Nullable(Nothing) の内部型としてのみ現れます。つまり、取り得る有効な値が「値が存在しないこと」だけである SELECT NULL のような式に対して、server が返す型です。概念的にはユニット型に相当します。
wire 上では、これは1行あたり正確に1バイトのプレースホルダーを占有します。server は ASCII 文字 '0' (0x30) を出力しますが、デシリアライザーはそのバイトを無視します。内容は未定義であり、デコーダーは特定の値に依存してはいけません。書き込まれるバイト数は num_rows × 1 なので、カラムヘッダーの num_rows によって読み取るべき量が完全に決まります。
この 1 行 1 バイトという方式により、Block の不変条件は保たれます。すべてのカラムの長さは num_rows から導き出せるため、デコーダーは各 cell の長さプレフィックスなしで前方にスキャンできます。外側の Nullable は常にすべての位置を NULL として報告するため、プレースホルダーが参照されることはありません。
3 行 (すべて NULL) の Nullable(Nothing) カラム:
01 01 01 null map: 1, 1, 1 (three NULLs)
30 30 30 Nothing placeholder bytes (one per row)null-map のプレフィックスは標準的な Nullable のフレーミングです (Nullable を参照) 。内側の 3 バイトは Nothing のペイロードで、デコーダーはこれを読み飛ばします。
可変長型
各値には、ワイヤ形式でそれぞれの長さが含まれます。
String
型文字列: String。String カラムには、プロトコルのリビジョンによって選択される2つのワイヤレイアウトがあります (リビジョンによって制御される内容を参照) 。リビジョンとは、native 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 個の長さプレフィックス付きバイト列からなる数列です。
[VarUInt: byte_length] [byte_length bytes: raw value]
[VarUInt: byte_length] [byte_length bytes: raw value]
...長さプレフィックス以外に行間の区切りはなく、行レベルの状態もありません。空文字列は 0x00 という 1 バイトで表されます。カラムで消費される合計バイト数は、すべての行について Σ (varuint_size(len_i) + len_i) となります。
3 つの文字列 ["ab", "", "c"] を含むカラム (合計 6 バイト) :
02 61 62 row 0: length 2, "ab"
00 row 1: length 0, empty
01 63 row 2: length 1, "c"Offsets レイアウト — リビジョン 54492 以降: 2 つのストリームを連結し、先頭に累積バイトオフセット、その後にすべてのデータを配置します:
[UInt64 × num_rows: cumulative byte offset (end of each value), little-endian]
[last offset bytes: all values concatenated, no separators]offset はそのまま送信されます。これは Array がネイティブプロトコルで offset を送信する方法と同じです。offset i はデータブロブ内の値 i の終了位置であるため、値 i のサイズは offset[i] - offset[i-1] (offset[-1] = 0) となり、最後の offset はデータブロブの合計サイズです。デコーダはまず offset の 8 × num_rows バイトを読み取り、次に最後の offset を正確な長さとしてデータブロブ全体を一括で読み取ります。これにより、バッファを正確に事前割り当てでき、行ごとのパースではなく一括コピーが可能になり、サイズから offset への変換も不要です。num_rows = 0 のカラムは、どちらのストリームにもバイトを追加しません。
同じ 3 つの文字列 ["ab", "", "c"] (合計 27 バイト) :
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"offsets レイアウトが有効な場合、複合型 (Array(String)、Nullable(String)、Map、Tuple、Variant、Dynamic、JSON) 内にネストされた String を含め、block 内のすべての String に適用されます。ただし、例外が 1 つあります。LowCardinality カラムの dictionary は常にデフォルトのネストされたシリアライゼーションで書き込まれるため、その String 値はすべてのリビジョンで値ごとのレイアウトを維持します。これは、LowCardinality(String)、LowCardinality(Nullable(String))、および String を含むその他の LowCardinality dictionary に該当します。
どちらのレイアウトでも、ClickHouse の String はテキスト指向ではなくバイト指向です。UTF-8 の妥当性は検証されず、値には埋め込み NUL を含む任意のバイトを含めることができます。UTF-8 文字列型を対象とするデコーダは、読み取り時に検証するか、生データのバイトを呼び出し元に公開します。
FixedString(N)
型文字列は FixedString(N) で、N は正の整数です (例: FixedString(16)) 。このカラムは長さプレフィックスや区切りを持たず、サイズは常に N × num_rows raw bytes です。デコーダーは型文字列から N を解析し、1行あたりそのバイト数を読み取ります。
SQL が N バイトより短い値を insert した場合 (例: CAST('abc' AS FixedString(5))) 、server は宣言された長さになるまで右側を NUL バイト (0x00) で埋めます。これらのパディングバイトは格納される値の一部であり、そのまま wire 上に送信されます。トリミングは client 側で対処します。String と同様に、FixedString(N) はテキストというよりバイト配列に近く、通常は固定幅の識別子、アドレスバイト、または hash ダイジェストに使用されます。
2 つの FixedString(3) の値 ["abc", "de\0"] (合計 6 バイト) :
61 62 63 row 0: 3 bytes, "abc"
64 65 00 row 1: 3 bytes, "de" + NUL padding比較する 2 つの文字列型:
| プロパティ | String |
FixedString(N) |
|---|---|---|
| 1 行あたりの長さプレフィックス | あり (VarUInt)、またはリビジョン 54492+ では別個の offsets stream |
なし |
| 行サイズ | 可変 | 常に N バイト |
| カラムの合計バイト数 | 可変 | N × num_rows |
| NUL バイトのパディング | 該当なし | server により右側が埋められる |
| UTF-8 を想定 | 通常は想定される (強制ではない) | なし (raw bytes として扱われる) |
| 型パラメータ | None | 整数 N が必須 |
複合型
複合型は1つ以上の内部型を包み、共通の wire モデル、つまり 1つのカラムにつき複数のストリーム を共有します。1つの論理カラムは、独立して読み取られる2つ以上のバイト列としてエンコードされ、それらを連結した形になります。
これらには、共通する3つの構造的な性質があります。
- スキーマごとに形状が固定される。 構造はデコード時に型文字列だけで完全に決まります。
Array(UInt32)は常に同じストリームレイアウトを持ち、block が変わっても変わりません。 - 独自のバージョンプレフィックスを持たない。 複合ラッパー自体はバージョンバイトを追加せず、そのフレーミング (offsets、null-map、要素ストリーム) は ClickHouse の releases をまたいでも安定しています。これは wrapper 自体にのみ当てはまります。内部のバージョン付き型については、以下のプレフィックスフェーズの注記を参照してください。
- 独自の block 間 state を持たない。 ラッパーのフレーミングは block ごとに完全に self-describing です。block 間 state に関する問題は、ラッパーではなく内部のバージョン付き型に起因します。
複合型は再帰的です。内部型自体が複合型であることもあります。
データストリームの前にあるプレフィックスフェーズ。 カラムの読み取りは、次の順序で2段階に分かれます。まず state prefix フェーズ、次に data-stream 段階 です。複合ラッパー自体は独自のプレフィックスバイトを持ちませんが、自身のデータストリームを書き込む前に、内部のシリアライゼーションにプレフィックスフェーズを 委譲 します。SerializationArray は array offsets が書き込まれる前に内部型のプレフィックスフェーズを実行し、Tuple、Map、Nested、Nullable も要素のシリアライゼーションを通じて同様に動作します (Nullable は null map の前に内部プレフィックスを実行します) 。
そのため、複合型が versioned/stateful type (LowCardinality、Variant、Dynamic、JSON) を包む場合、その内部型の version/state prefix が 最初に 出力され、ラッパーの offsets や要素 ペイロード より前に置かれます。たとえば、Array(LowCardinality(String)) のレイアウトは [LowCardinality state prefix] → [array offsets] → [flattened LowCardinality element payload] であり、offsets-first にはなりません。
内部プレフィックスフェーズを実行する前に offsets を読み取るデコーダーは、LowCardinality、Variant、Dynamic、または JSON を含む複合型では同期がずれます。すべての内部型が通常のリーフ型、または別の非バージョン付き複合型である場合、プレフィックスフェーズはバイトを出力せず、以下の offsets-first の説明がそのまま当てはまります。
Nullable(T)
型文字列: Nullable(InnerType)。例: Nullable(UInt32)、Nullable(String)、Nullable(FixedString(16))、Nullable(DateTime('UTC'))。
他の複合型と同様に、Nullable は null マップを書き込む前に、プレフィックスフェーズ を内部のシリアライゼーションに委譲します。内部型がバージョン付きの場合は、内部の state prefix が最初に出力されます。したがって、Nullable(Tuple(LowCardinality(String))) は null マップではなく、LowCardinality の state prefix で始まります。内部型がリーフ型またはその他の非バージョン型である場合、プレフィックスフェーズ ではバイトは出力されません。
ワイヤレイアウト は、内部のプレフィックスフェーズ (内部型がバージョン付きでない限り空) に続いて、2 つの stream を連結したものです。先頭は 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 valuesnull-map は num_rows バイトちょうどで、各行に 1 バイトずつ対応します。
| Byte value | Meaning |
|---|---|
0x00 |
この行には値があります。 |
non-zero (canonical 0x01) |
値は NULL です。values ストリーム内の対応するバイトはプレースホルダーです。 |
values ストリームには、NULL の位置を含む すべての num_rows 行について、内部型の標準エンコーディングが格納されます。デコーダーはストリームを先に進めるため、NULL の位置にあるプレースホルダーバイトも読み取る必要がありますが、個々の値を解釈する前に null-map を参照しなければなりません。送信側は NULL の位置に任意のバイトを書き込めるため、デコーダーは特定のプレースホルダー値を前提にしてはいけません。
内部型ファミリーごとのプレースホルダー値:
| Inner type family | Placeholder at null position |
|---|---|
| Fixed-width (UInt/Int/Float/DateTime/UUID/etc.) | 型の幅ぶん、ゼロで初期化されたバイト |
String |
空文字列 — 0x00 バイト 1 つ |
FixedString(N) |
N 個のゼロバイト |
Array(T) |
空の配列 — offsets は 0 だけ進む |
Tuple(T1, T2, ...) |
各要素はそれぞれ自身のプレースホルダーを使う |
Nullable(T) は Array、Tuple、Map、Nested の中に現れることがあります — Array(Nullable(T)) や Tuple(Nullable(T1), T2) は一般的です。NULL 許容性はそれ自体とは組み合わせられません。Nullable(Nullable(T)) は server によって拒否されます。
3 行 [5, NULL, 9] を持つ Nullable(UInt8) (合計 6 バイト) :
00 01 00 null-map: present, null, present
05 00 09 values: 5, placeholder, 93 行からなる ["hello", NULL, "world"] を持つ Nullable(String) (合計 15 バイト) :
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))。
ワイヤレイアウトは、内側のプレフィックスフェーズ (内側の型がバージョン付きでない限り空) の後に、2 つの連結された stream が続く構成で、最初に offsets が来ます:
[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] valuesoffsets ストリームは、num_rows 個の little-endian UInt64 値で正確に構成され、各値はその行の要素までを書き込んだ時点での values ストリーム内の累積終端位置を表します。
- 行
Nの要素開始索引 =offsets[N - 1](N == 0の場合は0) 。 - 行
Nの要素終了索引 (exclusive) =offsets[N]。 - 行
Nの要素数 =offsets[N] - offsets[N - 1]。
したがって、offsets[num_rows - 1] は全行にわたる要素の総数を表し、values ストリームにはその数だけの内部値が末尾まで連結された形で格納されます。
offsets は単調非減少であり、連続する offsets が同じであれば空の行を意味します。また、デコーダーは単調でない offsets を破損データとして拒否する必要があります。空のカラム (num_rows == 0) は 0 バイトを書き込みます — offsets ストリームも values ストリームも存在しません。内部型には、他の複合型を含む任意の型を指定できます。Array(Array(T))、Array(Tuple(...))、Array(Nullable(T)) はいずれも有効です。
行が [[10, 20, 30], [], [40, 50]] の Array(UInt32) (合計 44 バイト) :
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各オフセットは、共有の値ストリーム内で各行に対応するスライスの累積的な終端位置を表します。始点は 1 つ前のオフセット (行 0 の場合は 0) です。連続するオフセットが同じ場合、その行は空です:
Array(String) で、行 [["a", "bb"], []] の場合 (合計20バイト) :
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]]] の場合、同じ形状が入れ子になっています。
- 外側の offsets:
[1, 1, 3]— 行 0 には内側の配列が 1 つ、行 1 には 0 個、行 2 には 2 つあります。 - 中間の
Array(UInt32)は、offsets[2, 3, 5]を持つ 3 行にデコードされます。 - 最も内側の
UInt32は、5 つの値[1, 2, 3, 4, 5]にデコードされます。
合計は、24 (外側の offsets) + 24 (中間の offsets) + 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) による名前付きTupleもサポートしています。名前はメタデータにすぎず、ワイヤ形式には影響しません。
ワイヤ上のワイヤレイアウトは、要素の プレフィックスフェーズ (各バージョン付き要素は、その状態プレフィックスを宣言順に持ち、バージョンなし要素では空) に続き、宣言順で要素型ごとに 1 つずつ連結された N 個のストリームです:
[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 個の値をエンコードします。長さのプレフィックスはなく、offsets ストリームもなく、ストリーム間の区切りもありません。空のカラム (num_rows == 0) では、各ストリームに 0 バイトが書き込まれます。要素型には、他の複合型を含む任意の型を使用できます — Tuple(Tuple(...), ...)、Tuple(Array(...), ...)、Tuple(Nullable(T1), T2) はいずれも有効です。
要素数 0 のタプル Tuple() も有効です — これは SELECT tuple() や CAST(x AS Tuple()) のような式から生成されます。要素ストリームを持たないため、代わりに Nothing と同様にシリアライズされます。つまり、各行につき 1 バイトのプレースホルダー (0x30、ASCII '0') が書き込まれ、デシリアライザーはこれを破棄します。行数は、Nothing の場合とまったく同じく、block header から取得されます。
3 行の (1,4), (2,5), (3,6) を持つ Tuple(UInt8, UInt8):
Element 0 stream (3 × UInt8 = 3 bytes):
01 02 03
Element 1 stream (3 × UInt8 = 3 bytes):
04 05 06レイアウトは行優先ではありません。raw bytes を読み戻すと、要素 0 では [1, 2, 3]、要素 1 では [4, 5, 6] になります。
Tuple(UInt32, String)、2行 (10, "a"), (20, "bb") (合計 13 バイト) :
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)) とバイト単位で同一であるため、最初に内側の プレフィックスフェーズ から始まります (K または V がバージョン付きでない限り空です) :
[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) です。offsets ストリームは Array と同じセマンティクスを持ちます。キーは値と位置ごとに対応しており、ペア i は (keys[i], values[i]) です。
ClickHouse における Map カラムのインメモリ表現はタプルの配列ですが、型システム上では SQL で扱いやすいように独立した型として表されます (m['key']、mapKeys、mapValues) 。ワイヤ形式はこの保存表現をそのままシリアライズしたものなので、Map と Array(Tuple(K, V)) はバイト単位で完全に同一です。
offsets は単調非減少で、キーと値の両方のストリームにはちょうど total_pairs 個の値が含まれます。空のカラムは 0 バイトを書き込みます。1 つの行の中では通常キーは一意ですが、これはセマンティクス上の規則であり、ワイヤ形式で強制されるものではありません。ワイヤ形式では重複したキーもそのまま保持して往復でき、サーバー側のセマンティクスで重複が解決されるのは、Map 対応の関数がその行を処理するときだけです。
2 行 {1:10, 2:20}、{3:30} を持つ Map(UInt8, UInt8) (合計 22 バイト) :
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 バイト) :
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 2Nested(name1 T1, name2 T2, …)
Nested の wire 表現は、サーバー側の flatten_nested 設定によって決まり、2 つの異なるケースがあります。
ケース A: flatten_nested = 1 (サーバーのデフォルト) 。 テーブルがデフォルト設定で作成されている場合、Nested はwire type ではありません。サーバーはこのカラムを、ドット付きの名前 (outer.field1、outer.field2 など) を持つ N 本の並列な Array(T_i) カラムとして保存し、表示します。フォーマット層では新しい点はなく、ドット付きの各カラムは通常の Array です:
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 で作成されている場合、このカラムは wire 上では型文字列 Nested(name1 T1, name2 T2, ...) を持つ単一のカラムとして表れ、その型文字列に続くレイアウトは Array(Tuple(T1, T2, ..., Tn)) とバイト単位で完全に同一 です。これには内側の プレフィックスフェーズ も含まれるため、バージョン付きの各フィールド T_i は、offsets より前にまずその state prefixを出力します。以下の例ではバージョン付きでないフィールドを使用しているため、プレフィックスフェーズは空です:
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 の型文字列は、(name, type) の組をカンマ区切りで並べたリストです。最初の空白で名前とその型が区切られますが、型自体にはさらに空白、カンマ、括弧が含まれる場合があるため、パースには Tuple で使用するのと同じ、ネストの深さを考慮した分割処理が必要です。ワイヤレイアウト:
[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 時に、1 つの行内ではすべてのフィールドの要素数が同じであることを検証します。空のカラムは 0 バイトを書き込みます。
2 行 [(10,'x'),(20,'y')] および [(30,'z')] を持つ Nested(a UInt8, b String) (型文字列の後に 25 バイト) :
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"型の別名
いくつかの型は単なる別名です。server はカラムヘッダーでは別名を送りますが、その後に続くバイト列は実体となる型のものです。デコーダーは別名をその型に対応付け、その codec を再利用します。新しいワイヤ形式が導入されるわけではありません。
地理型は、ネストした配列やタプルの別名です。
| 型文字列 | 基底ワイヤ型 |
|---|---|
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 の別名です。その ペイロード は、上記 7 つの geo types の variant です。カラムヘッダーには型文字列 Geometry だけが含まれ、variant の内容までは示されません。そのため、デコーダーは自分でこれを展開する必要があります。ユーザーが明示した Variant とは異なり、Geometry の 判別子 は名前順にソートされた順序には従いません。これらは互換性のために固定されており、新しい geo types は常に末尾に追加されます。対応は 0 = LineString、1 = MultiLineString、2 = MultiPolygon、3 = Point、4 = Polygon、5 = Ring、6 = MultiPoint です (MultiPoint は 26.7 で追加されました。古い server からの streams には 判別子 6 は含まれません) 。選択された各値は、その後、上記の geo の別名に従ってデコードされます (NULL の場合は Variant の NULL 判別子 255 を使用します) 。
SimpleAggregateFunction(func, T) は、その値型 T の別名です。これはすでに確定済みの aggregate 値を格納するため、ワイヤ形式も表示も T と完全に同一です (SimpleAggregateFunction(sum, UInt64) は UInt64 としてデコードされます) 。このように別名として扱われるのは単一値型の形式だけで、基底型自体は複合型である場合もあります。
バージョン付き型
バージョン付き型は、後続するエンコーディングのどの Variant が続くかを示す、ワイヤ上のシリアライゼーション バージョン プレフィックスを持ちます。これらは (複合型と同様に) 複数のストリームを使用する場合もあります。Native wire では、プレフィックスと任意の Dictionary はブロックごとに付与されます。つまり、これらの型はブロックをまたぐ state を保持しません (下のブロックごとのプレフィックスに関する注記を参照) 。ブロック間のシリアライゼーション state が存在するのは、MergeTree の on-disk stream 内だけです。
これらの型は、固定 shape の複合型よりもかなり複雑なため、単純な分析クエリを対象とする client であれば後回しにできます。
シリアル化バージョン: 概念
シリアル化バージョンは、型ごと・カラムごとに定義される wire 上のバージョン番号で、送信側がその型のどのエンコーディング方式を使っているかを示します。これはカラムの state prefix の先頭にある最初の情報であるため、デコーダーはまずこれを読み取り、残りのカラムに対して適切なパーサーに処理を振り分けます。
これは protocol version とは別のものです。
| Dimension | Protocol version | Serialization version (this section) |
|---|---|---|
| Scope | 接続全体 | 型ごと・カラムごと |
| Negotiated | はい、handshake 時に行われる | いいえ — 送信側が書き込み、receiver が読み取る |
| Controls | どのパケットレベルの feature が有効か | 1 つの型のどの wire variant か |
| Mandatory to read | はい | はい、各 versioned column について |
ほとんどの versioned type では、他の state-prefix データの直前に、little-endian の UInt64 としてバージョンが書き込まれますが、一部では VarUInt または UInt8 が使われます。デコーダーは最初にバージョンを読み取り、未知の値は拒否します。より大きいバージョン値は、デコーダーが理解できない新しい送信側フォーマットを意味し、これを誤ってパースすると、それ以降のすべてのバイトが壊れてしまいます。
state prefix は、行数が 0 より大きいすべての block の先頭で、その block のペイロード の直前に出力されます。
Native writer と reader は、block をまたいでシリアル化状態を保持しません。NativeWriter は毎回新しい serialize state を作成し、書き込む空でない各 column block に対して state prefix を書き込みます。NativeReader も毎回新しい deserialize state を作成し、読み取る空でない各 block ごとにそれを読み込みます (どちらも rows == 0 の場合は prefix 全体を完全に省略します) 。
したがって、header block (rows = 0) と empty block では何も出力されず、デコーダーは空でない各 block の先頭で state prefix をあらためて読み取る必要があります。prefix を 1 回しか読まず、後続の block をペイロード だけだとみなすデコーダーは、次の block の prefix をデータとして読んでしまい、同期がずれます:
シリアル化バージョンのリファレンス
| 型 | フィールド幅 | 値 | Name | 意味 |
|---|---|---|---|---|
| Object (JSON のベース) | UInt64 LE | 0 |
V1 |
元のエンコーディングです。max_dynamic_paths パラメータと dynamic path のリストを含みます。 |
1 |
STRING |
ネイティブフォーマットの互換性モード — JSON テキストを含む単一の String カラムとして Object を転送します。 |
||
2 |
V2 |
max_dynamic_paths パラメータを除いた V1 のレイアウトです。 |
||
3 |
FLATTENED |
ネイティブフォーマットの互換性モード — flattened path 表現です。 | ||
4 |
V3 |
V2 に、shared data のシリアル化バージョン用サブフィールドと statistics フラグを追加したものです。 | ||
Object shared data (Object V3 で使用されるサブストリーム) |
VarUInt | 0 |
MAP |
shared data を Map(String, String) としてエンコードしたものです。 |
1 |
MAP_WITH_BUCKETS |
MAP と同じですが、スキャン効率のために N 個の bucket に分割されます。 |
||
2 |
ADVANCED |
path / mark / メタデータごとに個別の stream を持つ compact granule フォーマットです。 | ||
| Dynamic | UInt64 LE | 1 |
V1 |
元のエンコーディングです。max_dynamic_types と実行時の Variant 型のリストを含みます。 |
2 |
V2 |
max_dynamic_types パラメータを除いた V1 です。 |
||
3 |
FLATTENED |
ネイティブフォーマットの互換性モードです。 | ||
4 |
V3 |
V2 に、バイナリエンコードされた variant type names と empty-statistics のサポートを追加したものです。 | ||
| Variant discriminator モード | UInt64 LE | 0 |
BASIC |
各行の discriminator をそのまま書き込みます。 |
1 |
COMPACT |
granule 内のすべての行で discriminator が 1 つだけ共有されている場合、単一の値と granule マーカーのみが書き込まれます。 | ||
Variant granule フォーマット (mode が COMPACT の場合) |
UInt8 | 0 |
PLAIN |
granule は異なる discriminator を持ちます。 |
1 |
COMPACT |
granule はすべての行に対して 1 つの discriminator を持ちます。 | ||
| LowCardinality key のシリアル化 | 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です。数値が大きいほど新しいとは限りません。 - ネイティブフォーマット専用の値があります。
Object::STRING、Object::FLATTENED、Dynamic::FLATTENEDは、Object/Dynamic を完全には実装していない client との native プロトコル互換性のために存在します。これらは MergeTree のオンディスクストレージには現れません。 V3は主にオンディスク向けです。 native TCP プロトコルを利用する client では、通常V3(値4) ではなくFLATTENED(値3) が見えます。
LowCardinality(T)
最も単純なバージョン付き型です。N 個の内部の値からなるカラムを、重複のない値の小さな Dictionary と、その Dictionary への N 個のインデックスに置き換えます。
型文字列: LowCardinality(InnerType)。例: LowCardinality(String), LowCardinality(FixedString(4)), LowCardinality(Nullable(String))。
[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 はビットフィールドです。
| Bit range | Meaning |
|---|---|
| 0..7 | キー型コード: 0 = UInt8、1 = UInt16、2 = UInt32、3 = UInt64。dict_size 件のエントリに索引付けできる最小の型が選択されます。 |
8 (0x100) |
NeedGlobalDictionaryBit — ブロック間で共有される単一の辞書。Native フォーマットでは絶対に設定しません: Native writer は low_cardinality_max_dictionary_size = 0 を使用し、Native reader はこのビットを拒否します (native_format は INCORRECT_DATA — "cannot use global dictionary" を発生させます) 。これは wire ではなく、MergeTree のオンディスクストリームに属します。 |
9 (0x200) |
HasAdditionalKeysBit — ブロックに追加の辞書キーが含まれる場合に設定されます (索引の前に書き込まれます) 。空でない Native ブロックでは常に設定されます。 |
10 (0x400) |
NeedUpdateDictionary — ブロックに辞書の更新が含まれる場合に設定されます。空でない Native ブロックでは、各ブロックがそれぞれ自己完結した辞書を持つため、常に設定されます。 |
一般的なクエリ応答で、各カラムにつき単一の data block がある場合、メタデータは 0x600 (HasAdditionalKeys + NeedUpdateDictionary) です。
dict の値は、内部型 T を使ってエンコードされた dict_size 個の値です。辞書では特殊な値のために先頭のスロットが予約されます。非 Nullable のカラムでは 1 つ予約され (dict[0] には内部型のデフォルト値 (たとえば String なら "") が入る) 、実際の異なる値は dict[1] から始まります。
LowCardinality(Nullable(T)) では、dict は引き続き通常の T としてエンコードされます (null-map ストリームはありません) が、予約されるスロットは 2 つ です。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 が複合型の内部にある場合、この数は複合型が下位に渡すフラット化された値の個数になります。たとえば、3 行に合計 5 要素を持つ Array(LowCardinality(String)) では、keys_count は 3 ではなく 5 です。Map(K, LowCardinality(V)) ではペアの総数になり、以下同様です。デコーダーはブロックの行数を前提にせず、このフィールドから keys_count を取得しなければなりません。このフラット化された個数が 0 の場合 — たとえば、配列がすべて空のブロック — LowCardinality の data phase では何も書き込まれません。存在するのは状態プレフィックス (composite prefix phase で出力される) だけで、その後にメタデータ、辞書、keys_count は続きません。
行数が 0 より大きいすべてのブロックでは、先頭で状態プレフィックスが読み取られます — ヘッダーブロック (rows = 0) と空ブロックは何も出力しません。ブロック内では、keys_count は行数と等しく、dict_size は dict ストリーム内の値の数と等しく、各 key は 1 << key_type_code バイトに収まります。
値 ['a', 'b', 'a', 'c', 'b'] を持つ LowCardinality(String):
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)) では、予約済みの 2 つのスロット、つまり NULL 用の dict[0] と空文字列のデフォルト値用の dict[1] の両方が示されます:
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 型には複数の wire エンコーディングがあります (シリアル化バージョンのリファレンスを参照) 。Tier 1 は最も単純で、クエリごとの設定 output_format_native_write_json_as_string = 1 が有効な場合、サーバーは各 JSON 値をシリアル化されたテキストに変換し、state-prefix マーカー付きの String としてカラムを出力します。
型文字列: JSON.
[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 (native TCP プロトコルでのデフォルト) 、3 = FLATTENED、4 = V3 (シリアル化バージョンのリファレンスを参照) 。ここで 1 以外の値を見たデコーダーは、String フォールバックを見ているのではありません。プレフィックスは、行数が 0 より大きいすべてのブロックの先頭で読み取られ、values ストリームは num_rows 行分の標準的な String カラムです。
JSON 値 '{"a":1}' (1 行) :
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}値は {"a":1} のようなコンパクトな JSON テキストとして出力され、整数は整数のまま保持されます。このテキストは単なる String の値であるため、client は JSON をそのまま受け取るだけで、個々のパスやそれぞれの ClickHouse の型を復元することはできません。パスごとの忠実な型付けには、以下の Tier 2 エンコーディングが必要です。
Variant(T1, T2, …)
判別付きユニオンです。各行には、Variant 型のうちいずれか1つの値、または NULL が格納されます。各行には型を選択する 1 バイトのグローバル 判別子があり、その後、型ごとの値は Variant 型ごとに1つの連続した領域として密に格納されます。
型文字列: Variant(T1, T2, ...)。サーバーは順序を正規化し (Variant 型は名前順にソートされます) 、受信する型文字列にはすでにグローバル 判別子 順で型が並んでいます。判別子 0 は最初に列挙された型を選択し、1 は2番目の型を選択します。以下同様です。255 (NULL_DISCRIMINATOR) は、その行が NULL であることを意味します。Variant の要素が Nullable になることはありません。NULL は 判別子 が表現します。例: Variant(String, UInt64)、Variant(Array(UInt8), String)。
state prefix には、UInt64 LE の discriminator モードが含まれます。0 = BASIC (各行の 判別子 をそのまま書き込む) 、1 = COMPACT (granule 単位のランレングスエンコーディング) です。サーバーはデフォルトでネイティブプロトコルでは BASIC を使用します (use_compact_variant_discriminators_serialization = false) 。ここで規定されているのは BASIC のみです。
[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 の value run の index counter[d] にある値を取り、その後 counter[d] をインクリメントします。判別子 255 の行は NULL で、どの run からも値を消費しないため、型ごとのカウンターの合計は非 NULL の行数に等しくなります。
state prefix (mode UInt64) は、行数が 0 より大きいすべての block の先頭で読み取られます。header と空の block は何も出力しません。各非 NULL の 判別子 は Variant 型の数より小さく、Variant 型 i はちょうど count[i] 行分デコードされます。
値が [42, 'hi', NULL] の Variant(String, UInt64) (canonical order では String が UInt64 より前にソートされるため、判別子 0 = String、1 = UInt64) :
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 の判別子は対応する型の dense run から次の値を取り出します。一方、255 (NULL) は何も消費しません。この同じたどり方で Dynamic も再構成できますが、異なるのは NULL のエンコード方法だけです:
Dynamic
値の型が実行時に判明するカラムです。各行には、実行時に決定される型集合のいずれか 1 つの値、または NULL が入ります。Variant とは異なり、型集合はカラムの型文字列には含まれず、state prefix に格納されます。
型文字列: Dynamic または Dynamic(max_types=N)。max_types パラメータは、このカラムが追跡する異なる型の数の上限を定めますが、以下のワイヤ形式には影響しません。
Dynamic には 4 つのエンコーディングがあります — V1 = 1、V2 = 2、FLATTENED = 3、V3 = 4。server がどれを出力するかは、チャネルとクエリ設定によって決まります。
clickhouse-clientおよび HTTPFORMAT Nativeでは、writer の revision は0です (client_protocol_versionで引き上げない限り) 。そのため、デフォルトは V1 になります。- native TCP プロトコルでは、ネゴシエートされた revision におけるデフォルトは V2 です。
Nativewriter では statistics は無効のままのため、デフォルトのV2payload には variant ごとの statistics は含まれません。型リストの後には、ネストされたVariantprefix と data がそのまま続きます。 (variant ごとの statistics は MergeTree の on-disk に関するものであり、Native wire の一部ではありません。) - クエリ設定
output_format_native_use_flattened_dynamic_and_json_serialization = 1を指定すると、この両方を上書きし、revision に関係なく FLATTENED (version 3) を出力します。
その設定で選択される FLATTENED (version 3) レイアウト:
[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
[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 そのもので、NULL が固定値 255 である Variant とは異なります。復元は Variant と同じ密な走査で行います。型ごとのカウンターを保持し、判別子 d (≠ num_types) を持つ行 r は、型 d の並びから counter[d] 番目の値を取ります。
state prefix (バージョン + 型リスト) は、行数が 0 より大きいすべてのブロックの先頭で読み取られます。ヘッダーと空のブロックは何も出力しません。
実行時の型リストは通常、Variant の正規化に従います。通常の variant スロットは DataTypeVariant の順序 (型名順) で書き込まれるため、wire 上の順序は挿入順とは一致しません。ただし、常に全体がソートされているわけではありません。共有 variant にあふれた型 (たとえば Dynamic(max_types=N) の場合) は、通常のスロットの後に最初に現れた順で追加されるため、リストの tail では型名順が崩れることがあります。したがって、デコーダーは転送された型リストを 判別子 の割り当てにおける基準として扱う必要があり、自分で再ソートしてはいけません。行 [42::UInt64, "hi", NULL] では、2 つの型は String と UInt64 で、"String" は "UInt64" より前にソートされるため、判別子 は 0 = String、1 = UInt64、2 = 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 は、型リストと同じ wire 上の順序 (String が UInt64 より前) に従います。
JSON (Tier 2: FLATTENED Object)
よりリッチな JSON エンコーディングです。すべての値をテキストにフラット化する (Tier 1) のではなく、カラムは JSON パスごとに 1 つのサブカラムに分割されます。これは、フラット化シリアライゼーションのフラグがオン (output_format_native_use_flattened_dynamic_and_json_serialization = 1) のまま、Tier 1 へのフォールバックを要求しない (output_format_native_write_json_as_string = 0) 場合に選択されます。このとき、server はシリアライゼーション バージョン 3 を出力します。
パスには 2 種類あります。
- 型付きパス は型文字列で宣言されます。たとえば
JSON(a UInt32, b String)のように指定し、宣言された型としてデコードされます。ドットを含むパス名は、型文字列内でバッククォートで囲まれます。 - 動的パス は runtime に検出され、それぞれ Dynamic カラムとしてデコードされます。
FLATTENED モードでは 共有データカラムはありません (このオーバーフロー格納先は、非フラットな V2/V3 Object エンコーディングに属します) 。すべてのパスは、num_rows 個の値を持つ完全なカラムです。
[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)2 段階の構造になっている点に注意してください。まず すべて のパスの state prefix が先に現れ、その後に すべて のパスデータが続きます。したがって、動的パスの Dynamic プレフィックス (プレフィックスフェーズ) は、そのデータ (データフェーズ) とは分かれています。state prefix は、行数が 0 より大きいすべてのブロックの先頭で読み取られ、各パスカラム (型付きまたは動的) にはちょうど num_rows 個の値が格納されます。行 r のオブジェクトは、各パスのインデックス r にある値を読み取って組み立てられます。その行で Dynamic の判別子が NULL の動的パスは、キーを追加しません。
JSON 値 {"a": 42, "b": "hi"} (1 行で、両方のパスが動的) の例を示します。JSON の整数は Int64 として推論されます:
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 非フラット (V2/V3)
非フラット化 Object エンコーディング (V1/V2/V3) は、MergeTree のオンディスクストレージで使用され、flattened フラグがオフのときにサーバーが wire 上に出力する形式です。clickhouse-client / HTTP FORMAT Native (revision 0) では V1、native TCP プロトコルでは V2 が使われます。これらは shared-data カラムを含み、このページでは定義していません。また、Native wire 上ではパスごとの統計情報を含まない点に注意してください。NativeWriter は統計情報を無効のままにするため、Object structure prefix には統計情報セクションがなく、その後のバイト列には typed/dynamic/shared-data prefixes と data がそのまま続きます。統計情報が現れるのは、それを有効にしている MergeTree のオンディスクパスだけです。このページを使って JSON カラムをデコードするには、client は文書化されている tiers のいずれかを選択する必要があります。String fallback を使う場合は output_format_native_write_json_as_string = 1 を設定し、FLATTENED Object レイアウトを使う場合は output_format_native_use_flattened_dynamic_and_json_serialization = 1 (かつ output_format_native_write_json_as_string = 0) を設定します。
圧縮フレーム
ClickHouse では、Native ストリームのカラムデータを内部フレームフォーマットで圧縮できます。以下のフレームレイアウトはトランスポート非依存で、同じフレームが native TCP プロトコルでも HTTP 上でも使われます。ただし、圧縮の要求方法と、フレームの外側を構成するものはトランスポートによって異なります。
- Native TCP プロトコル。 圧縮は、Query packet の
compressionflag により、クエリ単位でオプトインして有効化します。有効な場合、各Data、Totals、Extremes、Log、ProfileEventspacket のボディ、つまりtable_namestring より後ろのバイト列が、このフレームフォーマットでラップされます。packet のエンベロープ自体、packet-type code、およびtable_namestring は圧縮されません。server はそれらを生のストリームに書き込みます。NativeWriterが出力するものはすべて圧縮ストリームに入るため、BlockInfoprefix は次元やカラムとともにフレーム内の先頭に配置されます。したがって、client はBlockInfoを読み取る前にフレームを展開する必要があります。 - HTTP。
SELECT ... FORMAT Native&compress=1は、FORMAT Nativeのバイトストリーム全体を同じフレームでラップします (server は同じ内部CompressedWriteBufferを使用します) 。また、?decompress=1はNativeの入力ボディで同じフレームを想定し、対応するCompressedReadBufferを通じてそれらをデコードします。この経路には TCP packet type、table_name、packet envelope はありません。圧縮された payload 全体は、単にフレーム化されたNativeblock です (BlockInfoprefix は、ネゴシエートされた revision が0より大きい場合にのみ存在し、これは上記の非圧縮レイアウトとまったく同じです) 。この内部compress/decompressフレーミングは、HTTP トランスポート圧縮 (Content-Encoding: gzip/zstd、enable_http_compressionにより有効化) とは別物です。後者は HTTP レイヤーでレスポンスをラップするものであり、以下のフレームフォーマットとは異なります。
したがって、非圧縮の FORMAT Native レイアウトしか実装していない client でも、圧縮された HTTP Native レスポンスを読み取ったり、decompress=1 の request body を送信したりするには、このフレームレイヤーに対応する必要があります。
フレームフォーマット
[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 です。ここで 2 つの対象範囲に注意してください。チェックサムは 9 バイトのヘッダーとボディを対象としますが、compressed_size はヘッダーとボディを数える一方、チェックサム自体は 含みません。
メソッドバイト値
これら 3 つの codec は、ストリーム全体の Native フレーミング に対してサーバーが出力するものです。HTTP の compress=1 出力では常に LZ4 が使われ、native TCP プロトコルでは network_compression_method に応じて LZ4、ZSTD、または NONE が使われます。汎用的な Native client が生成・処理する必要があるのは、この 3 つだけです。
| Byte | Method | Body encoding |
|---|---|---|
0x02 |
NONE | ボディは raw bytes です (圧縮なし) 。フレーム自体は引き続き出力され、receiver はチェックサムを検証します。 |
0x82 |
LZ4 | ボディは LZ4 ブロック format です。LZ4 のフレームフォーマットではありません。magic number は含まれません。 |
0x90 |
ZSTD | ボディは raw zstd の単一フレームストリームです (標準の zstd magic number はボディの一部です) 。 |
メソッドバイトは カラム-level codecs もエンコードします。これらはストリーム全体の フレーミング ではなく、MergeTree の オンディスク パスでカラムごとに適用されます。ただし、decompress=1 の HTTP 入力パスでは各フレームのメソッドバイトから codec を取得するため、これらのどのバイトも正当な入力として現れます。したがって、仕様に準拠したデコーダは割り当て済みの空間全体を認識し、未実装のバイトはボディを誤って解釈するのではなく拒否しなければなりません。これらのボディは codec 固有であり、この汎用フレーム契約の対象外です。
| Byte | Method |
|---|---|
0x91 |
Multiple (ネストされた codec の数列をラップする composite codec) |
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 |
0x9c (ALP) は、Float32 および Float64 向けのベータ版可逆 codec です (アダプティブな可逆浮動小数点圧縮)。enable_alp_codec 設定が有効な場合にのみ CODEC(ALP) を使って table を作成できますが、以前に書き込まれた data を引き続き読み取れるよう、展開時にはこのメソッドバイトが常に受け入れられます。0x9d (SZ3) は、Float32、Float64、およびそれらの型の Array 向けの、誤差境界付きの実験的な非可逆 codec です。enable_sz3_codec 設定が有効な場合にのみ CODEC(SZ3) を使って table を作成できますが、以前に書き込まれた data を引き続き読み取れるよう、展開時にはこのメソッドバイトが常に受け入れられます。0x9f (ZXC) は実験的な非対称 LZ codec で、圧縮は低速ですが、展開は非常に高速で、ratio は LZ4 と ZSTD の中間です。enable_zxc_codec 設定が有効な場合にのみ CODEC(ZXC) を使って table を作成できますが、以前に書き込まれた data を引き続き読み取れるよう、展開時にはこのメソッドバイトが常に受け入れられます。0x99 (DeflateQpl) と 0x9b (ZSTD_QPL) のバイトは、その後削除された codec に割り当てられていました。これらは予約済みで、再利用されません。
0x9e (Quantized) は、高密度ベクトルカラム (Array(Float32) など) 向けの実験的なカラム codec です。NONE と同様にパススルーで、フル精度のボディはそのまま保存されますが、この codec があると、ベクトル検索の高速化に使われる compact な量子化 companion ストリームを書き出すシリアライゼーションが関連付けられます。enable_quantized_codec 設定が有効な場合にのみ CODEC(Quantized(...)) を使って table を作成でき、展開時にはこのメソッドバイトが常に受け入れられます。
チェックサム
ClickHouse は CityHash v1.0.2 (旧来のバリアント) を使用しており、最新の Google CityHash は使用していません。この 2 つは異なる出力を生成します。
チェックサムは、9 バイトのヘッダー (method + compressed_size + uncompressed_size) と N バイトのボディ、つまりチェックサムから frame の末尾までのすべてのデータに対して計算されます。16 バイトの CityHash128 出力では、最初の 8 バイトが下位 половина (LE) 、次の 8 バイトが上位半分 (LE) です。デコーダーは受信したヘッダーとボディに対して CityHash128 を再計算し、その結果を先頭の 16 バイトと比較します。一致しない場合はデータ破損と見なされ、デコーダーは失敗します。
ブロックごとの境界
Block の compressed payload は、必ずしも単一の フレーム ではなく、1 つ以上の フレーム からなる stream です。送信側はシリアライズされた block を CompressedWriteBuffer を通じて書き込み、内部 buffer がいっぱいになるたび (約 1 MB、DBMS_DEFAULT_BUFFER_SIZE) に フレーム を出力し、block が flush されると最後の フレーム を出力します。したがって、小さい block は 1 つの フレーム ですが、大きい block は連続する複数の フレーム になります。
この不変条件が成り立つのは一方向だけです。送信側は各 block の末尾で compressed buffer を flush するため、すべての block の終端は フレーム 境界と一致します。ただし、その逆は成り立ちません。block の途中で buffer がいっぱいになったときに出力される中間の フレーム 境界は、block の途中にあり、block 境界ではありません。したがって、デコーダーは block の終端を見つける際に、block 自身の次元 (num_columns/num_rows) を使う必要があり、各 フレーム が 1 つの完全な block だと仮定してはいけません。
受信側は フレーム を stream として処理します。16 + 9 bytes を読み取り、続いて compressed_size - 9 bytes のボディを正確に読み取り、それを正確に uncompressed_size bytes に展開して、その bytes を block デコーダーに渡します。デコーダーが現在の フレーム に収まっている以上のデータを必要とする場合は、次の フレーム を取得します。送信側は block ごとに flush するため、block のデコードが完全に終わると frame buffer は空になり、次の block は新しい フレーム から始まります。
native TCP プロトコルでは、packet envelope (packet-type VarUInt と table_name 文字列) は compressed payload の外側にある raw stream に書き込まれ、フレーム 化されるのは block body (BlockInfo + columns) だけです。HTTP の compress/decompress パスにはこのような envelope はなく、stream 全体が フレーム 化された blocks になります。
ネゴシエーション
native TCP プロトコルでは、圧縮は接続単位ではなくクエリ単位です。Query パケットの compression: bool フィールドで、そのクエリに対してのみ圧縮を要求します。サーバーはこの要求に従い、クエリの存続期間中、圧縮された Data/Totals/Extremes/Log/ProfileEvents のボディを出力します (Log/ProfileEvents は v54481+ のみ) 。また、クライアントから送信される Data ブロック (external table、空のデータ終端マーカー、INSERT の行) についても、同じ形式でフレーム化されていることを前提とします。同じ connection 上の後続のクエリでは、設定が異なる場合があります。
HTTP では Query パケットが存在しないため、compress=1 クエリパラメータでそのリクエストのフレーム化された出力を指定し、decompress=1 で request body がフレーム化されていることを宣言します。compress=1 の出力は、network_compression_method ではなくサーバーのデフォルト codec (LZ4) で書き込まれます。一方、decompress=1 の reader は各フレームの method byte から codec を読み取るため、入力では任意の codec を受け付けます。
用語集
ブロック — Native format におけるデータ交換の単位。列指向で格納された行からなる、自己記述型の chunk です。block and column structure を参照してください。
BlockInfo — TCP Data-packet path で ブロック の前に置かれるメタデータヘッダーです (connection revision が 0 より大きい場合に常に書き込まれます) 。revision に応じて制御され、field ID でタグ付けされたフィールドの列で構成されます。Native 出力フォーマットでは省略されます。これは revision 0 でシリアライズされるためです。BlockInfo を参照してください。
Column body — Column header (name、type、has_custom_serialization byte) に続く、実際の値を保持する Column のバイト列です。レイアウトは type ごとに異なります。column wire layout を参照してください。
Composite type — 1 つ以上の inner type から構成される type で、各カラムにつき複数の stream としてエンコードされます。ワイヤ形式は stable で、バージョンはありません。composite types を参照してください。
Dictionary (LowCardinality) — LowCardinality(T) カラムが整数インデックスを通じて参照する、一意な値の配列です。LowCardinality を参照してください。
Empty block — num_columns = 0 かつ num_rows = 0 の ブロック です。番兵として使われ、client-side の入力終端マーカーであると同時に、server-side の stream 境界マーカーでもあります。block variants を参照してください。
Header block — num_columns > 0 かつ num_rows = 0 の ブロック で、クエリ応答の最初の Data packet として server から送信されます。結果のスキーマを通知します。block variants を参照してください。
Inner type — composite が内包する type です。Array(UInt32) の inner type は UInt32、Nullable(T) の inner type は T です。
Offsets stream — Array、Map、Nested が行ごとの要素境界を区切るために使う、累積終端位置を表す UInt64 配列です。Array を参照してください。
Placeholder value — Nullable(T) カラムの values stream で、null の位置に書き込まれるバイト列です。デコーダーは stream を進めるためにこれを読み取りますが、内容は無視します。Nullable を参照してください。
Result block — 実際のクエリ結果の行を含む、num_rows > 0 の ブロック です。block variants を参照してください。
Schema block — header block の同義語で、INSERT phase を説明する際に使われます。この場合、schema block は期待されるカラム形状を client に伝えます。
Serialization version — versioned type が、後続するエンコーディングのどの variant が使われるかを示すために用いる、type ごとの on-wire バージョン番号です。protocol version とは異なります。serialization version: concept を参照してください。
State prefix — versioned type のブロックごとのペイロードに先行するバイト列です。シリアル化バージョンと、 (LowCardinality の場合は) ブロック単位の dictionary メタデータを保持します。行数が 0 より大きいすべての block の先頭で出力され、block をまたいで保持されることはありません。
Stream — カラム body 内の連続したバイト列で、1 つの論理的な下位要素 (null-map、offsets array、values stream) をエンコードします。multi-stream type は、各カラムにつき 2 つ以上の stream を連結します。