Native 형식

Native 형식

Native 형식은 ClickHouse에서 가장 효율적인 형식이에요. 컬럼을 행으로 변환하지 않는 진정한 "컬럼형(columnar)" 형식이라서, 블록 단위로 바이너리 형식으로 데이터를 쓰고 읽어요. 서버 간 상호작용, 커맨드라인 클라이언트, C++ 클라이언트를 위한 네이티브 인터페이스에서 사용되는 형식입니다.

출처: 문서

본문

Input Output Alias

설명 (Description)

Native 형식의 전체 공식 사양은 여기에서, 이를 운반하는 TCP 와이어 프로토콜인 Native 프로토콜의 동반 사양은 여기에서 확인할 수 있어요. 두 사양 모두 ClickHouse 소스 코드에서 LLM으로 생성되었습니다. 코드가 여전히 주요 진실의 원천입니다. 사양과 코드가 불일치하면 코드가 올바릅니다.

Native 형식은 컬럼을 행으로 변환하지 않는다는 점에서 진정한 "컬럼형"이기 때문에 ClickHouse에서 가장 효율적인 형식입니다.

이 형식에서는 데이터가 블록 단위로 바이너리 형식으로 쓰고 읽습니다. 각 블록에 대해 블록의 행 수, 컬럼 수, 컬럼 이름과 타입, 컬럼의 일부가 차례로 기록됩니다.

이것은 서버 간 상호작용, 커맨드라인 클라이언트 사용, C++ 클라이언트를 위한 네이티브 인터페이스에서 사용되는 형식입니다. 이 형식을 사용하여 ClickHouse DBMS에서만 읽을 수 있는 덤프를 빠르게 생성할 수 있어요. 이 형식을 직접 다루는 것은 실용적이지 않을 수 있습니다.

데이터 타입 와이어 형식 (Data types wire format)

데이터는 컬럼형 형식으로 와이어로 전송되며, 이는 각 컬럼이 별도로 전송되고 한 컬럼의 모든 값이 단일 배열로 함께 전송된다는 뜻입니다.

블록의 각 컬럼에는 RowBinaryWithNamesAndTypes와 유사한 헤더가 포함됩니다. 네이티브 TCP 바이너리 프로토콜을 사용할 때(또는 HTTP 엔드포인트가 ?client_protocol_version=을 받을 때) 블록의 컬럼 및 행 수 앞에 BlockInfo 구조가 기록됩니다. 이 섹션의 예시는 프로토콜 버전이 없는 일반 HTTP 인터페이스로, BlockInfo를 생략합니다.

블록 구조 (Block structure)

다음 쿼리는 number와 str 두 컬럼, 세 개의 행을 반환합니다:

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
])

여러 블록 (Multiple blocks)

그러나 많은 경우 데이터가 단일 블록에 맞지 않으며, ClickHouse는 데이터를 여러 블록으로 보냅니다. 블록 크기를 줄여 행 하나당 블록 하나로 데이터를 강제 분할하는 두 행을 가져오는 다음 쿼리를 생각해 보세요:

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
]);

단순 데이터 타입 (Simple data types)

더 단순한 데이터 타입 중 하나의 개별 값에 대한 와이어 형식은 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 데이터 타입 와이어 형식"의 위 타입 설명을 참조하세요.

복잡한 데이터 타입 (Complex data types)

다음 타입의 인코딩은 RowBinary 및 RowBinaryWithNamesAndTypes와 다릅니다.

  • Nullable
  • LowCardinality
  • Array
  • Map
  • Variant
  • Dynamic
  • JSON
Nullable

Native 형식에서 nullable 컬럼은 실제 데이터 앞에 블록의 행 수와 같은 바이트 수를 갖습니다. 각 바이트는 값이 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

LowCardinality가 투명한 RowBinary와 달리 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 래퍼 없이 직렬화됩니다.

예를 들어 5개 행 ['foo', 'bar', 'baz', 'foo', 'bar']의 LowCardinality(String):

// 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 형식은 배열을 두 개의 컬럼형 하위 스트림으로 인코딩합니다:

  • N개의 누적 UInt64 오프셋(리틀엔디언, 각 8바이트). 행 ioffset[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개 행 [[], ['0'], ['0','1'], ['0','1','2']]의 Array(String):

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

Map(K, V)은 Array(Tuple(K, V))로 인코딩됩니다 — 배열 오프셋 다음에 모든 키, 그 다음에 모든 값. 키와 값이 항목별로 인터리브되는 RowBinary와 다릅니다.

예를 들어 3개 행 [{'a':0,'b':10}, {'a':1,'b':11}, {'a':2,'b':12}]의 Map(String, UInt64):

// 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

각 행이 자체 판별(discriminant) 바이트와 그 뒤에 인라인 값이 오는 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 타입의 데이터를 판별자 순서로 일치하는 행만 포함하는 별도의 일괄 컬럼으로.

예를 들어 5개 행 [0::UInt32, 'hello', NULL, 3::UInt32, 'hello']의 Variant(String, UInt32)(정렬: 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

각 값이 자체 기술(self-describing) 형식(타입 접두사 + 값)인 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 하나)로 직렬화합니다.

더 알아보기 (Learn more)