- ネイティブ TCP プロトコル における
Data、Totals、Extremes、Log、ProfileEventsパケットのボディ (TableColumnsパケットは Native ブロックではありません。2 つのバイナリ文字列を持つため、そのレイアウトは ネイティブプロトコル仕様 に属します) ; - HTTP 経由の
SELECT ... FORMAT Nativeの出力; INTO OUTFILE ... FORMAT Nativeで書き出されるファイルエクスポート;- サーバー間レプリケーションのペイロード。
概要
data は、その型が属するファミリーに応じて配置されます。デコーダの複雑さが低いものから高いものへ順に、次のファミリーがあります。
- 固定幅型では、
dataはbytes_per_value × num_rowsの生バイトとして配置され、行ごとのフレーミングはありません。 - 複合型 (
Nullable,Array,Tuple,Map,Nested) は、型文字列から完全に導き出せる再帰的な構造を持ち、バージョンプレフィックスもブロック間の状態もありません。 - バージョン付き / ステートフル 型 (
LowCardinality,JSON,Variant,Dynamic) は、空でない各ブロックの先頭にシリアライゼーションのバージョン/状態プレフィックスを付加します。Nativewire では、このプレフィックスと任意の Dictionary はブロックごとです。つまり、このフォーマットはブロック間で状態を持ちません (writer はブロックごとに新しいシリアライゼーション状態を作成し、low_cardinality_max_dictionary_size = 0を設定します) 。ブロック間の状態は MergeTree のオンディスク上の話であり、Native wire layout の話ではありません。
ワイヤプリミティブ
VarUInt
1、最後のバイトでは 0 になります。
値
300 のエンコード:
0xAC 0x02 をデコードすると:
固定長整数
たとえば、UInt32 の値
1 は 01 00 00 00 にエンコードされ、Int32 の値 -1 は FF FF FF FF にエンコードされます。
String
0x00 としてエンコードされ、文字列には埋め込み NUL を含む任意のバイト値を含めることができます。文字列 "ab" は 02 61 62 としてエンコードされます。デコードするには、まず VarUInt の長さ (2) を読み取り、次にその長さ分のバイトを読み取ります。
Bool
0x00 は false、0x00 以外の値は true (標準的には 0x01) です。
ブロックとカラム構造
Block のワイヤレイアウト
BlockInfo プレフィックスの有無はチャネルによって異なります。これは、writer が リビジョン によってパラメータ化されているためです。
-
native TCP プロトコル では、server は接続時にネゴシエートされた リビジョン で block を書き込みます (大きな値であり、この release では
DBMS_TCP_PROTOCOL_VERSIONは54485です) 。BlockInfoは、その リビジョン が 0 より大きい場合に書き込まれます。実際の接続では、これは常に当てはまります。各 column のhas_custom_serializationバイト (column 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、および columns だけになります。 HTTP では、この リビジョン は固定ではありません。client は?client_protocol_version=<n>クエリパラメータでこれを引き上げることができ、server はその値をレスポンスのシリアライゼーション リビジョン として使用します。 十分に大きな値を指定すると、HTTP 出力にはBlockInfoプレフィックス (リビジョン が0より大きい場合に書き込まれます) とhas_custom_serializationバイト (リビジョン54454以上で書き込まれます) が含まれ、TCP 経路とまったく同じになります。したがって、clients はすべての HTTPFORMAT Nativepayload が リビジョン0だと想定してはなりません。
BlockInfo プレフィックスで始まるバイト例は、TCP Data-packet payload を示しています。同じクエリを FORMAT Native 経由で取得した場合は、それに対応する短い形式になります。
BlockInfo
0 で終端されます。ワイヤ形式は自己記述型ではありません。フィールド ID 自体には値の長さや型がエンコードされていないため、読み取り側は遭遇しうる各フィールド ID の型をあらかじめ把握している必要があります。ClickHouse のリーダー実装では、認識できないフィールド ID は破損と見なされ、例外 (UNKNOWN_BLOCK_INFO_FIELD) が発生します。前方互換性は代わりにプロトコルのリビジョンで担保されます。送信側は、ネゴシエートされたリビジョンがそのフィールドの最小リビジョン以上である場合にのみそのフィールドを書き込むため、古い受信側が未知のフィールドを見ることはありません。
フィールド
1 と 2 の最小リビジョンは 0 であるため、BlockInfo が書き込まれる場合は常に含まれます。フィールド 3 はリビジョン 54480 以上でのみ書き込まれます。一般的なケース (リビジョンが 54480 未満) でのワイヤレイアウトは次のとおりです。
Column のワイヤレイアウト
num_columns 回現れます。
デコーダは
type 文字列に基づいて処理を分岐します。型文字列には括弧付きのパラメーターが含まれることが多いため、デコーダはまず (...) の接尾辞を取り除いて基本型を特定し、その後でサイズ、scale、または内部型の判定に必要なパラメーターをパースします。ネストした型を含むパラメーターリスト (たとえば Array の中に Tuple がある場合) をパースするには、単純に , で分割するのではなく、括弧のネストを追跡する深さ対応のカンマ分割が必要です。
Binary type encoding
type フィールドがテキストの String になるのは既定モードの場合のみです。クエリ設定 output_format_native_encode_types_in_binary_format = 1 が設定されている場合、このフィールドは代わりに binary type encoding になります。これは data type binary encoding に記載されているものと同じタグベースのエンコーディングです。また、フラット化された Dynamic 型リストでも、型ごとの名前に同じバイナリエンコーディングが使われます。field 2 を常に長さプレフィックス付き文字列として読むデコーダは、最初のバイナリ型タグを文字列長として扱ってしまい、ストリームとの同期がずれるため、ストリームがどのモードを使用しているかを把握している必要があります。kind_stack とスパースエンコーディング
kind_stack バイトは、カラムごとの非デフォルトなシリアライゼーションを表します。
COMBINATION ペイロードでは別の enum を使います。 上の 5 行は compact な 1 バイトコードです。COMBINATION (0x05) は、それらで表せない任意のスタックに対する汎用エスケープで、後ろに VarUInt count、さらに count 個の 1 バイトエントリが続きます。これらのエントリは、表にある compact コードではなく、生の ISerialization::Kind 値です。
このネストされた 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 を書き込む前に、プレフィックスのフェーズを内部型に委譲します。プレフィックスが空の内部型 (リーフ型および通常の複合型) は、ここでは何のバイト列も追加しません。
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 のバリアント
バイトレベルの例
BlockInfo プレフィックスと has_custom_serialization バイトが含まれています。FORMAT Native では同じブロックはより短くなり、参考になる場合は対応する短縮形式も示します。
空のブロック (BlockInfo あり) 、合計 8 バイト:
SELECT 1 のヘッダーブロックは、型 UInt8 の "1" という名前のカラムが 1 つあり、行数は 0 であることを示します。プロトコル ≥ 54454 では、has_custom_serialization バイトが含まれます。
FORMAT Native (リビジョン 0) では、同じ結果ブロックに BlockInfo も has_custom_serialization バイトも含まれず、SELECT 1 FORMAT Native は 11 バイトです:
FORMAT Native では一切バイトを出力しません。出力フォーマットは空のブロックを出力しないためです。)
データ型
data に格納できる型のワイヤエンコーディングについて説明します。型は、デコーダの複雑さが増す順に 4 つのファミリーに分類されています。AggregateFunction(func, ...) と QBit(T, N) の 2 つは有効な Native のカラム型ですが、関数固有または型固有のペイロードを持つため、ここでの対象外です。これらについては、別名と誤解される可能性がある箇所で、その旨を以下に示しています。
固定幅型
M 行のカラムは、ワイヤ上で厳密に bytes_per_row × M バイトを占有し、区切りやパディングなしで連結されます。
整数型
UInt8–UInt256 と Int8–Int256 は、整数値を直接バイナリでエンコードしたものです。デコーダーは bytes_per_row × num_rows バイトを読み取り、その型に従って解釈します。
[1, 256, 65536] を保持する UInt32 カラム:
[-1, 42] の値を持つ Int32 カラム:
Float32 and Float64
binary32) と 8 バイトの倍精度 (binary64) があり、いずれもリトルエンディアンです。NaN、±Infinity、±0.0、非正規化数はいずれも、正規化されることなくそのまま往復変換されます。
Float32 の値 1.5 (0x3FC00000):
Float64 の値 1.5 (0x3FF8000000000000):
BFloat16
Float32 の上位 16 ビットで、1 ビットの符号、8 ビットの指数、7 ビットの仮数から構成されます。各値は 2 バイトで、リトルエンディアンの生の 16 ビットパターンを保持します。数値として復元するには、そのパターンを上位半分に配置し、下位半分を 0 にして Float32 に拡張します (bits << 16 を Float32 として reinterpret したもの) 。このように拡張した値のテキストフォーマットは、Float32 と同じです。
BFloat16 の値 1.5 (パターン 0x3FC0、Float32 0x3FC00000 の上位半分) :
Bool
UInt8 とワイヤ形式で互換性があるで、1行あたり 1 バイトです。0x00 = false、0x01 = true。wire 上の型文字列は文字どおり Bool (UInt8 ではなく) なので、型文字列に基づいて振り分けるデコーダーでは、これを別個の型として認識する必要があります。
Bool カラム [true, false, true]:
Date と Date32
1970-01-01 からの経過日数を整数としてエンコードします。どちらにも時刻の部分は含まれません。
Date の値 1970-01-02 (1日) :
Date32 の値 1900-01-01 (-25567日) :
DateTime
UInt32 と互換性があります。秒単位の Unixタイムスタンプで、4 バイトのリトルエンディアンです。この型は DateTime または DateTime('Timezone') として現れることがあり、タイムゾーンは表示にのみ影響し、ワイヤ上の値には含まれません。タイムゾーンのパラメータが異なる 2 つの DateTime カラムでも、同じ時点に対しては同一のバイト列を生成します。デコーダーは (...) のパラメータ接尾辞を取り除き、そのカラムを UInt32 として処理します。
DateTime('UTC') の値 2024-03-15 14:30:00 UTC (タイムスタンプ 1710513000) :
DateTime64(scale[, timezone])
10^-scale 秒単位のティックを表します。scale パラメーター (0~9) は型文字列に含まれ、時間の単位を設定します。
この型は、
DateTime64(s) (暗黙的に server のデフォルトタイムゾーンを使用) または DateTime64(s, 'TimezoneName') (明示的なタイムゾーン。表示のみに使用) として表されます。負の値は、epoch より前のティックを表します。
DateTime64(3, 'UTC') の値 2024-01-15 12:30:45.123 UTC (1705321845123 ms) :
DateTime64(0) の値 2024-01-15 12:30:45 UTC (1705321845 秒) :
Time と Time64(scale)
Time は符号付きの秒数で、4 バイトの リトルエンディアン Int32 です。Time64(scale) は、指定した小数スケール (0~9) における符号付き tick 数で、8 バイトの リトルエンディアン Int64 です。つまり、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):
Time64(3) の値 45296789 ティック (12:34:56.789) :
Time と Time64 は実験的な機能であり、サーバーで allow_experimental_time_time64_type = 1 を有効にする必要があります。Interval
Interval<Unit> — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond など。すべての単位で wire エンコーディングは共通で、値は符号付き 8 バイトのリトルエンディアン Int64 として表現されます。単位は型文字列にのみ含まれ、wire バイト列にも、単なる整数で表されるテキスト形式にも影響しません。すべての単位は 1 つのデコーダーパスで処理できます。
IntervalDay の値 5:
UUID
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx で表される128ビットの識別子で、バイトは慣例的にビッグエンディアンで記述されます。wireモデルでは、この標準的な16バイトを2つの8バイト単位に分割し、それぞれをリトルエンディアンで書き込みます。
- wireバイト 0..7 = 標準バイト 0..7 を逆順にしたもの。
- wireバイト 8..15 = 標準バイト 8..15 を逆順にしたもの。
550e8400-e29b-41d4-a716-446655440000:
IPv4 と IPv6
IPv4 は 4 バイトで、正規の 32 ビットアドレス (a.b.c.d から得られる値 (a << 24) | (b << 16) | (c << 8) | d) を保持するリトルエンディアンの UInt32 としてエンコードされます。wire 上のバイト列は、ネットワークバイトオーダーのバイト列を逆順にしたものです。
192.168.1.10 (正規の 32 ビット値 0xC0A8010A) :
IPv6 は 16 バイトで、ネットワークバイトオーダーでそのまま、スワップせずに書き込まれます。バイト順は inet_pton(AF_INET6, ...) と同じです。
2001:db8::1:
u32 として格納される一方、IPv6 は大半のネットワーキング API で一般的なネットワークバイトオーダーのレイアウトを維持します。
Enum8 と Enum16
Int8 および Int16 とワイヤ形式で互換性があります。1 行あたり 1 バイトまたは 2 バイトで、16 ビットのバリアントは 2 の補数のリトルエンディアンです。各バリアントの完全な対応関係は型文字列に含まれます:
(...) のパラメータ接尾辞を取り除き、Int8 / Int16 として処理することがあります。wire上のバイト列は単なる整数インデックスです。ラベルを扱うクライアントは、型文字列から 'name' = value の map を解析して取り出し、カラムと一緒に保持します。整数だけではラベルを復元できません。テキスト指向の出力では、インデックスではなくラベル (active) が表示され、enum が複合型の中にネストされている場合はシングルクォート付き ('active') で表示されます。map は整数カラムからは復元できないため、Array(Enum8(...)) や Map(Enum16(...), V) のようなネストされた enum では保持しておく必要があります。
Enum8('active' = 1, 'inactive' = 2) のカラム [active, inactive, active]:
Enum16(...) の値 30000:
Decimal(P, S)
P によって暗黙的に決まり、スケール S は負の 指数 (小数点以下の桁数) です。どちらも型文字列に含まれます。
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:
Decimal(18, 1) の値 -1.5 → 内部整数 -15:
Decimal(38, 4)の値 123.4567 (合計16バイト) :
Nothing
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) カラム:
Nullable のフレーミングです (Nullable を参照) 。内側の 3 バイトは Nothing のペイロードで、デコーダーはこれを読み飛ばします。
可変長型
String
String。String カラムは、長さプレフィックス付きのバイト列が num_rows 個並んだものです:
0x00 バイトです。ClickHouse の String はテキスト指向ではなく、バイト指向です。つまり、UTF-8 の妥当性は保証されず、値には埋め込み NUL を含む任意のバイトを含めることができます。UTF-8文字列型を対象とするデコーダでは、読み取り時に妥当性を検証するか、生のバイト列を呼び出し元に渡します。カラムが消費する総バイト数は、すべての行についての Σ (varuint_size(len_i) + len_i) です。
3 つの文字列 ["ab", "", "c"] からなるカラム (合計 6 バイト) :
FixedString(N)
FixedString(N) で、N は正の整数です (例: FixedString(16)) 。このカラムは長さプレフィックスや区切りを持たず、サイズは常に N × num_rows 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 バイト) :
複合型
- スキーマごとに形状が固定される。 構造はデコード時に型文字列だけで完全に決まります。
Array(UInt32)は常に同じストリームレイアウトを持ち、block が変わっても変わりません。 - 独自のバージョンプレフィックスを持たない。 複合ラッパー自体はバージョンバイトを追加せず、そのフレーミング (offsets、null-map、要素ストリーム) は ClickHouse の releases をまたいでも安定しています。これは wrapper 自体にのみ当てはまります。内部のバージョン付き型については、以下のプレフィックスフェーズの注記を参照してください。
- 独自の block 間 state を持たない。 ラッパーのフレーミングは block ごとに完全に self-describing です。block 間 state に関する問題は、ラッパーではなく内部のバージョン付き型に起因します。
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 マップです:
num_rows バイトちょうどで、各行に 1 バイトずつ対応します。
values ストリームには、NULL の位置を含む すべての
num_rows 行について、内部型の標準エンコーディングが格納されます。デコーダーはストリームを先に進めるため、NULL の位置にあるプレースホルダーバイトも読み取る必要がありますが、個々の値を解釈する前に null-map を参照しなければなりません。送信側は NULL の位置に任意のバイトを書き込めるため、デコーダーは特定のプレースホルダー値を前提にしてはいけません。
内部型ファミリーごとのプレースホルダー値:
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 バイト) :
["hello", NULL, "world"] を持つ Nullable(String) (合計 15 バイト) :
Array(T)
Array(InnerType)。例: Array(UInt32)、Array(String)、Array(Nullable(UInt32))、Array(Array(UInt8))。
ワイヤレイアウトは、内側のプレフィックス フェーズ (内側の型がバージョン付きでない限り空) の後に、2 つの連結された stream が続く構成で、最初に offsets が来ます:
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 バイト) :
0) です。連続するオフセットが同じ場合、その行は空です:
Array(String) で、行 [["a", "bb"], []] の場合 (合計20バイト) :
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]にデコードされます。
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 個のストリームです:
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):
[1, 2, 3]、要素 1 では [4, 5, 6] になります。
Tuple(UInt32, String)、2行 (10, "a"), (20, "bb") (合計 13 バイト) :
Map(K, V)
Map(KeyType, ValueType)。例: Map(String, UInt32)、Map(String, Array(UInt32))、Map(UInt8, Tuple(Int32, String))、Map(Array(String), Int8)。ワイヤ形式では、どちらの型にも制限はなく、K と V はどちらも、複合型を含む任意のサポート対象型を使用できます。 (許可されるキー型に関する ClickHouse の SQL レベルの規則はリリースによって異なるため、対象のサーバーバージョンに対応する SQL ドキュメントを参照してください。)
ワイヤレイアウトは Array(Tuple(K, V)) とバイト単位で同一であるため、最初に内側の プレフィックスフェーズ から始まります (K または V がバージョン付きでない限り空です) :
total_pairs = offsets[num_rows - 1] (num_rows == 0 の場合は 0) です。offsets ストリームは Array と同じセマンティクスを持ちます。キーは値と位置ごとに対応しており、ペア i は (keys[i], values[i]) です。
ClickHouse における Map カラムのインメモリ表現はタプルの配列ですが、型システム上では SQL で扱いやすいように独立した型として表されます (m['key']、mapKeys、mapValues) 。ワイヤ形式はこの保存表現をそのままシリアライズしたものなので、Map と Array(Tuple(K, V)) はバイト単位で完全に同一です。
offsets は単調非減少で、キーと値の両方のストリームにはちょうど total_pairs 個の値が含まれます。空のカラムは 0 バイトを書き込みます。1 つの行の中では通常キーは一意ですが、これはセマンティクス上の規則であり、ワイヤ形式で強制されるものではありません。ワイヤ形式では重複したキーもそのまま保持して往復でき、サーバー側のセマンティクスで重複が解決されるのは、Map 対応の関数がその行を処理するときだけです。
2 行 {1:10, 2:20}、{3:30} を持つ Map(UInt8, UInt8) (合計 22 バイト) :
i は keys[i] と values[i] を一緒に読み出すことで再構築されます。
1 行 {'a':1, 'b':2} を持つ Map(String, UInt32) (合計 20 バイト) :
Nested(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 です:
flatten_nested = 0。 テーブルが flatten_nested = 0 で作成されている場合、このカラムは wire 上では型文字列 Nested(name1 T1, name2 T2, ...) を持つ単一のカラムとして表れ、その型文字列に続くレイアウトは Array(Tuple(T1, T2, ..., Tn)) とバイト単位で完全に同一 です。これには内側の プレフィックスフェーズ も含まれるため、バージョン付きの各フィールド T_i は、offsets より前にまずその state プレフィックスを出力します。以下の例ではバージョン付きでないフィールドを使用しているため、プレフィックスフェーズは空です:
Nested はフィールド名 (a, b) を保持しますが、Array(Tuple) ではそれらは名前付きスロットとしては保持されません。
Case B の型文字列は、(name, type) の組をカンマ区切りで並べたリストです。最初の空白で名前とその型が区切られますが、型自体にはさらに空白、カンマ、括弧が含まれる場合があるため、パースには Tuple で使用するのと同じ、ネストの深さを考慮した分割処理が必要です。wire レイアウト:
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 バイト) :
型の別名
したがって、
Point カラムは Tuple(Float64, Float64) とまったく同じようにデコードされ (表示は (1,2)) 、Ring は Array(Tuple(Float64, Float64)) ([(0,0),(1,1)]) としてデコードされます。以降も同様に、階層に沿って上位の型へと続きます。
Geometry も別名ですが、ネストした配列ではなく Variant の別名です。その payload は、上記 6 つの geo types の variant です。カラムヘッダーには型文字列 Geometry だけが含まれ、variant の内容までは示されません。そのため、デコーダーは自分でこれを展開する必要があります。ほかの Variant と同様に、discriminator は geo の別名の正規名を名前順にソートした順序に従います。0 = LineString、1 = MultiLineString、2 = MultiPolygon、3 = Point、4 = Polygon、5 = Ring です。選択された各値は、その後、上記の geo の別名に従ってデコードされます (NULL の場合は Variant の NULL discriminator 255 を使用します) 。
SimpleAggregateFunction(func, T) は、その値型 T の別名です。これはすでに確定済みの aggregate 値を格納するため、ワイヤ形式も表示も T と完全に同一です (SimpleAggregateFunction(sum, UInt64) は UInt64 としてデコードされます) 。このように別名として扱われるのは単一値型の形式だけで、基底型自体は複合型である場合もあります。
関連する 2 つの型は別名ではありません。これらは有効な
Native カラム型です。たとえば client は -State combinator や distributed aggregation から AggregateFunction カラムを受け取ることがあります。ただし、どちらもこのページの範囲外となる専用の payload を持ちます。AggregateFunction(func, ...)は 中間 aggregation state を保持します (確定済みの値ではありません) 。そのバイナリ layout は aggregate function とバージョンに固有です。QBit(T, N)は、vector-search workloads 向けに bit planes を転置したベクトルを格納します。
バージョン付き型
Native wire では、プレフィックスと任意の Dictionary はブロックごとに付与されます。つまり、これらの型はブロックをまたぐ state を保持しません (下のブロックごとのプレフィックスに関する注記を参照) 。ブロック間のシリアライゼーション state が存在するのは、MergeTree の on-disk stream 内だけです。
これらの型は、固定 shape の複合型よりもかなり複雑なため、単純な分析クエリを対象とする client であれば後回しにできます。
シリアル化バージョン: 概念
ほとんどの 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 をデータとして読んでしまい、同期がずれます:
シリアル化バージョンのリファレンス
この表について、いくつか補足があります。
- 値は連番ではありません。
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))。
sharedDictionariesWithAdditionalKeys です。その他の値は予約されています。
ブロックごとのメタデータ UInt64 はビットフィールドです。
一般的なクエリ応答で、各カラムにつき単一の 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 バイトに収まります。
Native フォーマットでは、各ブロックは 自己完結したブロックローカルな Dictionary を送信します — ブロックをまたいで共有される Dictionary state はありません。Native writer は low_cardinality_max_dictionary_size = 0 を設定するため、SerializationLowCardinality が共有 Dictionary を構築することはありません。つまり、空でない各ブロックは、その key を NeedGlobalDictionaryBit を設定せずに (メタデータ 0x600) 、ブロックローカルな追加 key として書き込み、native_format が true の場合、Native reader は NeedGlobalDictionaryBit を拒否します。したがって、デコーダーはブロックごとに Dictionary をリセットし、そのブロックに含まれる dict_size 件のエントリを読み取る必要があります。前のブロックの Dictionary を引き継ぐと、次のブロックの key を誤って読み取ることになります。 (ブロックをまたいで LC Dictionary を永続化するのは Native wire layout ではなく、MergeTree のオンディスク上の事項です。)['a', 'b', 'a', 'c', 'b'] を持つ LowCardinality(String):
dict[1], dict[2], dict[1], dict[3], dict[2] = ["a", "b", "a", "c", "b"]。
値 ['a', NULL, '', 'b'] を持つ LowCardinality(Nullable(String)) では、予約済みの 2 つのスロット、つまり NULL 用の dict[0] と空文字列のデフォルト値用の dict[1] の両方が示されます:
dict[2] = "a", dict[0] = NULL, dict[1] = "", dict[3] = "b"、すなわち ["a", NULL, "", "b"] です。dict[0] と dict[1] はどちらもワイヤ上では空のバイト列です。NULL かどうかはバイト列ではなく、キーがスロット 0 を指していることによって決まります。
JSON (Tier 1: String フォールバック)
JSON 型には複数の wire エンコーディングがあります (シリアル化バージョンのリファレンスを参照) 。Tier 1 は最も単純で、クエリごとの設定 output_format_native_write_json_as_string = 1 が有効な場合、サーバーは各 JSON 値をシリアル化されたテキストに変換し、state-prefix マーカー付きの String としてカラムを出力します。
型文字列: JSON.
1 です。その他の値は、異なる JSON/Object エンコーディングを表します: 0 = V1、2 = V2 (native TCP プロトコルでのデフォルト) 、3 = FLATTENED、4 = V3 (シリアル化バージョンのリファレンスを参照) 。ここで 1 以外の値を見たデコーダーは、String フォールバックを見ているのではありません。プレフィックスは、行数が 0 より大きいすべてのブロックの先頭で読み取られ、values ストリームは num_rows 行分の標準的な String カラムです。
JSON 値 '{"a":1}' (1 行) :
{"a":1} のようなコンパクトな JSON テキストとして出力され、整数は整数のまま保持されます。このテキストは単なる String の値であるため、client は JSON をそのまま受け取るだけで、個々のパスやそれぞれの ClickHouse の型を復元することはできません。パスごとの忠実な型付けには、以下の Tier 2 エンコーディングが必要です。
Variant(T1, T2, …)
Variant(T1, T2, ...)。サーバーは順序を正規化し (variant type は名前順にソートされます) 、受信する型文字列にはすでにグローバル 判別子 順で型が並んでいます。判別子 0 は最初に列挙された型を選択し、1 は2番目の型を選択します。以下同様です。255 (NULL_DISCRIMINATOR) は、その行が NULL であることを意味します。Variant の要素が Nullable になることはありません。NULL は 判別子 が表現します。例: Variant(String, UInt64)、Variant(Array(UInt8), String)。
状態プレフィックス には、UInt64 LE の 判別子 モードが含まれます。0 = BASIC (各行の 判別子 をそのまま書き込む) 、1 = COMPACT (granule 単位のランレングスエンコーディング) です。サーバーはデフォルトでネイティブプロトコルでは BASIC を使用します (use_compact_variant_discriminators_serialization = false) 。ここで規定されているのは BASIC のみです。
d (≠ 255) を持つ行 r は、variant 型 d の value run の index counter[d] にある値を取り、その後 counter[d] をインクリメントします。判別子 255 の行は NULL で、どの run からも値を消費しないため、型ごとのカウンターの合計は非 NULL の行数に等しくなります。
状態プレフィックス (mode UInt64) は、行数が 0 より大きいすべての block の先頭で読み取られます。header と空の block は何も出力しません。各非 NULL の 判別子 は variant 型の数より小さく、variant 型 i はちょうど count[i] 行分デコードされます。
要素自体が stateful な (
LowCardinality, Variant, Dynamic, JSON) Variant 要素は、mode UInt64 の後、要素ごとの state-prefix フェーズでそれぞれ独自の 状態プレフィックス を出力します。リーフ型と通常の複合型 (リーフ型の Array, Tuple, Map) の 状態プレフィックス は空で、自由に組み合わせられます。[42, 'hi', NULL] の Variant(String, UInt64) (canonical order では String が UInt64 より前にソートされるため、判別子 0 = String、1 = UInt64) :
42; 行 1 = String run[0] = "hi"; 行 2 = NULL。
判別子ストリームが索引であり、各非 NULL の判別子は対応する型の dense run から次の値を取り出します。一方、255 (NULL) は何も消費しません。この同じたどり方で Dynamic も再構成できますが、異なるのは NULL のエンコード方法だけです:
Dynamic
Variant とは異なり、型集合はカラムの型文字列には含まれず、状態プレフィックス に格納されます。
型文字列: 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 レイアウトのみを規定します。フラットではない V1/V2/V3 の binary layout は内部表現 / on-disk 表現 (binary エンコードされた型リスト、variant ごとの statistics) であり、ここでは規定しません。このページを使って Dynamic をデコードしたい client は、output_format_native_use_flattened_dynamic_and_json_serialization = 1 を設定して FLATTENED を要求する必要があります。以下のレイアウトはその設定を前提としています。version byte は prefix の先頭にあるため、decoder は実際に受信したエンコーディングを検出し、FLATTENED のみを実装している場合は V1/V2/V3 を拒否できます。num_types 個の型に NULL スロットを加えたものを表せる最小の符号なし整数です。num_types ≤ 255 の場合は UInt8、それ以降は UInt16、UInt32、UInt64 になります。NULL は判別子の値 num_types そのもので、NULL が固定値 255 である Variant とは異なります。復元は Variant と同じ密な走査で行います。型ごとのカウンターを保持し、判別子 d (≠ num_types) を持つ行 r は、型 d の並びから counter[d] 番目の値を取ります。
状態プレフィックス (バージョン + 型リスト) は、行数が 0 より大きいすべてのブロックの先頭で読み取られます。ヘッダーと空のブロックは何も出力しません。
シリアライゼーションが状態を持つ実行時の型 (
LowCardinality、Variant、Dynamic、JSON) は、型名リストの後にネストされた状態プレフィックスを持ちます。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 です:
42; 行 1 = String run[0] = "hi"; 行 2 = NULL。型ごとの run は、型リストと同じ wire 上の順序 (String が UInt64 より前) に従います。
JSON (Tier 2: FLATTENED Object)
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 カラムとしてデコードされます。
num_rows 個の値を持つ完全なカラムです。
Dynamic プレフィックス (プレフィックスフェーズ) は、そのデータ (データフェーズ) とは分かれています。状態プレフィックスは、行数が 0 より大きいすべてのブロックの先頭で読み取られ、各パスカラム (型付きまたは動的) にはちょうど num_rows 個の値が格納されます。行 r のオブジェクトは、各パスのインデックス r にある値を読み取って組み立てられます。その行で Dynamic の判別子が NULL の動的パスは、キーを持ちません。
JSON 値 {"a": 42, "b": "hi"} (1 行で、両方のパスが動的) の例を示します。JSON の整数は Int64 として推論されます:
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) を設定します。
圧縮フレーム
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 + compressed_size = 16 + 9 + body_size = 25 + body_size です。ここで 2 つの対象範囲に注意してください。checksum は 9 バイトのヘッダーとボディを対象としますが、compressed_size はヘッダーとボディを数える一方、checksum 自体は 含みません。
メソッドのバイト値
チェックサム
ブロックごとの境界
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 になります。
ネゴシエーション
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 を受け付けます。
圧縮が有効な場合、サーバーは複数の行を含む blocks に対して、カラムを並列 block マーシャリング /
ColumnBLOB パス (PARALLEL_BLOCK_MARSHALLING、v54478) に流すこともあります。INSERT data を圧縮する実装では、ストリームの同期ずれを避けるため、このパスを処理できるようにするか、明示的に無効化する必要があります。用語集
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 を連結します。