Native 형식은 ClickHouse가 표 형식 데이터를 전송할 때 사용하는 컬럼형 wire 형식입니다. 이 형식은 다음과 같은 여러 위치에서 사용됩니다.
- native TCP protocol의
Data, Totals, Extremes, Log, ProfileEvents 패킷 본문에 사용됩니다 (TableColumns 패킷은 Native 블록이 아닙니다. 이 패킷은 2개의 바이너리 문자열을 전달하므로, 해당 레이아웃은 native protocol spec에 속합니다);
- HTTP를 통한
SELECT ... FORMAT Native의 출력;
INTO OUTFILE ... FORMAT Native로 작성한 파일 내보내기;
- 서버 간 복제 payload.
이 페이지에서는 블록 내부 바이트, 즉 컬럼형 payload와 이를 구성하는 컬럼별 데이터 타입 인코딩을 설명합니다. 패킷 프레이밍, 연결 상태, 버전 협상은 native protocol specification에서 다룹니다.
여러 바이트로 구성된 모든 정수 필드는 리틀 엔디언입니다. 부호 있는 정수는 2의 보수 표현을 사용합니다.
사용자용 Native 형식 소개(curl 예시 포함)는 Native 형식 page를 참조하십시오. 이 사양은 더 저수준의 wire 참고 문서입니다.
wire를 통해 행을 전달하는 모든 것은 블록입니다. 블록은 컬럼 단위로 저장된 행으로 이루어진 자체 설명형 청크입니다. 먼저 1번 컬럼의 모든 값이 오고, 그다음 2번 컬럼의 모든 값이 오며, 이런 식으로 이어집니다. 블록에는 쿼리에서 참조하는 컬럼만 포함되며, 전체 테이블이 담기는 일은 없습니다.
컬럼의 data는 해당 타입이 속한 family에 따라 배치됩니다. 디코더 복잡도가 낮은 것부터 높은 것 순서로, family는 다음과 같습니다.
- 고정 폭(Fixed-width) 타입은
data를 행별 프레이밍 없이 bytes_per_value × num_rows 크기의 raw bytes로 배치합니다.
- 복합(Composite) 타입(
Nullable, Array, Tuple, Map, Nested)은 타입 문자열만으로 완전히 도출할 수 있는 재귀적 구조를 가지며, 버전 접두사도 없고 블록 간 상태도 없습니다.
- 버전 지정 / 상태 유지(Versioned / stateful) 타입(
LowCardinality, JSON, Variant, Dynamic)은 비어 있지 않은 각 블록의 시작 부분에 serialization-version/state 접두사를 둡니다. Native wire에서는 이 접두사와 모든 딕셔너리가 블록별입니다 — 즉, 이 포맷은 블록 간 상태를 전달하지 않습니다(작성기는 모든 블록마다 새로운 serialization state를 생성하고 low_cardinality_max_dictionary_size = 0으로 설정합니다). 블록 간 상태는 Native wire 레이아웃이 아니라 MergeTree의 온디스크 관련 사항입니다.
Native 형식은 4가지 기본 인코딩을 기반으로 합니다.
| Primitive | Size | Description |
|---|
| VarUInt | 1–10 B | LEB-128 가변 길이 부호 없는 정수 |
| Fixed-width int | 1, 2, 4, 8, 16, 32 B | 리틀 엔디언, 부호 있는 정수에는 2의 보수를 사용 |
| String | variable | VarUInt 길이 프리픽스 + raw bytes |
| Bool | 1 B | 0x00 = false, 0이 아니면 true |
LEB-128 인코딩을 사용하는 가변 길이 부호 없는 정수입니다. 각 바이트는 0–6 위치에 7개의 데이터 비트, 7 위치에 1개의 연속 비트를 포함합니다. 뒤에 바이트가 더 있으면 연속 비트는 1이고, 마지막 바이트에서는 0입니다.
| 값 범위 | 바이트 |
|---|
| 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로 인코딩됩니다.
길이 정보가 앞에 붙는 바이트 시퀀스:
[VarUInt: byte_length] [byte_length bytes: raw value]
바이트 시퀀스는 유효한 UTF-8일 필요가 없습니다. 빈 문자열은 단일 0x00 바이트로 인코딩되며, 문자열에는 중간에 포함된 NUL을 비롯해 어떤 바이트 값이든 들어갈 수 있습니다. 문자열 "ab"는 02 61 62로 인코딩됩니다. 디코딩하려면 먼저 VarUInt 길이(2)를 읽은 다음, 그 길이만큼 바이트를 읽으십시오.
단일 바이트입니다. 0x00은 false이고, 0이 아닌 값은 모두 true입니다(관례적으로 0x01).
[BlockInfo] metadata (only on the TCP Data-packet path; see below)
[VarUInt: num_columns] number of columns in this block
[VarUInt: num_rows] number of rows in this block
[Column × num_columns] column entries, omitted when num_columns = 0
BlockInfo 접두사의 포함 여부는 채널에 따라 달라집니다. writer가 revision을 매개변수로 사용하기 때문입니다.
-
native TCP protocol에서는 서버가 연결 시 협상된 revision으로 블록을 기록합니다(이 릴리스에서
DBMS_TCP_PROTOCOL_VERSION은 큰 값인 54485입니다). BlockInfo는 해당 revision이 0보다 큰 경우 항상 기록되며, 실제 연결에서는 언제나 이 조건이 성립합니다. 각 컬럼의 has_custom_serialization 바이트(column wire layout 참고)는 revision 54454 이상에서 기록됩니다.
-
Native 출력 형식 — HTTP를 통한 SELECT ... FORMAT Native, INTO OUTFILE ... FORMAT Native, 그리고 clickhouse-client가 생성하는 Native 포맷 — 은 기본적으로 revision 0으로 직렬화됩니다. revision 0에서는 BlockInfo 접두사와 has_custom_serialization 바이트가 모두 생략되므로, 블록은 num_columns, num_rows, 그리고 컬럼만으로 구성됩니다.
HTTP에서는 이 revision이 고정값이 아닙니다. 클라이언트는 ?client_protocol_version=<n> 쿼리 매개변수로 이 값을 높일 수 있으며, 서버는 그 값을 응답의 직렬화 revision으로 사용합니다.
값이 충분히 크면 HTTP 출력에도 TCP 경로와 마찬가지로 BlockInfo 접두사(revision이 0보다 클 때마다 기록됨)와 has_custom_serialization 바이트(revision 54454 이상에서 기록됨)가 포함됩니다. 따라서 클라이언트는 모든 HTTP FORMAT Native payload가 revision 0이라고 가정해서는 안 됩니다.
즉, 이 섹션에서 BlockInfo 접두사로 시작하는 바이트 예시는 TCP Data 패킷의 payload를 설명합니다. 동일한 쿼리를 FORMAT Native로 실행하면, 그 옆에 표시된 더 짧은 형식이 생성됩니다.
BlockInfo는 여러 필드로 이루어진 시퀀스이며, 각 필드 앞에는 VarUInt 필드 ID가 오고 필드 ID가 0이면 종료됩니다. wire 형식은 self-describing하지 않습니다. 즉, 필드 ID 자체에는 값의 길이나 유형 정보가 들어 있지 않으므로, 리더는 마주칠 수 있는 각 필드 ID의 유형을 미리 알고 있어야 합니다. ClickHouse의 자체 리더는 인식할 수 없는 필드 ID를 손상으로 간주하고 예외(UNKNOWN_BLOCK_INFO_FIELD)를 발생시킵니다. 전방 호환성은 대신 프로토콜 revision으로 처리됩니다. 송신자는 협상된 revision이 해당 필드의 최소 revision 이상일 때만 그 필드를 기록하므로, 더 오래된 수신기는 자신이 알지 못하는 필드를 보지 않게 됩니다.
| Field ID | Field | 유형 | 최소 revision | 설명 |
|---|
| 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의 최소 revision은 0이므로 BlockInfo가 기록되는 경우 항상 포함됩니다. 필드 3은 revision 54480 이상에서만 기록됩니다. 일반적인 경우(revision이 54480 미만일 때)의 wire 레이아웃은 다음과 같습니다.
[VarUInt: 1] [UInt8: is_overflows]
[VarUInt: 2] [Int32: bucket_number]
[VarUInt: 0]
하나의 블록 안에는 Column이 num_columns번 나타납니다.
| # | Field | Type | Condition | Description |
|---|
| 1 | name | String | always | 컬럼 이름 |
| 2 | type | String or binary type encoding | always | 기본적으로는 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 | when field 3 = 1 | 기본값이 아닌 직렬화(희소 등)을 나타내는 UInt8 enum 바이트 1개입니다(아래 참고). 값이 COMBINATION이면 VarUInt 개수 값 뒤에 그 수만큼 추가 kind 바이트가 이어집니다. Tuple(및 요소 수준 직렬화 정보가 있는 다른 복합 타입)의 경우 payload는 재귀적입니다. 자세한 내용은 아래를 참조하세요. |
| 5 | data | bytes | always | 모든 num_rows 행의 컬럼 값입니다. 레이아웃은 타입별로 다릅니다. 데이터 타입을 참조하세요. 희소 컬럼은 아래를 참조하세요. |
디코더는 type 문자열을 기준으로 분기합니다. 타입 문자열에는 괄호 안에 매개변수가 포함되는 경우가 많으므로, 디코더는 기본 타입을 찾기 위해 (...) 접미사를 제거한 다음 크기, scale 또는 내부 타입 판단에 필요한 매개변수를 파싱합니다. 중첩 타입이 포함된 매개변수 목록(예: Array 안의 Tuple)을 파싱하려면 ,를 기준으로 단순 분할해서는 안 되며, 괄호 중첩을 추적하는 깊이 인식형 쉼표 분리기가 필요합니다.
바이너리 타입 인코딩type 필드는 기본 모드에서만 텍스트 String입니다. 쿼리 설정 output_format_native_encode_types_in_binary_format = 1이 설정되면 이 필드는 대신 binary type encoding이 됩니다. 이는 데이터 타입 binary encoding에 문서화된 것과 동일한 태그 기반 인코딩이며, 평탄화된 Dynamic 타입 목록도 타입별 이름에 동일한 binary encoding을 사용합니다. 필드 2를 항상 길이 접두사가 있는 문자열로 읽는 디코더는 첫 번째 바이너리 타입 태그를 문자열 길이로 잘못 해석해 동기화가 어긋나므로, 스트림이 어떤 모드를 사용하는지 반드시 알고 있어야 합니다.
kind_stack 바이트는 컬럼별 비기본 직렬화 방식을 나타냅니다.
| Byte | Name | Meaning | Wire impact on data |
|---|
0x00 | DEFAULT | 기본 직렬화 | has_custom = 0과 동일 |
0x01 | SPARSE | 희소 직렬화(v54465+) | 오프셋 스트림 + 비기본값, 아래 참조 |
0x02 | DETACHED | 병렬 block 마샬링(v54478+)에 의해 ColumnBLOB로 감싸진 컬럼 | 사전 마샬링된 blob: VarUInt size + 해당 길이의 바이트, 아래 참조 |
0x03 | DETACHED_OVER_SPARSE | ColumnBLOB로 감싸진 희소 컬럼 | DETACHED와 동일한 blob payload, 아래 참조 |
0x04 | REPLICATED | 반복되는 값에 대한 딕셔너리 형태(v54482+) | 인덱스 스트림 + 조밀하게 인코딩된 요소 값, 아래 참조 |
0x05 | COMBINATION | 다중 kind 스택 | 뒤에 VarUInt count와 count개의 추가 kind 바이트가 이어짐 — 아래 참고 사항 참조 |
COMBINATION payload는 다른 enum을 사용합니다. 위의 5개 행은 compact 1바이트 코드입니다. COMBINATION (0x05)은 여기에 해당하지 않는 모든 스택을 표현하는 일반 escape이며, 뒤에 VarUInt count와 이어서 count개의 1바이트 항목이 옵니다. 이 항목들은 표의 compact 코드가 아니라 원시 ISerialization::Kind 값입니다.
| Byte | Nested Kind |
|---|
0x00 | DEFAULT |
0x01 | SPARSE |
0x02 | DETACHED |
0x03 | REPLICATED |
바이트 값은 compact 코드와 다릅니다. REPLICATED는 이 중첩 enum에서는 0x03이지만 compact 코드에서는 0x04이며, DETACHED_OVER_SPARSE 항목은 없습니다 — 이 조합은 SPARSE, DETACHED라는 두 개의 연속된 항목으로 나타납니다. 중첩 바이트에 대해서도 compact 표를 계속 사용하는 디코더는 0x03/0x04를 잘못 매핑하여 동기화가 어긋나게 됩니다.
count는 모든 스택의 시작에 있는 선행 DEFAULT 항목을 포함한 전체 스택 길이입니다. compact 코드는 이미 모든 1항목 및 2항목 스택을 포괄하므로, COMBINATION의 count는 항상 최소 3입니다.
Tuple 컬럼의 재귀적 kind_stack. 위의 kind_stack payload는 한 컬럼 자체의 직렬화 정보에 해당하는 바이트(또는 COMBINATION 시퀀스)입니다. Tuple은 SerializationInfoTuple을 가지며, 먼저 tuple 자체의 고유한 kind-stack payload를 기록한 뒤 각 요소에 대해 순서대로 전체 kind-stack payload를 하나씩 기록합니다. 디코더도 동일한 재귀 구조로 이를 다시 읽습니다. 따라서 Tuple(A, B, C)에서 field-4 바이트는 [tuple_kind][A_kind][B_kind][C_kind]이며, 어떤 요소가 다시 복합 타입이면 해당 요소 payload 자체도 재귀적입니다. tuple 자체의 정보 또는 어느 요소의 정보라도 비기본이면 has_custom_serialization 바이트(field 3)가 설정되므로, 특별한 요소가 희소, 복제된 또는 분리된 것뿐인 Tuple도 kind-stack payload를 트리거합니다. Tuple에 대해 맨 앞의 enum 바이트 하나만 읽는 디코더는 너무 일찍 멈추게 되며, 남아 있는 요소 kind 바이트를 컬럼 데이터로 잘못 읽게 됩니다.
희소 wire 형식. kind_stack = 0x01이면 컬럼 data는 하나의 공유 TCP 스트림에 연속해서 기록되는 두 개의 스트림으로 구성됩니다.
- 오프셋 스트림 —
VarUInt 시퀀스입니다. 각 값 v는 다음 중 하나입니다.
- 위치 62의 상위 비트가 꺼져 있는
v: (v & 0x3FFFFFFFFFFFFFFF) = 다음 명시적 비기본값 앞에 있는 기본 위치의 수입니다. 해당 비기본 위치는 cursor + group_size이며, 여기서 cursor는 현재 누적 위치입니다. 이후 cursor는 group_size + 1만큼 증가합니다.
- 비트 62가 설정된
v (END_OF_GRANULE_FLAG): 플래그를 제거한 값 = 마지막 비기본값 뒤에 있는 후행 기본 위치의 수입니다. 이는 block에 대한 오프셋 스트림의 끝을 표시합니다.
- 값 스트림 — 내부 유형으로 조밀하게 인코딩된
count개의 비기본값이며, 여기서 count는 위에서 읽은 EOG가 아닌 VarUInt의 개수입니다.
디코더는 명시되지 않은 모든 위치를 내부 타입의 기본값(정수와 부동소수점은 0, String은 "", Date는 0일 등)으로 채워, num_rows개 항목으로 이루어진 조밀한 컬럼을 재구성합니다.
희소 Nullable(T) 컬럼은 Nullable(T)의 기본값이 NULL이므로 특수한 경우입니다. 희소 인코딩에서는 일반적인 Nullable null-map 스트림을 완전히 생략합니다. 오프셋 스트림은 기본값이 아닌 위치, 즉 NULL이 아닌 위치를 식별하고, values 스트림은 그 NULL이 아닌 값만 T 타입으로 조밀하게 저장하며, 명시되지 않은 모든 위치는 NULL로 재구성됩니다. 따라서 디코더는 values 스트림에서 null map을 찾아서는 안 되며, 빈 구간을 값이 존재하는 0으로 채워서도 안 됩니다. 대신 NULL로 채웁니다.
복제된 wire 형식. kind_stack = 0x04일 때 컬럼 data는 딕셔너리입니다. 즉, 서로 다른 요소 값의 목록과 각 행에서 그 목록을 가리키는 인덱스로 구성됩니다(LowCardinality와 동일한 lookup 형태). 내부 타입 자체에 버전 정보가 있는 경우(예: 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)(공유 딕셔너리는 유지되고 각 요소별 키만 인덱싱됨)가 포함됩니다.
분리된 wire 형식. DETACHED (0x02)와 DETACHED_OVER_SPARSE (0x03)는 실제로 wire를 통해 전송됩니다. 즉, 순전히 내부 전용은 아닙니다. TCP 경로에서는 압축이 활성화되어 있고 협상된 리비전이 DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING(v54478) 이상이면, 컬럼은 다음 3단계를 거칩니다.
- 적격한 각 컬럼(
const가 아니고, Tuple이 아니며, 블록에 2개 이상의 행이 있는 경우)은 메인 스레드 밖에서 이미 마샬링되고 압축된 컬럼을 담는 ColumnBLOB로 래핑됩니다.
DETACHED가 래핑된 컬럼의 kind stack에 추가됩니다.
- 컬럼
data는 VarUInt blob 크기를 기록한 뒤, 바로 이어서 정확히 그 크기만큼의 blob 바이트를 기록합니다.
래핑된 컬럼이 희소 컬럼이었다면 해당 stack은 {DEFAULT, SPARSE, DETACHED}가 되며, 이는 DETACHED_OVER_SPARSE로 직렬화됩니다. 이러한 컬럼을 디코딩하는 클라이언트는 blob 길이와 바이트를 읽은 다음, blob의 압축을 해제해 내부 컬럼 payload를 복원합니다(압축에 대해서는 ColumnBLOB 참고를 참조하십시오).
모든 Data 계열 패킷은 동일한 Block wire 형식을 사용합니다. 변형 간 차이는 컬럼 수와 행 수뿐입니다:
| 변형 | num_columns | num_rows | 용도 |
|---|
| 헤더 블록 | N > 0 | 0 | 결과 스키마(schema)(컬럼 이름 + 타입)를 알립니다. |
| 결과 블록 | N > 0 | M > 0 | 실제 결과 행입니다. |
| 빈 블록 | 0 | 0 | 센티널 — 클라이언트 측에서는 입력 종료를, 서버 측에서는 경계 마커를 나타냅니다. |
이 섹션의 모든 예시는 TCP 데이터 패킷 경로(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 = 0
SELECT 1의 header 블록은 이름이 "1"이고 유형이 UInt8인 컬럼 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 = 1
FORMAT Native(revision 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에서는 바이트를 전혀 생성하지 않습니다. 출력 형식은 빈 블록을 내보내지 않습니다.)
이 섹션에서는 컬럼의 data 안에 Native 형식이 담을 수 있는 타입의 wire 인코딩을 설명합니다. 이러한 타입은 디코더 복잡도가 증가하는 순서에 따라 4개의 계열로 나뉩니다. AggregateFunction(func, ...)와 QBit(T, N)의 두 타입은 유효한 Native 컬럼 타입이지만, 여기서 다루는 범위를 벗어나는 함수별 또는 타입별 payload를 가집니다. 따라서 아래에서는 별칭으로 오해될 수 있는 위치에서 이들을 별도로 언급합니다.
| Family | Section | Streams per column | Cross-block state |
|---|
| 고정 폭 | 고정 폭 타입 | 하나 | 없음 |
| 가변 길이 | 가변 길이 타입 | 하나 | 없음 |
| 복합(고정 shape) | 복합 타입 | 여러 개 | 없음 |
| 버전 지정 / 상태 유지 | 버전 지정 타입 | 여러 개 | Native wire에는 없음 — 블록별 상태 prefix가 있으며, 각 블록마다 새로 시작됨 |
각 값은 일정한 수의 바이트를 차지합니다. M개의 행이 있는 컬럼은 wire에서 정확히 bytes_per_row × M바이트를 차지하며, 구분자나 패딩 없이 그대로 이어서 저장됩니다.
| Type string | Bytes per value | Logical value | Wire encoding |
|---|
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 timestamp | 리틀 엔디언 UInt32 |
DateTime(tz) | 4 | DateTime와 동일하며, 시간대는 metadata입니다 | 리틀 엔디언 UInt32 |
DateTime64(s) | 8 | 스케일 s의 틱(epoch 이후 10^-s초) | 리틀 엔디언 Int64 |
DateTime64(s, tz) | 8 | DateTime64(s)와 동일하며, 시간대는 metadata입니다 | 리틀 엔디언 Int64 |
Time | 4 | 초 단위의 부호 있는 시계 duration | 리틀 엔디언 Int32 |
Time64(s) | 8 | 스케일 s의 틱 단위 부호 있는 시계 duration | 리틀 엔디언 Int64 |
Interval<Unit> | 8 | 부호 있는 개수이며, 단위는 타입 문자열에 포함됩니다 | 리틀 엔디언 Int64 |
UUID | 16 | 128비트 식별자 | 바이트 순서를 스왑한 LE UInt64 절반 2개(UUID 참조) |
IPv4 | 4 | IPv4 주소 | 리틀 엔디언 UInt32 |
IPv6 | 16 | IPv6 주소 | 네트워크 바이트 순서, 스왑 없음 |
Enum8 | 1 | 부호 있는 8비트(variant 인덱스) | 원시 바이트 |
Enum16 | 2 | 부호 있는 16비트(variant 인덱스) | 리틀 엔디언 |
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: 42
표준 IEEE 754 이진 부동소수점 형식입니다. 4바이트 단정밀도(binary32)와 8바이트 배정밀도(binary64)이며, 각각 리틀 엔디언입니다. NaN, ±Infinity, ±0.0, 그리고 비정규수(subnormal)도 모두 정규화 없이 그대로 왕복 변환됩니다.
Float32 값 1.5 (0x3FC00000):
00 00 C0 3F little-endian IEEE 754
Float64 값 1.5 (0x3FF8000000000000):
00 00 00 00 00 00 F8 3F little-endian IEEE 754
브레인 부동소수점 포맷입니다. IEEE 754 Float32의 상위 16비트로, 부호 비트 1개, 지수 비트 8개, 가수 비트 7개로 이루어집니다. 각 값은 2바이트이며 리틀 엔디언으로 저장되고, 원시 16비트 패턴을 담고 있습니다. 수치 값을 복원하려면 패턴을 상위 절반에 배치하고 하위 절반을 0으로 채워(bits << 16을 Float32로 reinterpret) Float32로 다시 확장합니다. 이렇게 확장한 값은 Float32와 동일한 텍스트 포맷을 사용합니다.
BFloat16 값 1.5(패턴 0x3FC0, Float32 0x3FC00000의 상위 절반):
C0 3F little-endian, widens to Float32 1.5
UInt8와 wire 호환됩니다. 행당 1바이트를 사용하며 0x00 = false, 0x01 = true입니다. wire상의 타입 문자열은 문자 그대로 Bool(UInt8가 아님)이므로, 타입 문자열을 기준으로 디스패치하는 디코더는 이를 별도로 인식해야 합니다.
Bool 컬럼 [true, false, true]:
둘 다 Unix epoch 1970-01-01을 기준으로 날짜를 정수 일수로 인코딩합니다. 둘 다 시간 component는 포함하지 않습니다.
| 유형 | 바이트 | 인코딩 | 범위 |
|---|
Date | 2 | 리틀 엔디언 UInt16 | 1970-01-01부터 2149-06-06까지 |
Date32 | 4 | 리틀 엔디언 Int32 | 넓은 signed 범위, 1970년 이전도 가능 |
Date 값 1970-01-02 (1일):
Date32 값 1900-01-01 (-25567일):
21 9C FF FF Int32 LE = -25567
UInt32와 wire 호환됩니다. 즉, 초 단위 Unix timestamp를 나타내며 4바이트 리틀 엔디언입니다. 이 유형은 DateTime 또는 DateTime('Timezone')로 표시될 수 있습니다. 시간대는 표시 방식에만 영향을 미치며 wire 값 자체에는 포함되지 않습니다. 시간대 매개변수가 다른 두 DateTime 컬럼도 동일한 시점을 나타내면 동일한 바이트를 생성합니다. 디코더는 (...) 매개변수 접미사를 제거한 뒤 해당 컬럼을 UInt32로 처리합니다.
DateTime('UTC') 값 2024-03-15 14:30:00 UTC (timestamp 1710513000):
68 5B F4 65 UInt32 LE = 1710513000
DateTime64(scale[, timezone])
8바이트 리틀 엔디언 Int64로, Unix epoch 이후 경과한 시간을 10^-scale초 단위의 틱으로 나타냅니다. scale 매개변수(0–9)는 타입 문자열에 포함되며 시간 단위를 결정합니다:
| Scale | Tick size | Common name |
|---|
| 0 | 1초 | 초 |
| 3 | 1밀리초 | ms |
| 6 | 1마이크로초 | µs |
| 9 | 1나노초 | 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 = 1705321845123
DateTime64(0) 값 2024-01-15 12:30:45 UTC (1705321845 s):
75 25 A5 65 00 00 00 00 Int64 LE = 1705321845
시점이 아니라 시계상의 지속 시간을 나타냅니다. Time은 부호 있는 초 단위 카운트로, 4바이트 리틀 엔디언 Int32입니다. Time64(scale)은 지정된 Decimal scale(0–9)에서의 부호 있는 틱 카운트로, 8바이트 리틀 엔디언 Int64이며 DateTime64와 동일한 wire 형식을 가집니다.
텍스트 형식은 [-]HH:MM:SS[.fraction]이지만, DateTime과 달리 시(hour) 필드는 24시간 단위로 순환되지 않습니다. 즉, 전체 시간 수를 나타내므로 23을 초과할 수 있습니다. 표시되는 크기는 999:59:59(3599999초)로 제한되며, 이보다 큰 값은 소수부를 0으로 채운 상한값(999:59:59.000)으로 표시됩니다. CAST도 저장된 값을 이 범위로 제한하지만, 산술 연산으로는 범위를 벗어난 값을 만들 수 있으며 이러한 값은 표시할 때만 제한됩니다. 이 어떤 동작도 wire 바이트에는 영향을 주지 않으며, wire 바이트는 일반적인 부호 있는 정수입니다.
Time 값 45296 (12:34:56):
F0 B0 00 00 Int32 LE = 45296
Time64(3) 값 45296789틱 (12:34:56.789):
95 2C B3 02 00 00 00 00 Int64 LE = 45296789
Time 및 Time64는 실험적 기능이며, server에서 allow_experimental_time_time64_type = 1을 설정해야 합니다.
Interval<Unit> — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond 등입니다. 모든 단위는 동일한 wire 인코딩을 사용합니다. 개수는 부호 있는 8바이트 리틀 엔디언 Int64로 인코딩됩니다. 단위 정보는 오직 타입 문자열에만 있으며, wire 바이트나 텍스트 표현에는 영향을 주지 않습니다. 텍스트 표현은 꾸밈없는 정수입니다. 모든 단위는 단일 디코더 경로로 처리됩니다.
IntervalDay 값 5:
05 00 00 00 00 00 00 00 Int64 LE = 5
값당 16바이트입니다. wire 인코딩은 정규 16바이트 빅 엔디언 형식이 아니며, 각 8바이트 절반의 바이트 순서가 각각 독립적으로 뒤집힙니다.
논리적 모델은 정규 텍스트 형식 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx를 사용하는 128비트 식별자이며, 여기서 바이트는 관례적으로 빅 엔디언으로 표기됩니다. wire 모델은 이 16개의 정규 바이트를 두 개의 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-reversed
nil UUID(모든 값이 0인 UUID)는 두 표현에서 동일하게 나타납니다.
서로 관련되어 있지만 인코딩 방식이 다른 두 가지 주소 타입입니다.
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 UInt32
IPv6는 16바이트이며, 스왑 없이 network byte order 그대로 기록됩니다. 바이트 순서는 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에서 일반적으로 사용하는 네트워크 바이트 순서 형식을 유지합니다.
각각 Int8 및 Int16과 wire 호환됩니다. 행당 1바이트 또는 2바이트를 사용하며, 16비트 variant는 2의 보수 리틀 엔디언 형식을 사용합니다. 전체 variant 매핑은 타입 문자열에 포함됩니다:
Enum8('active' = 1, 'inactive' = 2, 'banned' = -1)
Enum16('a' = 1, 'b' = 30000)
디코더는 (...) 매개변수 접미사를 제거한 뒤 Int8 / Int16으로 디스패치할 수 있습니다. wire 바이트는 단지 정수 인덱스일 뿐입니다. 레이블을 표시하는 클라이언트는 타입 문자열에서 'name' = value 맵을 파싱해 컬럼과 함께 유지합니다. 정수값만으로는 레이블을 복원할 수 없습니다. 텍스트 기반 출력은 인덱스가 아니라 레이블(active)을 렌더링하며, enum이 복합 타입 내부에 중첩된 경우에는 작은따옴표로 감싼 형태('active')로 표시합니다. 맵은 정수 컬럼만으로 복원할 수 없으므로, Array(Enum8(...)) 또는 Map(Enum16(...), V) 같은 중첩 enum의 경우 이를 유지해야 합니다.
Enum8('active' = 1, 'inactive' = 2) 컬럼 [active, inactive, active]:
30000인 Enum16(...) 값:
10의 거듭제곱으로 스케일링된 부호 있는 정수입니다. 정수의 바이트 폭은 precision P로 결정되며, scale S는 음의 지수(소수점 이하 자릿수)를 나타냅니다. 둘 다 타입 문자열에 포함됩니다.
| Precision (P) | Backing integer | Bytes |
|---|
| 1 ≤ P ≤ 9 | Int32 | 4 |
| 10 ≤ P ≤ 18 | Int64 | 8 |
| 19 ≤ P ≤ 38 | Int128 | 16 |
| 39 ≤ P ≤ 76 | Int256 | 32 |
wire 인코딩은 리틀 엔디언 2의 보수 형식의 backing integer이며, 논리적 십진수 값은 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 → backing integer 1234567:
87 D6 12 00 Int32 LE = 1234567
Decimal(18, 1) 값 -1.5 → 내부 정수 -15:
F1 FF FF FF FF FF FF FF Int64 LE = -15
Decimal(38, 4) 값 123.4567 (총 16바이트):
87 D6 12 00 00 00 00 00 00 00 00 00 00 00 00 00
Nothing 유형은 어떤 값도 담지 않습니다. 실제로는 Nullable(Nothing)의 내부 유형으로만 나타납니다. 즉, 유효한 값이 값의 부재뿐인 SELECT NULL 같은 표현식에 대해 서버가 반환하는 유형입니다. 개념적으로는 단위 타입(unit type)입니다.
wire 상에서는 행당 정확히 1개의 자리표시자 바이트를 차지합니다. 서버는 ASCII 문자 '0' (0x30)를 내보내지만, 역직렬화기는 이 바이트들을 무시합니다 — 내용은 정의되어 있지 않으며 디코더는 특정 값에 의존해서는 안 됩니다. 기록되는 바이트 수는 num_rows × 1이므로, 컬럼 헤더의 num_rows만으로 얼마나 읽어야 하는지가 완전히 결정됩니다.
이 행당 1바이트 방식은 Block의 불변성을 그대로 유지합니다. 즉, 모든 컬럼의 길이는 num_rows로부터 도출할 수 있으므로 디코더는 셀별 길이 프리픽스 없이 앞쪽으로 스캔할 수 있습니다. 바깥쪽의 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 프레이밍입니다(널 허용 참조). 안쪽 3바이트는 Nothing 페이로드이며, 디코더가 이를 건너뜁니다.
각 값은 wire 상에서 자신의 길이 정보를 함께 포함합니다.
유형 문자열: String. String 컬럼은 길이 접두사가 붙은 바이트 시퀀스 num_rows개로 이루어집니다:
[VarUInt: byte_length] [byte_length bytes: raw value]
[VarUInt: byte_length] [byte_length bytes: raw value]
...
행 사이에는 길이 프리픽스 외에는 구분자가 없고, 행 수준 상태도 없습니다. 빈 문자열은 0x00 바이트 1개로 표현됩니다. ClickHouse String은 텍스트 지향이 아니라 바이트 지향입니다. 즉, UTF-8 유효성은 강제되지 않으며 값에는 내장 NUL을 포함해 어떤 바이트든 들어갈 수 있습니다. UTF-8 string 유형을 대상으로 하는 디코더는 읽을 때 유효성을 검사하거나 호출자에게 raw bytes를 그대로 노출합니다. 컬럼이 차지하는 총 바이트 수는 모든 행에 대해 Σ (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"
유형 문자열은 FixedString(N)이며, 여기서 N은 양의 정수입니다(예: FixedString(16)). 이 컬럼은 정확히 N × num_rows개의 raw bytes로 구성되며, 길이 프리픽스나 구분자는 없습니다. 디코더는 유형 문자열에서 N을 파싱한 뒤 각 행마다 해당 바이트 수만큼 읽습니다.
SQL에서 N바이트보다 짧은 값을 삽입할 때(예: CAST('abc' AS FixedString(5))), server는 선언된 길이에 맞게 오른쪽을 NUL 바이트(0x00)로 채웁니다. 이 패딩 바이트는 저장된 값의 일부이며, wire로 전송될 때도 그대로 전달됩니다. 트리밍은 클라이언트 측에서 처리해야 합니다. String과 마찬가지로 FixedString(N)은 텍스트형이라기보다 바이트 배열에 가까우며, 일반적으로 고정 폭 식별자, 주소 바이트, 또는 hash 다이제스트에 사용됩니다.
두 개의 FixedString(3) 값 ["abc", "de\0"] (총 6바이트):
61 62 63 row 0: 3 bytes, "abc"
64 65 00 row 1: 3 bytes, "de" + NUL padding
비교 대상인 두 문자열 타입:
| 속성 | String | FixedString(N) |
|---|
| 행별 길이 접두어 | 예 (VarUInt) | 아니요 |
| 행 크기 | 가변 | 정확히 N바이트 |
| 컬럼 총 바이트 수 | 가변 | N × num_rows |
| NUL 바이트 패딩 | 해당 없음 | 서버에서 오른쪽으로 패딩 |
| UTF-8 사용 예상 | 일반적으로 예 (강제되지는 않음) | 아니요 (raw bytes로 처리) |
| 타입 매개변수 | None | 정수 N 필수 |
복합 타입은 하나 이상의 내부 타입을 감싸며, 공통 wire 모델인 컬럼당 여러 스트림을 공유합니다. 하나의 논리적 컬럼은 독립적으로 읽을 수 있는 2개 이상의 바이트 시퀀스로 인코딩된 후 이어 붙여집니다.
이들은 세 가지 구조적 속성을 공유합니다:
- 스키마별로 shape가 고정됩니다. 구조는 디코드 시점의 타입 문자열만으로 완전히 결정됩니다.
Array(UInt32)는 block이 달라도 항상 동일한 stream layout을 가집니다.
- 자체 version prefix는 없습니다. 복합 래퍼 자체는 version 바이트를 추가하지 않으며, 해당 프레이밍(offsets, null-map, element streams)은 ClickHouse release 전반에서 안정적입니다. 이는 래퍼에만 적용됩니다 — 내부 version 타입에 대해서는 아래 prefix 단계 참고 사항을 확인하십시오.
- 자체적인 cross-block state는 없습니다. 래퍼의 프레이밍은 각 block별로 완전히 self-describing하며, block 간 state와 관련된 문제는 래퍼가 아니라 내부의 version 타입에서 발생합니다.
복합 타입은 재귀적입니다 — 내부 타입 자체가 또 다른 복합 타입일 수 있습니다.
데이터 스트림 이전의 prefix 단계. 컬럼 읽기는 다음 순서의 두 단계로 이루어집니다: 먼저 state-prefix 단계, 그다음 data-stream 단계입니다. 복합 래퍼 자체에는 prefix 바이트가 없지만, 자체 데이터 스트림을 쓰기 전에 내부 serialization에 prefix 단계를 위임합니다. 즉, SerializationArray는 배열 offsets를 쓰기 전에 내부 타입의 prefix 단계를 먼저 실행하고, Tuple, Map, Nested, Nullable도 element serialization을 통해 동일하게 동작합니다(Nullable은 null map보다 먼저 내부 prefix를 실행합니다).
따라서 복합 타입이 versioned/stateful type (LowCardinality, Variant, Dynamic, JSON)을 감싸면, 해당 내부 타입의 version/state prefix가 래퍼의 offsets와 element payload보다 먼저 기록됩니다. 예를 들어 Array(LowCardinality(String))의 layout은 [LowCardinality state prefix] → [array offsets] → [flattened LowCardinality element payload]이며, offsets-first가 아닙니다.
내부 prefix 단계를 실행하기 전에 offsets를 읽는 디코더는 LowCardinality, Variant, Dynamic, JSON을 포함하는 모든 복합 타입에서 동기화가 어긋납니다. 모든 내부 타입이 일반 leaf이거나 version이 없는 또 다른 복합 타입이라면, prefix 단계는 바이트를 전혀 내보내지 않으므로 아래의 offsets-first 설명이 그대로 적용됩니다.
유형 문자열: Nullable(InnerType). 예시: Nullable(UInt32), Nullable(String), Nullable(FixedString(16)), Nullable(DateTime('UTC')).
다른 복합 타입과 마찬가지로 Nullable은 null 맵을 쓰기 전에 prefix 단계를 내부 직렬화에 위임합니다. 즉, 내부 타입에 버전 정보가 있으면 내부의 상태 접두사가 먼저 기록됩니다. 따라서 Nullable(Tuple(LowCardinality(String)))은 null 맵이 아니라 LowCardinality 상태 접두사로 시작합니다. 내부가 리프 타입이거나 버전 정보가 없는 다른 타입이면 prefix 단계에서는 어떤 바이트도 기록되지 않습니다.
wire 레이아웃은 내부 prefix 단계(내부 타입에 버전 정보가 없으면 비어 있음) 다음에 이어지는 2개의 연결된 스트림으로 구성되며, null 맵이 먼저 옵니다:
[inner type's state prefix] empty for leaf/non-versioned inners; emitted first when the inner is versioned
[null-map stream] num_rows × UInt8
[values stream] inner type's encoding for num_rows values
널 맵은 정확히 num_rows바이트이며, 각 행당 1바이트입니다:
| Byte value | Meaning |
|---|
0x00 | 이 행에는 값이 있습니다. |
non-zero (canonical 0x01) | 값이 NULL입니다. values 스트림의 해당 바이트는 플레이스홀더입니다. |
values 스트림에는 NULL 위치를 포함해 모든 num_rows개 행에 대한 내부 유형의 표준 인코딩이 들어 있습니다. 디코더는 스트림 위치를 앞으로 진행시키기 위해 NULL 위치의 플레이스홀더 바이트도 읽어야 하지만, 개별 값을 해석하기 전에는 반드시 널 맵을 확인해야 합니다. 전송자는 NULL 위치에 임의의 바이트를 쓸 수 있으므로, 디코더는 특정 플레이스홀더 값에 의존해서는 안 됩니다.
내부 유형 계열별 플레이스홀더 값:
| Inner type family | Placeholder at null position |
|---|
| Fixed-width (UInt/Int/Float/DateTime/UUID/etc.) | 해당 유형의 너비만큼 0으로 초기화된 바이트 |
String | 빈 문자열 — 0x00 바이트 1개 |
FixedString(N) | 0 바이트 N개 |
Array(T) | 빈 배열 — offsets가 0만큼 증가 |
Tuple(T1, T2, ...) | 각 요소는 자체 플레이스홀더를 사용 |
Nullable(T)는 Array, Tuple, Map, Nested 안에 올 수 있으며, Array(Nullable(T))와 Tuple(Nullable(T1), T2)가 일반적인 예입니다. 널 허용은 자기 자신과 중첩될 수 없습니다. Nullable(Nullable(T))는 서버에서 거부됩니다.
3개의 행 [5, NULL, 9]을 가진 Nullable(UInt8)(총 6바이트):
00 01 00 null-map: present, null, present
05 00 09 values: 5, placeholder, 9
3개의 행 ["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(InnerType). 예시: Array(UInt32), Array(String), Array(Nullable(UInt32)), Array(Array(UInt8)).
wire 레이아웃은 내부 prefix 단계(내부 타입에 버전이 있는 경우가 아니면 비어 있음) 다음에 이어지는 2개의 연결된 스트림으로 구성되며, 먼저 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] values
오프셋 스트림은 정확히 num_rows개의 리틀 엔디언 UInt64 값으로 구성되며, 각 값은 해당 행의 요소까지 기록된 뒤 values 스트림에서의 누적 끝 위치를 나타냅니다:
- 행
N의 요소 시작 인덱스 = offsets[N - 1] (또는 N == 0일 때 0)
- 행
N의 요소 끝 인덱스(배타적) = offsets[N]
- 행
N의 요소 개수 = offsets[N] - offsets[N - 1]
따라서 offsets[num_rows - 1]은 모든 행에 걸친 전체 요소 수이며, values 스트림에는 그 수만큼의 내부 값이 끝과 끝이 맞닿도록 이어서 저장됩니다.
오프셋은 단조 비감소해야 합니다. 연속된 오프셋 값이 같으면 빈 행을 의미하며, 디코더는 단조성을 깨는 오프셋을 손상된 데이터로 간주하고 거부해야 합니다. 빈 컬럼(num_rows == 0)은 0바이트를 기록합니다. 즉, 오프셋 스트림도 없고 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
각 오프셋은 공유 값 스트림에서 해당 행의 슬라이스가 끝나는 누적 지점이며, 시작점은 이전 오프셋입니다(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(외부 오프셋) + 24(중간 오프셋) + 20(값) = 68바이트입니다.
유형 문자열: 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도 지원합니다. 이름은 메타데이터일 뿐이며 wire 형식에는 영향을 주지 않습니다.
wire 레이아웃은 요소의 prefix 단계(버전이 있는 각 요소는 선언 순서대로 자신의 state prefix를 추가하고, 버전이 없는 요소는 비어 있음) 뒤에, 선언 순서대로 각 요소 타입별 stream 하나씩으로 이루어진 N개의 연결된 stream이 이어지는 형태입니다:
[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
각 stream은 정확히 num_rows개의 값을 인코딩합니다. 길이 프리픽스는 없고, offsets stream도 없으며, stream 사이의 구분자도 없습니다. 빈 컬럼(num_rows == 0)은 stream마다 0바이트를 씁니다. 요소 타입은 다른 복합 타입을 포함해 어떤 타입이든 될 수 있습니다 — Tuple(Tuple(...), ...), Tuple(Array(...), ...), Tuple(Nullable(T1), T2)는 모두 유효합니다.
요소가 0개인 튜플 Tuple()도 유효합니다 — 이는 SELECT tuple() 또는 CAST(x AS Tuple()) 같은 표현식에서 생성됩니다. 요소 stream이 없으므로, 대신 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
레이아웃은 행 우선(row-major) 이 아닙니다. 원시 바이트(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(KeyType, ValueType). 예시: Map(String, UInt32), Map(String, Array(UInt32)), Map(UInt8, Tuple(Int32, String)), Map(Array(String), Int8). wire 형식은 두 타입 모두에 아무런 제한을 두지 않으므로 K와 V는 복합 타입을 포함해 지원되는 어떤 타입이든 될 수 있습니다. (허용되는 키 타입에 대한 ClickHouse의 SQL 수준 규칙은 릴리스마다 달라져 왔으므로, 대상 서버 버전에 해당하는 SQL 문서를 참조하십시오.)
wire 레이아웃은 Array(Tuple(K, V))와 바이트 수준에서 동일하므로, 내부 prefix 단계로 시작합니다(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 스트림은 배열과 같은 의미를 가집니다. 키와 값은 위치별로 서로 대응됩니다. 즉, 쌍 i는 (keys[i], values[i])입니다.
ClickHouse에서 Map 컬럼의 메모리 내 표현은 튜플의 배열이며, 타입 시스템에서는 SQL 사용 편의성을 위해 이를 별도의 타입으로 노출합니다(m['key'], mapKeys, mapValues). wire 형식은 이 저장 표현을 그대로 직렬화한 것이므로 Map과 Array(Tuple(K, V))는 바이트 단위까지 완전히 동일하며 서로 바꿔 사용할 수 있습니다.
Offsets는 단조 비감소하고, keys 및 values 스트림에는 모두 정확히 total_pairs개의 값이 들어 있습니다. 빈 컬럼은 0바이트를 기록합니다. 하나의 행 안에서 키는 일반적으로 고유하지만, 이는 의미적 규칙일 뿐 wire 형식에서 강제되는 규칙은 아닙니다. wire 형식은 중복 키도 그대로 왕복 변환할 수 있게 하며, 서버 측 의미 체계는 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 2
Nested(name1 T1, name2 T2, …)
Nested의 wire 표현은 서버 측 flatten_nested 설정에 따라 달라지며, 크게 두 가지 경우로 나뉩니다.
사례 A: flatten_nested = 1 (서버 기본값). 테이블이 기본 설정으로 생성된 경우 Nested는 wire 타입이 아닙니다. 서버는 해당 컬럼을 점으로 구분된 이름(outer.field1, outer.field2 등)을 사용하는 N개의 병렬 Array(T_i) 컬럼으로 저장하고 표시합니다. 포맷 계층에서는 새로운 것이 없으며, 점으로 구분된 각 컬럼은 일반적인 배열입니다:
DESCRIBE TABLE t -- t has column n Nested(a UInt8, b String)
id UInt8
n.a Array(UInt8)
n.b Array(String)
Case B: flatten_nested = 0. 테이블이 flatten_nested = 0으로 생성된 경우, 이 컬럼은 wire 상에서 타입 문자열 Nested(name1 T1, name2 T2, ...)을 갖는 단일 컬럼으로 나타나며, 타입 문자열 뒤에 오는 레이아웃은 내부 prefix 단계까지 포함하여 Array(Tuple(T1, T2, ..., Tn))와 바이트 수준에서 동일합니다. 따라서 버전이 있는 각 필드 T_i는 offsets보다 앞서 state prefix를 먼저 출력합니다. 아래 예시에서는 버전이 없는 필드를 사용하므로 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
유일한 차이점은 유형 문자열(type string) 텍스트입니다. Nested는 필드 이름(a, b)을 유지하지만, Array(Tuple)는 이를 이름이 있는 슬롯으로 유지하지 않습니다.
Case B 유형 문자열은 쉼표로 구분된 (name, type) 쌍 목록입니다. 첫 번째 공백은 이름과 유형을 구분합니다. 유형 자체에는 추가 공백, 쉼표, 괄호가 포함될 수 있으므로, parsing에는 Tuple에 사용하는 것과 같은 깊이 인식 분할기(splitter)가 필요합니다. wire 레이아웃:
[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 시점에 단일 행 내의 모든 필드가 동일한 개수의 요소를 갖도록 강제합니다. 빈 컬럼은 0바이트를 기록합니다.
2개의 행 [(10,'x'),(20,'y')] 및 [(30,'z')]가 있는 Nested(a UInt8, b String)(type 문자열 뒤에 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"
여러 타입은 순수한 별칭입니다. 서버는 컬럼 헤더에 별칭 이름을 보내지만, 그 뒤에 오는 바이트는 기반 타입의 것입니다. 디코더는 별칭을 해당 타입에 매핑한 뒤 그 코덱을 재사용하며, 새로운 wire 형식이 추가되지는 않습니다.
지리 공간 타입은 중첩된 배열과 튜플의 별칭입니다.
| Type string | Underlying wire type |
|---|
Point | Tuple(Float64, Float64) |
Ring, LineString | 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의 별칭입니다. 즉, 그 payload는 위 6개의 geo 타입으로 이루어진 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의 별칭입니다. 이미 최종화된 집계 값을 저장하므로, wire 형식과 렌더링 결과가 정확히 T와 같습니다(SimpleAggregateFunction(sum, UInt64)는 UInt64로 디코딩됨). 이런 방식으로 별칭이 되는 것은 단일 값 타입 형식뿐이며, 기반 타입 자체는 복합 타입일 수 있습니다.
관련된 두 타입은 별칭이 아닙니다. 이들은 유효한 Native 컬럼 타입입니다. 예를 들어 클라이언트는 -State combinator나 분산 집계에서 AggregateFunction 컬럼을 받을 수 있습니다. 다만 각각 이 페이지의 범위를 벗어나는 자체적인 특수 payload를 가집니다.
AggregateFunction(func, ...)는 중간 집계 상태를 담습니다(최종화된 값이 아님). 바이너리 레이아웃은 집계 함수와 버전에 따라 달라집니다.
QBit(T, N)는 벡터 검색 workload를 위해 비트 평면이 전치된 벡터를 저장합니다.
버전이 지정된 타입은 뒤이어 오는 인코딩 변형이 무엇인지 나타내는 wire 직렬화 버전 접두사를 가집니다. 또한 (복합 타입과 마찬가지로) 여러 스트림을 사용할 수도 있습니다. Native wire에서는 접두사와 딕셔너리가 모두 블록별로 적용되며, 이러한 타입은 블록 간 상태를 유지하지 않습니다(아래의 블록별 접두사 참고 참조). 블록 간 직렬화 상태는 MergeTree 온디스크 스트림에만 존재합니다.
이러한 타입은 고정된 형태의 복합 타입보다 훨씬 더 복잡하므로, 단순한 분석 쿼리를 대상으로 하는 클라이언트라면 이들에 대한 지원을 나중으로 미뤄도 됩니다.
Serialization version: 개념
**직렬화 버전(serialization version)**은 타입별, 컬럼별 wire상의 버전 번호로, 송신자가 해당 타입 인코딩의 어떤 변형을 사용하고 있는지 나타냅니다. 이는 컬럼의 상태 접두사에서 가장 먼저 오는 항목이므로, 디코더는 이를 읽은 뒤 해당 컬럼의 나머지 부분에 맞는 parser를 선택합니다.
이는 protocol version과는 별개입니다:
| 구분 | Protocol version | Serialization version (이 섹션) |
|---|
| 범위 | 연결 전체 | 타입별, 컬럼별 |
| 협상 여부 | 예, handshake 시 | 아니요 — 송신자가 기록하고 수신기가 읽음 |
| 제어 대상 | 어떤 패킷 수준 기능이 활성화되는지 | 한 타입의 어떤 wire 변형인지 |
| 읽기 필수 여부 | 예 | 예, 버전이 있는 각 컬럼마다 |
버전이 있는 대부분의 타입은 다른 상태 접두사 데이터 바로 앞에 버전을 리틀 엔디언 UInt64로 기록하며, 일부는 VarUInt 또는 UInt8을 사용합니다. 디코더는 먼저 버전을 읽고 알 수 없는 값은 거부합니다. 더 높은 버전은 디코더가 이해하지 못하는 더 새로운 송신자 포맷을 뜻하며, 이를 잘못 parsing하면 이후의 모든 바이트 해석이 어긋나게 됩니다.
상태 접두사는 행 수가 0보다 큰 모든 block의 시작 부분에서 출력되며, 해당 block의 payload 바로 앞에 위치합니다.
Native writer와 reader는 block 간에 직렬화 상태를 유지하지 않습니다. NativeWriter는 새 serialize state를 만들고, 기록하는 각각의 비어 있지 않은 컬럼 block마다 상태 접두사를 씁니다. NativeReader는 새 deserialize state를 만들고, 읽는 각각의 비어 있지 않은 block마다 이를 읽습니다(둘 다 rows == 0일 때는 접두부를 완전히 건너뜁니다).
따라서 헤더 block(rows = 0)과 빈 block은 아무것도 출력하지 않으며, 디코더는 비어 있지 않은 각 block의 시작에서 상태 접두사를 다시 읽어야 합니다. 접두부를 한 번만 읽고 이후 block을 payload만 있는 것으로 처리하는 디코더는 다음 block의 접두부를 데이터로 읽게 되어 동기화가 어긋납니다:
| 유형 | 필드 너비 | 값 | 이름 | 의미 |
|---|
| Object (JSON의 기반) | UInt64 LE | 0 | V1 | 원래 인코딩입니다. max_dynamic_paths 매개변수와 동적 경로 목록을 포함합니다. |
| | 1 | STRING | 네이티브 포맷 호환 모드 — JSON 텍스트를 담은 단일 String 컬럼으로 Object를 전송합니다. |
| | 2 | V2 | max_dynamic_paths 매개변수를 제외한 V1 레이아웃입니다. |
| | 3 | FLATTENED | 네이티브 포맷 호환 모드 — 평탄화된 경로 표현입니다. |
| | 4 | V3 | V2에 shared data 직렬화 버전 하위 필드와 통계 플래그를 추가한 버전입니다. |
Object shared data (Object V3에서 사용되는 하위 스트림) | VarUInt | 0 | MAP | shared data를 Map(String, String)으로 인코딩합니다. |
| | 1 | MAP_WITH_BUCKETS | MAP과 같지만 스캔 효율을 위해 N개의 버킷으로 분할합니다. |
| | 2 | ADVANCED | 경로 / 마크 / metadata용 별도 스트림을 사용하는 compact granule 포맷입니다. |
| Dynamic | UInt64 LE | 1 | V1 | 원래 인코딩입니다. max_dynamic_types와 런타임 variant type 목록을 포함합니다. |
| | 2 | V2 | max_dynamic_types 매개변수를 제외한 V1입니다. |
| | 3 | FLATTENED | 네이티브 포맷 호환 모드입니다. |
| | 4 | V3 | V2에 바이너리로 인코딩된 variant type 이름과 빈 통계 지원을 추가한 버전입니다. |
| Variant discriminators mode | UInt64 LE | 0 | BASIC | 모든 행의 discriminator를 그대로 기록합니다. |
| | 1 | COMPACT | granule의 모든 행이 하나의 discriminator를 공유하면 단일 값 + granule 마커만 기록합니다. |
Variant granule format (mode가 COMPACT일 때) | UInt8 | 0 | PLAIN | granule의 discriminator가 서로 다릅니다. |
| | 1 | COMPACT | granule의 모든 행이 하나의 discriminator를 가집니다. |
| LowCardinality key serialization | 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을 구현하지 않은 클라이언트와의 네이티브 protocol 호환성을 위해 존재합니다. 이 값들은 MergeTree 온디스크 스토리지에는 나타나지 않습니다.
V3는 주로 온디스크용입니다. 네이티브 TCP protocol을 사용하는 클라이언트는 일반적으로 V3(값 4)보다 FLATTENED(값 3)를 보게 됩니다.
가장 단순한 버전형 데이터 타입입니다. N개의 내부 값을 가진 컬럼을, 고유 값으로 이루어진 작은 딕셔너리와 그 딕셔너리를 참조하는 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 블록에서는 각 블록이 자체적으로 완결된 딕셔너리를 함께 전송하므로 항상 설정됩니다. |
일반적인 쿼리 응답에서 컬럼당 데이터 블록이 하나라면 메타데이터는 0x600(HasAdditionalKeys + NeedUpdateDictionary)입니다.
dict 값은 내부 타입 T로 인코딩된 dict_size개의 값입니다. 딕셔너리는 특수 값을 위해 앞부분 슬롯을 예약합니다. 널 비허용 컬럼은 슬롯 1개를 예약하며(dict[0]에는 내부 타입의 기본값이 들어갑니다. 예: String의 경우 ""), 실제 고유 값은 dict[1]부터 시작합니다.
LowCardinality(Nullable(T))의 경우에도 dict는 여전히 일반 T로 인코딩됩니다(null-map 스트림 없음). 하지만 두 개의 슬롯이 예약됩니다. 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가 복합 타입 아래에 있으면, 이 개수는 해당 복합 타입이 하위로 전달하는 평탄화된 값 개수입니다. 예를 들어 총 5개의 요소를 담은 3개 행의 Array(LowCardinality(String))에서는 keys_count가 3이 아니라 5입니다. Map(K, LowCardinality(V))에서는 전체 쌍의 개수이며, 다른 경우도 마찬가지입니다. 디코더는 블록 행 수를 가정하지 말고 반드시 이 필드에서 keys_count를 가져와야 합니다. 이 평탄화된 개수가 0이면 — 예를 들어 모든 배열이 비어 있는 블록처럼 — LowCardinality 데이터 단계에서는 아무것도 기록되지 않습니다. 복합 타입 접두사 단계에서 출력된 상태 접두사만 존재하고, 그 뒤에 메타데이터, 딕셔너리, keys_count는 이어지지 않습니다.
상태 접두사는 행 수가 0보다 큰 모든 블록의 시작에서 읽습니다. 반면 header block(행 = 0)과 빈 블록은 아무것도 내보내지 않습니다. 블록 내부에서는 keys_count가 행 수와 같고, dict_size는 dict stream에 있는 값의 개수와 같으며, 각 키는 1 << key_type_code바이트에 들어갑니다.
Native 포맷에서는 각 블록이 완전히 독립적인 블록 로컬 딕셔너리와 함께 전송되며, 블록 간에 공유되는 딕셔너리 상태는 없습니다. Native writer는 low_cardinality_max_dictionary_size = 0으로 설정하므로 SerializationLowCardinality는 공유 딕셔너리를 빌드하지 않습니다. 따라서 비어 있지 않은 모든 블록은 NeedGlobalDictionaryBit가 설정되지 않은 상태(메타데이터 0x600)에서 키를 블록 로컬 추가 키로 기록하며, native_format이 true일 때 Native reader는 NeedGlobalDictionaryBit를 거부합니다. 따라서 디코더는 블록마다 딕셔너리를 재설정하고 해당 블록에 있는 dict_size 항목을 읽어야 합니다. 이전 블록의 딕셔너리를 그대로 유지하면 다음 블록의 키를 잘못 읽게 됩니다. (블록 간에 LC 딕셔너리를 유지하는 것은 Native wire 레이아웃이 아니라 MergeTree의 온디스크 관련 사항입니다.)
값이 ['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))은 예약된 두 슬롯을 모두 보여줍니다. 즉, 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]는 모두 wire 상에서 빈 바이트이지만, NULL 여부는 바이트 때문이 아니라 키가 슬롯 0을 가리키기 때문에 결정됩니다.
ClickHouse의 JSON 유형에는 여러 wire 인코딩이 있습니다(serialization version 참고 참조). Tier 1이 가장 단순합니다. 쿼리별 설정 output_format_native_write_json_as_string = 1이 설정되면 서버는 각 JSON 값을 직렬화된 텍스트로 펼친 뒤, 상태 접두사 마커가 붙은 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 (네이티브 TCP protocol의 기본값), 3 = FLATTENED, 4 = V3 (serialization version 참고 참조). 여기서 1이 아닌 값을 읽는 디코더는 String 폴백을 보고 있는 것이 아닙니다. 상태 접두사는 행 수가 0보다 큰 모든 block의 시작 부분에서 읽으며, values stream은 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}
값은 정수는 정수 그대로 유지한 간결한 JSON 텍스트 {"a":1} 형태로 출력됩니다. 이 텍스트는 단지 String 값일 뿐이므로, 클라이언트는 JSON을 불투명한 형태로 전달받지만 개별 경로와 해당 ClickHouse 타입은 복원하지 못합니다. 경로별 타입을 정확하게 보존하려면 아래의 Tier 2 인코딩이 필요합니다.
판별자를 사용하는 유니언입니다. 각 행은 variant 타입 중 정확히 하나의 값을 가지거나 NULL입니다. 모든 행에는 타입을 선택하는 1바이트 전역 판별자가 있으며, 각 타입별 값은 이후 variant 타입마다 하나의 연속 구간으로 조밀하게 저장됩니다.
타입 문자열: Variant(T1, T2, ...). 서버는 순서를 표준화합니다(variant 타입은 이름순으로 정렬됨). 따라서 수신된 타입 문자열에는 이미 타입이 전역 판별자 순서로 나열되어 있습니다. 판별자 0은 첫 번째로 나열된 타입을, 1은 두 번째 타입을 선택합니다. 255 (NULL_DISCRIMINATOR)는 해당 행이 NULL임을 의미합니다. Variant 요소는 Nullable일 수 없습니다 — NULL은 판별자가 처리합니다. 예시: Variant(String, UInt64), Variant(Array(UInt8), String).
상태 접두사에는 UInt64 LE 판별자 모드가 포함됩니다. 0 = BASIC (모든 행의 판별자를 그대로 기록), 1 = COMPACT (run-length 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의 값 run에서 인덱스 counter[d]에 있는 값을 가져오고, 이후 counter[d]를 증가시킵니다. 판별자가 255인 행은 NULL이므로 어떤 run에서도 값을 소비하지 않으며, 따라서 타입별 카운터의 합은 non-NULL 행 수와 같습니다.
상태 접두사(모드 UInt64)는 행 수가 0보다 큰 모든 block의 시작에서 읽습니다. header와 빈 block은 아무것도 출력하지 않습니다. 각 non-NULL 판별자는 variant 타입 수보다 작고, variant 타입 i는 정확히 count[i]개의 행에 대해 디코딩됩니다.
자체적으로 stateful한 Variant 요소(LowCardinality, Variant, Dynamic, JSON)는 모드 UInt64 뒤, 요소별 상태 접두사 단계에서 자체 상태 접두사를 출력합니다. 리프 타입과 일반 복합 타입(Array, Tuple, 리프 타입으로 이루어진 Map)은 빈 상태 접두사를 가지며 자유롭게 조합할 수 있습니다.
값이 [42, 'hi', NULL]인 Variant(String, UInt64)의 경우(String이 UInt64보다 앞서도록 canonical order로 정렬되므로 판별자 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.
판별자 스트림은 인덱스입니다. 각 non-NULL 판별자는 해당 유형의 조밀하게 저장된 run에서 다음 값을 가져오며, 255 (NULL)는 아무것도 소비하지 않습니다. 이와 동일한 방식으로 Dynamic도 복원되며, 차이점은 NULL이 인코딩되는 방식뿐입니다:
값 타입이 런타임에 결정되는 컬럼입니다. 각 행에는 런타임에 결정된 타입 집합 중 하나에 속하는 값 또는 NULL이 저장됩니다. Variant와 달리 타입 집합은 컬럼의 타입 문자열에 포함되지 않고 상태 접두사에 담깁니다.
타입 문자열: Dynamic 또는 Dynamic(max_types=N)입니다. max_types 매개변수는 컬럼이 추적하는 서로 다른 타입 수의 상한을 정하지만, 아래의 wire 형식에는 영향을 주지 않습니다.
Dynamic에는 4가지 인코딩이 있습니다 — V1 = 1, V2 = 2, FLATTENED = 3, V3 = 4. 서버가 어떤 인코딩을 출력하는지는 채널과 쿼리 설정에 따라 달라집니다.
clickhouse-client 및 HTTP FORMAT Native에서는 writer의 revision이 0이므로(client_protocol_version으로 높이지 않는 한) 기본값은 V1입니다.
- 네이티브 TCP protocol에서는 협상된 revision 기준으로 기본값이 V2입니다.
Native writer는 statistics를 비활성화한 상태로 두므로, 기본 V2 payload에는 variant별 statistics가 포함되지 않습니다. 타입 목록 뒤에는 중첩된 Variant 접두부와 데이터가 바로 이어집니다. (variant별 statistics는 MergeTree의 온디스크 관련 사항이며, Native wire의 일부는 아닙니다.)
- 쿼리 설정
output_format_native_use_flattened_dynamic_and_json_serialization = 1은 앞의 두 경우를 모두 재정의하며, revision과 관계없이 **FLATTENED (version 3)**를 출력합니다.
범위이 페이지에서는 FLATTENED 레이아웃만 지정합니다. 비평면 V1/V2/V3 바이너리 레이아웃은 내부/온디스크 표현(바이너리로 인코딩된 타입 목록, variant별 statistics)이며 여기서는 지정하지 않습니다. 이 페이지를 사용해 Dynamic을 디코드하려는 클라이언트는 output_format_native_use_flattened_dynamic_and_json_serialization = 1을 설정해 FLATTENED를 요청해야 합니다. 아래 레이아웃은 이 설정을 전제로 합니다. 버전 바이트가 접두부의 맨 앞에 오므로, 디코더는 실제로 수신한 인코딩을 감지하고 FLATTENED만 구현한 경우 V1/V2/V3를 거부할 수 있습니다.
해당 설정으로 선택되는 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의 run에서 값 counter[d]를 가져옵니다.
상태 접두사(버전 + 타입 목록)는 행이 1개 이상인 모든 블록의 시작에서 읽습니다. 헤더와 빈 블록은 아무것도 내보내지 않습니다.
직렬화가 상태를 유지하는 런타임 타입(LowCardinality, Variant, Dynamic, JSON)은 타입 이름 목록 뒤에 중첩된 상태 접두사를 포함합니다.
런타임 타입 목록은 일반적으로 Variant 정규화(canonicalization)를 따릅니다. 일반 Variant 슬롯은 DataTypeVariant의 (타입 이름) 순서로 기록되므로, wire 순서는 삽입 순서를 따르지 않습니다. 하지만 항상 전역적으로 정렬되는 것은 아닙니다. 공유 Variant로 오버플로우된 타입(예: Dynamic(max_types=N))은 처음 나타난 순서대로 일반 슬롯 뒤에 추가되므로, 목록의 tail 부분에서는 타입 이름 순서가 깨질 수 있습니다. 따라서 디코더는 전송된 타입 목록을 판별자 할당의 기준으로 간주해야 하며, 이를 자체적으로 다시 정렬해서는 안 됩니다. 행 [42::UInt64, "hi", NULL]에서는 두 타입이 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 경로마다 하나의 하위 컬럼으로 분리됩니다. 이 방식은 평탄화 직렬화 플래그가 켜진 상태(output_format_native_use_flattened_dynamic_and_json_serialization = 1)에서 Tier 1 폴백을 요청하지 않을 때(output_format_native_write_json_as_string = 0) 선택되며, 그러면 서버는 직렬화 version 3을 출력합니다.
경로에는 두 가지 종류가 있습니다:
- 유형이 지정된 경로는 타입 문자열에 선언됩니다. 예를 들어
JSON(a UInt32, b String)와 같으며, 선언된 유형으로 디코딩됩니다. 점이 포함된 경로 이름은 타입 문자열에서 백틱으로 묶습니다.
- Dynamic 경로는 런타임에 발견되며, 각각 Dynamic 컬럼으로 디코딩됩니다.
FLATTENED 모드에서는 공유 데이터 컬럼이 없습니다(해당 오버플로우 저장소는 non-flat 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단계 구조에 유의하십시오: 모든 경로 상태 접두사가 먼저 오고, 그다음 모든 경로 데이터가 옵니다. 따라서 동적 경로의 Dynamic 프리픽스(프리픽스 단계)는 해당 데이터(데이터 단계)와 분리됩니다. 상태 접두사는 행 수가 0보다 큰 모든 블록의 시작에서 읽히며, 모든 경로 컬럼(타입 지정 또는 동적)은 정확히 num_rows개의 값을 가집니다. 행 r의 객체는 각 경로에서 인덱스 r의 값을 읽어 조합되며, 해당 행에서 Dynamic 판별자가 NULL인 동적 경로는 어떤 key도 추가하지 않습니다.
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"
평탄화되지 않은 Object 인코딩(V1/V2/V3)은 MergeTree 온디스크 저장소에서 사용되며, flattened flag가 꺼져 있을 때 server가 wire를 통해 내보내는 형식이기도 합니다. 즉, clickhouse-client / HTTP FORMAT Native(revision 0)에서는 V1이, 네이티브 TCP protocol에서는 V2가 사용됩니다. 이 인코딩에는 shared-data 컬럼이 포함되며, 이 페이지에서는 정의하지 않습니다. 또한 Native wire에서는 경로별 statistics를 포함하지 않습니다. NativeWriter는 statistics를 비활성화한 상태로 두므로 Object 구조체 접두사에는 statistics 섹션이 없고, 그 뒤에는 typed/dynamic/shared-data 프리픽스와 데이터 바이트가 바로 이어집니다. Statistics는 이를 활성화한 MergeTree 온디스크 경로에서만 나타납니다. 이 페이지를 사용해 JSON 컬럼을 디코딩하려면 클라이언트는 문서화된 tier 중 하나를 선택해야 합니다. String 폴백을 사용하려면 output_format_native_write_json_as_string = 1로 설정하고, FLATTENED Object layout을 사용하려면 output_format_native_use_flattened_dynamic_and_json_serialization = 1로 설정하십시오(output_format_native_write_json_as_string = 0과 함께).
ClickHouse는 내부 프레임 포맷을 사용해 Native 스트림의 컬럼 데이터를 압축할 수 있습니다. 아래의 프레임 레이아웃은 전송 방식과 무관합니다. 즉, 동일한 프레임이 네이티브 TCP 프로토콜과 HTTP 모두에서 사용됩니다. 다만 압축을 요청하는 방식과 프레임 바깥을 감싸는 구조는 전송 방식에 따라 다릅니다.
- 네이티브 TCP 프로토콜. 압축은 Query packet의
compression flag를 통해 쿼리별로 선택적으로 활성화됩니다. 활성화되면 각 Data, Totals, Extremes, Log, ProfileEvents packet의 body, 즉 table_name string 뒤의 바이트가 프레임 포맷으로 래핑됩니다. packet envelope 자체와 packet-type code, 그리고 table_name string은 압축되지 않으며, server는 이를 원시 스트림에 그대로 기록합니다. NativeWriter가 출력하는 모든 내용은 압축된 스트림에 들어가므로, BlockInfo prefix가 차원 및 컬럼과 함께 프레임 내부의 첫 번째 요소가 됩니다. 따라서 클라이언트는 BlockInfo를 읽기 전에 먼저 프레임의 압축을 해제해야 합니다.
- HTTP.
SELECT ... FORMAT Native&compress=1은 전체 FORMAT Native 바이트 스트림을 동일한 프레임으로 래핑하며(server는 동일한 내부 CompressedWriteBuffer를 사용함), ?decompress=1은 Native input body에서 동일한 프레임을 기대하고, 이에 대응하는 CompressedReadBuffer를 통해 이를 디코딩합니다. 이 경로에는 TCP packet type, table_name, packet envelope이 없습니다. 전체 압축 payload는 단순히 프레임 처리된 Native block 전체입니다(BlockInfo prefix는 협상된 revision이 0보다 큰 경우에만 존재하며, 이는 위의 비압축 레이아웃과 정확히 같습니다). 이 내부 compress/decompress 프레이밍은 HTTP 전송 압축(Content-Encoding: gzip/zstd, enable_http_compression으로 활성화됨)과는 별개이며, 이는 HTTP 계층에서 response를 감싸는 것이지 아래 설명하는 프레임 포맷이 아닙니다.
따라서 압축되지 않은 FORMAT Native 레이아웃만 구현한 클라이언트라도, 압축된 HTTP Native response를 읽거나 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입니다. 여기서 두 구간에 유의하십시오. checksum은 9바이트 header와 body를 포함하는 반면, compressed_size는 header와 body는 계산하지만 checksum 자체는 포함하지 않습니다:
| 바이트 | 메서드 | 본문 인코딩 |
|---|
0x02 | NONE | 본문은 raw bytes입니다(압축 없음). 프레임은 계속 전송되며, 수신기가 체크섬을 검증합니다. |
0x82 | LZ4 | 본문은 LZ4 block 포맷이며, LZ4 frame 포맷은 아닙니다. 매직 넘버는 없습니다. |
0x90 | ZSTD | 본문은 원시 zstd 단일 프레임 스트림이며, 표준 zstd 매직 넘버는 본문의 일부입니다. |
ClickHouse는 최신 Google CityHash가 아니라 CityHash v1.0.2(기존 변형)를 사용합니다. 두 구현은 서로 다른 출력을 생성합니다.
체크섬은 9바이트 헤더(method + compressed_size + uncompressed_size)와 N바이트 바디, 즉 체크섬과 프레임 끝 사이의 모든 바이트를 대상으로 계산됩니다. 16바이트 CityHash128 출력에서 처음 8바이트는 하위 절반(LE)이고, 다음 8바이트는 상위 절반(LE)입니다. 디코더는 수신한 헤더와 바디를 기준으로 CityHash128을 다시 계산해 앞쪽 16바이트와 비교합니다. 일치하지 않으면 데이터가 손상된 것이므로 디코더는 실패합니다.
Block의 압축된 payload는 반드시 단일 frame이 아니라 하나 이상의 frame으로 이루어진 stream입니다. 송신자는 직렬화된 block을 CompressedWriteBuffer를 통해 기록하며, 이 버퍼는 내부 buffer가 가득 찰 때마다(≈1 MB, DBMS_DEFAULT_BUFFER_SIZE) frame을 내보내고, block이 플러시될 때 마지막 frame을 하나 더 내보냅니다. 따라서 작은 block은 frame 하나로, 큰 block은 여러 개의 연속된 frame으로 구성됩니다.
이 불변 조건은 한 방향으로만 성립합니다. 송신자는 각 block의 끝에서 압축 buffer를 플러시하므로 모든 block의 끝은 frame 경계와 일치합니다. 하지만 그 반대는 성립하지 않습니다. buffer가 block 중간에 가득 차 생성된 중간 frame 경계는 block의 중간에 위치하며, block 경계가 아닙니다. 따라서 디코더는 block이 끝나는 위치를 찾기 위해 block 자체의 차원(num_columns/num_rows)을 사용해야 하며, 각 frame이 완전한 block 하나라고 가정해서는 안 됩니다.
수신기는 frame을 stream 방식으로 처리합니다. 먼저 16 + 9바이트를 읽고, 이어서 정확히 compressed_size - 9바이트의 body를 읽은 다음, 이를 정확히 uncompressed_size바이트로 압축 해제하여 그 바이트를 block 디코더에 전달합니다. 디코더가 현재 frame에 들어 있는 것보다 더 많은 데이터를 필요로 하면 다음 frame을 가져옵니다. 송신자는 block별로 플러시하므로 block 하나가 완전히 디코딩되고 나면 frame buffer는 비어 있고, 다음 block은 새로운 frame에서 시작됩니다.
네이티브 TCP protocol에서는 패킷 envelope, 즉 packet-type VarUInt와 table_name 문자열이 압축된 payload 바깥의 raw stream에 기록되며, frame으로 구성되는 것은 block body(BlockInfo + columns)뿐입니다. HTTP compress/decompress 경로에는 이런 envelope가 없습니다. 전체 stream이 frame으로 구성된 block들로 이루어집니다.
네이티브 TCP 프로토콜에서는 압축이 connection 단위가 아니라 쿼리 단위로 적용됩니다. Query packet의 compression: bool field는 해당 단일 쿼리에 대해 압축을 요청합니다. server는 이 요청을 반영하여 쿼리의 lifetime 동안 압축된 Data/Totals/Extremes/Log/ProfileEvents body를 전송합니다(Log/ProfileEvents는 v54481+에서만 해당). 또한 클라이언트의 outgoing Data block, 즉 external table, 비어 있는 end-of-data marker, 그리고 INSERT 행도 동일한 방식으로 프레이밍되기를 기대합니다. 동일한 connection에서 이후에 실행되는 쿼리는 다르게 설정될 수 있습니다.
HTTP에는 Query packet이 없습니다. compress=1 query parameter는 해당 request에 대해 프레이밍된 출력을 선택하고, decompress=1은 request body가 프레이밍된 형식임을 나타냅니다. compress=1 출력은 network_compression_method가 아니라 server의 기본 코덱(LZ4)으로 기록됩니다. 반면 decompress=1 reader는 각 frame의 method byte에서 코덱을 읽어 오므로, 입력에서는 어떤 코덱이든 허용됩니다.
압축이 활성화되면 server는 행이 2개 이상인 block에 대해 컬럼을 병렬 block-marshalling / ColumnBLOB 경로(PARALLEL_BLOCK_MARSHALLING, v54478)로도 전달할 수 있습니다. INSERT 데이터를 압축하는 구현은 stream 비동기화를 방지하기 위해 이 경로를 처리할 수 있어야 하며, 또는 명시적으로 이 경로를 사용하지 않도록 해야 합니다.
Block — Native 형식에서 데이터 교환의 단위입니다. 열 지향으로 저장되는 행 청크이며 자체 설명형입니다. block and column structure를 참조하십시오.
BlockInfo — TCP Data-packet 경로에서 Block 앞에 오는 메타데이터 헤더입니다(연결 revision이 0보다 클 때마다 기록됨). revision 조건에 따라 포함되며 field ID 태그가 붙은 필드들의 시퀀스입니다. Native 출력 형식은 revision 0으로 직렬화되므로 생략됩니다. BlockInfo를 참조하십시오.
Column body — 컬럼 헤더(name, type, has_custom_serialization byte) 다음에 위치하며 실제 값을 담는 Column의 바이트입니다. 레이아웃은 타입별로 다릅니다. column wire layout을 참조하십시오.
Composite type — 하나 이상의 내부 타입으로 구성된 타입으로, 컬럼마다 여러 stream으로 인코딩됩니다. wire 형식은 안정적이며 버전이 없습니다. composite types를 참조하십시오.
Dictionary (LowCardinality) — LowCardinality(T) 컬럼이 정수 인덱스를 통해 참조하는 고유 값 배열입니다. LowCardinality를 참조하십시오.
Empty block — num_columns = 0 및 num_rows = 0인 Block입니다. 센티널로 사용되며, 클라이언트 측 입력 종료 마커이자 서버 측 stream 경계 마커입니다. block variants를 참조하십시오.
Header block — num_columns > 0 및 num_rows = 0인 Block으로, 서버가 쿼리 응답의 첫 번째 Data packet으로 전송합니다. 결과 schema를 알립니다. block variants를 참조하십시오.
Inner type — composite가 감싸는 타입입니다. 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입니다. block variants를 참조하십시오.
Schema block — header block의 동의어로, INSERT 단계를 설명할 때 사용됩니다. 이때 schema block은 예상되는 컬럼 shape를 클라이언트에 알려줍니다.
Serialization version — 버전이 있는 타입이 뒤따르는 인코딩 변형을 선언할 때 사용하는 타입별 on-wire 버전 번호입니다. protocol version과는 다릅니다. serialization version: concept를 참조하십시오.
상태 접두사 — 버전이 있는 타입의 블록별 payload 앞에 오는 바이트입니다. serialization version과 (LowCardinality의 경우) 블록별 딕셔너리 메타데이터를 담습니다. rows > 0인 모든 block의 시작 부분에 기록되며, block 간에 유지되지는 않습니다.
Stream — 컬럼 body 안의 연속된 바이트 구간으로, 하나의 논리적 하위 구성 요소(null-map, offsets 배열, values stream)를 인코딩합니다. 다중 stream 타입은 컬럼마다 2개 이상의 stream을 이어 붙입니다.