Split View: ClickHouse MergeTree 완벽 가이드 — Columnar, Sparse Index, Part, Materialized View 모든 것 (2025)
ClickHouse MergeTree 완벽 가이드 — Columnar, Sparse Index, Part, Materialized View 모든 것 (2025)
들어가며 — OLAP의 왕좌는 어떻게 바뀌었나
2020년대 초반까지만 해도 "대규모 분석"이라고 하면 선택지는 정해져 있었다. Redshift, BigQuery, Snowflake 같은 클라우드 데이터 웨어하우스. 아니면 Druid, Presto/Trino, Impala 같은 현장 배치형 도구. 다들 각자의 트레이드오프가 있었다 — 비용, 지연, 쿼리 언어, 운영 복잡도.
그리고 ClickHouse가 나타났다.
Yandex(러시아의 구글)가 2009년부터 내부 웹 분석 시스템 Yandex.Metrica를 위해 개발했고 2016년 오픈소스화했다. 2026년 현재 ClickHouse는 오픈소스 OLAP 시장의 사실상 표준이다. Uber, Cloudflare, Spotify, eBay가 쓴다. Observability 도구의 새로운 세대 (SigNoz, Posthog)가 기반 엔진으로 쓴다. ClickHouse Cloud (회사)는 유니콘이 됐다.
왜 이긴 건가? 한마디로: 속도. 10억 row 테이블에 GROUP BY 쿼리를 초당 한 개씩 돌릴 수 있다. 같은 하드웨어에서 Postgres가 수 분 걸릴 일을 1초 안에 끝낸다. 이 10–1000배의 속도 차이는 단일 최적화가 아니라 저장, 인덱스, 실행 엔진의 모든 레이어에서의 의도적 선택의 결과다.
이 글은 ClickHouse의 내부, 특히 MergeTree 엔진 계열의 모든 것을 1,400줄에 걸쳐 파헤친다. Columnar 저장의 바이트 레이아웃, Part와 Granule 구조, Sparse Primary Index가 B-tree와 어떻게 다른가, Materialized View가 어떻게 실시간 집계를 만드는가, Distributed table과 Replication의 작동 방식, Vectorized execution engine이 CPU 캐시를 어떻게 뚝뚝 끊는가.
이 글은 LSM-Tree 글의 후속이다. MergeTree는 LSM의 OLAP용 사촌이며, 많은 설계 결정이 LSM의 원리를 응용한다. 동시에 Postgres 글의 반대편에 선다 — OLTP와 OLAP가 같은 "데이터베이스"라는 단어로 묶이기엔 너무 다른 시스템이라는 증거다.
1. Columnar Storage — 왜 열로 저장하는가
1.1 행 저장 vs 열 저장
전통적인 DB는 row 단위로 저장한다. 한 row의 모든 컬럼 값이 디스크에 연속적으로 있다.
Row layout:
(id=1, name="Alice", age=30, city="Seoul")
(id=2, name="Bob", age=25, city="Tokyo")
(id=3, name="Carol", age=35, city="Seoul")
디스크에서는:
1, Alice, 30, Seoul | 2, Bob, 25, Tokyo | 3, Carol, 35, Seoul
이 레이아웃은 OLTP에 최적이다. 한 row 전체를 한 번에 읽거나 쓰는 작업이 빠르다.
Columnar는 완전히 뒤집는다:
id: [1, 2, 3]
name: [Alice, Bob, Carol]
age: [30, 25, 35]
city: [Seoul, Tokyo, Seoul]
각 컬럼이 독립된 파일/영역에 저장된다.
1.2 왜 OLAP에 유리한가
SELECT city, AVG(age) FROM users GROUP BY city 쿼리를 생각하자.
- Row store: 모든 row의 모든 컬럼을 읽는다. 10개 컬럼이면 필요한 데이터의 10배를 I/O.
- Column store:
city와age컬럼 파일만 읽는다. I/O가 80% 줄어든다.
이점은 더 있다:
- 압축률: 같은 타입의 값이 연속돼 있으니 압축이 극적으로 잘 된다. int 컬럼은 delta encoding, string 컬럼은 dictionary encoding. 일반적으로 5–20배 압축.
- CPU 캐시: 한 번에 같은 타입 값을 처리하니 CPU 파이프라이닝과 SIMD에 유리.
- 선택적 스캔: 컬럼을 골라 읽으니 스캔량이 줄어든다.
단점:
- Row 재구성 비용: 한 row의 모든 값을 보려면 여러 컬럼 파일에서 같은 인덱스를 다 읽어와야 함.
- 쓰기가 비쌈: INSERT 하나가 여러 파일 수정. 그래서 OLAP는 배치 INSERT를 선호.
- UPDATE/DELETE가 매우 비쌈: ClickHouse는 "ALTER로 수정" 모델을 쓰며, 실시간 업데이트는 거의 포기.
1.3 ClickHouse의 컬럼 파일
MergeTree 테이블의 각 컬럼은 디스크에 두 개의 파일로 저장된다:
column_name.bin # 실제 데이터 (압축됨)
column_name.mrk2 # 마크 파일 — granule 경계 오프셋
.bin 파일은 블록 단위로 압축된다 (기본 LZ4, 옵션 ZSTD). 한 블록은 index_granularity_bytes (기본 10MB) 또는 max_compress_block_size (기본 1MB)에 맞춰진다.
.mrk2는 "각 granule이 .bin 파일의 어느 오프셋에서 시작하는가"를 기록한 인덱스다. 다음 절에서 granule 개념을 보자.
2. Part와 Granule — MergeTree의 단위들
2.1 Part — MergeTree의 SSTable
MergeTree에서 part는 LSM의 SSTable과 같은 역할. 불변(immutable)의 정렬된 데이터 청크.
INSERT를 하면 새 part가 만들어진다. INSERT INTO t VALUES (...) 한 번이 하나의 part. 백그라운드 merge가 여러 part를 합쳐 큰 part로 만든다.
디스크 레이아웃:
/var/lib/clickhouse/data/<db>/<table>/
├── 202604_1_1_0/ # part 이름
│ ├── checksums.txt
│ ├── columns.txt # 컬럼 목록
│ ├── count.txt # row 수
│ ├── primary.idx # sparse primary index
│ ├── minmax_created_at.idx # 파티션 min/max
│ ├── partition.dat
│ ├── id.bin, id.mrk2
│ ├── name.bin, name.mrk2
│ ├── age.bin, age.mrk2
│ └── ...
├── 202604_2_2_0/
└── 202604_1_2_1/ # merge 결과
part 이름 202604_1_2_1의 의미:
202604: partition id (기본은 월별).1_2: min_block_number = 1, max_block_number = 2 (이 part가 포함한 INSERT 범위)._1: merge level (0 = 방금 INSERT됨).
2.2 Granule — 인덱싱의 최소 단위
Granule은 part 내부에서 인덱스가 가리키는 최소 단위. 기본 8192 rows가 1 granule.
왜 granule인가? B-tree는 매 row마다 인덱스 엔트리를 가질 수 있다. 하지만 10억 row면 인덱스가 수 GB. ClickHouse는 sparse index를 쓴다: 8192 rows마다 하나의 인덱스 엔트리. 덕분에 10억 row 테이블의 primary index가 수 MB.
granule 0: row 0 - row 8191
granule 1: row 8192 - row 16383
granule 2: row 16384 - row 24575
...
각 granule의 첫 row의 primary key가 primary.idx 파일에 기록된다:
primary.idx:
granule 0: (2026-04-01 00:00:00)
granule 1: (2026-04-01 00:15:00)
granule 2: (2026-04-01 00:32:00)
...
2.3 Sparse Primary Index — B-tree와의 결정적 차이
WHERE created_at = '2026-04-01 00:20:00' 쿼리는 어떻게 처리되는가?
- primary.idx를 메모리에서 이진 탐색.
- 해당 값이
00:15:00 < value < 00:32:00사이 → granule 1에 있을 수 있음. - 마크 파일
created_at.mrk2에서 granule 1의 .bin 오프셋 조회. - .bin의 해당 블록을 decompress.
- Granule 내부(최대 8192 rows)를 linear scan.
즉 정확히 어디에 있는지는 모르지만, 어느 granule에 있을 수 있는지는 안다. 나머지는 스캔.
이 구조의 장점:
- 인덱스가 매우 작음 → 메모리에 전체 로드 가능.
- 인덱스 유지비용이 작음 → 쓰기 빠름.
- 범위 쿼리에 유리 → "연속된 granule들"만 읽으면 됨.
단점:
- point lookup은 항상 granule 하나 (8192 rows)를 스캔해야 함 → OLTP에는 부적합.
2.4 index_granularity 튜닝
기본 8192. 상황에 따라:
- 매우 선택적 쿼리(대부분 rare row 찾기) → 1024나 2048로 낮춤.
- 대량 스캔이 주 → 16384나 32768로 높임. 인덱스 더 작아짐.
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
data String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;
index_granularity_bytes (Postgres 21.8+ 기본 10MB)도 있다. row 수 기준 granule이 너무 커질 경우 바이트 크기로 잘라준다.
3. ORDER BY — MergeTree의 심장
3.1 ORDER BY는 곧 clustered index
ORDER BY (event_time, user_id)는 데이터가 이 순서로 물리적으로 정렬됨을 의미한다. MySQL의 clustered index와 유사. 그리고 이 컬럼들이 primary key에 자동 포함된다.
이 선택 하나가 성능을 전부 결정한다. 쿼리의 WHERE 조건이 ORDER BY 컬럼을 앞에서부터 맞출수록 극적으로 빨라진다.
3.2 Good vs Bad ORDER BY
서비스에서 WHERE user_id = 123 AND event_time > '2026-04-01' 패턴의 쿼리가 많다면?
Bad: ORDER BY (event_time, user_id)
event_time으로 정렬됨 → user_id 필터는 전체 스캔.
Good: ORDER BY (user_id, event_time)
- user_id로 정렬됨 → 해당 user_id의 연속된 블록만 읽음.
- 그 안에서 event_time이 정렬돼 있어 다시 빠른 범위 조회.
일반적인 규칙: 카디널리티가 낮은 컬럼을 앞에. 국가 코드, 이벤트 타입 같은 것을 앞에 두고, 시간 컬럼을 뒤에.
3.3 PARTITION BY — 물리적 분할
PARTITION BY toYYYYMM(event_time)은 월별로 part를 분리한다. 중요한 점:
- 다른 partition의 part는 절대 merge되지 않음.
- 오래된 partition을
ALTER TABLE t DROP PARTITION '202501'로 통째 삭제. WHERE event_time > ...쿼리에서 관련 없는 partition은 건너뜀 (partition pruning).
파티션 키를 잘못 고르면 성능 파탄:
- 너무 세밀 (예: 시간별) → part 수 폭발 → merge 폭풍.
- 너무 거칠 (예: 연별) → 오래된 데이터 삭제 불가능.
월별이 sweet spot. 일별도 괜찮지만 part 수 관리 필요.
3.4 Primary Key와 ORDER BY의 분리
Postgres와 달리 ClickHouse의 primary key는 unique constraint가 아니다. 단지 sparse index의 기준일 뿐.
ENGINE = MergeTree
PRIMARY KEY (country, event_type)
ORDER BY (country, event_type, event_time)
이렇게 분리하면:
- 인덱스는 (country, event_type)만 포함 → 작음.
- 실제 정렬은 (country, event_type, event_time)까지 → event_time 기준 범위 쿼리도 빠름.
4. Data Skipping Index — 추가 인덱스
4.1 왜 필요한가
Primary index는 ORDER BY 컬럼에 대해서만 효율적이다. 다른 컬럼으로 필터링하면 풀 granule 스캔. data skipping index가 이를 보완한다.
4.2 인덱스 종류
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
GRANULARITY 4는 "4개 granule마다 하나의 skip index entry". 즉 32768 rows 묶음마다 bloom filter 하나.
지원 타입:
- minmax: 각 묶음의 min/max 값. 범위 쿼리에 유용. 숫자/날짜에 최적.
- set(N): N개의 unique 값까지 집합 저장. equality 쿼리에 유용. 저-카디널리티 컬럼에.
- bloom_filter: bloom filter. equality 쿼리에 유용. 고-카디널리티에.
- ngrambf_v1: n-gram bloom filter. 문자열 부분 일치 (
LIKE '%foo%')에 유용. - tokenbf_v1: 토큰 bloom filter. 단어 단위 검색에.
4.3 실전 예
User ID 기반 쿼리가 많지만 ORDER BY에 user_id를 앞에 두기는 어려운 경우:
ALTER TABLE events ADD INDEX idx_user_bf user_id TYPE bloom_filter(0.01) GRANULARITY 4;
이제 WHERE user_id = 12345가 대부분의 granule 그룹을 bloom filter로 건너뛴다.
주의: skipping index는 잘 정렬된 데이터에서만 효과적이다. 같은 user_id의 이벤트가 전체에 흩뿌려져 있으면 bloom filter가 거의 모든 그룹에서 true를 뱉어 도움 안 됨. ORDER BY에 정렬되지 않은 컬럼에 skipping index를 거는 게 기본 전략.
5. Compression — 5–20배의 비밀
5.1 블록 단위 압축
.bin 파일은 블록 단위로 압축된다. 기본 블록 크기 min_compress_block_size = 65536 (64KB). 한 블록은 여러 granule을 포함할 수 있다.
LZ4가 기본. 빠르고(decompression 수 GB/s) 합리적인 압축률. 저장 공간이 중요하면 ZSTD:
CREATE TABLE t (...)
ENGINE = MergeTree
ORDER BY id
SETTINGS
min_compress_block_size = 65536,
max_compress_block_size = 1048576,
compress_on_write = 1;
또는 컬럼별 압축 코덱 지정:
CREATE TABLE t (
id UInt64 CODEC(Delta, ZSTD), -- 증분 값에 최적
created_at DateTime CODEC(DoubleDelta, ZSTD), -- 시계열에 최적
value Float64 CODEC(Gorilla, LZ4), -- 시계열 float에 최적 (Facebook Gorilla)
category LowCardinality(String), -- 저 카디널리티 string
big_text String CODEC(ZSTD(3)) -- 긴 텍스트
)
5.2 특수 코덱
- Delta:
x_n - x_{n-1}. 단조 증가 integer에 완벽. - DoubleDelta: delta의 delta. 시계열 타임스탬프에 최적. 규칙적 간격이면 거의 0 바이트.
- Gorilla: Facebook이 시계열 float용으로 개발. 값 변화가 작으면 수 비트.
- T64: 64비트 integer를 transpose + delta. 비트 수준 압축.
- LZ4, ZSTD: 범용. ZSTD는 LZ4보다 느리지만 압축률 30% 높음.
시계열 테이블 (메트릭, 로그)에서는 적절한 코덱 조합만으로 20–50배 압축을 보는 일이 흔하다.
5.3 LowCardinality — 딕셔너리 인코딩
event_type LowCardinality(String)
ClickHouse가 자동으로 딕셔너리를 만든다. 내부적으로는 UInt8/UInt16으로 저장되고 딕셔너리 테이블로 복원. distinct 값 수가 1만 이하인 문자열 컬럼에 최적. 크기 10배 감소 + 쿼리 5–10배 빠름.
상태값, enum, 카테고리 이름 등 거의 모든 저-카디널리티 문자열은 LowCardinality로 감싸는 게 기본.
6. Merge — Part는 계속 합쳐진다
6.1 왜 Merge인가
LSM과 같은 이유. 매 INSERT가 새 part → part 수가 폭증 → 읽기마다 모든 part를 뒤져야 함. 그래서 백그라운드 스레드가 주기적으로 작은 part들을 큰 part로 merge.
Merge는 정렬된 다수 입력을 정렬된 단일 출력으로 합치는 k-way merge다. O(N log k) — 입력이 이미 정렬돼 있으므로.
6.2 Merge 스케줄링
ClickHouse는 part 수를 제한한다:
- 기본
parts_to_throw_insert = 300. 파티션 내 part 300개 넘으면 INSERT에 에러. - 기본
parts_to_delay_insert = 150. 150개 넘으면 INSERT 지연.
이 신호가 나오면: INSERT 빈도가 너무 잦거나 merge가 못 따라가고 있음.
해결책:
- INSERT를 배치로. 초당 수천 INSERT는 재앙. 수 초마다 10만 row 한 번씩 넣기.
- Buffer engine이나 AsyncInsert 활용 (후술).
6.3 Merge 크기 조절
max_bytes_to_merge_at_max_space_in_pool = 150GB # 한 merge의 최대 크기
max_bytes_to_merge_at_min_space_in_pool = 1MB
background_pool_size = 16 # merge 스레드 수
background_pool_size를 올리면 merge 처리량 증가. 하지만 CPU를 훔쳐가므로 쿼리가 느려질 수 있음. 보통 CPU 코어의 절반 정도가 적당.
6.4 OPTIMIZE — 강제 Merge
OPTIMIZE TABLE events PARTITION '202604' FINAL;
해당 파티션의 모든 part를 하나로 merge. 배치 작업 후 조회 성능 극대화에 쓴다. 단 비싸므로 운영 중에는 신중.
FINAL 키워드의 의미: "최종 병합까지 수행". ReplacingMergeTree 같은 엔진에서 중복 제거도 수행.
7. MergeTree의 변형들 — Engine 계열
7.1 ReplacingMergeTree
같은 primary key를 가진 row를 merge 시 최신 하나만 남김.
CREATE TABLE user_state (
user_id UInt64,
name String,
version UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
UPDATE가 아닌 INSERT로 상태를 갱신한다. version 컬럼이 큰 것이 이긴다.
주의: merge가 일어나기 전에는 중복이 남아있다. 쿼리에 FINAL을 붙이면 merge되지 않은 중복을 런타임에 제거 (비쌈). SELECT argMax(name, version) FROM user_state GROUP BY user_id 같은 쿼리로 우회하는 게 보통.
7.2 SummingMergeTree
같은 primary key row를 merge 시 숫자 컬럼을 합산.
CREATE TABLE metrics_hourly (
metric_name String,
hour DateTime,
value UInt64,
count UInt64
)
ENGINE = SummingMergeTree((value, count))
ORDER BY (metric_name, hour);
매 INSERT마다 증분 값을 넣어도 merge가 자동으로 합산. 사전 집계 테이블에 유용.
7.3 AggregatingMergeTree
가장 강력. 커스텀 집계 함수의 중간 상태를 저장.
CREATE TABLE metrics_agg (
metric_name String,
hour DateTime,
unique_users AggregateFunction(uniq, UInt64),
p99_latency AggregateFunction(quantile(0.99), Float64)
)
ENGINE = AggregatingMergeTree
ORDER BY (metric_name, hour);
INSERT INTO metrics_agg SELECT
metric_name,
toStartOfHour(event_time),
uniqState(user_id),
quantileState(0.99)(latency)
FROM events
GROUP BY metric_name, toStartOfHour(event_time);
SELECT
metric_name,
hour,
uniqMerge(unique_users),
quantileMerge(0.99)(p99_latency)
FROM metrics_agg
GROUP BY metric_name, hour;
uniqState/uniqMerge 같은 "-State" / "-Merge" 함수가 트릭. State는 HyperLogLog 같은 sketch를 저장, Merge는 sketch들을 합쳐 최종 값 계산.
수 TB 원본 테이블에서 GROUP BY가 초 단위로 답하게 만든다.
7.4 CollapsingMergeTree / VersionedCollapsingMergeTree
Sign 컬럼으로 "생성"과 "삭제"를 토글. merge 시 상쇄.
CREATE TABLE user_state (
user_id UInt64,
name String,
sign Int8 -- +1 = insert, -1 = cancel
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY user_id;
INSERT INTO user_state VALUES (1, 'Alice', 1);
-- Alice 변경: 이전 row 취소 후 새 row
INSERT INTO user_state VALUES (1, 'Alice', -1);
INSERT INTO user_state VALUES (1, 'Alice_v2', 1);
Merge 후 sign=-1과 대응되는 sign=1이 상쇄. 상태 머신 저장에 유용하지만 사용 난이도가 높다.
7.5 ReplicatedMergeTree — 복제
위의 모든 엔진에 Replicated 접두사를 붙이면 복제 대상. ZooKeeper(또는 ClickHouse Keeper)가 메타데이터 조정.
CREATE TABLE events_local (
event_time DateTime,
user_id UInt64,
...
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
경로 안의 shard/replica 플레이스홀더는 config에서 치환된다. 같은 shard의 replica끼리는 part 단위로 동기화 — INSERT한 replica가 part를 만들면 ZooKeeper에 기록, 다른 replica가 당겨옴.
8. Distributed Table — Sharding
8.1 구조
ClickHouse는 shared-nothing. 각 shard가 독립된 노드, 데이터를 분산 저장. 그 위에 Distributed 가상 테이블이 올라간다.
CREATE TABLE events ON CLUSTER my_cluster (...)
ENGINE = ReplicatedMergeTree(...)
ORDER BY (...);
CREATE TABLE events_distributed ON CLUSTER my_cluster
AS events
ENGINE = Distributed(my_cluster, default, events, rand());
Distributed(cluster, db, table, sharding_key):
- cluster: config에 정의된 클러스터 이름.
- sharding_key: INSERT 시 어느 shard로 보낼지 결정.
8.2 쿼리 분산
SELECT ... FROM events_distributed WHERE ... 는:
- 코디네이터 노드가 모든 shard에 쿼리 전파.
- 각 shard가 로컬 part에서 부분 결과 생성.
- 코디네이터가 결과를 모아 최종 집계.
GROUP BY는 자동으로 2단계: 각 shard에서 partial aggregate, 코디네이터에서 final aggregate.
8.3 Sharding Key 설계
rand(): 균등 분산. 조인 어려움.
cityHash64(user_id): 같은 user_id는 같은 shard로. user별 JOIN/GROUP BY에 유리. 단 skew 위험.
사용자 정의: 비즈니스 도메인에 따라. 예: tenant_id % N.
원칙: 조인/집계가 같은 shard 안에서 일어나게 하라. Cross-shard join은 치명적.
8.4 Replication + Sharding
전형적 셋업: 3 shard × 2 replica = 6 노드.
shard1: node1 (replica1), node2 (replica2)
shard2: node3 (replica1), node4 (replica2)
shard3: node5 (replica1), node6 (replica2)
데이터는 3-way sharding, 각 shard는 2-way replication.
9. ClickHouse Keeper — ZooKeeper와의 작별
9.1 ZooKeeper의 짐
ClickHouse는 오랫동안 ZooKeeper를 메타데이터 조정용으로 썼다. Replication, DDL 등. 하지만 ZK는 JVM 기반이라 운영 복잡도가 높고, 매우 많은 znode에서 성능이 떨어진다.
9.2 Keeper의 등장
2021년 ClickHouse Keeper가 등장. ZooKeeper와 wire protocol 호환. C++로 작성, ClickHouse 서버에 내장 또는 독립 실행 가능.
장점:
- SSD 친화적 (Raft 기반 snapshot).
- JVM 의존성 제거.
- 수 GB의 메타데이터도 원활.
Keeper는 Raft 글의 실제 응용이다. 내부는 NuRaft 라이브러리 기반.
10. Materialized View — 실시간 집계
10.1 개념
INSERT 트리거. 원본 테이블에 INSERT하면 자동으로 대상 테이블에 집계된 row가 INSERT된다.
CREATE MATERIALIZED VIEW events_hourly
ENGINE = SummingMergeTree
ORDER BY (hour, event_type)
AS SELECT
toStartOfHour(event_time) AS hour,
event_type,
count() AS cnt,
sum(value) AS sum_value
FROM events
GROUP BY hour, event_type;
이제 events에 INSERT할 때마다:
- 원본은 그대로 저장.
events_hourly에 시간별/타입별 부분 집계가 INSERT.SummingMergeTree가 백그라운드에서 합산.
쿼리는 events_hourly에서 직접:
SELECT hour, event_type, sum(cnt), sum(sum_value)
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, event_type;
10억 row events에서 시간당 집계를 초 단위로 답한다.
10.2 주의점
- Materialized view는 INSERT 시점에만 실행. 과거 데이터는 다시 넣거나
POPULATE써야. - 원본 테이블에 INSERT가 실패하면 MV도 롤백.
- 너무 많은 MV는 INSERT를 느리게 한다.
- MV는 "view"가 아니라 실제 테이블이다. 디스크를 먹고 자체 part를 만든다.
10.3 TO 절
CREATE TABLE events_hourly (...) ENGINE = SummingMergeTree ORDER BY (...);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT ... FROM events;
TO로 기존 테이블 지정. 더 명시적, 권장.
10.4 여러 단계 집계
-- 1단계: 시간별
CREATE MATERIALIZED VIEW events_hourly TO events_hourly_dest AS
SELECT toStartOfHour(event_time) AS hour, ... FROM events GROUP BY hour, ...;
-- 2단계: 일별 (시간별 위에)
CREATE MATERIALIZED VIEW events_daily TO events_daily_dest AS
SELECT toDate(hour) AS day, ... FROM events_hourly_dest GROUP BY day, ...;
계단형 집계. BI 대시보드에 매우 효과적.
11. Projection — 같은 테이블에 여러 인덱스
11.1 문제
MergeTree는 ORDER BY 하나만 가능. 하지만 WHERE user_id = ... 쿼리와 WHERE event_time BETWEEN ... 쿼리가 둘 다 빠르려면?
예전에는 두 개의 MV로 해결: 동일 데이터, 다른 ORDER BY. 저장소 2배.
11.2 Projection
Projection은 같은 데이터의 "다른 정렬 순서 + 집계" 저장. 같은 part 내부에 보관.
ALTER TABLE events ADD PROJECTION user_proj (
SELECT * ORDER BY user_id
);
ALTER TABLE events MATERIALIZE PROJECTION user_proj;
이제 옵티마이저가 자동으로 판단: "WHERE user_id ... 쿼리면 user_proj를 쓰자".
저장은 증가하지만 (1.5–2배) 운영은 단일 테이블.
11.3 Aggregating Projection
MV의 AggregatingMergeTree와 비슷한 효과:
ALTER TABLE events ADD PROJECTION hourly_agg (
SELECT
toStartOfHour(event_time) AS hour,
event_type,
count(),
sum(value)
GROUP BY hour, event_type
);
12. Vectorized Execution Engine — 속도의 진짜 비밀
12.1 Row-at-a-time vs Vectorized
전통적 DB 엔진(Volcano 모델)은 한 번에 한 row씩 처리:
while (row = scan->next()) {
if (filter(row)) {
project(row);
aggregate(row);
}
}
각 연산이 함수 호출. 분기 예측 실패, CPU 캐시 misses.
Vectorized는 배치 (보통 65536 rows)씩 처리:
while (batch = scan->next_batch(65536)) {
mask = filter(batch); // SIMD로 필터링
projected = project(batch, mask); // 컬럼 전체 변환
aggregate_batch(projected); // 누적 합 SIMD
}
- 함수 호출 오버헤드 분산 (65536 rows당 한 번).
- SIMD 활용.
- CPU 캐시 친화적 (한 컬럼 배치가 L1/L2에 머무름).
DuckDB가 비슷한 모델 — DuckDB 글에서 다뤘다.
12.2 ClickHouse의 블록
ClickHouse 내부 단위는 Block. 컬럼들의 묶음. 기본 max_block_size = 65505. 한 스캔 단계는 한 블록을 읽어 다음 단계에 전달.
Block[0] = { col1: [val, val, ...], col2: [val, val, ...], ... } // 65505 rows
Block[1] = ...
연산자 트리: ReadFromMergeTree → Filter → Projection → Aggregator → .... 각 단계가 블록 단위로 소비/생산.
12.3 JIT Compilation
ClickHouse는 LLVM을 활용해 런타임 JIT 컴파일. 예:
SELECT (a + b) * c - d FROM t;
이 표현식은 매 row마다 interpreter로 실행하면 느림. JIT가 한번에 네이티브 코드로 변환:
; 벡터화된 어셈블리
vpaddd ymm0, ymm1, ymm2 ; a + b
vpmulld ymm0, ymm0, ymm3 ; * c
vpsubd ymm0, ymm0, ymm4 ; - d
10–100배 빠름. compile_expressions = 1 (기본).
12.4 Parallelism
한 쿼리가 CPU 코어를 어떻게 활용하는가:
- Part 병렬: 쿼리가 읽을 part 여럿을 각 코어에 분배.
- Pipeline 병렬: 연산자 파이프라인을 여러 스레드로.
- Shard 병렬: Distributed 테이블이면 여러 노드에 분산.
max_threads 파라미터가 스레드 수 제한. 기본은 코어 수.
13. AsyncInsert — 작은 INSERT의 구원
13.1 문제
ClickHouse는 배치 INSERT 선호. 하지만 Kafka consumer나 이벤트 스트림은 작은 INSERT를 자주 보낸다. 각 INSERT가 새 part → merge 폭풍 → 성능 붕괴.
13.2 해결책
INSERT INTO events SETTINGS async_insert=1 VALUES ...;
서버가 여러 INSERT를 메모리 버퍼에 모음. 조건 충족 시 한 번에 part 생성:
async_insert_max_data_size(기본 1MB)async_insert_busy_timeout_ms(기본 200ms)
wait_for_async_insert=1이면 클라이언트는 실제 디스크 flush까지 기다림 (durability 보장). 0이면 버퍼 도달만 확인 (빠르지만 손실 가능).
13.3 Buffer Engine (구식)
CREATE TABLE events_buffer AS events
ENGINE = Buffer('default', 'events', 16, 10, 60, 10000, 1000000, 10485760, 1073741824);
Buffer engine도 비슷한 역할. 메모리 버퍼 + 주기적 flush. 단 이 테이블이 죽으면 유실. AsyncInsert가 권장 방식.
14. 실전 운영
14.1 좋은 스키마의 체크리스트
CREATE TABLE events (
event_time DateTime CODEC(DoubleDelta, ZSTD),
user_id UInt64,
event_type LowCardinality(String), -- 저-카디널리티
country LowCardinality(String),
device LowCardinality(String),
url String CODEC(ZSTD(3)),
duration_ms UInt32 CODEC(T64, LZ4),
session_id UUID,
data String CODEC(ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time) -- 월별
ORDER BY (country, event_type, event_time, user_id) -- 카디 낮은 것 먼저
SETTINGS index_granularity = 8192;
ALTER TABLE events ADD INDEX idx_user user_id TYPE bloom_filter GRANULARITY 4;
DateTime에DoubleDelta코덱.- 저-카디 string은
LowCardinality. - ORDER BY 앞은 저-카디.
- skipping index로 후순위 컬럼 보완.
14.2 모니터링 쿼리
part 수 체크:
SELECT database, table, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY parts DESC;
part 수가 수천 넘으면 경고.
압축률:
SELECT
database, table,
formatReadableSize(sum(bytes_on_disk)) AS disk,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
sum(data_uncompressed_bytes) / sum(bytes_on_disk) AS ratio
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY ratio;
ratio 5 미만이면 코덱/스키마 재검토.
slow queries:
SELECT query_duration_ms, query, user, memory_usage
FROM system.query_log
WHERE event_date >= today() AND type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 10;
14.3 안티 패턴
- 자주 UPDATE/DELETE: ClickHouse에 UPDATE는 비동기 ALTER. 수초~수분 지연. 사용 지양.
- 작은 INSERT: 초당 수천 INSERT → 재앙. 배치 또는 AsyncInsert.
- 과도한 컬럼: 수백 컬럼 테이블은 쓰기 비용 폭발. wide → narrow 테이블 고려.
- ORDER BY에 고-카디 컬럼 앞: primary index가 무쓸모.
- partition 너무 세밀: hour 단위 partition은 거의 항상 재앙. day 이하로 가지 말 것.
14.4 마이그레이션 전략
기존 Postgres/MySQL OLTP에서 OLAP 쿼리를 ClickHouse로 옮기기:
- Debezium + Kafka로 CDC. 원본 OLTP의 변경을 캡처.
- ClickHouse Kafka engine이 consume.
- Materialized view로 Kafka table → MergeTree table 적재.
이 파이프라인은 분 이하의 지연으로 OLTP 데이터를 OLAP 분석 가능하게 만든다.
15. ClickHouse의 한계 — 모든 것의 답은 아니다
15.1 잘 맞지 않는 워크로드
- OLTP: point update, 단일 row 조회. 가능하지만 느림.
- 많은 조인: star schema의 한두 조인은 괜찮지만 6–10 way join은 고통.
- Ad-hoc 탐색: Trino/Presto가 더 유리 (Parquet/Iceberg 직접 조회).
- 실시간 개별 이벤트 조회: 개별 레코드 조회는 granule 스캔 필요 → 느림.
15.2 경쟁 구도
- Snowflake / BigQuery: 완전 관리형, 거의 모든 SQL. 비쌈. ClickHouse는 10–100배 저렴.
- Druid / Pinot: 실시간 ingest + 쿼리. ClickHouse와 가까운 경쟁자. 운영 더 복잡.
- DuckDB: 단일 노드. 인메모리/작은 데이터. 역할 다름.
- StarRocks / Doris: 중국발 MPP DB. 조인 성능이 ClickHouse보다 낫다고 주장.
15.3 ClickHouse의 현재 가치 제안
- 오픈소스.
- 단일 노드에서 TB급, 클러스터로 PB급.
- SQL 호환성 괜찮음.
- 초당 수십억 row 스캔 가능.
- 운영 비용이 매우 낮음.
그래서 Observability(로그/메트릭/트레이스), 웹 분석, 리얼타임 대시보드, CDN 로그 분석 등에서 표준이 됐다.
맺음 — 10억 줄을 1초에
ClickHouse의 비밀을 한 줄로: "컬럼 저장 + 희소 인덱스 + 벡터화 실행". 이 세 가지가 서로를 증폭시켜 10–1000배의 속도를 만든다.
운영자로서 본능적으로 체크할 세 가지:
- ORDER BY를 제대로 설계했는가. 이 한 줄이 성능을 결정한다. 카디 낮은 컬럼 먼저.
- 작은 INSERT를 막았는가. AsyncInsert, 배치, Kafka 엔진 중 하나로 안정화.
- Part 수를 감시하는가. 수천 넘으면 merge가 못 따라가는 중. 확인하라.
이 세 가지가 깔려 있으면 ClickHouse는 거의 마법 같은 성능을 보여준다.
더 큰 그림에서, 지난 세 글은 저장 엔진의 스펙트럼을 훑었다:
- Postgres: 행 저장 + B-tree + MVCC. OLTP의 황제.
- LSM / RocksDB: 행 저장 + LSM + KV. 쓰기 중심 분산 시스템.
- ClickHouse: 열 저장 + MergeTree + 벡터 실행. 분석의 챔피언.
세 시스템은 같은 "데이터베이스"라는 단어를 공유하지만 완전히 다른 생명체다. 각자의 유전자를 이해하는 것이 올바른 도구를 고르는 첫걸음이다.
다음 글은 TLS 1.3과 QUIC의 내부를 다룬다. 모든 HTTPS 요청의 숨은 춤을 바이트 수준에서 풀어낸다. 암호화도, 네트워크도, 성능도 전부 한 주제에서 만난다.
ClickHouse MergeTree — The Complete Guide to Columnar, Sparse Index, Part, and Materialized View (2025)
Prologue — How the Throne of OLAP Changed Hands
Through the early 2020s, "large-scale analytics" had a predictable menu. Cloud warehouses like Redshift, BigQuery, and Snowflake. Or on-prem batch tools like Druid, Presto/Trino, and Impala. Each had its trade-offs — cost, latency, query dialect, operational complexity.
Then ClickHouse arrived.
Yandex (Russia's Google) had been building it since 2009 for its internal web analytics product, Yandex.Metrica, and open-sourced it in 2016. As of 2026, ClickHouse is the de facto standard for open-source OLAP. Uber, Cloudflare, Spotify, and eBay use it. A new generation of observability tools (SigNoz, Posthog) is built on top of it. ClickHouse Cloud (the company) is a unicorn.
Why did it win? In one word: speed. You can run GROUP BY queries over a billion-row table at roughly one per second. Work that takes Postgres minutes on the same hardware finishes in under a second here. That 10×–1000× gap is not the result of a single optimization — it is the cumulative effect of deliberate choices at every layer: storage, indexing, and the execution engine.
This post dissects the internals of ClickHouse, especially the MergeTree engine family, across 1,400 lines. The byte layout of columnar storage, the structure of parts and granules, how the sparse primary index differs from a B-tree, how materialized views produce real-time aggregates, how distributed tables and replication work, and how the vectorized execution engine keeps the CPU cache humming.
This post is a sequel to the LSM-Tree post. MergeTree is the OLAP cousin of LSM, and many of its design decisions reuse LSM principles. At the same time, it stands on the opposite side of the Postgres post — evidence that OLTP and OLAP are too different to share the word "database" comfortably.
1. Columnar Storage — Why Store by Column
1.1 Row Store vs. Column Store
Traditional databases store data row by row. All the column values for a single row sit contiguously on disk.
Row layout:
(id=1, name="Alice", age=30, city="Seoul")
(id=2, name="Bob", age=25, city="Tokyo")
(id=3, name="Carol", age=35, city="Seoul")
On disk:
1, Alice, 30, Seoul | 2, Bob, 25, Tokyo | 3, Carol, 35, Seoul
This layout is optimal for OLTP. Reading or writing all columns of one row in a single operation is fast.
Columnar flips it completely:
id: [1, 2, 3]
name: [Alice, Bob, Carol]
age: [30, 25, 35]
city: [Seoul, Tokyo, Seoul]
Each column lives in its own file/region.
1.2 Why It Wins for OLAP
Consider the query SELECT city, AVG(age) FROM users GROUP BY city.
- Row store: reads every column of every row. With 10 columns, that is 10× the needed I/O.
- Column store: reads only the
cityandagecolumn files. I/O drops by 80%.
More benefits:
- Compression ratio: values of the same type lie next to each other, which compresses dramatically well. Delta encoding for integer columns, dictionary encoding for strings. Typically 5–20× compression.
- CPU cache: processing values of the same type one batch at a time benefits CPU pipelining and SIMD.
- Selective scan: reading fewer columns means scanning less data.
Downsides:
- Row reconstruction cost: to see all values of a single row, you must read the same index from multiple column files.
- Writes are expensive: a single INSERT touches many files. So OLAP prefers batch INSERTs.
- UPDATE/DELETE is very expensive: ClickHouse uses an "ALTER to mutate" model and essentially gives up on live row-level updates.
1.3 ClickHouse's Column Files
Each column of a MergeTree table lives on disk as two files:
column_name.bin # actual data (compressed)
column_name.mrk2 # marks file — granule boundary offsets
The .bin file is compressed in blocks (LZ4 by default, ZSTD optionally). A block is sized by index_granularity_bytes (default 10MB) or max_compress_block_size (default 1MB).
.mrk2 records "at what offset in the .bin file each granule begins." We will unpack granules next.
2. Parts and Granules — The Units of MergeTree
2.1 Part — MergeTree's SSTable
A part in MergeTree plays the same role as an SSTable in LSM. An immutable, sorted chunk of data.
An INSERT creates a new part. One INSERT INTO t VALUES (...) becomes one part. Background merges combine multiple parts into larger parts.
Disk layout:
/var/lib/clickhouse/data/<db>/<table>/
├── 202604_1_1_0/ # part name
│ ├── checksums.txt
│ ├── columns.txt # column list
│ ├── count.txt # row count
│ ├── primary.idx # sparse primary index
│ ├── minmax_created_at.idx # per-partition min/max
│ ├── partition.dat
│ ├── id.bin, id.mrk2
│ ├── name.bin, name.mrk2
│ ├── age.bin, age.mrk2
│ └── ...
├── 202604_2_2_0/
└── 202604_1_2_1/ # merge result
What the name 202604_1_2_1 means:
202604: partition id (monthly by default).1_2: min_block_number = 1, max_block_number = 2 (the INSERT range this part covers)._1: merge level (0 = just inserted).
2.2 Granule — The Smallest Indexing Unit
A granule is the smallest unit that the index points to inside a part. The default is 8192 rows per granule.
Why granules? A B-tree can hold one index entry per row. But for a billion rows that index would take gigabytes. ClickHouse uses a sparse index: one index entry per 8192 rows. That keeps the primary index of a billion-row table in the low MBs.
granule 0: row 0 - row 8191
granule 1: row 8192 - row 16383
granule 2: row 16384 - row 24575
...
The primary key of the first row of each granule goes into the primary.idx file:
primary.idx:
granule 0: (2026-04-01 00:00:00)
granule 1: (2026-04-01 00:15:00)
granule 2: (2026-04-01 00:32:00)
...
2.3 Sparse Primary Index — The Decisive Difference from a B-tree
How does WHERE created_at = '2026-04-01 00:20:00' get processed?
- Binary-search the in-memory primary.idx.
- The value falls between
00:15:00 < value < 00:32:00→ it might be in granule 1. - Look up the .bin offset for granule 1 in the marks file
created_at.mrk2. - Decompress the corresponding block of the .bin file.
- Linear-scan within the granule (up to 8192 rows).
In other words, we do not know exactly where a value is, but we know which granule it could be in. The rest is a scan.
Advantages:
- The index is tiny → entirely loadable into memory.
- Index maintenance cost is low → writes are fast.
- Range queries benefit — you just read a contiguous run of granules.
Drawback:
- A point lookup always scans one full granule (8192 rows) → unsuitable for OLTP.
2.4 Tuning index_granularity
Default is 8192. Depending on workload:
- Very selective queries (finding rare rows most of the time) → lower to 1024 or 2048.
- Large scans are the norm → raise to 16384 or 32768. The index shrinks further.
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
data String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;
There is also index_granularity_bytes (default 10MB since 21.8). If a row-count-sized granule grows too large in bytes, it gets chopped by byte size.
3. ORDER BY — The Heart of MergeTree
3.1 ORDER BY Is a Clustered Index
ORDER BY (event_time, user_id) means the data is physically sorted in that order. Similar to MySQL's clustered index. And those columns automatically belong to the primary key.
This one choice determines all the performance. The more your query's WHERE clause matches the ORDER BY columns from the left, the more dramatic the speedup.
3.2 Good vs. Bad ORDER BY
Suppose your service frequently runs queries like WHERE user_id = 123 AND event_time > '2026-04-01'.
Bad: ORDER BY (event_time, user_id)
- Sorted by
event_time→ the user_id filter forces a full scan.
Good: ORDER BY (user_id, event_time)
- Sorted by user_id → only the contiguous block for that user_id is read.
- Within that block event_time is sorted, giving another fast range scan.
Rule of thumb: lower-cardinality columns first. Put things like country code or event type first, and time columns later.
3.3 PARTITION BY — Physical Splitting
PARTITION BY toYYYYMM(event_time) separates parts by month. Key points:
- Parts from different partitions are never merged together.
- Drop an old partition wholesale with
ALTER TABLE t DROP PARTITION '202501'. - Queries with
WHERE event_time > ...skip unrelated partitions (partition pruning).
Choose the partition key poorly and you ruin performance:
- Too fine (e.g., hourly) → part count explodes → merge storm.
- Too coarse (e.g., yearly) → impossible to drop old data gracefully.
Monthly is the sweet spot. Daily is fine too but demands part-count management.
3.4 Separating Primary Key and ORDER BY
Unlike Postgres, ClickHouse's primary key is not a unique constraint. It is purely the basis for the sparse index.
ENGINE = MergeTree
PRIMARY KEY (country, event_type)
ORDER BY (country, event_type, event_time)
Separating them like this means:
- The index covers only (country, event_type) → small.
- Actual sort order extends to (country, event_type, event_time) → range queries by event_time are still fast.
4. Data Skipping Index — Additional Indexes
4.1 Why We Need Them
The primary index is efficient only for ORDER BY columns. Filtering by other columns forces a full-granule scan. Data skipping indexes cover that gap.
4.2 Index Types
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
GRANULARITY 4 means "one skip-index entry per 4 granules." That is, one bloom filter per 32768-row block.
Supported types:
- minmax: min/max value per block. Good for range queries, ideal for numbers/dates.
- set(N): stores up to N unique values. Good for equality queries on low-cardinality columns.
- bloom_filter: a bloom filter. Good for equality queries on high-cardinality columns.
- ngrambf_v1: n-gram bloom filter. Good for string substring matching (
LIKE '%foo%'). - tokenbf_v1: token-level bloom filter. Good for word-level search.
4.3 A Real Example
User-ID-based queries are frequent, but putting user_id at the front of ORDER BY is hard:
ALTER TABLE events ADD INDEX idx_user_bf user_id TYPE bloom_filter(0.01) GRANULARITY 4;
Now WHERE user_id = 12345 can skip most granule groups via the bloom filter.
Caveat: skipping indexes are only effective on well-sorted data. If events for the same user_id are scattered throughout the table, the bloom filter returns true for almost every group and the index barely helps. The standard strategy is to add a skipping index to a column that is NOT part of the ORDER BY.
5. Compression — The Secret Behind 5–20×
5.1 Block-Level Compression
The .bin file is compressed in blocks. The default block size is min_compress_block_size = 65536 (64KB). A block can include multiple granules.
LZ4 is the default — fast (several GB/s decompression) with a reasonable ratio. For disk-space-sensitive deployments, ZSTD:
CREATE TABLE t (...)
ENGINE = MergeTree
ORDER BY id
SETTINGS
min_compress_block_size = 65536,
max_compress_block_size = 1048576,
compress_on_write = 1;
Or per-column codecs:
CREATE TABLE t (
id UInt64 CODEC(Delta, ZSTD), -- optimal for incremental values
created_at DateTime CODEC(DoubleDelta, ZSTD), -- optimal for time series
value Float64 CODEC(Gorilla, LZ4), -- optimal for time-series floats (Facebook Gorilla)
category LowCardinality(String), -- low-cardinality string
big_text String CODEC(ZSTD(3)) -- long text
)
5.2 Specialized Codecs
- Delta:
x_n - x_{n-1}. Perfect for monotonically increasing integers. - DoubleDelta: delta of deltas. Optimal for time-series timestamps. With regular intervals, nearly zero bytes.
- Gorilla: developed by Facebook for time-series floats. A few bits per value when changes are small.
- T64: transposes 64-bit integers and applies delta. Bit-level compression.
- LZ4, ZSTD: general purpose. ZSTD is slower than LZ4 but ~30% denser.
For time-series tables (metrics, logs), the right codec combination alone commonly yields 20–50× compression.
5.3 LowCardinality — Dictionary Encoding
event_type LowCardinality(String)
ClickHouse builds the dictionary automatically. Under the hood values are stored as UInt8/UInt16 and restored through a dictionary table. Ideal for string columns with 10K or fewer distinct values. Roughly 10× smaller + 5–10× faster queries.
Status enums, category names — wrap nearly every low-cardinality string in LowCardinality by default.
6. Merge — Parts Keep Merging
6.1 Why Merge
Same reason as LSM. Every INSERT creates a new part → part count explodes → every read scans every part. So a background thread periodically merges small parts into bigger ones.
A merge is a k-way merge: many sorted inputs into one sorted output. O(N log k) — because inputs are already sorted.
6.2 Merge Scheduling
ClickHouse caps part counts:
parts_to_throw_insert = 300by default. Over 300 parts per partition and INSERTs error out.parts_to_delay_insert = 150by default. Over 150 and INSERTs slow down.
If you see these: INSERTs are too frequent or merge cannot keep up.
Fixes:
- Batch INSERTs. Thousands of INSERTs per second are a disaster. Instead, push ~100K rows every few seconds.
- Use the Buffer engine or AsyncInsert (covered below).
6.3 Sizing Merges
max_bytes_to_merge_at_max_space_in_pool = 150GB # max single merge size
max_bytes_to_merge_at_min_space_in_pool = 1MB
background_pool_size = 16 # number of merge threads
Raising background_pool_size increases merge throughput. But it steals CPU from queries, so around half the CPU cores is usually sensible.
6.4 OPTIMIZE — Force a Merge
OPTIMIZE TABLE events PARTITION '202604' FINAL;
Merges every part in that partition into one. Used after batch jobs to maximize scan performance. Expensive — use carefully in production.
The FINAL keyword means "perform the final merge." For engines like ReplacingMergeTree it also performs deduplication.
7. The MergeTree Variants — The Engine Family
7.1 ReplacingMergeTree
At merge time, only the latest row survives among those with the same primary key.
CREATE TABLE user_state (
user_id UInt64,
name String,
version UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
Instead of UPDATE, you INSERT a new state. The row with the larger version wins.
Caveat: duplicates linger until a merge runs. Adding FINAL to queries resolves duplicates at query time (expensive). A common workaround is a query like SELECT argMax(name, version) FROM user_state GROUP BY user_id.
7.2 SummingMergeTree
At merge time, numeric columns are summed across rows sharing the same primary key.
CREATE TABLE metrics_hourly (
metric_name String,
hour DateTime,
value UInt64,
count UInt64
)
ENGINE = SummingMergeTree((value, count))
ORDER BY (metric_name, hour);
Insert incremental values and merges sum them automatically. Great for pre-aggregation tables.
7.3 AggregatingMergeTree
The most powerful one. Stores the intermediate state of custom aggregate functions.
CREATE TABLE metrics_agg (
metric_name String,
hour DateTime,
unique_users AggregateFunction(uniq, UInt64),
p99_latency AggregateFunction(quantile(0.99), Float64)
)
ENGINE = AggregatingMergeTree
ORDER BY (metric_name, hour);
INSERT INTO metrics_agg SELECT
metric_name,
toStartOfHour(event_time),
uniqState(user_id),
quantileState(0.99)(latency)
FROM events
GROUP BY metric_name, toStartOfHour(event_time);
SELECT
metric_name,
hour,
uniqMerge(unique_users),
quantileMerge(0.99)(p99_latency)
FROM metrics_agg
GROUP BY metric_name, hour;
The "-State" / "-Merge" function pair is the trick. State stores sketches like HyperLogLog, Merge combines the sketches into the final value.
It lets a GROUP BY over a multi-TB source table answer in seconds.
7.4 CollapsingMergeTree / VersionedCollapsingMergeTree
A sign column toggles "insert" and "delete". On merge, opposites cancel each other.
CREATE TABLE user_state (
user_id UInt64,
name String,
sign Int8 -- +1 = insert, -1 = cancel
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY user_id;
INSERT INTO user_state VALUES (1, 'Alice', 1);
-- Alice changes: cancel the old row then add the new one
INSERT INTO user_state VALUES (1, 'Alice', -1);
INSERT INTO user_state VALUES (1, 'Alice_v2', 1);
After a merge the matched sign=-1 and sign=1 rows cancel. Useful for state-machine storage, but it is hard to use correctly.
7.5 ReplicatedMergeTree — Replication
Prefix any of the engines above with Replicated and it becomes replicated. ZooKeeper (or ClickHouse Keeper) coordinates metadata.
CREATE TABLE events_local (
event_time DateTime,
user_id UInt64,
...
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
The shard/replica placeholders in the path are substituted from config. Replicas in the same shard sync at the part level — the replica that received the INSERT produces a part, records it in ZooKeeper, and the others pull it.
8. Distributed Table — Sharding
8.1 Structure
ClickHouse is shared-nothing. Each shard is an independent node holding part of the data. A Distributed virtual table sits on top.
CREATE TABLE events ON CLUSTER my_cluster (...)
ENGINE = ReplicatedMergeTree(...)
ORDER BY (...);
CREATE TABLE events_distributed ON CLUSTER my_cluster
AS events
ENGINE = Distributed(my_cluster, default, events, rand());
Distributed(cluster, db, table, sharding_key):
- cluster: the cluster name defined in config.
- sharding_key: decides which shard an INSERT targets.
8.2 Query Distribution
SELECT ... FROM events_distributed WHERE ... goes like this:
- The coordinator node fans out the query to every shard.
- Each shard produces partial results from its local parts.
- The coordinator gathers the partial results and aggregates the final answer.
GROUP BY is automatically two-phase: partial aggregate per shard, final aggregate on the coordinator.
8.3 Sharding Key Design
rand(): even distribution. Hard to JOIN.
cityHash64(user_id): the same user_id lands on the same shard. Ideal for per-user JOIN/GROUP BY. Risk: skew.
Custom: per your business domain. For example, tenant_id % N.
Principle: keep joins and aggregations inside a single shard. Cross-shard joins are lethal.
8.4 Replication + Sharding
A canonical setup: 3 shards × 2 replicas = 6 nodes.
shard1: node1 (replica1), node2 (replica2)
shard2: node3 (replica1), node4 (replica2)
shard3: node5 (replica1), node6 (replica2)
Data is 3-way sharded, and each shard is 2-way replicated.
9. ClickHouse Keeper — Goodbye ZooKeeper
9.1 The Weight of ZooKeeper
For a long time ClickHouse used ZooKeeper to coordinate metadata — replication, DDL, etc. But ZK is JVM-based, meaning high operational complexity, and performance degrades with a very large number of znodes.
9.2 Enter Keeper
In 2021 ClickHouse Keeper arrived. Wire-protocol compatible with ZooKeeper. Written in C++, embeddable inside the ClickHouse server or runnable standalone.
Advantages:
- SSD-friendly (Raft-based snapshots).
- No JVM dependency.
- Handles multi-GB of metadata smoothly.
Keeper is a real-world application of Raft. Under the hood it is built on the NuRaft library.
10. Materialized View — Real-Time Aggregation
10.1 Concept
An INSERT trigger. When you INSERT into the source table, aggregated rows are automatically INSERTed into the target table.
CREATE MATERIALIZED VIEW events_hourly
ENGINE = SummingMergeTree
ORDER BY (hour, event_type)
AS SELECT
toStartOfHour(event_time) AS hour,
event_type,
count() AS cnt,
sum(value) AS sum_value
FROM events
GROUP BY hour, event_type;
Now every time you INSERT into events:
- The raw INSERT is stored as-is.
- A partial aggregate by hour/type is INSERTed into
events_hourly. SummingMergeTreesums them in the background.
Query directly from events_hourly:
SELECT hour, event_type, sum(cnt), sum(sum_value)
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, event_type;
Hourly aggregates on a billion-row events table answer in seconds.
10.2 Caveats
- A materialized view runs only at INSERT time. Historical data must be re-inserted or loaded via
POPULATE. - If the INSERT into the source fails, the MV is rolled back.
- Too many MVs slow INSERTs down.
- An MV is not a "view" — it is an actual table. It consumes disk and produces its own parts.
10.3 The TO Clause
CREATE TABLE events_hourly (...) ENGINE = SummingMergeTree ORDER BY (...);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT ... FROM events;
The TO clause points at an existing table. More explicit and recommended.
10.4 Multi-Stage Aggregation
-- Stage 1: hourly
CREATE MATERIALIZED VIEW events_hourly TO events_hourly_dest AS
SELECT toStartOfHour(event_time) AS hour, ... FROM events GROUP BY hour, ...;
-- Stage 2: daily (on top of hourly)
CREATE MATERIALIZED VIEW events_daily TO events_daily_dest AS
SELECT toDate(hour) AS day, ... FROM events_hourly_dest GROUP BY day, ...;
A cascading aggregation. Very effective for BI dashboards.
11. Projection — Multiple Indexes on the Same Table
11.1 The Problem
A MergeTree has only one ORDER BY. But what if you want both WHERE user_id = ... and WHERE event_time BETWEEN ... to be fast?
Historically the answer was two MVs: the same data with a different ORDER BY. Storage doubles.
11.2 Projection
A projection stores the same data with a different sort order and/or aggregation. It lives inside the same part.
ALTER TABLE events ADD PROJECTION user_proj (
SELECT * ORDER BY user_id
);
ALTER TABLE events MATERIALIZE PROJECTION user_proj;
The optimizer now decides on the fly: "this is a WHERE user_id ... query, use user_proj."
Storage grows (1.5–2×) but operations stay single-table.
11.3 Aggregating Projection
Works similarly to an AggregatingMergeTree MV:
ALTER TABLE events ADD PROJECTION hourly_agg (
SELECT
toStartOfHour(event_time) AS hour,
event_type,
count(),
sum(value)
GROUP BY hour, event_type
);
12. Vectorized Execution Engine — The Real Secret of Speed
12.1 Row-at-a-Time vs. Vectorized
A traditional DB engine (the Volcano model) processes one row at a time:
while (row = scan->next()) {
if (filter(row)) {
project(row);
aggregate(row);
}
}
Each operator is a function call. Branch mispredictions, CPU cache misses.
Vectorized processes batches (typically 65536 rows) at a time:
while (batch = scan->next_batch(65536)) {
mask = filter(batch); // SIMD filter
projected = project(batch, mask); // transform the whole column
aggregate_batch(projected); // SIMD accumulation
}
- Function-call overhead amortizes (once per 65536 rows).
- Leverages SIMD.
- CPU-cache friendly (a column batch stays in L1/L2).
DuckDB uses a similar model — covered in the DuckDB post.
12.2 ClickHouse's Block
The internal unit in ClickHouse is the Block — a bundle of columns. Default max_block_size = 65505. A scan step reads one block and passes it to the next.
Block[0] = { col1: [val, val, ...], col2: [val, val, ...], ... } // 65505 rows
Block[1] = ...
Operator tree: ReadFromMergeTree → Filter → Projection → Aggregator → .... Each stage consumes/produces blocks.
12.3 JIT Compilation
ClickHouse uses LLVM for runtime JIT compilation. Example:
SELECT (a + b) * c - d FROM t;
Running this expression per row via an interpreter is slow. JIT converts it into native code in one shot:
; vectorized assembly
vpaddd ymm0, ymm1, ymm2 ; a + b
vpmulld ymm0, ymm0, ymm3 ; * c
vpsubd ymm0, ymm0, ymm4 ; - d
10–100× faster. Enabled by compile_expressions = 1 (default).
12.4 Parallelism
How a single query uses CPU cores:
- Part parallelism: parts the query needs to read are distributed across cores.
- Pipeline parallelism: the operator pipeline runs across multiple threads.
- Shard parallelism: with a Distributed table, work spreads across nodes.
The max_threads parameter caps threads. Default is the number of cores.
13. AsyncInsert — Salvation for Small INSERTs
13.1 The Problem
ClickHouse prefers batch INSERTs. But Kafka consumers and event streams emit many small INSERTs. Each INSERT becomes a new part → merge storm → collapse.
13.2 The Fix
INSERT INTO events SETTINGS async_insert=1 VALUES ...;
The server buffers multiple INSERTs in memory. When a condition is met, it creates a part in one shot:
async_insert_max_data_size(default 1MB)async_insert_busy_timeout_ms(default 200ms)
With wait_for_async_insert=1 the client waits until the buffer is actually flushed to disk (durability guaranteed). With 0 it confirms only that the buffer received the data (faster but lossy).
13.3 Buffer Engine (Legacy)
CREATE TABLE events_buffer AS events
ENGINE = Buffer('default', 'events', 16, 10, 60, 10000, 1000000, 10485760, 1073741824);
The Buffer engine plays a similar role — in-memory buffer with periodic flush. If this table dies, data is lost. AsyncInsert is the recommended approach.
14. Running It in Production
14.1 Good Schema Checklist
CREATE TABLE events (
event_time DateTime CODEC(DoubleDelta, ZSTD),
user_id UInt64,
event_type LowCardinality(String), -- low cardinality
country LowCardinality(String),
device LowCardinality(String),
url String CODEC(ZSTD(3)),
duration_ms UInt32 CODEC(T64, LZ4),
session_id UUID,
data String CODEC(ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time) -- monthly
ORDER BY (country, event_type, event_time, user_id) -- low cardinality first
SETTINGS index_granularity = 8192;
ALTER TABLE events ADD INDEX idx_user user_id TYPE bloom_filter GRANULARITY 4;
- DoubleDelta codec on
DateTime. - Low-cardinality strings wrapped in
LowCardinality. - Low-cardinality columns first in ORDER BY.
- Skipping index for columns that did not fit at the front.
14.2 Monitoring Queries
Part count check:
SELECT database, table, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY parts DESC;
Thousands of parts is a warning sign.
Compression ratio:
SELECT
database, table,
formatReadableSize(sum(bytes_on_disk)) AS disk,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
sum(data_uncompressed_bytes) / sum(bytes_on_disk) AS ratio
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY ratio;
A ratio under 5 warrants codec/schema review.
Slow queries:
SELECT query_duration_ms, query, user, memory_usage
FROM system.query_log
WHERE event_date >= today() AND type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 10;
14.3 Anti-Patterns
- Frequent UPDATE/DELETE: in ClickHouse an UPDATE is an asynchronous ALTER. Seconds to minutes of delay. Avoid.
- Small INSERTs: thousands of INSERTs per second → disaster. Batch or use AsyncInsert.
- Too many columns: tables with hundreds of columns explode write cost. Consider wide → narrow table splits.
- High-cardinality column at the front of ORDER BY: the primary index becomes useless.
- Partitions too fine: hourly partitions are almost always a disaster. Never go below daily.
14.4 Migration Strategy
Moving OLAP queries out of an existing Postgres/MySQL OLTP into ClickHouse:
- CDC with Debezium + Kafka. Capture changes from the source OLTP.
- ClickHouse Kafka engine consumes.
- Materialized view loads the Kafka table into a MergeTree table.
This pipeline keeps OLAP analytics within sub-minute latency of OLTP.
15. The Limits of ClickHouse — Not the Answer to Everything
15.1 Workloads That Fit Poorly
- OLTP: point updates, single-row lookups. Possible but slow.
- Join-heavy: one or two joins in a star schema are fine, but 6–10-way joins hurt.
- Ad-hoc exploration: Trino/Presto have the edge (querying Parquet/Iceberg directly).
- Real-time lookup of individual events: individual record lookup requires a granule scan → slow.
15.2 The Competitive Landscape
- Snowflake / BigQuery: fully managed, almost all SQL. Expensive. ClickHouse is 10–100× cheaper.
- Druid / Pinot: real-time ingest + query. Closest competitors. More operationally complex.
- DuckDB: single-node. In-memory / small data. Different role.
- StarRocks / Doris: MPP databases from China. Claim better join performance than ClickHouse.
15.3 The Current Value Proposition
- Open source.
- Terabyte-scale on a single node, petabyte-scale on a cluster.
- Decent SQL compatibility.
- Scans tens of billions of rows per second.
- Very low operational cost.
That is why it became the standard for observability (logs/metrics/traces), web analytics, real-time dashboards, CDN log analysis, and more.
Closing — A Billion Rows in One Second
The ClickHouse secret in one line: "columnar storage + sparse index + vectorized execution". The three amplify each other into a 10–1000× speedup.
Three things an operator should check reflexively:
- Is ORDER BY designed well? This one line dictates performance. Low-cardinality columns first.
- Are small INSERTs blocked? Stabilize with AsyncInsert, batching, or the Kafka engine.
- Are you watching the part count? Thousands of parts means merge cannot keep up. Investigate.
Get those three right and ClickHouse feels almost magical.
Zooming out, the last three posts have covered the storage-engine spectrum:
- Postgres: row storage + B-tree + MVCC. The king of OLTP.
- LSM / RocksDB: row storage + LSM + KV. The write-heavy distributed systems engine.
- ClickHouse: columnar storage + MergeTree + vectorized execution. The analytics champion.
Three systems share the word "database" but are entirely different organisms. Understanding each one's genome is the first step to picking the right tool.
The next post covers the internals of TLS 1.3 and QUIC. We will unwind the hidden dance behind every HTTPS request at the byte level. Cryptography, networking, and performance all converge on one topic.