- native TCP protocol의
Data,Totals,Extremes,Log,ProfileEvents패킷 본문에 사용됩니다 (TableColumns패킷은 Native 블록이 아닙니다. 이 패킷은 2개의 바이너리 문자열을 전달하므로, 해당 레이아웃은 native protocol spec에 속합니다); - HTTP를 통한
SELECT ... FORMAT Native의 출력; INTO OUTFILE ... FORMAT Native로 작성한 파일 내보내기;- 서버 간 복제 payload.
개요
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 접두사를 둡니다.Nativewire에서는 이 접두사와 모든 딕셔너리가 블록별입니다 — 즉, 이 포맷은 블록 간 상태를 전달하지 않습니다(작성기는 모든 블록마다 새로운 serialization state를 생성하고low_cardinality_max_dictionary_size = 0으로 설정합니다). 블록 간 상태는 Native wire 레이아웃이 아니라 MergeTree의 온디스크 관련 사항입니다.
Wire 기본 타입
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이고, 0이 아닌 값은 모두 true입니다(관례적으로 0x01).
블록 및 컬럼 구조
블록 wire 레이아웃
BlockInfo 접두사의 포함 여부는 채널에 따라 달라집니다. writer가 revision을 매개변수로 사용하기 때문입니다.
-
native TCP protocol에서는 서버가 연결 시 협상된 revision으로 블록을 기록합니다(이 릴리스에서
DBMS_TCP_PROTOCOL_VERSION은 큰 값인54485입니다).BlockInfo는 해당 revision이 0보다 큰 경우 항상 기록되며, 실제 연결에서는 언제나 이 조건이 성립합니다. 각 컬럼의has_custom_serialization바이트(column wire layout 참고)는 revision54454이상에서 기록됩니다. -
Native출력 형식 — HTTP를 통한SELECT ... FORMAT Native,INTO OUTFILE ... FORMAT Native, 그리고clickhouse-client가 생성하는Native포맷 — 은 기본적으로 revision0으로 직렬화됩니다. revision0에서는BlockInfo접두사와has_custom_serialization바이트가 모두 생략되므로, 블록은num_columns,num_rows, 그리고 컬럼만으로 구성됩니다. HTTP에서는 이 revision이 고정값이 아닙니다. 클라이언트는?client_protocol_version=<n>쿼리 매개변수로 이 값을 높일 수 있으며, 서버는 그 값을 응답의 직렬화 revision으로 사용합니다. 값이 충분히 크면 HTTP 출력에도 TCP 경로와 마찬가지로BlockInfo접두사(revision이0보다 클 때마다 기록됨)와has_custom_serialization바이트(revision54454이상에서 기록됨)가 포함됩니다. 따라서 클라이언트는 모든 HTTPFORMAT Nativepayload가 revision0이라고 가정해서는 안 됩니다.
BlockInfo 접두사로 시작하는 바이트 예시는 TCP Data 패킷의 payload를 설명합니다. 동일한 쿼리를 FORMAT Native로 실행하면, 그 옆에 표시된 더 짧은 형식이 생성됩니다.
BlockInfo
0이면 종료됩니다. wire 형식은 self-describing하지 않습니다. 즉, 필드 ID 자체에는 값의 길이나 유형 정보가 들어 있지 않으므로, 리더는 마주칠 수 있는 각 필드 ID의 유형을 미리 알고 있어야 합니다. ClickHouse의 자체 리더는 인식할 수 없는 필드 ID를 손상으로 간주하고 예외(UNKNOWN_BLOCK_INFO_FIELD)를 발생시킵니다. 전방 호환성은 대신 프로토콜 revision으로 처리됩니다. 송신자는 협상된 revision이 해당 필드의 최소 revision 이상일 때만 그 필드를 기록하므로, 더 오래된 수신기는 자신이 알지 못하는 필드를 보지 않게 됩니다.
필드
1과 2의 최소 revision은 0이므로 BlockInfo가 기록되는 경우 항상 포함됩니다. 필드 3은 revision 54480 이상에서만 기록됩니다. 일반적인 경우(revision이 54480 미만일 때)의 wire 레이아웃은 다음과 같습니다.
컬럼 wire 레이아웃
num_columns번 나타납니다.
디코더는
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 및 희소 인코딩
kind_stack 바이트는 컬럼별 비기본 직렬화 방식을 나타냅니다.
COMBINATION payload는 다른 enum을 사용합니다. 위의 5개 행은 compact 1바이트 코드입니다. COMBINATION (0x05)은 여기에 해당하지 않는 모든 스택을 표현하는 일반 escape이며, 뒤에 VarUInt count와 이어서 count개의 1바이트 항목이 옵니다. 이 항목들은 표의 compact 코드가 아니라 원시 ISerialization::Kind 값입니다.
바이트 값은 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에 대한 오프셋 스트림의 끝을 표시합니다.
- 위치 62의 상위 비트가 꺼져 있는
- 값 스트림 — 내부 유형으로 조밀하게 인코딩된
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를 기록하기 전에 접두사 단계를 내부 타입에 먼저 위임합니다. 접두사가 비어 있는 내부 타입(리프 타입과 일반 복합 타입)은 여기에서 바이트를 추가하지 않습니다.
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는VarUIntblob 크기를 기록한 뒤, 바로 이어서 정확히 그 크기만큼의 blob 바이트를 기록합니다.
{DEFAULT, SPARSE, DETACHED}가 되며, 이는 DETACHED_OVER_SPARSE로 직렬화됩니다. 이러한 컬럼을 디코딩하는 클라이언트는 blob 길이와 바이트를 읽은 다음, blob의 압축을 해제해 내부 컬럼 payload를 복원합니다(압축에 대해서는 ColumnBLOB 참고를 참조하십시오).
블록 변형
바이트 수준 예시
BlockInfo 접두사와 has_custom_serialization 바이트를 포함합니다. FORMAT Native에서는 동일한 블록이 더 짧으며, 필요할 때는 이에 대응하는 축약형도 함께 제시합니다.
빈 블록(BlockInfo 포함), 총 8바이트:
SELECT 1의 header 블록은 이름이 "1"이고 유형이 UInt8인 컬럼 1개와 행 0개를 나타냅니다. 프로토콜 ≥ 54454에서는 has_custom_serialization 바이트가 포함됩니다:
FORMAT Native(revision 0)에서는 동일한 결과 블록에 BlockInfo와 has_custom_serialization 바이트가 없으므로, SELECT 1 FORMAT Native는 11바이트입니다:
FORMAT Native에서는 바이트를 전혀 생성하지 않습니다. 출력 형식은 빈 블록을 내보내지 않습니다.)
데이터 타입
data 안에 Native 형식이 담을 수 있는 타입의 wire 인코딩을 설명합니다. 이러한 타입은 디코더 복잡도가 증가하는 순서에 따라 4개의 계열로 나뉩니다. AggregateFunction(func, ...)와 QBit(T, N)의 두 타입은 유효한 Native 컬럼 타입이지만, 여기서 다루는 범위를 벗어나는 함수별 또는 타입별 payload를 가집니다. 따라서 아래에서는 별칭으로 오해될 수 있는 위치에서 이들을 별도로 언급합니다.
고정 폭 타입
M개의 행이 있는 컬럼은 wire에서 정확히 bytes_per_row × M바이트를 차지하며, 구분자나 패딩 없이 그대로 이어서 저장됩니다.
정수 타입
UInt8–UInt256 및 Int8–Int256은 정수 값을 직접 이진 형식으로 인코딩한 것입니다. 디코더는 bytes_per_row × num_rows 바이트를 읽어 타입에 따라 해석합니다.
[1, 256, 65536] 값을 담은 UInt32 컬럼:
[-1, 42] 값을 가진 Int32 컬럼:
Float32 및 Float64
binary32)와 8바이트 배정밀도(binary64)이며, 각각 리틀 엔디언입니다. NaN, ±Infinity, ±0.0, 그리고 비정규수(subnormal)도 모두 정규화 없이 그대로 왕복 변환됩니다.
Float32 값 1.5 (0x3FC00000):
Float64 값 1.5 (0x3FF8000000000000):
BFloat16
Float32의 상위 16비트로, 부호 비트 1개, 지수 비트 8개, 가수 비트 7개로 이루어집니다. 각 값은 2바이트이며 리틀 엔디언으로 저장되고, 원시 16비트 패턴을 담고 있습니다. 수치 값을 복원하려면 패턴을 상위 절반에 배치하고 하위 절반을 0으로 채워(bits << 16을 Float32로 reinterpret) Float32로 다시 확장합니다. 이렇게 확장한 값은 Float32와 동일한 텍스트 포맷을 사용합니다.
BFloat16 값 1.5(패턴 0x3FC0, Float32 0x3FC00000의 상위 절반):
Bool
UInt8와 wire 호환됩니다. 행당 1바이트를 사용하며 0x00 = false, 0x01 = true입니다. wire상의 타입 문자열은 문자 그대로 Bool(UInt8가 아님)이므로, 타입 문자열을 기준으로 디스패치하는 디코더는 이를 별도로 인식해야 합니다.
Bool 컬럼 [true, false, true]:
Date 및 Date32
1970-01-01을 기준으로 날짜를 정수 일수로 인코딩합니다. 둘 다 시간 component는 포함하지 않습니다.
Date 값 1970-01-02 (1일):
Date32 값 1900-01-01 (-25567일):
DateTime
UInt32와 wire 호환됩니다. 즉, 초 단위 Unix timestamp를 나타내며 4바이트 리틀 엔디언입니다. 이 유형은 DateTime 또는 DateTime('Timezone')로 표시될 수 있습니다. 시간대는 표시 방식에만 영향을 미치며 wire 값 자체에는 포함되지 않습니다. 시간대 매개변수가 다른 두 DateTime 컬럼도 동일한 시점을 나타내면 동일한 바이트를 생성합니다. 디코더는 (...) 매개변수 접미사를 제거한 뒤 해당 컬럼을 UInt32로 처리합니다.
DateTime('UTC') 값 2024-03-15 14:30:00 UTC (timestamp 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 s):
Time 및 Time64(scale)
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):
Time64(3) 값 45296789틱 (12:34:56.789):
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:
UUID
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx를 사용하는 128비트 식별자이며, 여기서 바이트는 관례적으로 빅 엔디언으로 표기됩니다. wire 모델은 이 16개의 정규 바이트를 두 개의 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바이트이며, 스왑 없이 network byte order 그대로 기록됩니다. 바이트 순서는 inet_pton(AF_INET6, ...)와 동일합니다.
2001:db8::1:
u32로 저장되며, IPv6는 대부분의 네트워킹 API에서 일반적으로 사용하는 네트워크 바이트 순서 형식을 유지합니다.
Enum8 and Enum16
Int8 및 Int16과 wire 호환됩니다. 행당 1바이트 또는 2바이트를 사용하며, 16비트 variant는 2의 보수 리틀 엔디언 형식을 사용합니다. 전체 variant 매핑은 타입 문자열에 포함됩니다:
(...) 매개변수 접미사를 제거한 뒤 Int8 / Int16으로 디스패치할 수 있습니다. wire 바이트는 단지 정수 인덱스일 뿐입니다. 레이블을 표시하는 클라이언트는 타입 문자열에서 'name' = value 맵을 파싱해 컬럼과 함께 유지합니다. 정수값만으로는 레이블을 복원할 수 없습니다. 텍스트 기반 출력은 인덱스가 아니라 레이블(active)을 렌더링하며, enum이 복합 타입 내부에 중첩된 경우에는 작은따옴표로 감싼 형태('active')로 표시합니다. 맵은 정수 컬럼만으로 복원할 수 없으므로, Array(Enum8(...)) 또는 Map(Enum16(...), V) 같은 중첩 enum의 경우 이를 유지해야 합니다.
Enum8('active' = 1, 'inactive' = 2) 컬럼 [active, inactive, active]:
30000인 Enum16(...) 값:
Decimal(P, S)
P로 결정되며, scale S는 음의 지수(소수점 이하 자릿수)를 나타냅니다. 둘 다 타입 문자열에 포함됩니다.
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:
Decimal(18, 1) 값 -1.5 → 내부 정수 -15:
Decimal(38, 4) 값 123.4567 (총 16바이트):
Nothing
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) 컬럼:
Nullable 프레이밍입니다(널 허용 참조). 안쪽 3바이트는 Nothing 페이로드이며, 디코더가 이를 건너뜁니다.
가변 길이 타입
String
String. String 컬럼은 길이 접두사가 붙은 바이트 시퀀스 num_rows개로 이루어집니다:
0x00 바이트 1개로 표현됩니다. ClickHouse String은 텍스트 지향이 아니라 바이트 지향입니다. 즉, UTF-8 유효성은 강제되지 않으며 값에는 내장 NUL을 포함해 어떤 바이트든 들어갈 수 있습니다. UTF-8 string 유형을 대상으로 하는 디코더는 읽을 때 유효성을 검사하거나 호출자에게 raw bytes를 그대로 노출합니다. 컬럼이 차지하는 총 바이트 수는 모든 행에 대해 Σ (varuint_size(len_i) + len_i)입니다.
3개의 문자열 ["ab", "", "c"]로 이루어진 컬럼(총 6바이트):
FixedString(N)
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바이트):
복합 타입
- 스키마별로 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 타입에서 발생합니다.
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(T)
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 맵이 먼저 옵니다:
num_rows바이트이며, 각 행당 1바이트입니다:
values 스트림에는 NULL 위치를 포함해 모든
num_rows개 행에 대한 내부 유형의 표준 인코딩이 들어 있습니다. 디코더는 스트림 위치를 앞으로 진행시키기 위해 NULL 위치의 플레이스홀더 바이트도 읽어야 하지만, 개별 값을 해석하기 전에는 반드시 널 맵을 확인해야 합니다. 전송자는 NULL 위치에 임의의 바이트를 쓸 수 있으므로, 디코더는 특정 플레이스홀더 값에 의존해서는 안 됩니다.
내부 유형 계열별 플레이스홀더 값:
Nullable(T)는 Array, Tuple, Map, Nested 안에 올 수 있으며, Array(Nullable(T))와 Tuple(Nullable(T1), T2)가 일반적인 예입니다. 널 허용은 자기 자신과 중첩될 수 없습니다. Nullable(Nullable(T))는 서버에서 거부됩니다.
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)).
wire 레이아웃은 내부 prefix 단계(내부 타입에 버전이 있는 경우가 아니면 비어 있음) 다음에 이어지는 2개의 연결된 스트림으로 구성되며, 먼저 offsets가 옵니다:
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바이트):
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도 지원합니다. 이름은 메타데이터일 뿐이며 wire 형식에는 영향을 주지 않습니다.
wire 레이아웃은 요소의 prefix 단계(버전이 있는 각 요소는 선언 순서대로 자신의 state prefix를 추가하고, 버전이 없는 요소는 비어 있음) 뒤에, 선언 순서대로 각 요소 타입별 stream 하나씩으로 이루어진 N개의 연결된 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):
[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). wire 형식은 두 타입 모두에 아무런 제한을 두지 않으므로 K와 V는 복합 타입을 포함해 지원되는 어떤 타입이든 될 수 있습니다. (허용되는 키 타입에 대한 ClickHouse의 SQL 수준 규칙은 릴리스마다 달라져 왔으므로, 대상 서버 버전에 해당하는 SQL 문서를 참조하십시오.)
wire 레이아웃은 Array(Tuple(K, V))와 바이트 수준에서 동일하므로, 내부 prefix 단계로 시작합니다(K 또는 V가 버전 지정된 경우가 아니면 비어 있음):
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바이트):
i는 keys[i]와 values[i]를 함께 읽어 복원합니다.
행 1개 {'a':1, 'b':2}가 있는 Map(String, UInt32)(총 20바이트):
Nested(name1 T1, name2 T2, …)
Nested의 wire 표현은 서버 측 flatten_nested 설정에 따라 달라지며, 크게 두 가지 경우로 나뉩니다.
사례 A: flatten_nested = 1 (서버 기본값). 테이블이 기본 설정으로 생성된 경우 Nested는 wire 타입이 아닙니다. 서버는 해당 컬럼을 점으로 구분된 이름(outer.field1, outer.field2 등)을 사용하는 N개의 병렬 Array(T_i) 컬럼으로 저장하고 표시합니다. 포맷 계층에서는 새로운 것이 없으며, 점으로 구분된 각 컬럼은 일반적인 배열입니다:
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, b)을 유지하지만, Array(Tuple)는 이를 이름이 있는 슬롯으로 유지하지 않습니다.
Case B 유형 문자열은 쉼표로 구분된 (name, type) 쌍 목록입니다. 첫 번째 공백은 이름과 유형을 구분합니다. 유형 자체에는 추가 공백, 쉼표, 괄호가 포함될 수 있으므로, parsing에는 Tuple에 사용하는 것과 같은 깊이 인식 분할기(splitter)가 필요합니다. wire 레이아웃:
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바이트):
타입 별칭
따라서
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를 위해 비트 평면이 전치된 벡터를 저장합니다.
버전이 지정된 타입
Native wire에서는 접두사와 딕셔너리가 모두 블록별로 적용되며, 이러한 타입은 블록 간 상태를 유지하지 않습니다(아래의 블록별 접두사 참고 참조). 블록 간 직렬화 상태는 MergeTree 온디스크 스트림에만 존재합니다.
이러한 타입은 고정된 형태의 복합 타입보다 훨씬 더 복잡하므로, 단순한 분석 쿼리를 대상으로 하는 클라이언트라면 이들에 대한 지원을 나중으로 미뤄도 됩니다.
Serialization version: 개념
버전이 있는 대부분의 타입은 다른 상태 접두사 데이터 바로 앞에 버전을 리틀 엔디언 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의 접두부를 데이터로 읽게 되어 동기화가 어긋납니다:
직렬화 버전 참고
표와 관련해 주목할 만한 몇 가지 사항은 다음과 같습니다:
- 값은 연속적이지 않습니다.
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)를 보게 됩니다.
LowCardinality(T)
N개의 내부 값을 가진 컬럼을, 고유 값으로 이루어진 작은 딕셔너리와 그 딕셔너리를 참조하는 N개의 인덱스로 대체합니다.
타입 문자열: LowCardinality(InnerType). 예시: LowCardinality(String), LowCardinality(FixedString(4)), LowCardinality(Nullable(String)).
sharedDictionariesWithAdditionalKeys이며, 다른 값은 예약되어 있습니다.
블록별 메타데이터 UInt64는 비트필드입니다.
일반적인 쿼리 응답에서 컬럼당 데이터 블록이 하나라면 메타데이터는
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):
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]입니다:
dict[2] = "a", dict[0] = NULL, dict[1] = "", dict[3] = "b", 즉 ["a", NULL, "", "b"]입니다. dict[0]와 dict[1]는 모두 wire 상에서 빈 바이트이지만, NULL 여부는 바이트 때문이 아니라 키가 슬롯 0을 가리키기 때문에 결정됩니다.
JSON (Tier 1: String 폴백)
JSON 유형에는 여러 wire 인코딩이 있습니다(serialization version 참고 참조). Tier 1이 가장 단순합니다. 쿼리별 설정 output_format_native_write_json_as_string = 1이 설정되면 서버는 각 JSON 값을 직렬화된 텍스트로 펼친 뒤, 상태 접두사 마커가 붙은 String 컬럼으로 출력합니다.
타입 문자열: JSON.
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개 행):
{"a":1} 형태로 출력됩니다. 이 텍스트는 단지 String 값일 뿐이므로, 클라이언트는 JSON을 불투명한 형태로 전달받지만 개별 경로와 해당 ClickHouse 타입은 복원하지 못합니다. 경로별 타입을 정확하게 보존하려면 아래의 Tier 2 인코딩이 필요합니다.
Variant(T1, T2, …)
Variant(T1, T2, ...). 서버는 순서를 표준화합니다(variant 타입은 이름순으로 정렬됨). 따라서 수신된 타입 문자열에는 이미 타입이 전역 판별자 순서로 나열되어 있습니다. 판별자 0은 첫 번째로 나열된 타입을, 1은 두 번째 타입을 선택합니다. 255 (NULL_DISCRIMINATOR)는 해당 행이 NULL임을 의미합니다. Variant 요소는 Nullable일 수 없습니다 — NULL은 판별자가 처리합니다. 예시: Variant(String, UInt64), Variant(Array(UInt8), String).
상태 접두사에는 UInt64 LE 판별자 모드가 포함됩니다. 0 = BASIC (모든 행의 판별자를 그대로 기록), 1 = COMPACT (run-length granule 인코딩). 서버는 기본적으로 네이티브 프로토콜에서 BASIC을 사용합니다(use_compact_variant_discriminators_serialization = false). 여기서는 BASIC만 다룹니다.
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):
42; 행 1 = String run[0] = "hi"; 행 2 = NULL.
판별자 스트림은 인덱스입니다. 각 non-NULL 판별자는 해당 유형의 조밀하게 저장된 run에서 다음 값을 가져오며, 255 (NULL)는 아무것도 소비하지 않습니다. 이와 동일한 방식으로 Dynamic도 복원되며, 차이점은 NULL이 인코딩되는 방식뿐입니다:
Dynamic
Variant와 달리 타입 집합은 컬럼의 타입 문자열에 포함되지 않고 상태 접두사에 담깁니다.
타입 문자열: Dynamic 또는 Dynamic(max_types=N)입니다. max_types 매개변수는 컬럼이 추적하는 서로 다른 타입 수의 상한을 정하지만, 아래의 wire 형식에는 영향을 주지 않습니다.
Dynamic에는 4가지 인코딩이 있습니다 — V1 = 1, V2 = 2, FLATTENED = 3, V3 = 4. 서버가 어떤 인코딩을 출력하는지는 채널과 쿼리 설정에 따라 달라집니다.
clickhouse-client및 HTTPFORMAT Native에서는 writer의 revision이0이므로(client_protocol_version으로 높이지 않는 한) 기본값은 V1입니다.- 네이티브 TCP protocol에서는 협상된 revision 기준으로 기본값이 V2입니다.
Nativewriter는 statistics를 비활성화한 상태로 두므로, 기본V2payload에는 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를 거부할 수 있습니다.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입니다:
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) 선택되며, 그러면 서버는 직렬화 version 3을 출력합니다.
경로에는 두 가지 종류가 있습니다:
- 유형이 지정된 경로는 타입 문자열에 선언됩니다. 예를 들어
JSON(a UInt32, b String)와 같으며, 선언된 유형으로 디코딩됩니다. 점이 포함된 경로 이름은 타입 문자열에서 백틱으로 묶습니다. - Dynamic 경로는 런타임에 발견되며, 각각 Dynamic 컬럼으로 디코딩됩니다.
num_rows 값을 갖는 완전한 컬럼입니다.
Dynamic 프리픽스(프리픽스 단계)는 해당 데이터(데이터 단계)와 분리됩니다. 상태 접두사는 행 수가 0보다 큰 모든 블록의 시작에서 읽히며, 모든 경로 컬럼(타입 지정 또는 동적)은 정확히 num_rows개의 값을 가집니다. 행 r의 객체는 각 경로에서 인덱스 r의 값을 읽어 조합되며, 해당 행에서 Dynamic 판별자가 NULL인 동적 경로는 어떤 key도 추가하지 않습니다.
JSON 값 {"a": 42, "b": "hi"} (행 1개, 두 경로 모두 동적). JSON 정수는 Int64로 추론됩니다:
JSON 비평탄형 (V2/V3)
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과 함께).
압축 프레임
Native 스트림의 컬럼 데이터를 압축할 수 있습니다. 아래의 프레임 레이아웃은 전송 방식과 무관합니다. 즉, 동일한 프레임이 네이티브 TCP 프로토콜과 HTTP 모두에서 사용됩니다. 다만 압축을 요청하는 방식과 프레임 바깥을 감싸는 구조는 전송 방식에 따라 다릅니다.
- 네이티브 TCP 프로토콜. 압축은 Query packet의
compressionflag를 통해 쿼리별로 선택적으로 활성화됩니다. 활성화되면 각Data,Totals,Extremes,Log,ProfileEventspacket의 body, 즉table_namestring 뒤의 바이트가 프레임 포맷으로 래핑됩니다. packet envelope 자체와 packet-type code, 그리고table_namestring은 압축되지 않으며, server는 이를 원시 스트림에 그대로 기록합니다.NativeWriter가 출력하는 모든 내용은 압축된 스트림에 들어가므로,BlockInfoprefix가 차원 및 컬럼과 함께 프레임 내부의 첫 번째 요소가 됩니다. 따라서 클라이언트는BlockInfo를 읽기 전에 먼저 프레임의 압축을 해제해야 합니다. - HTTP.
SELECT ... FORMAT Native&compress=1은 전체FORMAT Native바이트 스트림을 동일한 프레임으로 래핑하며(server는 동일한 내부CompressedWriteBuffer를 사용함),?decompress=1은Nativeinput body에서 동일한 프레임을 기대하고, 이에 대응하는CompressedReadBuffer를 통해 이를 디코딩합니다. 이 경로에는 TCP packet type,table_name, packet envelope이 없습니다. 전체 압축 payload는 단순히 프레임 처리된Nativeblock 전체입니다(BlockInfoprefix는 협상된 revision이0보다 큰 경우에만 존재하며, 이는 위의 비압축 레이아웃과 정확히 같습니다). 이 내부compress/decompress프레이밍은 HTTP 전송 압축(Content-Encoding: gzip/zstd,enable_http_compression으로 활성화됨)과는 별개이며, 이는 HTTP 계층에서 response를 감싸는 것이지 아래 설명하는 프레임 포맷이 아닙니다.
FORMAT Native 레이아웃만 구현한 클라이언트라도, 압축된 HTTP Native response를 읽거나 decompress=1 request body를 보내려면 이 프레임 계층을 추가로 구현해야 합니다.
프레임 포맷
16 + compressed_size = 16 + 9 + body_size = 25 + body_size입니다. 여기서 두 구간에 유의하십시오. checksum은 9바이트 header와 body를 포함하는 반면, compressed_size는 header와 body는 계산하지만 checksum 자체는 포함하지 않습니다:
메서드 바이트 값
체크섬
method + compressed_size + uncompressed_size)와 N바이트 바디, 즉 체크섬과 프레임 끝 사이의 모든 바이트를 대상으로 계산됩니다. 16바이트 CityHash128 출력에서 처음 8바이트는 하위 절반(LE)이고, 다음 8바이트는 상위 절반(LE)입니다. 디코더는 수신한 헤더와 바디를 기준으로 CityHash128을 다시 계산해 앞쪽 16바이트와 비교합니다. 일치하지 않으면 데이터가 손상된 것이므로 디코더는 실패합니다.
블록별 경계
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들로 이루어집니다.
협상
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 비동기화를 방지하기 위해 이 경로를 처리할 수 있어야 하며, 또는 명시적으로 이 경로를 사용하지 않도록 해야 합니다.용어집
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을 이어 붙입니다.