네이티브 포맷

네이티브 포맷 (Native Format)

Native 포맷은 ClickHouse가 표 형식 데이터를 옮길 때 사용하는 컬럼 지향 와이어 포맷이에요. 이 페이지에서는 Block 내부의 바이트와, Block을 구성하는 컬럼별 타입 인코딩을 다뤄요. 패킷 프레이밍, 연결 상태, 버전 협상은 네이티브 프로토콜 명세에 속해요.

출처: 문서

본문

Native 포맷은 ClickHouse가 표 형식 데이터를 옮길 때 사용하는 컬럼 지향 와이어 포맷이에요. 다음과 같은 여러 곳에서 나타나요:

  • Data, Totals, Extremes, Log, ProfileEvents 패킷의 본문 (네이티브 TCP 프로토콜). TableColumns 패킷은 Native 블록이 아니고, 두 개의 이진 문자열을 실어 나르므로 그 레이아웃은 네이티브 프로토콜 명세에 속해요.
  • HTTP를 통한 SELECT ... FORMAT Native의 출력
  • INTO OUTFILE ... FORMAT Native로 작성한 파일 내보내기
  • 서버 간 복제 페이로드

이 페이지는 Block 내부의 바이트 — 컬럼 지향 페이로드 — 와 그것을 구성하는 컬럼별 타입 인코딩을 설명해요. 패킷 프레이밍, 연결 상태, 버전 협상은 네이티브 프로토콜 명세에 속해요.

모든 멀티바이트 정수 필드는 리틀 엔디언(little-endian)이에요. 부호 있는 정수는 2의 보수(two's complement)를 사용해요.

팁

사용자를 위한 Native 포맷 소개(예제 포함)는 Native format 페이지를 보세요. 이 명세는 더 저수준의 와이어 참조 문서예요.

개요 (Overview)

와이어를 통해 행을 나르는 모든 것은 Block이에요. Block은 컬럼별로 저장되는 자기-기술적(self-describing) 행 묶음이에요. 컬럼 1의 모든 값이 먼저 오고, 그 다음 컬럼 2의 모든 값이 오는 식이에요. Block은 쿼리가 참조하는 컬럼만 실어 나르며, 전체 테이블은 절대로 나르지 않아요.

컬럼의 data는 해당 타입이 속한 패밀리(family)에 따라 배치돼요. 디코더 복잡도가 증가하는 순서의 패밀리는 다음과 같아요:

  • 고정 너비 (Fixed-width) 타입은 data를 bytes_per_value × num_rows 개의 원시 바이트로 배치하며, 행별 프레이밍이 없어요.
  • 복합 (Composite) 타입 (Nullable, Array, Tuple, Map, Nested)은 타입 문자열에서 완전히 유도할 수 있는 재귀적 형태를 갖고, 버전 접두사나 블록 간 상태가 없어요.
  • 버전 지정 / 상태 유지 (Versioned / stateful) 타입 (LowCardinality, JSON, Variant, Dynamic) 은 각 비어 있지 않은 블록을 직렬화 버전/상태 접두사로 시작해요. Native 와이어에서는 이 접두사와 모든 딕셔너리가 블록별이에요 — 포맷이 블록을 가로질러 상태를 나르지 않아요 (작성자는 모든 블록에 대해 새 직렬화 상태를 만들고 low_cardinality_max_dictionary_size = 0을 설정해요). 블록 간 상태는 MergeTree 온디스크의 관심사이지, Native 와이어 레이아웃의 관심사가 아니에요.

와이어 프리미티브 (Wire primitives)

Native 포맷은 네 가지 프리미티브 인코딩을 기반으로 해요.

원시 타입 (Primitive) 크기 (Size) 설명 (Description)
VarUInt 1–10 B LEB-128 가변 길이 부호 없는 정수
고정 너비 정수 (Fixed-width int) 1, 2, 4, 8, 16, 32 B 리틀 엔디언, 부호 있는 경우 2의 보수
String 가변 VarUInt 길이 접두사 + 값별 원시 바이트; 개정판 54492+ 에서는 별도의 오프셋 스트림
Bool 1 B 0x00 = false, 0이 아니면 true

VarUInt

LEB-128 인코딩을 사용하는 가변 길이 부호 없는 정수예요. 각 바이트는 위치 0–6에 7개의 데이터 비트를, 위치 7에 1개의 연속 비트를 담아요. 연속 비트는 더 많은 바이트가 뒤따르면 1, 마지막 바이트에서는 0이에요.

값 범위 (Value range) 바이트 수 (Bytes)
0 – 127 1
128 – 16383 2
16384 – 2097151 3
UInt64 전체까지 최대 10

값 300을 인코딩하기:

300 = 0b100101100

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

바이트 0xAC 0x02를 디코딩하기:

Byte 0: data = 0x2C, continuation = 1 → accumulator = 0x2C, shift = 7
Byte 1: data = 0x02, continuation = 0 → accumulator = (0x02 << 7) | 0x2C = 300

고정 너비 정수 (Fixed-width integers)

타입 (Type) 바이트 수 (Bytes) 인코딩 (Encoding)
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의 보수

예를 들어 UInt32 값 1은 01 00 00 00으로, Int32 값 -1은 FF FF FF FF로 인코딩돼요.

String

길이 접두사가 붙는 바이트 시퀀스:

[VarUInt: byte_length] [byte_length bytes: raw value]

바이트 시퀀스는 반드시 유효한 UTF-8일 필요는 없어요. 빈 문자열은 단일 0x00 바이트로 인코딩되며, 문자열은 중첩된 NUL을 포함해 어떤 바이트 값이든 담을 수 있어요. 문자열 "ab"는 02 61 62로 인코딩돼요. 디코딩하려면 VarUInt 길이(2)를 읽은 다음 그만큼의 바이트를 읽으면 돼요.

Bool

단일 바이트예요. 0x00은 false, 0이 아닌 값은 true(표준적으로 0x01)예요.

블록과 컬럼 구조 (Block and column structure)

블록 와이어 레이아웃 (Block wire layout)

[BlockInfo]               메타데이터 (TCP Data-패킷 경로에서만; 아래 참조)
[VarUInt: num_columns]    이 블록의 컬럼 수
[VarUInt: num_rows]       이 블록의 행 수
[Column × num_columns]    컬럼 항목들, num_columns = 0 이면 생략

BlockInfo 접두사가 있는지 여부는 채널에 따라 달라져요. 작성자는 개정판(revision)으로 매개변수화되기 때문이에요 (자세한 처리는 프로토콜 개정판과 Native 포맷 참조, 여기에는 client_protocol_version이 출력 전용이라는 점도 포함돼요):

  • 네이티브 TCP 프로토콜에서는 서버가 연결의 협상된 개정판(큰 값 — DBMS_TCP_PROTOCOL_VERSION, src/Core/ProtocolDefines.h 참조)으로 블록을 작성해요. BlockInfo는 해당 개정판이 0보다 클 때마다 작성되는데, 실제 연결에서는 항상 그렇지요. 각 컬럼의 has_custom_serialization 바이트( 컬럼 와이어 레이아웃 참조)는 개정판 54454 이상에서 작성돼요.
  • Native 출력 포맷 — HTTP를 통한 SELECT ... FORMAT Native, INTO OUTFILE ... FORMAT Native, clickhouse-client가 만드는 Native 포맷 — 은 기본적으로 개정판 0으로 직렬화해요. 개정판 0에서는 BlockInfo 접두사와 has_custom_serialization 바이트가 모두 생략되므로, 블록은 그저 num_columns, num_rows, 그리고 컬럼들뿐이에요.
    • HTTP에서는 이 개정판이 고정되지 않아요. 클라이언트가 ?client_protocol_version=<n> 쿼리 파라미터로 올릴 수 있으며, 서버는 그 값을 응답의 직렬화 개정판으로 사용해요.
    • 충분히 큰 값이면 HTTP 출력에 BlockInfo 접두사(개정판이 0보다 크면 작성)와 has_custom_serialization 바이트(개정판 54454 이상에서 작성)가 포함돼요. TCP 경로와 정확히 동일하죠. 따라서 클라이언트는 모든 HTTP FORMAT Native 페이로드가 개정판 0이라고 가정하면 안 돼요.

즉, 이 섹션에서 BlockInfo 접두사로 시작하는 바이트 예제는 TCP Data-패킷 페이로드를 설명해요. 같은 쿼리를 FORMAT Native로 가져오면 그 옆에 보이는 더 짧은 형태를 만들어요.

BlockInfo

BlockInfo는 각각 VarUInt 필드 ID가 앞에 붙는 필드들의 시퀀스이며, 필드 ID 0으로 끝나요. 와이어 포맷은 자기-기술적이지 않아요: 필드 ID는 자기 값의 길이나 타입을 인코딩하지 않으므로, 읽는 쪽은 만날 수 있는 모든 필드 ID의 타입을 미리 알아야 해요. ClickHouse 자체 리더는 인식하지 못하는 필드 ID를 손상으로 취급하고 예외(UNKNOWN_BLOCK_INFO_FIELD)를 던져요. 전방 호환성은 대신 프로토콜 개정판으로 처리돼요: 보내는 쪽은 협상된 개정판이 그 필드의 최소 개정판 이상일 때만 그 필드를 작성하므로, 오래된 수신자는 모르는 필드를 절대 보지 못해요.

필드 ID (Field ID) 필드 (Field) 타입 (Type) 최소 개정판 (Min revision) 설명 (Description)
1 is_overflows UInt8 0 GROUP BY의 오버플로 블록. 오버플로가 아닌 블록에서는 0.
2 bucket_number Int32 0 집계 버킷. 버킷화되지 않은 블록에서는 -1.
3 out_of_order_buckets List of Int32 54480 분산 집계 중 지연된 버킷들. VarUInt 개수 뒤에 그만큼의 Int32 값으로 인코딩.
0 (종결자) — — BlockInfo의 끝. 항상 필요.

필드 1과 2는 최소 개정판이 0이므로 BlockInfo가 작성될 때면 항상 존재해요. 필드 3은 개정판 54480 이상에서만 작성돼요. 일반적인 경우(개정판 54480 미만)의 와이어 레이아웃:

[VarUInt: 1] [UInt8: is_overflows]
[VarUInt: 2] [Int32: bucket_number]
[VarUInt: 0]

컬럼 와이어 레이아웃 (Column wire layout)

Column은 Block 안에 num_columns 번 나타나요.

# 필드 (Field) 타입 (Type) 조건 (Condition) 설명 (Description)
1 name String 항상 컬럼 이름
2 type String 또는 이진 타입 인코딩 항상 기본적으로 ClickHouse 타입 문자열(예: "UInt64", "Array(String)"); output_format_native_encode_types_in_binary_format = 1이면 이진 타입 인코딩 (아래 참고 참조)
3 has_custom_serialization UInt8 기능 CUSTOM_SERIALIZATION (v54454) 0 = 기본, 1 = 사용자 지정 (kind_stack이 뒤따름)
4 kind_stack bytes 필드 3 = 1일 때 비기본 직렬화(sparse 등)를 설명하는 하나의 UInt8 열거 바이트(아래 참조). COMBINATION 값의 경우 VarUInt 개수와 그만큼의 추가 kind 바이트가 뒤따름. Tuple(및 요소 수준 직렬화 정보를 가진 다른 복합 타입)의 경우 페이로드는 재귀적 — 아래 참조.
5 data bytes 항상 모든 num_rows 행의 컬럼 값. 타입별 레이아웃 — 데이터 타입 참조. Sparse 컬럼은 아래 참조.

디코더는 type 문자열을 기준으로 분기해요. 타입 문자열은 종종 괄호 안에 매개변수를 담아요. 디코더는 (...) 접미사를 떼어 기본 타입을 찾고, 그런 다음 크기, 배율, 내부 타입 결정을 위해 매개변수를 파싱해요. Tuple 안의 Array 같은 내포 타입이 있는 매개변수 리스트를 파싱하려면 , 단순 분리가 아니라 괄호 내포를 추적하는 깊이 인식 콤마 분리기가 필요해요.

이진 타입 인코딩

type 필드는 기본 모드에서만 텍스트 String이에요. 쿼리 설정 output_format_native_encode_types_in_binary_format = 1이 설정되면 이 필드는 대신 이진 타입 인코딩이 돼요 — 데이터 타입 이진 인코딩에 문서화된 것과 같은 태그 기반 인코딩이에요. 펼쳐진(flattened) Dynamic 타입 목록도 타입 이름에 같은 이진 인코딩을 사용해요. 필드 2를 항상 길이 접두사 문자열로 읽는 디코더는 첫 이진 타입 태그를 문자열 길이로 오해해 동기화를 잃게 되므로, 스트림이 어떤 모드인지 알아야 해요.

kind_stack과 sparse 인코딩

kind_stack 바이트는 비기본 컬럼별 직렬화를 열거해요:

바이트 (Byte) 이름 (Name) 의미 (Meaning) data에 대한 와이어 영향 (Wire impact)
0x00 DEFAULT 기본 직렬화 has_custom = 0과 동일
0x01 SPARSE Sparse 직렬화 (v54465+) 오프셋 스트림 + 비기본 값들; 아래 참조
0x02 DETACHED 병렬 블록 마샬링으로 ColumnBLOB에 감싸인 컬럼 (v54478+) 사전 마샬된 blob: VarUInt size + 그만큼의 바이트; 아래 참조
0x03 DETACHED_OVER_SPARSE ColumnBLOB에 감싸인 sparse 컬럼 DETACHED와 같은 blob 페이로드; 아래 참조
0x04 REPLICATED 반복 값에 대한 딕셔너리 형태 (v54482+) 인덱스 스트림 + 조밀한 요소 값들; 아래 참조
0x05 COMBINATION 다중 kind 스택 VarUInt 개수와 그만큼의 추가 kind 바이트가 뒤따름 — 아래 참고 참조

COMBINATION 페이로드는 다른 열거를 사용해요. 위의 다섯 행은 컴팩트한 1바이트 코드예요. COMBINATION(0x05)은 이들이 다루지 않는 모든 스택을 위한 일반 이스케이프예요: 그 뒤에 VarUInt count와 count 개의 1바이트 항목이 따라와요. 그 항목들은 표의 컴팩트 코드가 아니라 원시 ISerialization::Kind 값이에요:

바이트 (Byte) 중첩 Kind (Nested Kind)
0x00 DEFAULT
0x01 SPARSE
0x02 DETACHED
0x03 REPLICATED

바이트 값은 컴팩트 코드와 달라요: REPLICATED는 이 중첩 열거에서 0x03이지만 컴팩트 코드로는 0x04이며, DETACHED_OVER_SPARSE 항목은 없어요 — 그 조합은 SPARSE, DETACHED 두 개의 연속 항목으로 나타나요. 중첩 바이트에 컴팩트 표를 계속 사용하는 디코더는 0x03/0x04를 잘못 맵핑해 동기화를 잃어요.

count는 모든 스택을 시작하는 선두 DEFAULT 항목을 포함한 전체 스택 길이예요. 컴팩트 코드는 이미 모든 1-항목과 2-항목 스택을 다루므로, COMBINATION은 항상 count가 최소 3이에요.

Kind 스택 유효성. 디코더는 잘못된 스택을 그것이 설명하는 직렬화를 만드는 대신 INCORRECT_DATA로 거부해요:

  • 스택은 DEFAULT로 시작해요.
  • 스택은 DEFAULT, SPARSE, REPLICATED, DETACHED의 부분 수열이에요 — kind들이 서로를 감싸는 순서죠. SPARSE는 REPLICATED 안에 들어갈 수 있지만 그 반대는 아니며, DETACHED는 ColumnBLOB이 그 아래의 모든 것의 직렬화된 형태를 담으므로 항상 마지막이에요. 이는 또한 어떤 kind도 두 번 나타나지 않는다는 뜻이며, count를 kind의 개수로 제한해요.

이 순서는 위의 kind 바이트 값의 순서가 아니라는 점에 주의하세요. 다른 어떤 스택도 어떤 작성자가 만들지 않는 컬럼 레이아웃을 설명하며, 어떤 구체화 단계도 선언된 타입의 완전한 컬럼으로 되감아 풀지 않아요 — 예를 들어 값 컬럼 자체가 sparse이거나 replicated인 ColumnSparse는 감싸인 채로 남아요.

Tuple 컬럼의 재귀적 kind_stack. 위의 kind_stack 페이로드는 한 컬럼 자신의 직렬화 정보에 대한 바이트(또는 COMBINATION 시퀀스)예요. Tuple은 SerializationInfoTuple을 나르는데, 이는 먼저 튜플 자신의 kind-스택 페이로드를 작성하고, 그 다음 각 요소에 대해 하나의 전체 kind-스택 페이로드를 순서대로 작성해요. 디코더는 같은 재귀 구조를 다시 읽어요. 따라서 Tuple(A, B, C)의 필드-4 바이트는 [tuple_kind][A_kind][B_kind][C_kind]이고, 각 요소 페이로드는 그 요소가 다시 복합 타입이면 그 자체로 재귀적이에요. has_custom_serialization 바이트(필드 3)는 튜플 자신의 정보 또는 어떤 요소의 정보가 비기본일 때마다 설정되므로, 특별한 요소가 sparse 또는 replicated뿐인 Tuple도 kind-스택 페이로드를 트리거해요. Tuple에 대해 단일 선두 열거 바이트만 읽는 디코더는 너무 일찍 멈춰 남은 요소-kind 바이트를 컬럼 데이터로 잘못 읽어요.

Sparse 와이어 포맷. kind_stack = 0x01일 때 컬럼 data는 단일 공유 TCP 스트림에서 백투백으로 작성되는 두 스트림이에요:

  1. 오프셋 스트림 — 일련의 VarUInt. 각 값 v는 둘 중 하나예요:
    • 위치 62의 상위 비트가 지워진 v: (v & 0x3FFFFFFFFFFFFFFF) = 다음 명시적 비기본 값 앞의 기본 위치 수. 그 비기본 위치는 cursor + group_size인데, 여기서 cursor는 진행 중인 위치예요. 이후 cursor는 group_size + 1만큼 진행돼요.
    • 비트 62가 설정된 v (END_OF_GRANULE_FLAG): 플래그가 지워진 값 = 마지막 비기본 값 뒤의 후행 기본 위치 수. 이것은 블록의 오프셋 스트림 끝을 표시해요.
  2. 값 스트림 — 내부 타입으로 조밀하게 인코딩된 count 개의 비기본 값. 여기서 count는 위에서 읽은 비-EOG VarUInt의 수예요.

디코더는 모든 명시적 위치가 아닌 위치를 내부 타입의 기본 값(0은 정수·실수, String은 "", Date는 0일 등)으로 채워 num_rows 항목의 조밀한 컬럼을 재구성해요.

Sparse Nullable(T) 컬럼은 특별한 경우예요. Nullable(T)의 기본 값이 NULL이기 때문이죠. Sparse 인코딩은 흔한 Nullable null-map 스트림을 완전히 없애요: 오프셋 스트림이 비기본 — 즉 비-NULL — 위치를 식별하고, 값 스트림은 그 비-NULL 값들만 T로 조밀하게 담으며, 모든 명시적 위치가 아닌 위치는 NULL로 재구성돼요. 따라서 디코더는 값 스트림에서 null map을 찾으면 안 되고, 빈 자리를 존재하는 0으로 채우면 안 되며, NULL로 채워야 해요.

Replicated 와이어 포맷. kind_stack = 0x04일 때 컬럼 data는 딕셔너리예요: 고유 요소 값 목록 + 그 목록에 대한 행별 인덱스(LowCardinality와 같은 조회 형태). 내부 타입 자체가 버전 지정 — 예를 들어 LowCardinality(T) — 이면 그 상태 접두사가 인덱스 스트림보다 먼저 작성돼요: replicated 직렬화는 num_rows를 쓰기 전에 내부 타입에 접두사 단계를 위임해요. 빈 접두사를 가진 내부 타입(리프 타입과 일반 복합 타입)은 여기서 어떤 바이트도 기여하지 않아요.

[inner type's state prefix]              리프 내부 타입이면 빈 값; 예: LowCardinality 버전 (Int64 = 1)
[VarUInt num_rows]
[UInt8  size_of_indexes_type]            각 인덱스의 너비: 1, 2, 4, 또는 8 바이트
[indexes: num_rows × size_of_indexes_type bytes]
[VarUInt num_elements]
[elements: num_elements dense inner-type values]

디코더는 각 출력 행 i에 대해 elements[indexes[i]]를 선택하여 조밀한 컬럼을 재구성해요. 복합 내부 타입은 재귀해요: 요소 목록이 내부 타입으로 구체화된 뒤 인덱싱돼요. 지원되는 내부 타입에는 리프 타입, Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...)(각 필드가 Array처럼 펼쳐짐), LowCardinality(T)(공유 딕셔너리는 유지되고 요소별 키만 인덱싱됨)가 있어요.

Detached 와이어 포맷. DETACHED(0x02)와 DETACHED_OVER_SPARSE(0x03)는 와이어에 실제로 나타나요 — 순수 내부용이 아니에요. TCP 경로에서 압축이 활성화되고 협상된 개정판이 DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING(v54478) 이상이면 컬럼은 세 단계를 거쳐요:

  1. 각 적격 컬럼(비-const, 비-Tuple, 한 행보다 많은 블록 안)은 메인 스레드 밖에서 이미 마샬되고 압축된 컬럼을 담는 ColumnBLOB에 감싸져요.
  2. DETACHED가 감싸진 컬럼의 kind 스택에 추가돼요.
  3. 컬럼 data가 VarUInt blob 크기 뒤에 정확히 그만큼의 blob 바이트로 작성돼요.

감싸진 컬럼이 sparse였다면 그 스택은 {DEFAULT, SPARSE, DETACHED}이며, 이는 DETACHED_OVER_SPARSE로 직렬화돼요. 그런 컬럼을 디코딩하는 클라이언트는 blob 길이와 바이트를 읽은 뒤, blob을 압축 해제해 내부 컬럼 페이로드를 복구해요 (압축 아래의 ColumnBLOB 참고 참조).

DETACHED는 방향성이 있어요: 서버가 보내는 결과 블록에서만, 그리고 전체 최상위 컬럼에 대해서만 나타날 수 있어요 — Tuple의 요소 kind 중 하나로는 절대 나타나지 않아요. 다른 kind들과 달리, 런타임 컬럼을 선언된 타입의 컬럼 대신 ColumnBLOB으로 만들기 때문에, 수신자는 자신의 파이프라인이 blob을 다시 변환할 때만 받아들여요. 따라서 서버는 클라이언트가 보내는 어떤 블록(외부 테이블 데이터, 스칼라, insert 데이터 모두)에서도 DETACHED를 INCORRECT_DATA로 거부해요.

블록 변형 (Block variants)

모든 Data-계열 패킷은 같은 Block 와이어 포맷을 공유해요. 변형들은 컬럼 수와 행 수만 다를 뿐이에요:

변형 (Variant) num_columns num_rows 목적 (Purpose)
헤더 블록 (Header block) N > 0 0 결과 스키마(컬럼 이름 + 타입)를 알림.
결과 블록 (Result block) N > 0 M > 0 실제 결과 행.
빈 블록 (Empty block) 0 0 센티넬 — 클라이언트 쪽에서는 입력 끝, 서버 쪽에서는 경계 표시.

바이트 수준 예제 (Byte-level examples)

이 섹션의 모든 예제는 TCP Data-패킷 경로에서 가져온 것이므로 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의 헤더 블록은 이름 "1", 타입 UInt8의 컬럼 하나, 행 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(개정판 0)를 통하면 같은 결과 블록에 BlockInfo도 has_custom_serialization 바이트도 없어요 — SELECT 1 FORMAT Native는 11바이트예요:

01                      num_columns = 1
01                      num_rows = 1
01 "1"                  Column[0].name = "1"
05 "UInt8"              Column[0].type = "UInt8"
01                      Column[0].data: one UInt8 byte = 1

(헤더 전용 블록 같은 0행 결과는 FORMAT Native에서 어떤 바이트도 만들지 않아요: 출력 포맷은 빈 블록을 내보내지 않아요.)

프로토콜 개정판과 Native 포맷 (Protocol revision and the Native format)

Native 바이트 스트림의 형태는 무엇보다 작성자와 리더가 실행하는 프로토콜 개정판이 결정해요. 개정판은 바이트 자체의 어디에도 없어요 — 와이어에 개정판 필드가 없죠 — 하지만 몇몇 기능이 나타날지 여부를 결정해요. 그 때문에 디코더는 페이로드가 어떤 개정판으로 작성됐는지 파싱 전에 알아야 해요. 개정판이 스트림에 없으므로, 리더와 작성자는 다른 방식으로 정해야 해요.

그것은 단일 UInt64이며, NativeWriter와 NativeReader 모두 그것을 생성자 인자로 받아요. 작성자는 client_revision이라 부르고 리더는 server_revision이라 부르지만, 같은 숫자예요. 이 릴리스가 아는 가장 새로운 개정판은 DBMS_TCP_PROTOCOL_VERSION(src/Core/ProtocolDefines.h 참조)이에요.

개정판이 제어하는 것 (What the revision gates)

각 기능은 DBMS_MIN_REVISION_WITH_* 임계값 뒤에 있어요. 작성자는 개정판이 임계값에 도달했을 때만 그 기능을 내보내고, 리더는 정확히 같은 규칙으로 그것을 찾으므로 둘은 보조를 맞춰요 — 어느 한쪽이라도 개정판을 잘못 알면 동기화를 잃어요. Native 포맷에 중요한 게이트는:

기능 (Feature) 임계값 상수 (Threshold constant) 개정판 (Revision) 임계값 미만일 때의 효과 (Effect when below threshold)
BlockInfo 접두사 (값 > 0이면 모두) 1 BlockInfo 접두사가 완전히 생략됨; 블록은 그저 num_columns, num_rows, 컬럼들.
has_custom_serialization 바이트 DBMS_MIN_REVISION_WITH_CUSTOM_SERIALIZATION 54454 컬럼별 has_custom_serialization 바이트가 생략됨; 모든 컬럼이 기본 직렬화 사용 (sparse, replicated, detached 형태 없음).
와이어의 LowCardinality DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE 54405 특별한 경우 — 단순한 아래-임계값 규칙을 따르지 않아요. LowCardinality(T)는 개정판이 0이 아니고 54405 미만일 때만, 또는 강제로 따로 벗겨질 때만 기본 타입 T로 벗겨져요. 개정판 0은 유지해요. 아래 참고 참조.
V2 Dynamic / JSON 직렬화 DBMS_MIN_REVISION_WITH_V2_DYNAMIC_AND_JSON_SERIALIZATION 54473 Dynamic과 JSON/Object가 V2 대신 V1 직렬화(max_dynamic_* 매개변수 포함) 사용.
오프셋 String 직렬화 DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION 54492 임계값 미만이면 String 컬럼 데이터가 값별 레이아웃(VarUInt 길이 접두사 + 행별 원시 바이트)을 사용하는 대신 오프셋 레이아웃 (누적 바이트 오프셋을 UInt64로, 그 다음 모든 데이터를 연결)을 사용. 개정판에만 전적으로 의존하며 컬럼별 와이어 표시가 없음. 임계값 이상이면 오프셋 레이아웃이 복합 타입(Array, Nullable, Map, Tuple, Variant, Dynamic, JSON) 안에 중첩된 String에도 적용되지만, LowCardinality 컬럼의 딕셔너리에는 적용되지 않아요: LowCardinality 딕셔너리는 항상 기본 중첩 직렬화로 작성되므로 그 String 값은 값별 레이아웃을 유지해요 (이것은 LowCardinality(String)과 LowCardinality(Nullable(String))을 포함해요). Buffers 포맷은 프로토콜의 일부가 아니며 항상 값별 레이아웃을 사용해요.
집계 함수 버전 지정 DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING 54452 AggregateFunction 상태가 내장 버전 없이 작성됨.
quantileDeterministic 상태의 skip_degree DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE 54491 AggregateFunction(quantileDeterministic...) 상태가 버전 0으로 작성됨 (버전 1 전용 trailing UInt8 skip_degree 없음).

그렇게 하면 개정판 0은 거의 모든 것에 대해 가장 보수적인 인코딩이 돼요: 스트림은 BlockInfo도, has_custom_serialization 바이트도, V1 Dynamic/JSON도, 집계 함수 버전도 없고, 시간대 매개변수가 빠진 맨몸의 DateTime을 나르지요. 집계 함수 상태 버전은 개정판 0에서 예외예요: 버전을 유도할 상대가 없어요 — 스트림은 그것을 만든 사람(StripeLog 데이터 파일, Set/Join 백업, Native 포맷 파일)이 작성하고 다시 읽어요 — 그래서 작성자는 상태를 버전 0으로 낮추는 대신 타입에 고정된 버전을 유지해요(평소처럼 타입 문자열에 명시)고, 버전을 고정하지 않는 타입만 버전 0으로 작성돼요.

LowCardinality는 유일한 예외이며 중요한 예외예요. 작성자의 검사는 remove_low_cardinality || (client_revision && client_revision < DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE)이에요. 요령은 선두의 client_revision &&이에요: 개정판이 정확히 0이면 전체 조건이 false로 단락돼요.

그래서 개정판 0 — FORMAT Native의 기본 — 에서 LowCardinality(T)는 벗겨지지 않아요. 그 타입 문자열과 블록별 상태 접두사는 스트림에 남고, 개정판-0 리더는 그것들을 바로 읽어요. 벗겨지는 것은 54405 미만의 0이 아닌 개정판에서만, 또는 개정판과 무관하게 강제될 때만 일어나요.

그 강제가 remove_low_cardinality 플래그예요. FORMAT Native 출력은 결코 이것을 설정하지 않지만, 네이티브 TCP 경로는 low_cardinality_allow_in_native_format = 0(기본 1)일 때 설정해요. 즉, 그 설정은 네이티브 TCP 출력을 바꾸지만 FORMAT Native에는 아무 효과가 없어요.

실용적인 요점: 기본 FORMAT Native 스트림은 합법적으로 LowCardinality를 담을 수 있으므로, 개정판 0에서 없는 기능이라고 취급하지 마세요.

데이터 이동 방식에 따른 개정판 출처 (Where the revision comes from)

같은 Native 바이트가 서로 다른 경로로 이동할 수 있어요: 네이티브 TCP 프로토콜, HTTP 요청, 또는 디스크의 파일. 각 경로는 각자 방식으로 개정판을 정해요. 주의할 점 하나: 읽는 쪽과 쓰는 쪽은 따로 설정되므로 서로 다른 개정판에 도달할 수 있어요.

Native TCP 프로토콜 — 협상됨, 양방향

네이티브 TCP 프로토콜에서는 개정판이 Hello 핸드셰이크에서 나와요. 클라이언트가 DBMS_TCP_PROTOCOL_VERSION을 보내고, 서버가 자신의 것을 보내며, 그때부터 각 쪽은 상대가 광고한 개정판으로 직렬화해요: 서버는 client_tcp_protocol_version으로 자체 NativeReader/NativeWriter를 만들고, 클라이언트는 받은 server_revision을 사용해요. 명시적 최소값은 없지만, 어느 쪽도 구현하지 않은 기능을 내보낼 수는 없으므로 각 방향은 사실상 두 피어 중 오래된 쪽으로 상한이 정해져요.

두 피어가 같은 최신 빌드이면 두 방향이 같은 개정판(DBMS_TCP_PROTOCOL_VERSION, src/Core/ProtocolDefines.h 참조)에 도달하고 모든 게이트가 켜져요. 이것이 일반적인 경우지만 보장되지는 않아요. 혼합 버전이나 타사 피어에서는 두 방향이 서로 다른 개정판에 있을 수 있으므로, 게이트는 방향별로 읽어야 해요: BlockInfo는 어떤 0이 아닌 개정판에서도 있지만, 나머지 — has_custom_serialization 포함 — 는 그 방향의 유효 개정판이 각 임계값에 도달해야 나타나요. 예를 들어 54454 미만의 개정판을 광고하는 피어는 has_custom_serialization 바이트를 보내지도 받지도 않아요.

FORMAT Native 출력 — 기본 개정판 0, HTTP에서는 올릴 수 있음

Native 출력 포맷은 기본적으로 개정판 0이에요. 이것은 HTTP를 통한 SELECT ... FORMAT Native, INTO OUTFILE ... FORMAT Native, 그리고 clickhouse-client가 작성하는 Native 출력을 포함해요. 각 경우 출력 팩토리는 FormatSettings::client_protocol_version을 NativeWriter에 직접 넘기는데, 이는 BlockInfo / has_custom_serialization 프레이밍 과 오프셋 String 레이아웃을 결정해요. 따라서 올린 client_protocol_version(≥ 54492)을 가진 HTTP SELECT ... FORMAT Native는 오프셋 레이아웃을 작성하는 반면, 기본 개정판 0은 이식 가능한 값별 레이아웃을 유지해요.

그 기본값이 HTTP에서는 이야기의 끝이 아니에요. 클라이언트는 ?client_protocol_version= 쿼리 파라미터로 올릴 수 있는데, HTTP 핸들러는 이것을 SQL 설정이 아닌 예약 파라미터로 취급해요: 쿼리 컨텍스트에 놓이고, 포맷 레이어가 그것을 FormatSettings에 복사해요. 충분히 높게 설정하면 HTTP FORMAT Native 출력이 TCP 경로처럼 BlockInfo 접두사와 has_custom_serialization 바이트를 포함하기 시작해요 — 그래서 HTTP FORMAT Native 페이로드가 항상 개정판 0이라고 가정하지 마세요.

파라미터가 전체 쿼리 컨텍스트에 놓이므로, 결과 스트림뿐 아니라 그 요청에 만들어진 모든 NativeWriter에 도달해요. 특히 HTTP를 통한 INSERT INTO FUNCTION file('x.native', 'Native', ...) SELECT ... 같은 서버 측 쓰기는 올린 ?client_protocol_version=로 그 개정판에서 파일을 작성해요. 이것은 오랜 기간의 동작이에요(String 오프셋 직렬화보다 앞서며 BlockInfo와 has_custom_serialization에도 적용돼요), 그리고 비대칭적이에요: 읽는 쪽(아래)은 같은 방식으로 올라가지 않으므로 그런 파일은 file(...)을 통해 다시 읽히지 않아요. 다시 읽을 데이터를 쓰는 요청에서는 client_protocol_version을 떼어 두세요. INTO OUTFILE과 로컬 clickhouse-client 출력에는 그런 손잡이가 없고 0에 머물러요.

FORMAT Native 입력 — 항상 개정판 0

Native 입력 포맷은 항상 개정판 0으로 자체 NativeReader를 만들어요. BlockInfo 접두사를 절대 기대하지 않고, has_custom_serialization 바이트를 절대 읽지 않으며, 항상 기본 직렬화를 가정해요 — INSERT ... FORMAT Native의 본문을 파싱하든, Native 파일을 읽든, 스키마를 추론하든 마찬가지예요. ?client_protocol_version=는 입력 개정판을 올리지 않아요(출력 쪽만 그 파라미터를 읽어요).

입력 개정판이 항상 0이므로 입력 포맷은 항상 값별 String 레이아웃을 읽어요. 오프셋 레이아웃으로 작성된 스트림(네이티브 TCP 프로토콜을 거치거나, 올린 client_protocol_version의 SELECT ... FORMAT Native)은 FORMAT Native 입력을 통해 다시 읽히지 않아요 — BlockInfo 프레이밍과 같은 비대칭성이에요.

왕복(Round-trip) 함의

FORMAT Native에서는 기본 개정판이 양쪽 모두 0이에요. 개정판 0의 SELECT ... FORMAT Native로 작성된 데이터는 놀라움 없이 INSERT ... FORMAT Native로 바로 다시 읽혀요.

올린 출력 개정판(SELECT의 ?client_protocol_version=)으로 생성된 스트림은 BlockInfo와 has_custom_serialization 프레이밍 그리고 오프셋 String 레이아웃을 나르는데, 이 중 어느 것도 입력 포맷 — 항상 개정판 0 — 이 다시 읽지 않아요. 따라서 client_protocol_version을 올리는 것은 네이티브 TCP 프로토콜을 말하는 소비자에게만 의미가 있고, HTTP를 통한 FORMAT Native 왕복에는 의미가 없어요.

파일은 비대칭적인 경우예요. 서버 측 INSERT INTO FUNCTION file(...)(또는 s3, url)에 올린 ?client_protocol_version=는 그 개정판에서 파일을 작성하지만, file(...) 읽기는 항상 개정판 0으로 파싱해요 — 그래서 그런 파일은 왕복되지 않아요. 쓰기 요청에서 client_protocol_version을 빼서 파일을 평범한 개정판 0으로 만들거나, 데이터를 네이티브 TCP 프로토콜로 옮기세요. 거기서는 각 방향이 핸드셰이크에서 협상된 개정판을 사용해요.

채널 (Channel) 쓰기 개정판 (Write revision) 읽기 개정판 (Read revision) BlockInfo / 커스텀 직렬화
Native TCP Data 패킷 피어의 광고 개정판 (방향별) 피어의 광고 개정판 (방향별) 개정판 > 0이면 BlockInfo; ≥ 54454에서 has_custom_serialization
HTTP를 통한 SELECT ... FORMAT Native client_protocol_version (기본 0) 해당 없음 client_protocol_version이 올려진 경우에만
HTTP를 통한 INSERT ... FORMAT Native (본문) 해당 없음 0 (항상) 프레이밍 없음; String은 항상 값별
HTTP를 통한 INSERT INTO FUNCTION file/s3/url(..., 'Native') client_protocol_version (기본 0) 0 (항상 읽음) client_protocol_version이 올려진 경우에만 쓰기 — 그런 파일은 다시 읽히지 않음
INTO OUTFILE / 로컬 clickhouse-client FORMAT Native 0 0 없음 (단, LowCardinality는 유지 — 위 참고 참조)

프로토콜 개정판 vs 직렬화 버전

프로토콜 개정판과 직렬화 버전을 혼동하지 마세요. 여기서 개정판은 연결 또는 요청 전체에 걸쳐 있고 바이트에 결코 나타나지 않아요. 직렬화 버전은 컬럼별로, 버전 지정 타입이 나르며, 모든 비어 있지 않은 블록에 작성돼요. 개정판은 기능이 아예 있는지 여부를 결정하고, 직렬화 버전은 버전 지정 컬럼 안에 들어가면 그 한 타입의 인코딩의 어떤 변형이 이어지는지 고르지요.

데이터 타입 (Data types)

이 섹션은 Native 포맷이 컬럼의 data 안에서 나를 수 있는 타입들의 와이어 인코딩을 문서화하며, 디코더 복잡도가 증가하는 네 패밀리로 묶여 있어요. AggregateFunction(func, ...)와 QBit(T, N[, stride]) 두 타입은 유효한 Native 컬럼 타입이지만 함수 또는 타입 특유의 페이로드를 가지므로 이 페이지의 범위 밖이에요. 아래에서 별칭으로 오해할 수 있는 곳에 짚어 두었어요.

패밀리 (Family) 섹션 (Section) 컬럼당 스트림 (Streams per column) 블록 간 상태 (Cross-block state)
고정 너비 (Fixed-width) 고정 너비 타입 하나 없음
가변 길이 (Variable-length) 가변 길이 타입 하나 없음
복합 (Composite, 고정 형태) 복합 타입 여러 개 없음
버전 지정 / 상태 유지 (Versioned / stateful) 버전 지정 타입 여러 개 Native 와이어에서는 없음 — 블록별 상태 접두사, 블록마다 새로

고정 너비 타입 (Fixed-width types)

각 값은 일정한 바이트 수를 차지해요. M 행의 컬럼은 와이어에서 정확히 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 타임스탬프 리틀 엔디언 UInt32
DateTime(tz) 4 DateTime과 동일; 시간대는 메타데이터 리틀 엔디언 UInt32
DateTime64(s) 8 배율 s의 틱 (epoch 이후 10^-s 초) 리틀 엔디언 Int64
DateTime64(s, tz) 8 DateTime64(s)와 동일; 시간대는 메타데이터 리틀 엔디언 Int64
Time 4 초 단위 부호 있는 시계 지속시간 리틀 엔디언 Int32
Time64(s) 8 배율 s의 틱 단위 부호 있는 시계 지속시간 리틀 엔디언 Int64
Interval 8 부호 있는 개수; 단위는 타입 문자열에 리틀 엔디언 Int64
UUID 16 128비트 식별자 바이트-스왑된 두 LE UInt64 절반 (UUID 참조)
IPv4 4 IPv4 주소 리틀 엔디언 UInt32
IPv6 16 IPv6 주소 네트워크 바이트 순서, 스왑 없음
Enum8 1 부호 있는 8비트 (변형 인덱스) 원시 바이트
Enum16 2 부호 있는 16비트 (변형 인덱스) 리틀 엔디언
Decimal(P, S) 4 / 8 / 16 / 32 10^S 배의 부호 있는 정수; 너비는 P에 의존 (≤9 → 4 B, ≤18 → 8 B, ≤38 → 16 B, ≤76 → 32 B) 리틀 엔디언 부호 있는 정수
정수 타입 (Integer types)

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
Float32와 Float64

표준 IEEE 754 이진 실수: 단정밀도(binary32) 4바이트, 배정밀도(binary64) 8바이트, 각각 리틀 엔디언. 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
BFloat16

brain-floating-point 포맷: IEEE 754 Float32의 상위 16비트 — 부호 1비트, 지수 8비트, 가수 7비트. 각 값은 2바이트, 리틀 엔디언으로, 원시 16비트 패턴을 담아요. 수치 값을 복구하려면 패턴을 상위 절반에 놓고 하위 절반을 0으로 해 Float32로 넓히면 돼요(bits << 16을 Float32로 재해석). 넓혀진 값은 그 후 Float32의 텍스트 서식을 공유해요.

BFloat16 값 1.5 (패턴 0x3FC0, Float32 0x3FC00000의 상위 절반):

C0 3F                    little-endian, widens to Float32 1.5
Bool

UInt8과 와이어 호환: 행당 1바이트, 0x00 = false, 0x01 = true. 와이어의 타입 문자열은 문자 그대로 Bool(UInt8 아님)이므로, 타입 문자열로 분기하는 디코더는 그것을 별개로 인식해야 해요.

[true, false, true] Bool 컬럼:

01 00 01
Date와 Date32

두 타입 모두 Unix epoch 1970-01-01에 상대적인 정수 일 수로 날짜를 인코딩해요. 둘 다 시간 성분을 나르지 않아요.

타입 (Type) 바이트 (Bytes) 인코딩 (Encoding) 범위 (Range)
Date 2 리틀 엔디언 UInt16 1970-01-01 부터 2149-06-06
Date32 4 리틀 엔디언 Int32 넓은 부호 있는 범위, 1970 이전 가능

Date 값 1970-01-02 (1일):

01 00                    UInt16 LE = 1

Date32 값 1900-01-01 (-25567일):

21 9C FF FF              Int32 LE = -25567
DateTime

UInt32와 와이어 호환: 초 단위 Unix 타임스탬프, 4바이트 리틀 엔디언. 타입은 DateTime 또는 DateTime('Timezone')으로 나타날 수 있어요. 시간대는 표시에만 영향을 주며 와이어 값의 일부가 아니에요. 시간대 매개변수가 다른 두 DateTime 컬럼은 같은 순간에 대해 동일한 바이트를 만들어요. 디코더는 (...) 매개변수 접미사를 떼고 컬럼을 UInt32로 처리해요.

DateTime('UTC') 값 2024-03-15 14:30:00 UTC (타임스탬프 1710513000):

68 5B F4 65              UInt32 LE = 1710513000
DateTime64(scale[, timezone])

8바이트, 리틀 엔디언 Int64로, epoch 이후 10^-scale 초 단위의 틱을 나타내요. scale 매개변수(0–9)는 타입 문자열에 있고 시간 단위를 정해요:

배율 (Scale) 틱 크기 (Tick size) 일반 이름 (Common name)
0 1 초 seconds
3 1 밀리초 ms
6 1 마이크로초 µs
9 1 나노초 ns

타입은 DateTime64(s)(묵시적 서버 기본 시간대) 또는 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와 Time64(scale)

시점이 아니라 시계 지속시간이에요. Time은 부호 있는 초 수, 4바이트 리틀 엔디언 Int32예요. Time64(scale)은 주어진 십진 배율(0–9)의 부호 있는 틱 수, 8바이트 리틀 엔디언 Int64예요 — DateTime64와 같은 와이어 형태.

텍스트 형태는 [-]HH:MM:SS[.fraction]이지만, DateTime과 달리 시간 필드는 24시간제로 감싸지지 않아요: 총 시간 수이며 23을 초과할 수 있어요. 표시되는 크기는 999:59:59(3599999초)로 상한이 있어요. 더 큰 크기는 초를 상한에 두고 저장된 분수를 그대로 두고 렌더링되므로, 3700000.25의 틱 수는 999:59:59.250000으로 렌더링돼요. CAST는 저장된 값을 대상 배율의 가장 큰 표현 가능 틱(Time64(6)의 경우 999:59:59.999999)으로 포화시키지, 정수 초로는 포화시키지 않아요. 반면 산술은 여전히 표현 범위를 벗어난 틱 수를 컬럼에 남길 수 있고, 그것은 표시 시에만 상한이 정해져요. 이 중 어느 것도 와이어 바이트 — 평범한 부호 있는 정수 — 에는 영향을 주지 않아요.

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는 실험적이며 서버에서 allow_experimental_time_time64_type = 1이 필요해요.

Interval

Interval — IntervalSecond, IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear, IntervalNanosecond 등. 모든 단위가 하나의 와이어 인코딩을 공유해요: 부호 있는 8바이트 리틀 엔디언 Int64 개수. 단위는 타입 문자열에만 있어요 — 와이어 바이트도, 맨몸의 정수인 텍스트 형태도 바꾸지 않아요. 단일 디코더 경로가 모든 단위를 처리해요.

IntervalDay 값 5:

05 00 00 00 00 00 00 00  Int64 LE = 5
UUID

값당 16바이트. 와이어 인코딩은 표준 16 빅-엔디언 바이트가 아니에요 — 각 8바이트 절반이 독립적으로 바이트-역전돼요.

논리 모델은 표준 텍스트 형태 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx의 128비트 식별자이며, 바이트는 관례상 빅-엔디언으로 쓰여요. 와이어 모델은 그 16개 표준 바이트를 두 개의 8바이트 절반으로 나누고 각 절반을 리틀-엔디언으로 작성해요:

  • 와이어 바이트 0..7 = 표준 바이트 0..7 역전.
  • 와이어 바이트 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)는 두 표현에서 동일하게 나타나요.

IPv4와 IPv6

서로 관련되지만 다르게 인코딩되는 두 주소 타입.

IPv4는 4바이트이며, 표준 32비트 주소(a.b.c.d에서 값 (a << 24) | (b << 16) | (c << 8) | d)를 담는 리틀 엔디언 UInt32로 인코딩돼요. 와이어 바이트는 네트워크 순서 바이트를 역전한 것이에요.

192.168.1.10 (표준 32비트 값 0xC0A8010A):

0A 01 A8 C0              Little-endian UInt32

IPv6는 16바이트이며 네트워크 바이트 순서 그대로 스왑 없이 작성돼요 — inet_pton(AF_INET6, ...)와 같은 바이트 순서.

2001:db8::1:

20 01 0D B8 00 00 00 00  network bytes 0..7
00 00 00 00 00 00 00 01  network bytes 8..15

비대칭은 의도적이에요: IPv4는 산술과 컴팩트 범위 쿼리를 위해 u32로 저장되고, IPv6는 대부분의 네트워킹 API에 공통인 네트워크 순서 레이아웃을 유지해요.

Enum8과 Enum16

각각 Int8과 Int16과 와이어 호환: 행당 1 또는 2바이트, 16비트 변형은 2의 보수 리틀 엔디언. 전체 변형 매핑은 타입 문자열에 있어요:

Enum8('active' = 1, 'inactive' = 2, 'banned' = -1)
Enum16('a' = 1, 'b' = 30000)

디코더는 (...) 매개변수 접미사를 떼고 Int8 / Int16으로 분기할 수 있어요 — 와이어 바이트는 그저 정수 인덱스예요. 라벨을 표면화하는 클라이언트는 타입 문자열에서 'name' = value 맵을 파싱해 컬럼 옆에 유지해요: 정수만으로는 라벨을 복구하지 못해요. 텍스트 지향 출력은 인덱스 대신 라벨(active)을 렌더링하고, enum이 복합 타입 안에 중첩되면 단일 인용부호('active')로 렌더링해요. 맵이 정수 컬럼에서 복구되지 않으므로, Array(Enum8(...))나 Map(Enum16(...), V) 같은 중첩 enum에 대해서는 유지해야 해요.

Enum8('active' = 1, 'inactive' = 2) 컬럼 [active, inactive, active]:

01 02 01

Enum16(...) 값 30000:

30 75                    Int16 LE = 30000
Decimal(P, S)

10의 거듭제곱으로 배율이 조정된 부호 있는 정수. 정수의 바이트 너비는 정밀도 P가 암시하고, 배율 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

와이어 인코딩은 리틀 엔디언 2의 보수의 뒷받침 정수이고, 논리 십진 값은 wire_integer × 10^(-S)예요.

ClickHouse는 타입이 어떻게 선언됐든 항상 Decimal(P, S)를 내보내요. Decimal32(S), Decimal64(S) 등은 모두 와이어에서 Decimal(P, S)로 정규화돼요 (P는 자연 최대값 9, 18, 38, 76으로 설정). Decimal(P, S)만 인식하는 디코더는 서버가 내보내는 모든 스펠링을 다뤄요.

Decimal(9, 4) 값 123.4567 → 뒷받침 정수 1234567:

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

Nothing 타입은 값이 없어요. 실제로는 Nullable(Nothing)의 내부 타입으로만 나타나요 — 유일한 유효 값이 그 부재인 SELECT NULL 같은 표현에 대해 서버가 반환하는 것. 개념적으로는 단위 타입(unit type)이에요.

와이어에서는 행당 정확히 하나의 자리표시자 바이트를 차지해요. 서버는 ASCII 문자 '0'(0x30)을 내보내지만, 역직렬화기는 바이트를 무시해요 — 내용은 정의되지 않았고 디코더는 어떤 특정 값에도 의존하면 안 돼요. 작성되는 바이트 수는 num_rows × 1이므로, 컬럼 헤더의 num_rows가 얼마나 소비할지 완전히 결정해요.

행당 바이트는 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 프레이밍이에요( Nullable 참조). 내부 3바이트는 Nothing 페이로드이며 디코더가 건너뛰어요.

가변 길이 타입 (Variable-length types)

각 값은 와이어에서 자신의 길이를 나르지요.

String

타입 문자열: String. String 컬럼에는 두 가지 와이어 레이아웃이 있고, 프로토콜 개정판으로 선택돼요( 개정판이 제어하는 것 참조). 개정판은 네이티브 TCP 프로토콜의 협상된 개정판이고, FORMAT Native 출력에서는 client_protocol_version(기본 0)이에요. FORMAT Native 입력은 항상 개정판 0이고, Buffers 포맷은 항상 값별 레이아웃을 사용해요.

값별 레이아웃 (Per-value layout) — DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION(54492) 미만에서, FORMAT Native의 개정판-0 기본값을 포함해요: num_rows 개의 길이 접두사 바이트 시퀀스:

[VarUInt: byte_length] [byte_length bytes: raw value]
[VarUInt: byte_length] [byte_length bytes: raw value]
...

행 사이에 길이 접두사 외에는 구분자가 없고, 행 수준 상태도 없어요. 빈 문자열은 단일 0x00 바이트예요. 컬럼이 소비하는 총 바이트는 모든 행에 대해 Σ (varuint_size(len_i) + len_i)예요.

3개 문자열 ["ab", "", "c"] 컬럼 (총 6바이트):

02 61 62                 row 0: length 2, "ab"
00                       row 1: length 0, empty
01 63                    row 2: length 1, "c"

오프셋 레이아웃 (Offsets layout) — 개정판 54492 이상: 누적 바이트 오프셋이 먼저, 그 다음 모든 데이터인 두 개의 연결 스트림:

[UInt64 × num_rows: cumulative byte offset (end of each value), little-endian]
[last offset bytes: all values concatenated, no separators]

오프셋은 그대로 보내져요 — Array가 네이티브 프로토콜 위로 오프셋을 보내는 것과 같은 방식. 오프셋 i는 데이터 blob에서 값 i의 끝 위치이므로, 값 i의 크기는 offset[i] - offset[i-1](offset[-1] = 0)이고 마지막 오프셋이 데이터 blob의 전체 크기예요. 디코더는 8 × num_rows 바이트의 오프셋을 읽은 다음 전체 데이터 blob을 한 조각으로 읽고, 마지막 오프셋을 정확한 길이로 사용해요 — 이는 행별 파싱 대신 정확한 버퍼 사전 할당과 벌크 복사를 허용하고 크기-오프셋 변환이 필요 없어요. num_rows = 0인 컬럼은 어느 쪽 스트림에도 바이트를 기여하지 않아요.

같은 3개 문자열 ["ab", "", "c"] (총 27바이트):

02 00 00 00 00 00 00 00  row 0 offset: 2  (end of "ab")
02 00 00 00 00 00 00 00  row 1 offset: 2  (end of "", so unchanged)
03 00 00 00 00 00 00 00  row 2 offset: 3  (end of "c")
61 62 63                 "ab" + "" + "c"

오프셋 레이아웃이 적용될 때는 블록의 모든 String — 복합 타입 안에 중첩된 String(Array(String), Nullable(String), Map, Tuple, Variant, Dynamic, JSON) 포함 — 에 적용돼요. 단 하나의 예외: LowCardinality 컬럼의 딕셔너리는 항상 기본 중첩 직렬화로 작성되므로, 그 String 값은 모든 개정판에서 값별 레이아웃을 유지해요. 이것은 LowCardinality(String), LowCardinality(Nullable(String)), 그리고 다른 String-보유 LowCardinality 딕셔너리를 다뤄요.

두 레이아웃 모두에서 ClickHouse String은 텍스트 지향이 아니라 바이트 지향이에요: UTF-8 유효성은 강제되지 않고, 값은 중첩된 NUL을 포함한 어떤 바이트든 담을 수 있어요. UTF-8 문자열 타입을 대상으로 하는 디코더는 읽을 때 검증하거나 원시 바이트를 호출자에게 노출해요.

FixedString(N)

타입 문자열: FixedString(N), N은 양의 정수(예: FixedString(16)). 컬럼은 정확히 N × num_rows 원시 바이트이며, 길이 접두사도 구분자도 없어요. 디코더는 타입 문자열에서 N을 파싱하고 행당 그만큼의 바이트를 소비해요.

SQL이 N 바이트보다 짧은 값을 삽입할 때(예: CAST('abc' AS FixedString(5))) 서버는 선언된 길이까지 NUL 바이트(0x00)로 오른쪽 패딩해요. 이 패딩 바이트는 저장된 값의 일부이며 와이어에서 그대로 보내져요. 트리밍은 클라이언트 쪽 관심사예요. String처럼 FixedString(N)은 텍스트라기보다 바이트 배열에 가까워요 — 보통 고정 너비 식별자, 주소 바이트, 해시 다이제스트에 쓰여요.

두 FixedString(3) 값 ["abc", "de\0"] (총 6바이트):

61 62 63                 row 0: 3 bytes, "abc"
64 65 00                 row 1: 3 bytes, "de" + NUL padding

두 문자열 타입 비교:

속성 (Property) String FixedString(N)
행별 길이 접두사 있음 (VarUInt), 또는 개정판 54492+에서 별도 오프셋 스트림 없음
행 크기 가변 정확히 N 바이트
총 컬럼 바이트 가변 N × num_rows
NUL 바이트 패딩 해당 없음 서버가 오른쪽 패딩
UTF-8 예상 보통 (강제되지 않음) 없음 (원시 바이트로 취급)
타입 매개변수 없음 정수 N 필수

복합 타입 (Composite types)

복합 타입은 하나 이상의 내부 타입을 감싸고 공통 와이어 모델을 공유해요: 컬럼당 여러 스트림. 단일 논리 컬럼은 둘 이상의 독립적으로 읽히는 바이트 시퀀스(연결됨)로 인코딩돼요.

세 가지 구조적 속성을 공유해요:

  • 스키마당 고정 형태. 구조는 디코드 시점의 타입 문자열에 의해 전적으로 결정돼요. Array(UInt32)는 블록마다 항상 같은 스트림 레이아웃을 가져요.
  • 자체 버전 접두사 없음. 복합 래퍼 자체는 버전 바이트를 추가하지 않아요. 그 프레이밍(오프셋, null-map, 요소 스트림)은 ClickHouse 릴리스 전반에 걸쳐 안정적이에요. 이것은 래퍼에만 적용돼요 — 내부 버전 지정 타입의 아래 접두사 단계 참고 참조.
  • 자체 블록 간 상태 없음. 래퍼의 프레이밍은 블록마다 완전히 자기-기술적이에요. 블록 간 상태 우려는 내부 버전 지정 타입에서 나오지, 래퍼에서 나오지 않아요.

복합 타입은 재귀적이에요 — 내부 타입이 그 자체로 복합 타입일 수 있어요.

데이터 스트림 전의 접두사 단계. 컬럼을 읽는 것은 순서대로 두 단계예요: 상태-접두사 단계와 그 다음 데이터-스트림 단계. 복합 래퍼는 자체 접두사 바이트가 없지만, 자신의 데이터 스트림을 쓰기 전에 내부 직렬화에 접두사 단계를 위임해요: SerializationArray는 배열 오프셋이 작성되기 전에 내부 타입의 접두사 단계를 실행하고, Tuple, Map, Nested, Nullable도 요소 직렬화를 통해 같은 일을 해요(Nullable은 null map 전에 내부 접두사를 실행).

그래서 복합 타입이 버전 지정/상태 유지 타입(LowCardinality, Variant, Dynamic, JSON)을 감쌀 때, 그 내부 타입의 버전/상태 접두사가 래퍼의 오프셋과 요소 페이로드보다 먼저 내보내져요. 예를 들어 Array(LowCardinality(String))은 [LowCardinality state prefix] → [array offsets] → [flattened LowCardinality element payload]로 배치되지, offsets-first가 아니에요.

오프셋을 내부 접두사 단계를 실행하기 전에 읽는 디코더는 LowCardinality, Variant, Dynamic, JSON을 포함하는 어떤 복합 타입에서도 동기화를 잃어요. 모든 내부 타입이 평범한 리프 또는 다른 비버전 복합 타입이면 접두사 단계는 바이트를 내보내지 않고, 아래의 offsets-first 설명이 그대로 적용돼요.

Nullable(T)

타입 문자열: Nullable(InnerType). 예: Nullable(UInt32), Nullable(String), Nullable(FixedString(16)), Nullable(DateTime('UTC')).

다른 복합 타입처럼 Nullable은 null map을 쓰기 전에 내부 직렬화에 접두사 단계를 위임해요: 내부가 버전 지정이면 내부의 상태 접두사가 먼저 내보내져요. 따라서 Nullable(Tuple(LowCardinality(String)))은 null map이 아니라 LowCardinality 상태 접두사로 시작해요. 내부가 리프 또는 다른 비버전 타입이면 접두사 단계는 바이트를 내보내지 않아요.

와이어 레이아웃은 내부 접두사 단계(내부가 버전 지정이 아니면 비어 있음) 뒤에 두 개의 연결 스트림 — null-map이 먼저 — 이에요:

[inner type's state prefix]   리프/비버전 내부 타입이면 빈 값; 내부가 버전 지정이면 먼저 내보냄
[null-map stream]             num_rows × UInt8
[values stream]               내부 타입의 num_rows 값 인코딩

null-map은 정확히 num_rows 바이트, 행당 하나예요:

바이트 값 (Byte value) 의미 (Meaning)
0x00 값이 이 행에 있음.
0이 아닌 값 (표준 0x01) 값이 NULL. 값 스트림의 해당 바이트는 자리표시자.

값 스트림은 모든 num_rows 행 — null 위치 포함 — 에 대해 내부 타입의 표준 인코딩을 담아요. 디코더는 스트림을 진행하기 위해 null 위치의 자리표시자 바이트를 여전히 읽어야 하지만, 어떤 개별 값을 해석하기 전에 null-map을 확인해야 해요. 보내는 쪽은 null 위치에 어떤 바이트든 쓸 수 있으므로, 디코더는 특정 자리표시자 값에 의존하면 안 돼요.

내부 타입 패밀리별 자리표시자 값:

내부 타입 패밀리 (Inner type family) null 위치의 자리표시자 (Placeholder at null position)
고정 너비 (UInt/Int/Float/DateTime/UUID 등) 타입 너비의 0으로 초기화된 바이트
String 빈 문자열 — 단일 0x00 바이트
FixedString(N) N개의 0 바이트
Array(T) 빈 배열 — 오프셋이 0만큼 진행
Tuple(T1, T2, ...) 각 요소가 자체 자리표시자 사용

Nullable(T)은 Array, Tuple, Map, Nested 안에 나타날 수 있어요 — Array(Nullable(T))와 Tuple(Nullable(T1), T2)가 흔해요. Nullable은 그 자체와 합성되지 않아요: 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(T)

타입 문자열: Array(InnerType). 예: Array(UInt32), Array(String), Array(Nullable(UInt32)), Array(Array(UInt8)).

와이어 레이아웃은 내부 접두사 단계(내부 타입이 버전 지정이 아니면 빈 값) 뒤에 다음 두 개의 연결 스트림 — offsets 먼저 — 이에요:

[inner type's state prefix]   리프/비버전 내부 타입이면 빈 값; 내부가 버전 지정이면 먼저 내보냄
[offsets stream]              num_rows × UInt64 LE
[values stream]               offsets[num_rows - 1] 값에 대한 내부 타입 인코딩

오프셋 스트림은 정확히 num_rows개의 리틀 엔디언 UInt64 값이며, 각각 그 행의 요소들 다음 값 스트림의 누적 끝 위치예요:

  • 행 N의 요소 시작 인덱스 = offsets[N - 1](N == 0이면 0).
  • 행 N의 요소 끝 인덱스(배타적) = offsets[N].
  • 행 N의 요소 수 = offsets[N] - offsets[N - 1].

따라서 offsets[num_rows - 1]은 모든 행의 총 요소 수이고, 값 스트림은 그만큼의 내부 값을 끝에서 끝까지 연결해 담아요.

오프셋은 단조 비감소예요. 같은 연속 오프셋은 빈 행을 뜻하고, 디코더는 비단조 오프셋을 손상으로 거부해야 해요. 빈 컬럼(num_rows == 0)은 0바이트를 작성해요 — 오프셋 스트림도 값 스트림도 없어요. 내부 타입은 다른 복합 타입을 포함한 어떤 타입이든 될 수 있어요: 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). 같은 연속 오프셋은 빈 행이에요.

행 [["a", "bb"], []]의 Array(String) (총 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"

행 [[[1,2]], [], [[3], [4,5]]]의 Array(Array(UInt32))는 같은 형태를 중첩해요:

  • 외부 오프셋: [1, 1, 3] — 행 0은 내부 배열 1개, 행 1은 0개, 행 2는 2개.
  • 중간 Array(UInt32)는 오프셋 [2, 3, 5]로 3행을 디코딩.
  • 가장 안쪽 UInt32는 5개 값 [1, 2, 3, 4, 5]를 디코딩.

합계는 24 (외부 오프셋) + 24 (중간 오프셋) + 20 (값) = 68바이트.

Tuple(T1, T2, …)

타입 문자열: Tuple(T1, T2, ..., Tn). 예: Tuple(UInt32, String), Tuple(Int32), Tuple(Array(UInt32), String), Tuple(UInt8, Tuple(Int32, String)). ClickHouse는 Tuple(a UInt32, b String)을 통한 명명된 튜플도 지원해요. 이름은 메타데이터일 뿐이며 와이어 포맷에 영향을 주지 않아요.

와이어 레이아웃은 요소들의 접두사 단계(각 버전 지정 요소가 선언 순서대로 자체 상태 접두사를 기여하고, 비버전 요소는 빈 값) 뒤에 선언 순서대로 요소 타입당 하나씩인 N 개의 연결 스트림이에요:

[element state prefixes]   선언 순서대로; 요소 타입이 버전 지정이 아니면 빈 값
[stream for T1]    num_rows 값에 대한 내부 T1 인코딩
[stream for T2]    num_rows 값에 대한 내부 T2 인코딩
 ...
[stream for Tn]    num_rows 값에 대한 내부 Tn 인코딩

각 스트림은 정확히 num_rows 값을 인코딩해요. 길이 접두사도, 오프셋 스트림도, 스트림 사이 구분자도 없어요. 빈 컬럼(num_rows == 0)은 스트림당 0바이트를 작성해요. 요소 타입은 다른 복합 타입을 포함한 어떤 타입이든 될 수 있어요 — Tuple(Tuple(...), ...), Tuple(Array(...), ...), Tuple(Nullable(T1), T2) 모두 합법이에요.

0 요소 튜플 Tuple()도 합법이에요 — SELECT tuple()이나 CAST(x AS Tuple()) 같은 표현에서 생겨나요. 요소 스트림이 없으므로 대신 Nothing처럼 직렬화해요: 행당 하나의 자리표시자 바이트(0x30, ASCII '0') 을 가지며, 역직렬화기가 버려요. 행 수는 Nothing과 마찬가지로 블록 헤더에서 나와요.

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

레이아웃은 행-우선이 아니에요: 원시 바이트를 다시 읽으면 요소 0으로 [1, 2, 3], 요소 1로 [4, 5, 6]이 나와요.

2행 (10, "a"), (20, "bb")의 Tuple(UInt32, String) (총 13바이트):

Element 0 stream (2 × UInt32 LE = 8 bytes):
0A 00 00 00                  10
14 00 00 00                  20

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

타입 문자열: Map(KeyType, ValueType). 예: Map(String, UInt32), Map(String, Array(UInt32)), Map(UInt8, Tuple(Int32, String)), Map(Array(String), Int8). 와이어 포맷은 두 타입 모두 아무 제한을 두지 않아요 — K와 V 모두 복합 타입을 포함한 어떤 지원 타입이든 될 수 있어요. (허용되는 키 타입에 대한 ClickHouse의 SQL 수준 규칙은 릴리스마다 달라졌어요. 대상 서버 버전의 SQL 문서를 참조하세요.)

와이어 레이아웃은 Array(Tuple(K, V))와 바이트 단위로 동일하므로 내부 접두사 단계(K나 V가 버전 지정이 아니면 빈 값)로 시작해요:

[K/V state prefixes]   내부 Tuple의 접두사 단계에서; K나 V가 버전 지정이 아니면 빈 값
[offsets stream]    num_rows × UInt64 LE                   ← Array에서
[keys stream]       total_pairs 값에 대한 K 인코딩    ┐ Tuple의
[values stream]     total_pairs 값에 대한 V 인코딩    ┘ 요소별 스트림

여기서 total_pairs = offsets[num_rows - 1](num_rows == 0이면 0). 오프셋 스트림은 Array와 같은 의미를 가져요. 키는 값과 위치적으로 정렬돼요: 쌍 i는 (keys[i], values[i])예요.

Map 컬럼의 ClickHouse 인메모리 표현은 튜플의 배열이에요. 타입 시스템은 SQL 편의(m['key'], mapKeys, mapValues)를 위해 그것을 별개 타입으로 표면화해요. 와이어 포맷은 그 저장의 직접 직렬화이므로 Map과 Array(Tuple(K, V))는 바이트 단위로 서로 교환 가능해요.

오프셋은 단조 비감소이고, 키 스트림과 값 스트림 둘 다 정확히 total_pairs 값을 담아요. 빈 컬럼은 0바이트를 작성해요. 단일 행 안에서 키는 보통 고유하지만, 이것은 의미적 규칙이지 와이어로 강제되지는 않아요: 와이어 포맷은 중복 키가 왕복되게 두고, 서버 측 의미가 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의 와이어 표현은 서버 측 flatten_nested 설정에 따라 달라지며, 두 가지 뚜렷한 경우를 줘요.

경우 A: flatten_nested = 1 (서버 기본). 테이블이 기본 설정으로 만들어졌으면 Nested는 와이어 타입이 아니에요. 서버는 컬럼을 점 이름(outer.field1, outer.field2, ...)을 가진 N개의 병렬 Array(T_i) 컬럼으로 저장하고 제시해요. 포맷 레이어에는 새로운 것이 없어요 — 모든 점 컬럼은 일반 Array예요:

DESCRIBE TABLE t   -- t has column n Nested(a UInt8, b String)
id     UInt8
n.a    Array(UInt8)
n.b    Array(String)

경우 B: flatten_nested = 0. 테이블이 flatten_nested = 0으로 만들어졌으면 컬럼은 타입 문자열 Nested(name1 T1, name2 T2, ...)을 가진 단일 컬럼으로 와이어에 나타나고, 타입 문자열 이후의 레이아웃은 Array(Tuple(T1, T2, ..., Tn))과 바이트 단위로 동일해요 — 내부 접두사 단계를 포함하므로, 어떤 버전 지정 필드 T_i도 오프셋 앞에서 자체 상태 접두사를 먼저 내보내요. 아래 예제는 비버전 필드를 사용하므로 접두사 단계는 비어요:

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

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

유일한 차이는 타입-문자열 텍스트예요: Nested는 필드 이름(a, b)을 보존하는데, Array(Tuple)은 그것을 명명된 슬롯으로 나르지 않아요.

경우 B 타입 문자열은 (이름, 타입) 쌍의 콤마 분리 목록이에요. 첫 번째 공백이 이름을 타입과 분리해요. 타입 자체는 추가 공백, 콤마, 괄호를 포함할 수 있으므로, 파싱에는 Tuple에 쓰인 것과 같은 깊이 인식 분리기가 필요해요. 와이어 레이아웃:

[offsets stream]    num_rows × UInt64 LE                       ← Array에서
[field1 stream]     total_elements 값에 대한 T1 인코딩    ┐ Tuple의
[field2 stream]     total_elements 값에 대한 T2 인코딩    │ 요소별
 ...                                                            │ 스트림
[fieldn stream]     total_elements 값에 대한 Tn 인코딩    ┘

여기서 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) (타입 문자열 이후 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"

타입 별칭 (Type aliases)

여러 타입은 순수 별칭이에요: 서버는 컬럼 헤더에서 별칭 이름을 보내지만, 그 뒤에 오는 바이트는 밑바탕 타입의 것이에요. 디코더는 별칭을 그 타입에 맵핑하고 그 코덱을 재사용해요 — 새로운 와이어 포맷이 관련되지 않아요.

지리 타입은 중첩 배열과 튜플에 별칭돼요:

타입 문자열 (Type string) 밑바탕 와이어 타입 (Underlying wire type)
Point Tuple(Float64, Float64)
Ring, LineString, MultiPoint Array(Point)
Polygon, MultiLineString Array(Ring)
MultiPolygon Array(Polygon)

그래서 Point 컬럼은 정확히 Tuple(Float64, Float64)로 디코딩되고((1,2)로 렌더링), Ring은 Array(Tuple(Float64, Float64))([(0,0),(1,1)])로, 계층을 따라 이런 식이에요.

Geometry도 별칭이지만, 중첩 배열이 아니라 Variant에 별칭돼요: 그 페이로드는 위 7개 지리 타입의 variant예요. 컬럼 헤더는 타입 문자열 Geometry만 나르지, variant를 명시하지 않아요 — 그래서 디코더가 스스로 확장해야 해요. 사용자가 쓴 Variant와 달리 Geometry의 판별자(discriminator)는 이름 정렬 순서를 따르지 않아요: 호환성을 위해 고정되어 있고, 새 지리 타입은 끝에만 추가돼요. 매핑은 0 = LineString, 1 = MultiLineString, 2 = MultiPolygon, 3 = Point, 4 = Polygon, 5 = Ring, 6 = MultiPoint (26.7에서 추가 — 오래된 서버의 스트림은 판별자 6을 결코 담지 않아요). 각 선택된 값은 그 후 위의 지리 별칭을 통해 디코딩돼요 (NULL은 Variant NULL 판별자 255를 사용해요).

SimpleAggregateFunction(func, T)는 그 값 타입 T의 별칭이에요. 이미 최종화된 집계 값을 저장하므로, 그 와이어 형태와 렌더링은 정확히 T의 것이에요(SimpleAggregateFunction(sum, UInt64)는 UInt64로 디코딩). 단일 값 타입 형태만 이렇게 별칭이에요. 밑바탕 타입 자체는 복합 타입일 수 있어요.

참고

두 관련 타입은 별칭이 아니에요. 그것들은 유효한 Native 컬럼 타입이에요 — 클라이언트가 -State 조합자나 분산 집계에서 AggregateFunction 컬럼을 받을 수 있지요 — 하지만 각각 이 페이지 범위 밖의 특수한 페이로드를 나르지요:

  • AggregateFunction(func, ...)은 중간 집계 상태(최종화된 값이 아님)를 담아요. 그 이진 레이아웃은 집계 함수와 버전에 특정해요. 함수 이름은 AggregateFunction(topK(3), UInt64)처럼 SQL 리터럴로 각각 인쇄되는 매개변수를 담을 수 있어요. 일부 집계 함수는 AggregateFunction(topK('3'::Decimal32(0)), UInt64)처럼 매개변수에 명시적 ::Type 접미사를 인쇄하는데, Decimal이나 넓은 정수 값은 인쇄할 맨몸 리터럴 형태가 없기 때문이에요. 함수가 그렇게 하는지는 포맷이 아니라 그 함수의 속성이므로, 매개변수를 검사하는 디코더는 두 스펠링을 모두 받아들여야 해요. 인자 타입만 필요한 디코더는 영향을 받지 않아요. 버전 지정 집계 함수는 0이 아닌 상태 버전을 타입 문자열의 첫 번째 인자로 명시해요 — AggregateFunction(1, quantileDeterministic, UInt64, UInt64) — 반면 버전 0은 인쇄되지 않으므로 타입 문자열은 AggregateFunction(quantileDeterministic, UInt64, UInt64)로 남아요. 협상된 연결(0 초과 개정판)에서 작성자는 버전을 프로토콜 개정판 단독으로 고르지요 — 버전 0이 와이어에서 "버전 없음 선언"과 구분되지 않으므로 로컬 타입에 우연히 고정된 버전에서 고르지 않아요 — 따라서 같은 함수가 다른 연결에서 다른 버전을 선언할 수 있고, 리더는 가장 새 것이라고 가정하지 말고 타입 문자열에서 버전을 가져와야 해요. 개정판 0에서는 버전을 유도할 상대가 없어요 — 스트림은 자기-기술적이며 그것을 쓴 사람이 다시 읽어요 — 그래서 타입에 고정된 버전이 0으로 낮춰지지 않고 스트림(그리고 선언된 타입 문자열)에 살아남아요. quantileDeterministic(및 quantilesDeterministic, medianDeterministic 스펠링)은 개정판 54491부터 버전 지정돼요: 버전 1은 상태에 trailing UInt8 skip_degree를 추가하고, 그 접두사는 버전 0과 바이트 단위로 동일해요. 그 아래 개정판에서는 작성자가 버전 0 상태를 내보내므로 오래된 피어는 영향을 받지 않아요. 이 모든 것은 컨테이너 타입 — Array, Tuple, Map, Nullable, Variant(대안의 명시된 버전이 판별자 순서를 바꾸지 않고 타입 문자열을 바꾸는 곳), 또는 Nested / SimpleAggregateFunction 래퍼 — 안에 중첩된 상태에도 동일하게 적용돼요.

  • QBit(T, N[, stride])는 벡터 검색 워크로드를 위해 비트 평면이 전치된 벡터를 저장해요. 그 와이어 스트림 레이아웃(group-major FixedString 비트-평면 스트림, 명시적 stride가 있는 element_size * (N / stride) 개)과 이진 타입 인코딩(tag 0x36, stride != N이면 0x37 QBitWithStride)은 QBit 데이터 타입 페이지와 이진 타입 인코딩 참조에 문서화되어 있으므로, Native 리더는 C++ 소스에서 복구할 필요가 없어요.

버전 지정 타입 (Versioned types)

버전 지정 타입은 이어지는 인코딩의 어떤 변형인지 선언하는 와이어 직렬화-버전 접두사를 나르지요. 복합 타입처럼 여러 스트림을 사용할 수도 있어요. Native 와이어에서 접두사와 모든 딕셔너리는 블록별이에요 — 이 타입들은 블록 간 상태를 유지하지 않아요( 아래 블록별 접두사 참고 참조). 블록 간 직렬화 상태는 MergeTree 온디스크 스트림에만 존재해요.

이 타입들은 고정 형태 복합 타입보다 상당히 복잡하며, 단순 분석 쿼리를 목표로 하는 클라이언트는 미룰 수 있어요.

직렬화 버전: 개념 (Serialization version: concept)

직렬화 버전은 보내는 쪽이 사용하는 타입 인코딩의 어떤 변형인지 선언하는 타입별, 컬럼별 와이어 버전 번호예요. 그것은 컬럼의 상태 접두사에서 첫 번째 것이므로, 디코더가 읽고 나머지 컬럼의 올바른 파서로 분기해요.

그것은 프로토콜 버전과 구별돼요:

차원 (Dimension) 프로토콜 버전 (Protocol version) 직렬화 버전 (Serialization version, 이 섹션)
범위 (Scope) 연결 전체 타입별, 컬럼별
협상됨 (Negotiated) 있음, 핸드셰이크에서 없음 — 보내는 쪽이 쓰고, 받는 쪽이 읽음
제어 (Controls) 어떤 패킷 수준 기능이 활성인지 한 타입의 어떤 와이어 변형인지
읽기 필수 (Mandatory to read) 예 예, 각 버전 지정 컬럼에 대해

대부분의 버전 지정 타입은 버전을 다른 상태-접두사 데이터 바로 앞에 리틀 엔디언 UInt64로 작성해요. 일부는 VarUInt나 UInt8을 사용해요. 디코더는 버전을 먼저 읽고 알 수 없는 값을 거부해요 — 더 높은 버전은 디코더가 이해하지 못하는 더 새로운 보내는 쪽 포맷을 암시하고, 잘못 파싱하면 이어지는 모든 바이트를 손상시켜요.

상태 접두사는 행 수가 0보다 큰 모든 블록의 시작, 해당 블록의 페이로드 바로 앞에 내보내져요.

Native 작성자와 리더는 블록 간 직렬화 상태를 유지하지 않아요: NativeWriter는 쓰는 각 비어 있지 않은 컬럼 블록에 대해 새 serialize 상태를 만들고 상태 접두사를 작성하고, NativeReader는 읽는 각 비어 있지 않은 블록에 대해 새 deserialize 상태를 만들고 읽어요 (둘 다 rows == 0이면 접두사를 완전히 건너뛰어요).

헤더 블록(rows = 0)과 빈 블록은 따라서 아무것도 내보내지 않고, 디코더는 각 비어 있지 않은 블록의 시작에서 상태 접두사를 다시 읽어야 해요. 접두사를 한 번만 읽고 이후 블록을 페이로드 전용으로 취급하는 디코더는 다음 블록의 접두사를 데이터로 읽어 동기화를 잃어요.

직렬화 버전 참조 (Serialization version reference)
타입 (Type) 필드 너비 (Field width) 값 (Value) 이름 (Name) 의미 (Meaning)
Object** (JSON의 기반) UInt64 LE 0 V1 원래 인코딩. max_dynamic_paths 매개변수와 dynamic path 목록 포함.
1 STRING Native 포맷 호환 모드 — Object가 JSON 텍스트를 담은 단일 String 컬럼으로 전송.
2 V2 max_dynamic_paths 매개변수가 없는 V1 레이아웃.
3 FLATTENED Native 포맷 호환 모드 — 펼쳐진(flattened) path 표현.
4 V3 공유 데이터 직렬화 버전 하위 필드와 통계 플래그가 추가된 V2.
Object 공유 데이터** (Object V3에 쓰이는 하위 스트림) VarUInt 0 MAP Map(String, String)으로 인코딩된 공유 데이터.
1 MAP_WITH_BUCKETS MAP과 동일하지만 스캔 효율을 위해 N 버킷으로 분할.
2 ADVANCED paths / marks / metadata를 위한 별도 스트림이 있는 컴팩트 granule 포맷.
Dynamic** UInt64 LE 1 V1 원래 인코딩. max_dynamic_types와 런타임 variant 타입 목록 포함.
2 V2 max_dynamic_types 매개변수가 없는 V1.
3 FLATTENED Native 포맷 호환 모드.
4 V3 이진 인코딩 variant 타입 이름과 빈 통계 지원이 추가된 V2.
Variant** 판별자 모드 UInt64 LE 0 BASIC 모든 행의 판별자가 문자 그대로 작성됨.
1 COMPACT granule의 모든 행이 한 판별자를 공유하면 단일 값 + granule 마커만 작성.
Variant** granule 포맷 (모드가 COMPACT일 때) UInt8 0 PLAIN granule이 이질적 판별자를 가짐.
1 COMPACT granule이 모든 행에 대해 한 판별자를 가짐.
LowCardinality** 키 직렬화 Int64 1 sharedDictionariesWithAdditionalKeys 현재 정의된 유일한 버전.
JSON-as-String** 폴백 (output_format_native_write_json_as_string 활성화 시) UInt64 LE 1 JSONStringSerializationVersion JSON 컬럼이 이 접두사가 앞에 붙은 String 컬럼으로 도착.

표에 대해 주목할 몇 가지:

  • 값은 연속적이지 않아요. Dynamic은 1, 2, 3, 4를 사용하며 V3이 4, FLATTENED가 3이에요. 더 높은 숫자가 더 새로운 것은 아니에요.
  • 일부 값은 native-포맷 전용이에요. Object::STRING, Object::FLATTENED, Dynamic::FLATTENED는 완전한 Object/Dynamic을 구현하지 않는 클라이언트와의 네이티브 프로토콜 호환을 위해 존재해요. MergeTree 온디스크 저장에는 나타나지 않아요.
  • V3는 주로 온디스크예요. 네이티브 TCP 프로토콜을 소비하는 클라이언트는 보통 V3(값 4)보다 FLATTENED(값 3)를 봐요.
LowCardinality(T)

가장 단순한 버전 지정 타입. N개의 내부 값을 작은 고유 값 딕셔너리 + 그 딕셔너리에 대한 N개의 인덱스로 대체해요.

타입 문자열: LowCardinality(InnerType). 예: LowCardinality(String), LowCardinality(FixedString(4)), LowCardinality(Nullable(String)).

[rows > 0인 블록마다]:
  [8 bytes:  Int64 LE state prefix = 1]             ← 모든 비어 있지 않은 블록의 시작에서 반복
  [8 bytes:  UInt64 LE metadata]                    ← 키 타입 코드 (낮은 바이트) + 플래그 비트
  [8 bytes:  UInt64 LE dict_size]                   ← 딕셔너리 항목 수 (자리표시자 슬롯 포함)
  [N bytes:  dict values]                           ← dict_size 값에 대한 내부 타입 인코딩
  [8 bytes:  UInt64 LE keys_count]                  ← 이 재귀 수준의 값 수 (아래 참조)
  [K bytes:  keys]                                  ← 키당 (1 << key_type_code) 바이트

상태 접두사(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 작성자는 low_cardinality_max_dictionary_size = 0을 사용하고, Native 리더는 이 비트를 거부해요(native_format은 INCORRECT_DATA — "cannot use global dictionary"을 발생). MergeTree 온디스크 스트림에 속하지, 와이어에 속하지 않아요.
9 (0x200) HasAdditionalKeysBit — 블록이 (인덱스 앞에 작성되는) 추가 딕셔너리 키를 나를 때 설정. 비어 있지 않은 Native 블록에 대해 항상 설정.
10 (0x400) NeedUpdateDictionary — 블록이 딕셔너리 갱신을 나를 때 설정. 각 블록이 자체적으로 완결된 딕셔너리를 싣기 때문에 비어 있지 않은 Native 블록에 대해 항상 설정.

컬럼당 단일 데이터 블록이 있는 전형적인 쿼리 응답에서 메타데이터는 0x600(HasAdditionalKeys + NeedUpdateDictionary)이에요.

딕셔너리 값은 내부 타입 T로 인코딩된 dict_size 값이에요. 딕셔너리는 특수 값에 선두 슬롯을 예약해요: 널이 아닌 컬럼은 하나를 예약하고(dict[0]이 내부 타입의 기본 값, 예: String은 ""), 실제 고유 값은 dict[1]에서 시작돼요.

LowCardinality(Nullable(T))의 경우 딕셔너리는 여전히 평범한 T로 인코딩되고(null-map 스트림 없음), 하지만 두 슬롯이 예약돼요: dict[0]은 NULL 마커이고 dict[1]은 내부 타입의 기본 값(예: String은 ""), 실제 고유 값은 dict[2]에서 시작돼요. NULL 행의 키는 dict[0]을 가리키고, 그 슬롯은 와이어에서 내부 타입의 기본 바이트로 작성돼요.

키는 딕셔너리에 대한 인덱스예요. 각 인덱스는 1 << key_type_code 바이트(1, 2, 4, 또는 8)이고, 값 N은 dict[keys[N]]으로 재구성돼요.

keys_count는 현재 재귀 수준의 LowCardinality 값 수이며, 반드시 블록의 행 수는 아니에요. 최상위 LowCardinality 컬럼에서는 둘이 일치해요. 하지만 LowCardinality가 복합 타입 아래 있으면 그 개수는 복합 타입이 전달하는 펼쳐진 값 수예요: 세 행이 총 다섯 요소를 담는 Array(LowCardinality(String))의 경우 keys_count는 3이 아니라 5예요. Map(K, LowCardinality(V))에서는 총 쌍 수이고, 그런 식이에요. 디코더는 블록 행 수를 가정하지 말고 이 필드에서 keys_count를 가져와야 해요. 그 펼쳐진 개수가 0일 때 — 예를 들어 배열이 모두 빈 블록 — LowCardinality 데이터 단계는 아무것도 작성하지 않아요: ( 복합 접두사 단계에서 내보내진) 상태 접두사만 있고, 메타데이터, 딕셔너리, keys_count가 이어지지 않아요.

상태 접두사는 행 수가 0보다 큰 모든 블록의 시작에서 읽혀요 — 헤더 블록(rows = 0)과 빈 블록은 아무것도 내보내지 않아요. 블록 안에서 keys_count는 행 수와 같고, dict_size는 딕셔너리 스트림의 값 수와 같으며, 각 키는 1 << key_type_code 바이트에 들어맞아요.

참고

Native 포맷에서 각 블록은 자체 포함된 블록-로컬 딕셔너리를 싣지요 — 블록 간 딕셔너리 상태가 없어요. Native 작성자는 low_cardinality_max_dictionary_size = 0을 설정하므로 SerializationLowCardinality는 결코 공유 딕셔너리를 만들지 않아요: 모든 비어 있지 않은 블록은 NeedGlobalDictionaryBit가 설정되지 않은(메타데이터 0x600) 블록-로컬 추가 키로 자신의 키를 작성하고, Native 리더는 native_format이 true일 때 NeedGlobalDictionaryBit를 거부해요. 따라서 디코더는 각 블록에서 딕셔너리를 재설정하고 그 블록에 존재하는 dict_size 항목을 읽어야 해요. 이전 블록에서 딕셔너리를 가져오면 다음 블록의 키를 잘못 읽을 거예요. (LC 딕셔너리를 블록 간 유지하는 것은 MergeTree 온디스크 관심사이지, Native 와이어 레이아웃이 아니에요.)

값 ['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] 둘 다 와이어에서 빈 바이트예요. null-ness는 슬롯 0을 가리키는 키에서 나오지, 바이트에서 나오지 않아요.

JSON (Tier 1: String 폴백)

ClickHouse JSON 타입에는 여러 와이어 인코딩이 있어요( 직렬화 버전 참조 참조). Tier 1이 가장 단순해요: 쿼리별 설정 output_format_native_write_json_as_string = 1이 설정되면 서버는 모든 JSON 값을 직렬화된 텍스트로 펼치고 컬럼을 상태-접두사 마커가 있는 String으로 내보내요.

타입 문자열: JSON.

[8 bytes:  Int64 LE state prefix = 1]        ← JSONStringSerializationVersion
[rows > 0인 블록마다]:
  [N bytes: num_rows 개의 JSON 텍스트 값에 대한 String 컬럼 인코딩]

상태 접두사 값은 이 String 폴백의 경우 1이에요. 다른 값은 다른 JSON/Object 인코딩을 나타내요: 0 = V1, 2 = V2 (네이티브 TCP 프로토콜에서 기본), 3 = FLATTENED, 4 = V3 ( 직렬화 버전 참조 참조). 여기서 1이 아닌 값을 보는 디코더는 String 폴백을 보고 있지 않아요. 접두사는 rows > 0인 모든 블록의 시작에서 읽히고, 값 스트림은 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을 받지만 개별 path와 그 ClickHouse 타입을 복구하지 않아요. 충실한 path별 타이핑에는 아래 Tier 2 인코딩이 필요해요.

Variant(T1, T2, …)

판별된(discriminated) 합집합: 각 행은 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만 명세돼요.

[rows > 0인 블록마다]:
  [8 bytes:  UInt64 LE discriminators mode = 0]    ← 상태 접두사, 모든 비어 있지 않은 블록의 시작에서 반복;
                                                     각 variant 요소의 자체 상태 접두사가 뒤따름
                                                     (리프 타입은 빈 값)
  [num_rows bytes: UInt8 discriminators]           ← 행당 전역 판별자 하나; 255 = NULL
  [각 variant 타입 i에 대해, 선언 순서대로]:
    [discriminator == i인 행에 대한 값] ← 타입 i의 조밀 인코딩; 개수 = i를 선택한 행 수

재구성하려면 판별자를 왼쪽에서 오른쪽으로 걷면서 타입별 진행 카운터를 유지해요. 판별자 d(≠ 255)를 가진 행 r은 variant 타입 d의 값 실행에서 인덱스 counter[d]의 값을 가져온 후 counter[d]가 증가해요. 판별자 255를 가진 행은 NULL이고 어떤 실행에서도 값을 소비하지 않으므로, 타입별 카운터의 합은 비-NULL 행 수와 같아요.

상태 접두사(모드 UInt64)는 rows > 0인 모든 블록의 시작에서 읽히고, 헤더와 빈 블록은 아무것도 내보내지 않아요. 각 비-NULL 판별자는 variant 타입 수보다 작고, variant 타입 i는 정확히 count[i] 행에 대해 디코딩돼요.

참고

그 자체로 상태 유지인 Variant 요소(LowCardinality, Variant, Dynamic, JSON)는 모드 UInt64 뒤의 요소별 상태-접두사 단계에서 자체 상태 접두사를 내보내요. 리프 타입과 일반 복합 타입(리프 타입의 Array, Tuple, Map)은 빈 상태 접두사를 갖고 자유롭게 합성돼요.

값 [42, 'hi', NULL]의 Variant(String, UInt64) (정규화 순서는 String을 UInt64 앞에 정렬하므로 판별자 0 = String, 1 = UInt64):

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

재구성: 행 0 = UInt64 실행[0] = 42; 행 1 = String 실행[0] = "hi"; 행 2 = NULL.

판별자 스트림이 인덱스예요. 각 비-NULL 판별자는 자신의 타입 조밀 실행에서 다음 값을 끌어오고, 255(NULL)는 아무것도 소비하지 않아요. 이 같은 걷기는 Dynamic을 재구성하는데, NULL이 인코딩되는 방식만 다를 뿐이에요.

Dynamic

값 타입이 런타임에 발견되는 컬럼: 각 행은 런타임에 결정된 타입 집합 중 하나의 값, 또는 NULL을 담아요. Variant와 달리 타입 집합은 컬럼의 타입 문자열에 없어요 — 상태 접두사에 나르지요.

타입 문자열: Dynamic 또는 Dynamic(max_types=N). max_types 매개변수는 컬럼이 추적하는 고유 타입 수를 제한하지만 아래 와이어 포맷에는 영향을 주지 않아요.

Dynamic에는 네 가지 인코딩 — V1 = 1, V2 = 2, FLATTENED = 3, V3 = 4 — 이 있어요. 서버가 어느 것을 내보낼지는 채널과 쿼리 설정에 따라 달라져요:

  • clickhouse-client와 HTTP FORMAT Native에서 작성자의 개정판은 0(client_protocol_version으로 올리지 않으면)이므로 기본은 V1이에요.
  • 네이티브 TCP 프로토콜의 협상된 개정판에서는 기본이 V2예요. Native 작성자는 통계를 비활성화하므로 기본 V2 페이로드는 variant별 통계를 나르지 않아요 — 타입 목록 뒤에 중첩 Variant 접두사와 데이터가 직접 와요. (Variant별 통계는 MergeTree 온디스크 관심사이지, Native 와이어의 일부가 아니에요.)
  • 쿼리 설정 output_format_native_use_flattened_dynamic_and_json_serialization = 1은 개정판과 무관하게 둘 다 덮어쓰고 FLATTENED (버전 3) 을 내보내요.

범위

이 페이지는 FLATTENED 레이아웃만 명세해요. 비평면 V1/V2/V3 이진 레이아웃은 내부/온디스크 표현(이진 인코딩 타입 목록, variant별 통계)이며 여기서 명세되지 않아요. 이 페이지로 Dynamic을 디코딩하려는 클라이언트는 output_format_native_use_flattened_dynamic_and_json_serialization = 1을 설정해 FLATTENED를 요청해야 해요. 아래 레이아웃은 그 설정을 가정해요. 버전 바이트가 접두사를 이끄므로, 디코더는 받은 실제 인코딩을 감지하고 FLATTENED만 구현하면 V1/V2/V3을 거부할 수 있어요.

그 설정이 선택하는 FLATTENED (버전 3) 레이아웃:

[rows > 0인 블록마다]:
  [8 bytes:  UInt64 LE version = 3]                ← 상태 접두사, 모든 비어 있지 않은 블록의 시작에서 반복
  [VarUInt num_types]                              ← 런타임 타입 수
  [num_types × type]                               ← 타입 이름, 와이어 순서대로; 각각 String, 또는
                                                     output_format_native_encode_types_in_binary_format = 1일 때 이진 타입 인코딩
  [type마다: 자체 상태 접두사]                 ← 리프 타입은 빈 값; + indexes-type 접두사 (빈 값, 정수)
  [num_rows × discriminator]                       ← num_types에 의한 너비 (≤ 255면 UInt8, 아니면 UInt16/32/64);
                                                     NULL 판별자 = num_types (마지막 타입의 하나 뒤)
  [각 타입 i에 대해, 와이어 순서대로]:
    [discriminator == i인 행에 대한 값] ← 타입 i의 조밀 인코딩

판별자 너비는 num_types 타입에 NULL 슬롯을 더해 인덱싱할 수 있는 가장 작은 부호 없는 정수예요 — num_types ≤ 255면 UInt8, 그 다음 UInt16, UInt32, UInt64. NULL은 판별자 값 num_types 자체로, Variant의 NULL이 고정 값 255인 것과 달라요. 재구성은 Variant와 같은 조밀 걷기예요: 타입별 카운터를 유지하고, 판별자 d(≠ num_types)를 가진 행 r은 타입 d의 실행에서 값 counter[d]를 가져와요.

상태 접두사(버전 + 타입 목록)는 rows > 0인 모든 블록의 시작에서 읽히고, 헤더와 빈 블록은 아무것도 내보내지 않아요.

잘못된 개수

num_types는 어떤 타입 이름보다 먼저 스트림에서 읽혀요. 디코더는 그것을 신뢰할 수 없게 취급하고 직접 할당 크기를 정하는 데 사용하면 안 돼요 — 중간 산술을 오버플로하거나 비-DB::Exception을 던질 수 있는 SIZE_MAX에 가까운 개수도, 벡터의 max_size()보다 훨씬 작지만 단일 타입 이름이 읽히기 전에 수 기가바이트를 할당할 100000000 같은 크고도 표현 가능한 개수도 말이에요. ClickHouse는 상한이 있는 사전 할당 힌트와 함께 타입 목록을 한 번에 하나씩 읽으므로, 손상된 num_types는 INCORRECT_DATA("Dynamic column has too many types", 개수가 컨테이너가 담을 수 있는 것을 초과할 때) 또는 스트림이 타입 항목을 다 소진하면 보통의 읽기 오류로 거부돼요 — 절대 메모리 부족 오류로는 거부되지 않아요.

그러나 flattened num_types를 ColumnDynamic::MAX_DYNAMIC_TYPES_LIMIT(ClickHouse에서 254)로 제한하면 안 돼요: 펼쳐진 타입 목록은 공유 variant로 오버플로했던 것을 포함한 모든 고유 런타임 타입을 나르므로, 유효한 flattened 블록은 그보다 훨씬 더 많이 합법적으로 나열할 수 있어요. MAX_DYNAMIC_TYPES_LIMIT 경계는 비평면 V1/V2/V3 접두사의 num_dynamic_types 개수에만 적용되는데, 그것은 일반 variant 슬롯을 세고 그 한계로 상한이 정해져요 (ClickHouse는 공유 variant의 + 1 전에 그것을 검증해요).

참고

직렬화가 상태 유지인 런타임 타입(LowCardinality, Variant, Dynamic, JSON)은 타입-이름 목록 뒤에 중첩 상태 접두사를 나르지요.

런타임 타입 목록은 보통 Variant 정규화를 따라요 — 일반 variant 슬롯은 DataTypeVariant(타입-이름) 순서로 작성되므로, 와이어 순서는 삽입 순서를 따르지 않아요. 그러나 항상 전역적으로 정렬되지는 않아요: 공유 variant로 오버플로한 타입(예: Dynamic(max_types=N) 아래)은 일반 슬롯 뒤에 첫-발견 순서로 추가되므로 목록의 꼬리가 타입-이름 순서를 깨뜨릴 수 있어요. 따라서 디코더는 판별자 할당에 전송된 타입 목록을 권위로 취급하고 자체적으로 다시 정렬하면 안 돼요. 행 [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 실행[0] = 42; 행 1 = String 실행[0] = "hi"; 행 2 = NULL. 타입별 실행은 타입 목록과 같은 와이어 순서(String가 UInt64 앞)를 따르지요.

JSON (Tier 2: FLATTENED Object)

더 풍부한 JSON 인코딩: 모든 값을 텍스트로 펼치는(Tier 1) 대신, 컬럼을 JSON path당 하나의 하위 컬럼으로 나눠요. Tier 1 폴백을 요청하지 않는 것(output_format_native_write_json_as_string = 0) + flattened 직렬화 플래그가 켜진 것(output_format_native_use_flattened_dynamic_and_json_serialization = 1)으로 선택되며, 서버는 그때 직렬화 버전 3을 내보내요.

두 종류의 path가 있어요:

  • Typed paths는 타입 문자열에 선언돼요, 예를 들어 JSON(a UInt32, b String), 그리고 선언된 타입으로 디코딩돼요. 점을 포함하는 path 이름은 타입 문자열에서 백틱으로 인용돼요.
  • Dynamic paths는 런타임에 발견되고 각각 Dynamic 컬럼으로 디코딩돼요.

FLATTENED 모드에는 공유 데이터 컬럼이 없어요 (그 오버플로 저장소는 비평면 V2/V3 Object 인코딩에 속해요). 모든 path는 num_rows 값의 완전한 컬럼이에요.

[rows > 0인 블록마다]:
  -- 접두사 단계 (모든 비어 있지 않은 블록의 시작에서 반복):
  [8 bytes:  UInt64 LE version = 3]                ← 상태 접두사
  [VarUInt num_dynamic_paths]
  [num_dynamic_paths × String]                     ← dynamic path 이름, 와이어 순서대로
  [각 typed path: 그 컬럼의 상태 접두사]      ← 리프 타입은 빈 값
  [각 dynamic path: Dynamic 상태 접두사]       ← 버전 + 타입 목록 (Dynamic 참조)
  -- 데이터 단계:
  [각 typed path:   그 컬럼의 데이터]       ← 선언된 타입의 num_rows 값
  [각 dynamic path: 그 Dynamic 데이터]        ← num_rows 값 (판별자 + 실행)

두 단계 형태에 주목하세요: 모든 path 상태 접두사가 먼저 오고, 그 다음 모든 path 데이터가 와요. 따라서 dynamic path의 Dynamic 접두사(접두사 단계에서)는 그 데이터(데이터 단계에서)와 분리돼요. 상태 접두사는 rows > 0인 모든 블록의 시작에서 읽히고, 모든 path 컬럼(typed든 dynamic이든)은 정확히 num_rows 값을 담아요. 행 r의 객체는 각 path의 값(인덱스 r)을 읽어 조립되고, 그 행에서 판별자가 NULL인 dynamic path는 키를 기여하지 않아요.

잘못된 개수

여기 문서화된 FLATTENED 레이아웃의 num_dynamic_paths — 그리고 비평면 V1/V2/V3 인코딩의 dynamic-paths 개수 — 는 path 이름보다 먼저 스트림에서 읽혀요. (비평면 접두사에는 별도의 flattened-paths 필드가 없어요: V1/V2/V3은 dynamic-paths 개수만 나르고, V1에서는 읽고 버려지는 max_dynamic_paths 값과, 아래 V3 공유 데이터 메타데이터를 나르지요.) Dynamic처럼, 디코더는 이 개수들을 신뢰할 수 없게 취급하고 직접 할당 크기를 정하는 데 사용하면 안 돼요 — SIZE_MAX-계열 개수도 크고도 표현 가능한 개수도 말이에요. ClickHouse는 상한이 있는 사전 할당 힌트와 함께 path 이름을 한 번에 하나씩 읽으므로, 손상된 개수는 INCORRECT_DATA("JSON/Object column has too many paths", 컨테이너가 담을 수 있는 것을 초과할 때) 또는 스트림이 path 이름을 다 소진하면 보통의 읽기 오류로 거부돼요.

비평면 V3 접두사는 추가로 shared_data_buckets 개수를 나르지요(공유 데이터 직렬화 버전이 MAP_WITH_BUCKETS 또는 ADVANCED일 때). 그것은 버킷별 리더 상태와 컬럼 벡터를 (grow-on-demand 루프가 아니라) 직접 크기 정해요. 따라서 디코더는 말도 안 되는 버킷 개수를 미리 거부해야 해요. path와 타입 개수와 달리, 이 개수는 작성자 측 불변식이 빡빡해요: 버킷 수는 0이 아니고 256으로 상한이 정해진 작은 MergeTree 설정(object_shared_data_buckets_for_compact_part / object_shared_data_buckets_for_wide_part)에서 선택되므로, 유효한 와이어 범위는 1 … 256뿐이에요. ClickHouse는 그 범위 밖의 어떤 값 — 컨테이너의 max_size()보다 훨씬 작은 100000 같은 크고도 표현 가능한 개수 포함 — 을 INCORRECT_DATA("JSON/Object column has an invalid number of shared data buckets")로 거부해요.

JSON 값 {"a": 42, "b": "hi"} (행 1개, 두 path 모두 dynamic). JSON 정수는 Int64로 추론돼요:

03 00 00 00 00 00 00 00      version = 3 (Object)
02                           num_dynamic_paths = 2
01 61                        path "a"
01 62                        path "b"
03 00 00 00 00 00 00 00 01 05 49 6E 74 36 34      "a" Dynamic prefix: version 3, 1 type, "Int64"
03 00 00 00 00 00 00 00 01 06 53 74 72 69 6E 67   "b" Dynamic prefix: version 3, 1 type, "String"
00 2A 00 00 00 00 00 00 00   "a" data: discriminator 0, Int64 42
00 02 68 69                  "b" data: discriminator 0, String "hi"
JSON 비평면 (V2/V3)

비펼침 Object 인코딩(V1/V2/V3)은 MergeTree 온디스크 저장이 사용하며, flattened 플래그가 꺼져 있을 때 서버가 와이어로 내보내는 것이에요 — V1은 clickhouse-client / HTTP FORMAT Native(개정판 0)에서, V2는 네이티브 TCP 프로토콜에서. 그것들은 공유 데이터 컬럼을 나르고 이 페이지에서 명세되지 않아요. 주의: Native 와이어에서는 path별 통계를 나르지 않아요: NativeWriter는 통계를 비활성화하므로 Object 구조 접두사에 통계 섹션이 없고, 그 뒤의 바이트는 typed/dynamic/shared-data 접두사와 데이터가 직접 돼요. 통계는 그것을 활성화하는 MergeTree 온디스크 경로에만 나타나요. 이 페이지로 JSON 컬럼을 디코딩하려면 클라이언트는 문서화된 tier 중 하나를 선택해야 해요: String 폴백을 위해 output_format_native_write_json_as_string = 1을 설정하거나, FLATTENED Object 레이아웃을 위해 output_format_native_use_flattened_dynamic_and_json_serialization = 1(output_format_native_write_json_as_string = 0 포함)을 설정해요.

압축 프레임 (Compression frame)

ClickHouse는 내부 프레임 포맷으로 Native 스트림의 컬럼 데이터를 압축할 수 있어요. 아래 프레임 레이아웃은 전송 독립적이에요 — 같은 프레임이 네이티브 TCP 프로토콜과 HTTP 양쪽에 나타나요 — 하지만 압축이 어떻게 요청되는지, 그리고 프레임을 둘러싸는 것이 무엇인지는 전송에 따라 달라요.

  • 네이티브 TCP 프로토콜. 압축은 Query 패킷의 compression 플래그를 통해 쿼리별로 선택적이에요. 활성화되면 각 Data, Totals, Extremes, Log, ProfileEvents 패킷의 본문 — table_name 문자열 다음의 바이트 — 이 프레임 포맷으로 감싸져요. 패킷 봉투 자체, 패킷-타입 코드와 table_name 문자열은 압축되지 않아요; 서버는 그것들을 원시 스트림에 작성해요. NativeWriter가 내보내는 모든 것은 압축 스트림으로 들어가므로, BlockInfo 접두사가 dimensions와 columns와 함께 프레임 안의 첫 번째 것이에요. 따라서 클라이언트는 BlockInfo를 읽기 전에 프레임을 압축 해제해야 해요.
  • HTTP. SELECT ... FORMAT Native&compress=1은 전체 FORMAT Native 바이트 스트림을 같은 프레임으로 감싸고(서버는 같은 내부 CompressedWriteBuffer를 사용), ?decompress=1은 Native 입력 본문에서 같은 프레임을 기대해 matching CompressedReadBuffer를 통해 디코딩해요. 이 경로에는 TCP 패킷 타입, table_name, 패킷 봉투가 없어요: 전체 압축 페이로드는 그저 프레이밍된 Native 블록일 뿐이에요 (BlockInfo 접두사는 협상된 개정판이 0보다 클 때만 존재하며, 위 압축되지 않은 레이아웃과 정확히 같아요). 이 내부 compress/decompress 프레이밍은 HTTP 전송 압축(Content-Encoding: gzip/zstd, enable_http_compression으로 활성화)과 구별돼요. 후자는 HTTP 레이어에서 응답을 감싸며 아래 프레임 포맷이 아니에요.

따라서 압축되지 않은 FORMAT Native 레이아웃만 구현한 클라이언트는 압축된 HTTP Native 응답을 읽거나 decompress=1 요청 본문을 보내려면 여전히 이 프레임 레이어를 추가해야 해요.

프레임 포맷 (Frame format)

[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] ← 9바이트 헤더 포함, 16바이트 체크섬 제외
[4 bytes:  uncompressed_size LE u32]
[N bytes:  compressed body]        ← N = compressed_size - 9

총 프레이밍된 크기는 16 + compressed_size = 16 + 9 + body_size = 25 + body_size예요. 두 범위를 주목하세요: 체크섬은 9바이트 헤더 더하기 본문을 덮는 반면, compressed_size는 헤더 더하기 본문을 세지만 체크섬 자체는 세지 않아요:

방법 바이트 값 (Method byte values)

서버가 전체 스트림 Native 프레이밍을 위해 만드는 코덱은 다음 세 가지예요: HTTP compress=1 출력은 서버의 기본 코덱(ZSTD(3))을 사용하고, 네이티브 TCP 프로토콜은 network_compression_method에 따라 LZ4, ZSTD, NONE을 사용해요. 일반 Native 클라이언트는 이들만 만들어 내고 소비하면 돼요.

바이트 (Byte) 방법 (Method) 본문 인코딩 (Body encoding)
0x02 NONE 본문이 원시 바이트 (압축 없음). 프레임은 여전히 내보내지고, 수신자가 체크섬을 검증.
0x82 LZ4 본문이 LZ4 블록 포맷 — LZ4 프레임 포맷이 아님. 매직 넘버 없음.
0x90 ZSTD 본문이 원시 zstd 단일 프레임 스트림 (표준 zstd 매직 넘버가 본문의 일부).

방법 바이트는 컬럼 수준 코덱도 인코딩해요. 이것들은 전체 스트림 프레이밍보다는 MergeTree 온디스크 경로에서 컬럼별로 적용되지만, decompress=1 HTTP 입력 경로는 각 프레임의 방법 바이트에서 코덱을 가져오므로 이 바이트 중 어떤 것이든 입력에 합법적으로 나타날 수 있어요. 따라서 규정에 맞는 디코더는 할당된 전체 공간을 인식하고, 본문을 잘못 읽기보다는 구현하지 않는 바이트를 거부해야 해요. 그들의 본문은 코덱 특유이고 이 일반 프레임 계약 밖이에요:

바이트 (Byte) 방법 (Method)
0x91 Multiple (중첩 코덱 시퀀스를 감싸는 복합 코덱)
0x92 Delta
0x93 T64
0x94 DoubleDelta
0x95 Gorilla
0x96 AES_128_GCM_SIV (암호화)
0x97 AES_256_GCM_SIV (암호화)
0x98 FPC
0x9a GCD
0x9c ALP
0x9d SZ3
0x9e Quantized
0x9f ZXC

DoubleDelta(0x94)는 2차 차분을 MSB-우선 부호/부호/크기 비트로 저장해요. 단어 기반 디코딩은 이 인코딩, 요소 수, 그리고 마지막 바이트 패딩을 보존해요.

0x9c(ALP)는 Float32와 Float64용 베타 무손실 코덱(적응형 무손실 부동소수점 압축)이에요. enable_alp_codec 설정이 설정돼 있어야 테이블을 CODEC(ALP)로 만들 수 있지만, 이전에 작성된 데이터를 읽을 수 있게 유지하기 위해 방법 바이트는 압축 해제에서 항상 받아들여져요. 0x9d(SZ3)는 Float32, Float64, 그리고 그 타입의 Array용 실험적, 오류-경계 손실 코덱이에요. enable_sz3_codec 설정이 설정돼 있어야 테이블을 CODEC(SZ3)로 만들 수 있지만, 방법 바이트는 압축 해제에서 항상 받아들여져요. 0x9f(ZXC)는 실험적 비대칭 LZ 코덱이에요: 압축은 느리지만, LZ4와 ZSTD 사이의 비율로 압축 해제는 매우 빠르지요. enable_zxc_codec 설정이 설정돼 있어야 테이블을 CODEC(ZXC)로 만들 수 있지만, 방법 바이트는 압축 해제에서 항상 받아들여져요. 0x99(DeflateQpl)와 0x9b(ZSTD_QPL) 바이트는 이후 제거된 코덱에 할당됐어요. 예약되어 재사용되지 않아요.

0x9e(Quantized)는 조밀한 벡터 컬럼(Array(Float32) 등)용 실험적 컬럼 코덱이에요. NONE처럼 통과(passthrough)예요 — 전체 정밀도 본문이 그대로 저장돼요 — 하지만 그 존재는 벡터 검색을 가속화하는 데 쓰이는 컴팩트 양자화 동반 스트림을 작성하는 직렬화를 붙여요. enable_quantized_codec 설정이 설정돼 있어야 테이블을 CODEC(Quantized(...))로 만들 수 있고, 방법 바이트는 압축 해제에서 항상 받아들여져요.

체크섬 (Checksum)

ClickHouse는 CityHash v1.0.2(역사적 변형)를 사용해요 — 현대 Google CityHash가 아니에요. 둘은 다른 출력을 만들어요.

체크섬은 9개 헤더 바이트(method + compressed_size + uncompressed_size) 더하기 N개 본문 바이트 — 체크섬과 프레임 끝 사이의 모든 것 — 위에 계산돼요. 16바이트 CityHash128 출력의 처음 8바이트는 낮은 절반(LE), 다음 8바이트는 높은 절반(LE)이에요. 디코더는 받은 헤더와 본문 위에 CityHash128을 다시 계산해 선두 16바이트와 비교해요. 불일치는 손상이며 디코더가 실패해요.

블록별 경계 (Per-block boundaries)

Block의 압축 페이로드는 하나 이상의 프레임 스트림이지, 반드시 단일 프레임은 아니에요. 보내는 쪽은 CompressedWriteBuffer를 통해 직렬화된 블록을 작성하는데, 내부 버퍼가 채워질 때마다(≈1 MB, DBMS_DEFAULT_BUFFER_SIZE) 프레임을 내보내고 블록이 플러시될 때 마지막 프레임을 내보내요. 그래서 작은 블록은 프레임 하나, 큰 블록은 연속된 여러 프레임이에요.

불변식은 한 방향으로만 작동해요: 보내는 쪽이 각 블록 끝에서 압축 버퍼를 플러시하므로, 모든 블록 끝이 프레임 경계와 일치해요 — 하지만 그 역은 성립하지 않아요. 블록 중간에서 버퍼가 채워질 때 내보내진 중간 프레임 경계는 블록의 _중간_에 떨어지며 블록 경계가 아니에요. 따라서 디코더는 블록이 어디서 끝나는지 찾기 위해 블록 자체의 dimensions(num_columns/num_rows)를 사용해야 해요. 각 프레임이 하나의 완전한 블록이라고 가정하면 안 돼요.

수신자는 프레임을 스트리밍해요: 16 + 9바이트를 읽고, 정확히 compressed_size - 9 본문 바이트를 읽고, 정확히 uncompressed_size 바이트로 압축 해제해 그 바이트를 블록 디코더에 제공해요. 디코더가 현재 프레임이 담는 것보다 더 필요하면 다음 프레임을 끌어와요. 보내는 쪽이 블록별로 플러시하므로, 블록이 완전히 디코딩된 후 프레임 버퍼는 비어 있고 다음 블록은 새 프레임에서 시작해요.

네이티브 TCP 프로토콜에서는 패킷 봉투 — 패킷-타입 VarUInt와 table_name 문자열 — 이 압축 페이로드 밖, 원시 스트림에 작성돼요. 프레이밍되는 것은 블록 본문(BlockInfo + 컬럼)뿐이에요. HTTP compress/decompress 경로에는 그런 봉투가 없어요: 전체 스트림은 프레이밍된 블록이에요.

협상 (Negotiation)

네이티브 TCP 프로토콜에서 압축은 연결별이 아니라 쿼리별이에요. Query 패킷의 compression: bool 필드가 그 단일 쿼리에 대해 요청해요. 서버는 요청을 존중하고 쿼리 수명 동안 압축된 Data/Totals/Extremes/Log/ProfileEvents 본문을 내보내요(Log/ProfileEvents는 v54481+에서만). 또한 클라이언트의 나가는 Data 블록 — 외부 테이블, 빈 종료-데이터 마커, INSERT 행 — 이 같은 방식으로 프레이밍되기를 기대해요. 같은 연결의 후속 쿼리는 다를 수 있어요.

HTTP에는 Query 패킷이 없어요. compress=1 쿼리 파라미터가 그 요청에 대해 프레이밍된 출력을 선택하고, decompress=1은 요청 본문이 프레이밍됐다고 선언해요. compress=1 출력은 network_compression_method가 아니라 서버의 기본 코덱(ZSTD(3))으로 작성돼요. decompress=1 리더는 각 프레임의 방법 바이트에서 코덱을 가져오므로, 입력에서 어떤 코덱이든 받아들여져요.

참고

압축이 켜져 있으면 서버는 한 행보다 많은 블록에 대해 병렬 블록-마샬링 / ColumnBLOB 경로(PARALLEL_BLOCK_MARSHALLING, v54478)로 컬럼을 라우팅할 수도 있어요. INSERT 데이터를 압축하는 구현은 동기화를 잃은 스트림을 피하기 위해 그 경로를 처리(또는 명시적으로 선택 해제)할 준비가 돼 있어야 해요.

용어집 (Glossary)

  • Block — Native 포맷의 데이터 교환 단위. 컬럼 방식으로 저장된 행의 자기-기술적 묶음. 블록과 컬럼 구조 참조.
  • BlockInfo — TCP Data-패킷 경로에서 Block 앞에 오는 메타데이터 헤더(연결 개정판이 0보다 클 때마다 작성). 개정판으로 게이팅되고, 필드-ID 태그가 있는 필드들의 시퀀스. 개정판 0으로 직렬화하는 Native 출력 포맷은 생략. BlockInfo 참조.
  • 컬럼 본문 (Column body) — 컬럼 헤더(이름, 타입, has_custom_serialization 바이트) 뒤의, 실제 값을 담는 Column의 바이트. 레이아웃은 타입 특유. 컬럼 와이어 레이아웃 참조.
  • 복합 타입 (Composite type) — 하나 이상의 내부 타입으로 만들어진 타입, 컬럼당 여러 스트림으로 인코딩. 와이어 포맷은 안정적이고 버전이 없어요. 복합 타입 참조.
  • 딕셔너리 (Dictionary, LowCardinality) — LowCardinality(T) 컬럼이 정수 인덱스로 참조하는 고유 값 배열. LowCardinality 참조.
  • 빈 블록 (Empty block) — num_columns = 0이고 num_rows = 0인 Block. 센티넬로 사용: 클라이언트 쪽 입력-끝 마커이자 서버 쪽 스트림 경계 마커. 블록 변형 참조.
  • 헤더 블록 (Header block) — num_columns > 0이고 num_rows = 0인 Block, 서버가 쿼리 응답의 첫 번째 Data 패킷으로 보냄. 결과 스키마를 알려줌. 블록 변형 참조.
  • 내부 타입 (Inner type) — 복합 타입이 감싸는 타입. Array(UInt32)의 내부 타입은 UInt32, Nullable(T)의 내부 타입은 T.
  • 오프셋 스트림 (Offsets stream) — Array, Map, Nested가 행별 요소 경계를 구분하는 데 쓰는 누적-끝-위치 UInt64 배열. Array 참조.
  • 자리표시자 값 (Placeholder value) — Nullable(T) 컬럼의 값 스트림에서 null 위치에 작성되는 바이트. 디코더는 스트림을 진행하려고 읽지만 내용은 무시. Nullable 참조.
  • 결과 블록 (Result block) — num_rows > 0인 Block으로 실제 쿼리 결과 행을 담음. 블록 변형 참조.
  • 스키마 블록 (Schema block) — 헤더 블록의 동의어. INSERT 단계 설명에서 쓰이며, 스키마 블록이 클라이언트에게 예상 컬럼 형태를 알려줌.
  • 직렬화 버전 (Serialization version) — 버전 지정 타입이 이어지는 인코딩의 어떤 변형인지 선언하는 데 쓰는 타입별 와이어 버전 번호. 프로토콜 버전과 구별. 직렬화 버전: 개념 참조.
  • 상태 접두사 (State prefix) — 버전 지정 타입의 블록별 페이로드 앞에 오는 바이트. 직렬화 버전과 (LowCardinality의) 블록별 딕셔너리 메타데이터를 나름. rows > 0인 모든 블록의 시작에서 내보내짐; 블록 간 유지되지 않음.
  • 스트림 (Stream) — 컬럼 본문 안의 연속 바이트 실행, 한 논리 하위 구성요소(null-map, 오프셋 배열, 값 스트림)를 인코딩. 다중 스트림 타입은 컬럼당 둘 이상의 스트림을 연결.

더 알아보기 (Learn more)