Map(K, V)는 키-값 쌍을 저장합니다.
다른 데이터베이스와 달리 ClickHouse에서는 맵의 키가 고유하지 않습니다. 즉, 하나의 맵에 동일한 키를 가진 요소가 두 개 있을 수 있습니다.
(이는 맵이 내부적으로 Array(Tuple(K, V))로 구현되기 때문입니다.)
맵 m에서 키 k의 값을 가져오려면 m[k] 구문을 사용할 수 있습니다.
또한 m[k]는 맵 전체를 스캔하므로, 이 연산의 런타임은 맵 크기에 선형적으로 비례합니다.
매개변수
K— Map 키의 타입입니다. Nullable 및 Nullable 타입이 중첩된 LowCardinality를 제외한 임의의 타입을 사용할 수 있습니다.V— Map 값의 타입입니다. 임의의 타입을 사용할 수 있습니다.
Query
key2의 값을 선택하려면:
Query
Response
k가 맵에 없으면 m[k]는 값 타입의 기본값을 반환합니다. 예를 들어 정수 타입은 0, 문자열 타입은 ''를 반환합니다.
맵에 키가 존재하는지 확인하려면 mapContains 함수를 사용할 수 있습니다.
Query
Response
Tuple을 Map으로 변환하기
Tuple() 타입의 값은 CAST 함수를 사용해 Map() 타입의 값으로 변환할 수 있습니다:
예시
Query
Response
맵의 서브컬럼 읽기
keys와 values를 사용할 수 있습니다.
예시
Query
Response
MergeTree의 버킷형 맵 직렬화
Map 컬럼은 단일 Array(Tuple(K, V)) 스트림으로 저장됩니다.
m['key']로 특정 키 하나를 읽으려면, 하나의 키만 필요하더라도 모든 행의 모든 키-값 쌍이 들어 있는 전체 컬럼을 스캔해야 합니다.
서로 다른 키가 많은 맵에서는 이것이 병목이 됩니다.
버킷형 직렬화(with_buckets)는 키를 해시하여 키-값 쌍을 여러 개의 독립적인 하위 스트림(버킷)으로 분할합니다.
쿼리에서 m['key']에 접근하면 해당 키가 들어 있는 버킷만 디스크에서 읽고, 나머지 버킷은 모두 건너뜁니다.
버킷 단위 직렬화 활성화
INSERT 시 생성됨)에는 basic 직렬화를 유지하고 병합된 파트에만 with_buckets를 사용할 수 있습니다:
동작 방식
with_buckets 직렬화로 기록되면 다음과 같이 동작합니다.
- 행당 평균 키 수를 블록 통계에서 계산합니다.
- 버킷 수는 구성된 전략에 따라 결정됩니다(설정 참조).
- 각 키-값 쌍은 키를 해시하여 버킷에 할당됩니다:
bucket = hash(key) % num_buckets. - 각 버킷은 자체 키, 값, 오프셋을 가진 독립적인 서브스트림으로 저장됩니다.
buckets_info메타데이터 스트림에는 버킷 수와 통계가 기록됩니다.
m['key'])를 읽으면 옵티마이저가 표현식을 키 서브컬럼(m.key_<serialized_key>)으로 재작성합니다.
직렬화 계층은 요청된 키가 어느 버킷에 속하는지 계산하고, 해당 버킷 하나만 디스크에서 읽습니다.
전체 맵을 읽을 때(예: SELECT m)는 모든 버킷을 읽어 원래 맵으로 재구성합니다. 여러 서브스트림을 읽고 머지하는 오버헤드가 있으므로 basic 직렬화보다 더 느립니다.
with_buckets 직렬화를 사용하면 맵 값 내부의 키 순서가 원래 삽입 순서와 달라질 수 있습니다. 키는 해시에 따라 여러 버킷에 분산되며, 삽입 순서가 아니라 버킷 순서대로 다시 조합됩니다. basic 직렬화에서는 삽입된 맵의 키 순서가 유지됩니다.basic 직렬화와 with_buckets 직렬화는 같은 테이블에 함께 존재할 수 있으며, 머지 시에도 투명하게 처리됩니다.
설정
| Setting | Default | Description |
|---|---|---|
map_serialization_version | basic | Map 컬럼의 직렬화 포맷입니다. basic은 단일 배열 스트림으로 저장합니다. with_buckets는 단일 키 읽기 성능을 높이기 위해 키를 버킷으로 나눕니다. |
map_serialization_version_for_zero_level_parts | basic | 0-레벨 파트(INSERT로 생성됨)의 직렬화 포맷입니다. 삽입 시 쓰기 오버헤드를 피할 수 있도록 basic을 유지하면서, 병합된 파트에는 with_buckets를 사용할 수 있습니다. |
max_buckets_in_map | 32 | 버킷 수의 상한입니다. 실제 개수는 map_buckets_strategy에 따라 달라집니다. 허용되는 최댓값은 256입니다. |
map_buckets_strategy | sqrt | 평균 맵 크기를 기준으로 버킷 수를 계산하는 전략입니다. constant — 항상 max_buckets_in_map을 사용합니다. sqrt — round(coefficient * sqrt(avg_size))를 사용합니다. linear — round(coefficient * avg_size)를 사용합니다. 결과는 [1, max_buckets_in_map] 범위로 제한됩니다. |
map_buckets_coefficient | 1.0 | sqrt 및 linear 전략에 적용되는 계수입니다. 전략이 constant이면 무시됩니다. |
map_buckets_min_avg_size | 32 | 버킷 분할을 활성화하기 위한 행당 평균 최소 키 수입니다. 평균이 이 임계값보다 낮으면 다른 설정과 관계없이 단일 버킷이 사용됩니다. 임계값을 비활성화하려면 0으로 설정하십시오. |
성능 트레이드오프
with_buckets를 basic 직렬화(serialization)와 비교했을 때의 성능 영향을 요약합니다. 버킷 수는 최대 32로 제한하는 sqrt 전략으로 결정되었습니다. 정확한 수치는 키/값 타입, 데이터 분포, 하드웨어에 따라 달라집니다.
| Operation | 10 keys | 100 keys | 1,000 keys | 10,000 keys | Notes |
|---|---|---|---|---|---|
단일 키 조회 (m['key']) | 1.6–3.2배 빠름 | 4.5–7.7배 빠름 | 16–39배 빠름 | 21–49배 빠름 | 전체 컬럼 대신 버킷 하나만 읽습니다. |
| 키 5개 조회 | ~1배 | 1.5–3.1배 빠름 | 2.9–8.3배 빠름 | 4.5–6.7배 빠름 | 각 키는 해당 버킷을 읽으며, 일부 버킷은 겹칠 수 있습니다. |
PREWHERE (SELECT m WHERE m['key'] = ...) | 1.5–3.0배 빠름 | 2.9–7.3배 빠름 | 5.3–31배 빠름 | 20–45배 빠름 | PREWHERE 필터는 버킷 하나만 읽고, 전체 맵은 일치하는 행에 대해서만 읽습니다. 속도 향상 폭은 선택도에 따라 달라집니다 — 일치하는 그래뉼이 적을수록 전체 맵 I/O가 줄어듭니다. |
전체 맵 스캔 (SELECT m) | ~2배 느림 | ~2배 느림 | ~2배 느림 | ~2배 느림 | 모든 버킷을 읽어 다시 조합해야 합니다. |
| INSERT | 1.5–2.5배 느림 | 1.5–2.5배 느림 | 1.5–2.5배 느림 | 1.5–2.5배 느림 | 키를 해싱하고 여러 하위 스트림에 쓰는 데 오버헤드가 발생합니다. |
권장 사항
- 작은 맵(평균 키 수 32개 미만):
basic직렬화를 유지하십시오. 작은 맵에는 버킷 사용에 따른 오버헤드가 크지 않아 이 방식이 더 적합합니다. 기본값map_buckets_min_avg_size = 32가 이를 자동으로 적용합니다. - 중간 크기 맵(키 32~100개): 쿼리에서 개별 키에 자주 접근한다면
sqrt전략과 함께with_buckets를 사용하십시오. 단일 키 lookup은 4~8배 빨라집니다. - 큰 맵(키 100개 이상):
with_buckets를 사용하십시오. 단일 키 lookup은 16~49배 더 빠릅니다. 삽입 속도를 기준 수준에 가깝게 유지하려면map_serialization_version_for_zero_level_parts = 'basic'사용을 고려하십시오. - 전체 맵 스캔이 워크로드의 대부분을 차지하는 경우:
basic을 유지하십시오. 버킷 직렬화는 전체 스캔에서 약 2배의 오버헤드를 추가합니다. - 혼합 워크로드(일부 키 lookup, 일부 전체 스캔): 제로 수준 파트를
basic으로 설정한with_buckets를 사용하십시오.PREWHERE최적화는 필터에 해당하는 버킷만 먼저 읽고, 그다음 일치하는 행에 대해서만 전체 맵을 읽으므로 전체적으로 상당한 성능 향상을 얻을 수 있습니다.
대체 접근 방식
Map 직렬화가 사용 사례에 맞지 않는다면, 키 수준 접근 성능을 개선할 수 있는 대체 방법은 두 가지가 있습니다:
JSON 데이터 타입 사용
max_dynamic_paths 제한을 초과하는 경로는 공유 데이터 구조로 들어가며, 여기서는 단일 경로 읽기를 최적화하기 위해 advanced 직렬화를 사용할 수 있습니다. advanced 직렬화에 대한 자세한 내용은 블로그 글을 참조하십시오.
| 측면 | 버킷이 있는 Map | JSON |
|---|---|---|
| 단일 키 읽기 | 하나의 버킷을 읽습니다(다른 키가 포함될 수 있음). 버킷 안의 모든 key-value 쌍이 역직렬화됩니다. | 자주 등장하는 경로는 동적 서브컬럼에서 직접 읽습니다. 자주 등장하지 않는 경로는 공유 데이터로 들어가며, advanced 직렬화를 사용하면 해당 경로의 데이터만 읽습니다. |
| 값 타입 | 모든 값이 동일한 타입 V를 공유합니다 | 각 경로는 서로 다른 타입을 가질 수 있습니다. 타입 힌트가 없는 경로는 Dynamic을 사용합니다. |
| 스킵 인덱스 지원 | mapKeys/mapValues에 생성된 일부 인덱스 타입에서 작동합니다 | 스킵 인덱스는 특정 경로 서브컬럼에만 생성할 수 있으며, 모든 경로/값에 대해 한 번에 생성할 수는 없습니다. |
| 전체 컬럼 읽기 | 버킷을 다시 조합해야 하므로 basic보다 약 2배 느립니다 | Dynamic 타입 인코딩과 경로 재구성으로 인한 오버헤드가 있습니다. |
| 저장 오버헤드 | 추가 메타데이터가 거의 없습니다 | Dynamic 타입 인코딩, 경로 이름 저장, 그리고 advanced 직렬화의 추가 메타데이터로 인해 더 큽니다. |
| 스키마 유연성 | 테이블 생성 시 키와 값 타입이 고정됩니다 | 완전히 동적입니다 — 키와 값 타입이 행마다 달라질 수 있습니다. 알려진 경로에 대해서는 직접 서브컬럼에 접근할 수 있도록 타입이 지정된 경로 힌트를 선언할 수 있습니다. |
JSON을 사용하십시오.
여러 맵 컬럼으로 수동 세그먼트 분할
Map을 여러 컬럼으로 수동으로 나눌 수 있습니다:
m{hash(key) % 4}로 라우팅합니다. 쿼리 시에는 해당 컬럼에서 읽습니다: m{hash('target_key') % 4}['target_key'].
| 측면 | 버킷을 사용하는 Map | 수동 세그먼트 분할 |
|---|---|---|
| 사용 편의성 | 투명하게 처리됨 — 스토리지 엔진이 처리함 | 삽입 및 조회를 위해 애플리케이션 수준의 라우팅 로직이 필요함 |
| 수직 병합 | 지원되지 않음 — 모든 버킷이 하나의 컬럼에 속함 | 지원됨 — 각 Map 컬럼은 서로 독립적이며 수직으로 병합할 수 있음 |
| 스키마 변경 | 버킷 수가 각 파트(part)별로 자동 조정됨 | 세그먼트 수를 변경하려면 데이터를 다시 쓰거나 새 컬럼을 추가해야 함 |
| 쿼리 구문 | m['key']를 바로 사용할 수 있음 | 올바른 컬럼을 계산해야 함: m0['key'], m1['key'] 등 |
| 버킷 세분화 수준 | 파트별이며 데이터 통계에 맞게 조정됨 | 테이블 생성 시 고정됨 |