Native 포맷은 컬럼을 행으로 변환하지 않는 진정한 「열 지향(columnar)」 방식이기 때문에 ClickHouse에서 가장 효율적인 포맷입니다.
이 포맷에서는 데이터가 바이너리 형식의 블록 단위로 기록되고 읽힙니다.
각 블록마다 행 수, 컬럼 수, 컬럼 이름과 타입, 그리고 블록 내 컬럼의 파트가 차례대로 기록됩니다.
이 포맷은 서버 간 상호 작용을 위한 네이티브 인터페이스, 커맨드라인 클라이언트 및 C++ 클라이언트에서 사용됩니다.
팁
이 포맷을 사용하면 ClickHouse DBMS에서만 읽을 수 있는 덤프를 빠르게 생성할 수 있습니다.
직접 이 포맷을 사용하여 작업하는 것은 실용적이지 않을 수 있습니다.
데이터는 컬럼형 포맷으로 전송되며, 이는 각 컬럼이 개별적으로 전송되고
각 컬럼의 모든 값이 하나의 배열로 함께 전송된다는 뜻입니다.
블록의 각 컬럼에는 RowBinaryWithNamesAndTypes와 유사한 헤더가 포함됩니다.
참고
네이티브 TCP 바이너리 프로토콜을 사용하는 경우(또는 HTTP 엔드포인트가 ?client_protocol_version=<n>를 받는 경우),
컬럼 수와 행 수에 앞서 BlockInfo 구조가 기록됩니다. 이 섹션의 예시에서는 프로토콜 버전 없이
일반 HTTP 인터페이스를 사용하므로 BlockInfo가 생략됩니다.
블록 구조
다음 쿼리는 number와 str 두 컬럼으로 이루어진 3개의 행을 반환합니다:
curl -XPOST "http://localhost:8123?default_format=Native" --data-binary "SELECT number, toString(number) AS str FROM system.numbers LIMIT 3" > out.bin
출력 데이터는 단일 ClickHouse 블록에 들어가며, 다음과 같은 형태입니다:
const data = new Uint8Array([
// --- Block Header ---
0x02, // 2 columns
0x03, // 3 rows
// -- Column 1 Header --
0x06, // LEB128 - column name 'number' has 6 bytes
0x6e, 0x75, 0x6d,
0x62, 0x65, 0x72, // column name: 'number'
0x06, // LEB128 - column type 'UInt64' has 6 bytes
0x55, 0x49, 0x6e,
0x74, 0x36, 0x34, // 'UInt64'
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // 0 as UInt64
0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // 1 as UInt64
0x02, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // 2 as UInt64
0x03, // LEB128 - column name 'str' has 3 bytes
0x73, 0x74, 0x72, // column name: 'str'
0x06, // LEB128 - column type 'String' has 6 bytes
0x53, 0x74, 0x72,
0x69, 0x6e, 0x67, // 'String'
0x01, // LEB128 - the string has 1 byte
0x30, // '0' as String
0x01, // LEB128 - the string has 1 byte
0x31, // '1' as String
0x01, // LEB128 - the string has 1 byte
0x32, // '2' as String
])
여러 블록
하지만 대부분의 경우 데이터는 단일 블록에 담기지 않으며, ClickHouse는 이를 여러 블록으로 전송합니다.
다음 쿼리는 블록 크기를 줄여 데이터를 블록당 1행씩 나누도록 강제하고, 2개의 행을 가져오는 예입니다.
curl -XPOST "http://localhost:8123?default_format=Native" --data-binary "SELECT number, toString(number) AS str FROM system.numbers LIMIT 2 SETTINGS max_block_size=1" \ > out.bin
출력:
const data = new Uint8Array([
// ----- Block 1 -----
0x02, // 2 columns
0x01, // 1 row
0x06, // LEB128 - column name 'number' has 6 bytes
0x6E, 0x75, 0x6D,
0x62, 0x65, 0x72, // column name: 'number'
0x06, // LEB128 - column type 'UInt64' has 6 bytes
0x55, 0x49, 0x6E,
0x74, 0x36, 0x34, // 'UInt64'
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // 0 as UInt64
0x03, // LEB128 - column name 'str' has 3 bytes
0x73, 0x74, 0x72, // column name: 'str'
0x06, // LEB128 - column type 'String' has 6 bytes
0x53, 0x74, 0x72,
0x69, 0x6E, 0x67, // 'String'
0x01, // LEB128 - the string has 1 byte
0x30, // '0' as String
// ----- Block 2 -----
0x02, // 2 columns
0x01, // 1 row
0x06, // LEB128 - column name 'number' has 6 bytes
0x6E, 0x75, 0x6D,
0x62, 0x65, 0x72, // column name: 'number'
0x06, // LEB128 - column type 'UInt64' has 6 bytes
0x55, 0x49, 0x6E,
0x74, 0x36, 0x34, // 'UInt64'
0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // 1 as UInt64
0x03, // LEB128 - column name 'str' has 3 bytes
0x73, 0x74, 0x72, // column name: 'str'
0x06, // LEB128 - column type 'String' has 6 bytes
0x53, 0x74, 0x72,
0x69, 0x6E, 0x67, // 'String'
0x01, // LEB128 - the string has 1 byte
0x31, // '1' as String
]);
단순 데이터 타입
더 단순한 데이터 타입 중 하나에 해당하는 개별 값의 wire 형식은 RowBinary/RowBinaryWithNamesAndTypes와 유사합니다.
이 설명에 해당하는 전체 타입 목록은 다음과 같습니다.
- (U)Int8, (U)Int16, (U)Int32, (U)Int64, (U)Int128, (U)Int256
- Float32, Float64
- Bool
- String
- FixedString(N)
- Date
- Date32
- DateTime
- DateTime64
- IPv4
- IPv6
- UUID
자세한 내용은 "RowBinary 데이터 타입 wire 형식"의 위 타입 설명을 참조하십시오.
복합 데이터 형식
다음 형식의 인코딩은 RowBinary 및 RowBinaryWithNamesAndTypes와 다릅니다.
- 널 허용
- LowCardinality
- Array
- 맵
- Variant
- Dynamic
- JSON
널 허용
Native 포맷에서는 널 허용 컬럼의 실제 데이터 앞에 블록의 행 수와 같은 수의 바이트가 기록됩니다. 각 바이트는 해당 값이 NULL인지 아닌지를 나타냅니다. 예를 들어, 다음 쿼리에서는 각 홀수 값이 NULL이 됩니다:
curl -XPOST "http://localhost:8123?default_format=Native" \ --data-binary "SELECT if(number % 2 = 0, number, NULL) :: Nullable(UInt64) AS maybe_null FROM system.numbers LIMIT 5" \ > out.bin
출력은 다음과 같이 표시됩니다:
const data = new Uint8Array([
// --- Block Header ---
0x01, // LEB128 - 1 column
0x05, // LEB128 - 5 rows
// -- Column Header --
0x0A, // LEB128 - column name has 10 bytes
0x6D, 0x61, 0x79, 0x62, 0x65,
0x5F, 0x6E, 0x75, 0x6C, 0x6C, // column name: 'maybe_null'
0x10, // LEB128 - column type has 16 bytes
0x4E, 0x75, 0x6C, 0x6C,
0x61, 0x62, 0x6C, 0x65,
0x28, 0x55, 0x49, 0x6E,
0x74, 0x36, 0x34, 0x29, // column type: 'Nullable(UInt64)'
// -- Nullable mask --
0x00, // Row 0 is NOT NULL
0x01, // Row 1 is NULL
0x00, // Row 2 is NOT NULL
0x01, // Row 3 is NULL
0x00, // Row 4 is NOT NULL
// -- UInt64 values --
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // Row 0: 0 as UInt64
// even though we still might have a proper value for this number
// in the block, it should be still returned as NULL to the user!
0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // Row #1: NULL
0x02, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // Row #2: 2 as UInt64
0x03, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // Row #3: NULL, similar to Row #1
0x04, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // Row #4: 4 as UInt64
]);
Nullable(String)도 비슷하게 동작합니다. null 표시자는 항상 nullable 마스크 바이트에서 결정되며 —
마스크 값이 0x01이면 문자열 내용과 관계없이 해당 행은 NULL입니다. NULL 행의 경우,
기본 문자열은 빈 문자열(LEB128 길이 0)로 저장됩니다. NULL이 아닌 빈 문자열도
LEB128 길이가 0이므로, 두 경우를 구분하는 것은 마스크 바이트뿐입니다. 예를 들어, 다음 쿼리:
curl -XPOST "http://localhost:8123?default_format=Native" \ --data-binary "SELECT if(number % 2 = 0, toString(number), NULL) :: Nullable(String) AS maybe_str FROM system.numbers LIMIT 5" \ > out.bin
출력은 다음과 같습니다.
const data = new Uint8Array([
// --- Block Header ---
0x01, // LEB128 - 1 column
0x05, // LEB128 - 5 rows
// -- Column Header --
0x09, // LEB128 - column name has 9 bytes
0x6d,
0x61,
0x79,
0x62,
0x65,
0x5f,
0x73,
0x74,
0x72, // column name: 'maybe_str'
0x10, // LEB128 - column type has 16 bytes
0x4e,
0x75,
0x6c,
0x6c,
0x61,
0x62,
0x6c,
0x65,
0x28,
0x53,
0x74,
0x72,
0x69,
0x6e,
0x67,
0x29, // column type: 'Nullable(String)'
// -- Nullable mask --
0x00, // Row 0 is NOT NULL
0x01, // Row 1 is NULL
0x00, // Row 2 is NOT NULL
0x01, // Row 3 is NULL
0x00, // Row 4 is NOT NULL
// -- String values --
0x01,
0x30, // Row 0: LEB128 == 1, '0' as String
0x00, // Row 1: LEB128 == 0, NULL
0x01,
0x32, // Row 2: LEB128 == 1, '2' as String
0x00, // Row 3: LEB128 == 0, NULL
0x01,
0x34, // Row 4: LEB128 == 1, '4' as String
])
LowCardinality
RowBinary에서는 LowCardinality가 투명하게 처리되지만, Native 포맷은 딕셔너리 기반의 열 지향 인코딩을 사용합니다. 컬럼은 먼저 버전 접두사로 인코딩되고, 이어서 고유 값 딕셔너리와 해당 딕셔너리를 참조하는 정수 인덱스 배열로 인코딩됩니다.
참고
컬럼은 LowCardinality(Nullable(T))로 정의할 수 있지만, Nullable(LowCardinality(T))로 정의하는 것은 불가능합니다 — 이 경우 서버에서 항상 오류가 발생합니다.
버전 접두사는 값이 1인 UInt64(LE)이며, 컬럼마다 한 번씩 기록됩니다. 그다음 각 블록마다 다음 항목이 기록됩니다.
UInt64(LE) — IndexesSerializationType 비트필드입니다. 비트 0–7은 인덱스 너비를 인코딩합니다(0 = UInt8, 1 = UInt16, 2 = UInt32, 3 = UInt64). 비트 8(NeedGlobalDictionaryBit)은 Native 포맷에서 절대 설정되지 않습니다(이를 만나면 서버가 예외를 발생시킵니다). 비트 9는 추가 딕셔너리 키가 있음을 나타냅니다. 비트 10은 딕셔너리를 재설정해야 함을 나타냅니다.
UInt64(LE) — 딕셔너리 키 개수이며, 그 뒤에 내부 타입 인코딩을 사용해 키가 일괄 직렬화됩니다.
UInt64(LE) — 행 수이며, 그 뒤에 적절한 UInt 너비를 사용해 인덱스 값이 일괄 직렬화됩니다.
딕셔너리에는 항상 인덱스 0에 기본값이 포함됩니다(예: String의 경우 빈 문자열, 숫자 타입의 경우 0). LowCardinality(Nullable(T))의 경우 인덱스 0은 NULL을 나타내며, 키는 Nullable 래퍼 없이 직렬화됩니다.
예를 들어, LowCardinality(String)에 5개 행 ['foo', 'bar', 'baz', 'foo', 'bar']가 있는 경우:
// Version prefix
01 00 00 00 00 00 00 00 // UInt64(LE) = 1
// IndexesSerializationType: UInt8 indexes, has keys, update dictionary
00 06 00 00 00 00 00 00 // UInt64(LE) = 0x0600
04 00 00 00 00 00 00 00 // 4 dictionary keys
00 // key 0: "" (default)
03 66 6f 6f // key 1: "foo"
03 62 61 72 // key 2: "bar"
03 62 61 7a // key 3: "baz"
05 00 00 00 00 00 00 00 // 5 rows
01 02 03 01 02 // indexes → "foo", "bar", "baz", "foo", "bar"
LowCardinality(Nullable(String))에서는 인덱스 0이 NULL입니다:
01 00 00 00 00 00 00 00 // version
00 06 00 00 00 00 00 00 // IndexesSerializationType
03 00 00 00 00 00 00 00 // 3 keys
00 // key 0: NULL
00 // key 1: "" (default)
03 79 65 73 // key 2: "yes"
05 00 00 00 00 00 00 00 // 5 rows
02 00 02 00 02 // indexes → "yes", NULL, "yes", NULL, "yes"
Array
각 배열 앞에 LEB128 요소 개수가 붙는 RowBinary와 달리, Native 포맷에서는 배열을 2개의 열 지향 하위 스트림으로 인코딩합니다:
- 누적
UInt64 오프셋 N개(리틀 엔디언, 각 8바이트)입니다. i행의 요소 수는 offset[i] - offset[i-1]이며, offset[-1]은 암묵적으로 0입니다.
- 모든 행의 모든 중첩 요소를 하나로 이어서 일괄 직렬화합니다.
예를 들어, 3개의 행 [[0, 10], [1, 11], [2, 12]]로 구성된 Array(UInt32)는 다음과 같습니다:
// Offsets
02 00 00 00 00 00 00 00 // 2 (row 0: 2 elements)
04 00 00 00 00 00 00 00 // 4 (row 1: 2 elements)
06 00 00 00 00 00 00 00 // 6 (row 2: 2 elements)
// Nested UInt32 values (6 total)
00 00 00 00 // 0
0a 00 00 00 // 10
01 00 00 00 // 1
0b 00 00 00 // 11
02 00 00 00 // 2
0c 00 00 00 // 12
빈 배열은 이전 행과 동일한 오프셋을 사용합니다. 예를 들어, 4개의 행으로 이루어진 Array(String) [[], ['0'], ['0','1'], ['0','1','2']]는 다음과 같습니다:
00 00 00 00 00 00 00 00 // 0 (empty)
01 00 00 00 00 00 00 00 // 1
03 00 00 00 00 00 00 00 // 3
06 00 00 00 00 00 00 00 // 6
01 30 // "0"
01 30 // "0"
01 31 // "1"
01 30 // "0"
01 31 // "1"
01 32 // "2"
Map(K, V)는 Array(Tuple(K, V))로 인코딩됩니다. 즉, 배열 오프셋 뒤에 모든 키가 오고, 그다음에 모든 값이 옵니다. 이는 항목마다 키와 값이 교차되어 저장되는 RowBinary와는 다릅니다.
예를 들어, 3개의 행으로 이루어진 Map(String, UInt64) 값 [{'a':0,'b':10}, {'a':1,'b':11}, {'a':2,'b':12}]는 다음과 같습니다:
// Array offsets
02 00 00 00 00 00 00 00 // 2
04 00 00 00 00 00 00 00 // 4
06 00 00 00 00 00 00 00 // 6
// All keys (6 Strings)
01 61 // "a"
01 62 // "b"
01 61 // "a"
01 62 // "b"
01 61 // "a"
01 62 // "b"
// All values (6 UInt64s)
00 00 00 00 00 00 00 00 // 0
0a 00 00 00 00 00 00 00 // 10
01 00 00 00 00 00 00 00 // 1
0b 00 00 00 00 00 00 00 // 11
02 00 00 00 00 00 00 00 // 2
0c 00 00 00 00 00 00 00 // 12
Variant
각 행이 자체 판별자 바이트를 갖고 그 뒤에 값이 인라인으로 이어지는 RowBinary와 달리, Native 포맷은 판별자와 데이터를 분리합니다.
참고
RowBinary와 마찬가지로 정의에 포함된 타입은 항상 알파벳순으로 정렬되며, 판별자는 그 정렬된 목록에서의 인덱스입니다. 0xFF (255)는 NULL을 나타냅니다.
Variant 컬럼은 다음과 같이 인코딩됩니다.
UInt64(LE) 판별자 모드 접두사 (0 = BASIC, 1 = COMPACT). Native 포맷 출력은 일반적으로 BASIC (0)을 사용합니다. COMPACT 모드는 use_compact_variant_discriminators_serialization이 활성화된 상태로 저장된 데이터를 읽을 때 나타날 수 있습니다.
- N개의
UInt8 판별자, 각 행마다 하나씩입니다.
- 각 variant 타입의 데이터는 판별자 순서에 따라, 해당하는 행만 포함하는 별도의 벌크 컬럼으로 저장됩니다.
예를 들어, Variant(String, UInt32)에 5개의 행 [0::UInt32, 'hello', NULL, 3::UInt32, 'hello']가 있는 경우(정렬 순서: String = 0, UInt32 = 1):
00 00 00 00 00 00 00 00 // discriminators mode = BASIC
01 00 ff 01 00 // UInt32, String, NULL, UInt32, String
// String (2 values, rows 1 and 4)
05 68 65 6c 6c 6f // "hello"
05 68 65 6c 6c 6f // "hello"
// UInt32 (2 values, rows 0 and 3)
00 00 00 00 // 0
03 00 00 00 // 3
Dynamic
각 값이 스스로 타입 정보를 포함하는(타입 프리픽스 + 값) RowBinary와 달리, Native 포맷은 Dynamic을 구조 프리픽스 뒤에 Variant 컬럼이 오는 형태로 직렬화합니다.
구조 프리픽스에는 먼저 UInt64(LE) 시리얼화 버전이 들어가고, 이어서 동적 타입의 개수(VarUInt), 그리고 문자열로 된 타입 이름이 기록됩니다. V1 버전에서는 호환성을 위해 타입 개수를 두 번 기록합니다. 그 뒤의 데이터는 동적 타입들과 내부 SharedVariant 타입을 합친 후 알파벳순으로 정렬한 타입 목록을 가지는 Variant 컬럼입니다.
예를 들어, 5개의 행 [0::UInt32, 'hello', NULL, 3::UInt32, 'hello']을 가진 Dynamic은 다음과 같습니다.
// Structure prefix (V1)
01 00 00 00 00 00 00 00 // version = V1
02 // num types (V1 writes twice)
02 // num types
06 53 74 72 69 6e 67 // "String"
06 55 49 6e 74 33 32 // "UInt32"
// Variant data: Variant(SharedVariant, String, UInt32)
// discriminants: SharedVariant=0, String=1, UInt32=2
00 00 00 00 00 00 00 00 // discriminators mode = BASIC
02 01 ff 02 01 // UInt32, String, NULL, UInt32, String
// SharedVariant: 0 values
05 68 65 6c 6c 6f // String: "hello"
05 68 65 6c 6c 6f // String: "hello"
00 00 00 00 // UInt32: 0
03 00 00 00 // UInt32: 3
JSON
각 행이 경로 이름과 값으로 자체적으로 기술되는 RowBinary와 달리, Native 포맷은 JSON을 열 지향 구조로 직렬화합니다. 이 인코딩은 복잡하며 버전에 따라 달라집니다. 즉, 시리얼화 버전, 동적 경로 이름, 공유 데이터 레이아웃이 포함된 구조 프리픽스로 구성되며, 그 뒤에 타입이 지정된 경로(각각 벌크 컬럼), 동적 경로(각각 Dynamic 컬럼), 그리고 오버플로 경로용 공유 데이터가 이어집니다.
더 간단한 상호 운용성을 위해 output_format_native_write_json_as_string=1 설정 사용을 고려하세요. 이 설정은 JSON 컬럼을 일반 JSON 텍스트 문자열(행당 String 1개)로 직렬화합니다.