sulco · 논문 읽기

ClickHouse는 계속 쓰면서
어떻게 빨리 읽나

PVLDB 2024 「ClickHouse - Lightning Fast Analytics for Everyone」
1010 「메시지는 어떻게 쓰이고, 어떻게 다시 읽히나」 다음 이야기
Schulze 외, PVLDB 17(12): 3731–3744
계기

앞단은 그대로인데, 저장소만 바꾸면 뭐가 달라질까

브라우저 gateway Kafka Flink BigQuery ClickHouse 1010에서 본 구간. 여기는 그대로 둔다 "최근 5분 LCP p75를15초마다 보고 알림"
웹 성능 이벤트 파이프라인을 만들다가 생긴 궁금증
오늘의 질문

쿼리 하나를 따라가며 세 가지를 묻는다

SELECT page, quantile(0.75)(lcp_ms)
FROM page_events
WHERE event_time >= now() - INTERVAL 5 MINUTE
GROUP BY page
1부 · 저장
초당 수천 건이 들어오는 동안 데이터는 어떤 모양으로 디스크에 쌓이나
2부 · 찾기와 집계
최근 5분을 어떻게 골라내고, 남은 행을 CPU에서 어떻게 집계하나
3부 · 대가
그 속도를 얻으려고 무엇을 포기했나, 그리고 내 파이프라인에는 어떻게 쓰나
ClickHouse는

로그 필터와 집계 연산자에서 시작한 컬럼 DB다

접근 계층 · native, HTTP, MySQL, PostgreSQL 프로토콜 쿼리 처리 계층vectorized 실행 + 필요할 때만 LLVM 컴파일 (4장) 저장 계층 · table engine MergeTree 계열 (주 저장 형식, 3장) 특수 엔진 (dictionary, Distributed) 통합 계층 (5장) Kafka, S3, PostgreSQL, Iceberg 같은 외부 시스템을 table로 보이게 2009 웹 로그 분석용으로 시작 2016 오픈소스 C++ 정적 링크 바이너리 하나
오늘은 저장 계층과 쿼리 처리 계층만 본다
1부

INSERT는 part를 만들고,
merge가 뒤에서 치운다

데이터는 어떤 모양으로 디스크에 쌓이나
1부 · part

INSERT 한 번이 불변 part 하나가 된다

INSERTbackground merge조회 시점의 active part part 1 part 2 part 3 part 4 part 5 part 1_3k-way merge sort part 4_5정렬돼 있으니 합치기 싸다 part 1_3 part 4_5 원본 part는 inactive로 바뀌고, 읽던 쿼리가 끝나면 지운다
part는 primary key 순으로 정렬돼 있고, 기본값으로 150GB까지 합친다
1부 · 넣는 방식

작게 자주 넣으면 part가 쏟아진다. 그래서 묶는다

synchronous insert
INSERT 문 하나마다 part 하나. merge 부담을 줄이려면 클라이언트가 묶어서 보낸다. 논문은 한 번에 2만 행 정도를 권한다.
문제는 클라이언트에서 묶느라 기다리는 시간이다. 실시간 분석에는 이 지연이 아프다.
asynchronous insert
여러 클라이언트의 작은 INSERT를 서버가 버퍼에 모았다가 크기나 시간 기준을 넘으면 part 하나로 쓴다.
수천 개 에이전트가 조금씩 보내는 observability 같은 쓰임을 위한 모드다. 1010의 producer batch와 같은 발상을 서버 쪽에 둔 것이다.
1부 · LSM tree와 다른 점

LSM에서 출발했지만, 세 가지를 뺐다

level 계층 없음
모든 part를 동등하게 본다. 같은 level끼리만 합치는 제약이 없어 아무 part나 골라 합친다.
write-ahead log 없음
INSERT를 곧바로 디스크의 part로 쓴다. memtable에 모았다가 flush하는 단계가 없다.
tombstone 없음
part 사이에 시간 순서가 사라졌으니 "나중 기록이 이긴다"로 지울 수 없다. 수정과 삭제에 다른 장치가 필요하다. 3부에서 다시 본다.
1부 · part 안쪽

part는 컬럼 파일 묶음이고, 읽는 최소 단위는 granule이다

part 디렉터리 event_time.bin page.bin lcp_ms.bin block (압축, 약 1MB) granule 0 granule 1 block granule 2 granule = 8,192행 scan과 index가 다루는 최소 단위 block = 약 1MB 디스크 I/O와 압축 단위, 기본 LZ4 mark 파일 granule 번호 → 압축 block 위치와 block 안 위치. 압축돼도 바로 찾아간다
10MB보다 작은 part는 컬럼들을 파일 하나에 이어 붙여 쓴다
1부 · primary key

primary key는 유일성 제약이 아니라 정렬 순서와 희소 index다

index: granule마다 첫 행의 key만 10:00:00 10:01:12 10:02:30 10:03:41 10:04:55 10:06:08 10:07:20 건너뜀 건너뜀 건너뜀 건너뜀 읽음 읽음 읽음 granule (8,192행씩) WHERE event_time >= '10:05:00' → index를 이진 탐색해서 granule 범위만 읽는다 행마다가 아니라 granule마다 한 칸이라 작다. 논문 예시로 810만 행에 1,000칸, 통째로 메모리에 둔다
1부 · 직접 확인

520만 행 중 최근 5분을 찾을 때 읽은 granule은 30개였다

-- EXPLAIN indexes = 1
ReadFromMergeTree (web_perf_raw)
  MinMax     event_time
    Granules: 190/636
  Partition  toDate(event_time)
    Granules: 190/190
  PrimaryKey metric, event_time
    Granules: 30/190
단계남은 granule
전체636
part별 min, max로 거름190
primary key 이진 탐색30
실제로 읽은 행은 5,200,000 중 238,650. 필터 없이 전체를 읽은 쿼리보다 22배 적다.
로컬 docker ClickHouse 24.8, 합성 LCP 이벤트 520만 건. ORDER BY (metric, page, event_time)
1부 · 나머지 pruning

key가 아닌 컬럼으로 거를 때는 두 가지를 더 쓴다

방법하는 일대가
projection같은 행을 다른 primary key로 정렬한 사본을 table 안에 둔다. optimizer가 I/O 추정으로 고른다INSERT, merge, 저장 공간이 늘어난다. 기존 part는 직접 materialize해야 채워진다
skipping index
min-max, set, bloom filter
granule 여러 개를 묶은 단위마다 작은 요약을 둔다. 대충 정렬된 값엔 min-max, 값이 뭉쳐 있으면 set, 문자열 검색엔 bloom요약이 걸러 주지 못하면 읽기만 늘어난다. bloom은 범위 조건과 부정 조건에 못 쓴다
partitionpartition마다 partition 식의 min, max를 저장해 통째로 건너뛴다partition을 잘게 나누면 part 수가 늘어난다
1부 · merge가 하는 일

merge는 합치기만 하지 않고 데이터를 바꾼다

merge 종류합치면서 하는 일쓰임
Replacingprimary key가 같은 행 중 가장 최근 것만 남긴다merge 시점의 update, 중복 제거
Aggregatingkey가 같은 행의 부분 집계 상태를 하나로 합친다materialized view
TTL조건을 만족한 part를 S3로 옮기거나, 다시 압축하거나, 지우거나, roll-up한다오래된 데이터 정리
merge는 언제 돌지 모른다. 결과를 보장하려면 SELECT ... FINAL로 쿼리 시점에 같은 변환을 한 번 더 한다
1부 · materialized view

materialized view는 새 part가 들어올 때만 계산한다

새 partINSERT된 행만 변환 쿼리quantileTDigestState(0.75) 상태 part A 상태 part B 합친 상태Aggregating merge 주기적으로 원본 전체를 다시 계산 흔한 materialized view 방식. refresh 주기만큼 늦고, 매번 원본을 다시 읽는다 INSERT마다 그 행만 계산 결과 대신 -State로 부분 상태를 저장하고, 읽을 때 -Merge로 합쳐서 최종 값을 낸다
avg()라면 상태는 합과 개수 두 값. p75라면 t-digest 요약이 상태다
2부

남은 행은 묶음으로,
여러 코어에서 동시에

걸러낸 뒤의 데이터를 CPU가 어떻게 집계하나
2부 · 연산자 사이

연산자는 행 하나가 아니라 chunk를 주고받는다

행 단위 (Volcano 방식)
next()를 부를 때마다 행 하나. 행마다 가상 함수 호출이 붙고, 데이터 한 칸에 비해 호출 비용이 크다.
chunk 단위 (MonetDB/X100 방식)
한 번에 수천 행의 컬럼 배열을 넘긴다. 호출 비용이 chunk 하나로 나뉘고, 안쪽 반복문이 SIMD 명령으로 돌 수 있다.
같은 반복문을 SSE 4.2, AVX2, AVX-512용으로 따로 빌드해 두고 실행할 때 cpuid로 가장 빠른 것을 고른다
2부 · 코어 사이

계획을 lane 여러 개로 펼치고, 최대한 늦게 합친다

scan 범위FilterRepartitionAggregateGroupStateMergeSort 범위 1 범위 2 범위 3 Filter Filter Filter 고르게재분배 Aggregate부분 상태 Aggregate부분 상태 Aggregate부분 상태 상태 합치기pipeline breaker 정렬 lane 수 = worker thread 수(기본은 코어 수). 필터 통과율이 달라 생기는 쏠림은 Repartition이 고른다
2부 · 다시 정렬 순서

저장할 때 정한 정렬 순서가 실행 계획까지 줄인다

sort aggregation
GROUP BY 컬럼이 primary key의 앞부분이면 같은 값이 이어져 나온다. hash table 없이 이어진 묶음만 세면 되고 메모리가 훨씬 적다.
정렬 생략
ORDER BY가 primary key의 앞부분이면 디스크 순서대로 읽고 정렬 연산자를 계획에서 뺀다.
함수도 index로
toYear(k) = 2024를 k의 범위 조건으로 바꿔 index에 태운다. 단조성을 아는 함수라서 가능하다.
이 밖에 선택도가 높은 필터부터 차례로 거르기, 자주 쓰는 식은 LLVM으로 합쳐 컴파일하기, 30가지가 넘는 hash table 중 골라 쓰기
3부

그 속도는
공짜가 아니다

append-only와 비동기 작업이 남긴 비용
3부 · 얻은 것과 내준 것

쓰기를 막지 않으려고 정확성을 나중으로 미뤘다

설계얻은 것내준 것
merge는 비동기INSERT가 빠르다merge 전에는 중복이나 덜 합친 행이 보인다. 확실히 보려면 FINAL 비용
mutation물리적으로 확실히 고친다모든 part를 다시 쓰고, 도중에 SELECT가 고친 part와 안 고친 part를 섞어 읽는다
lightweight delete삭제가 빠르다bitmap 필터가 붙어 SELECT가 느려지고, 실제 삭제는 언젠가의 merge
복제Keeper(Raft) log로 어느 노드에서나 쓴다eventually consistent. 노드마다 잠깐 옛 상태를 읽는다
트랜잭션part snapshot으로 잠금 없이 읽는다snapshot isolation. 일반적으로 ACID가 아니고 기본값으로 fsync도 안 한다
3부 · 평가를 읽는 법

벤치마크는 "어떤 워크로드에서"를 같이 읽어야 한다

43개
ClickBench 쿼리. 1억 행 비정규화 table의 필터와 집계. ClickHouse가 원래 잘하던 일이다
1.72×
2018~2024년 77개 버전에서 VersionsBench가 빨라진 정도. 버전 사이 퇴보도 있었다
5 : 6
TPC-H에서 ClickHouse와 Snowflake가 이긴 쿼리 수. 상관 subquery 미지원 등으로 22개 중 11개는 빼고 비교했다
join이 많은 정규화 스키마는 논문도 "떠오르는 쓰임"이라고 부른다. 강점은 넓은 table 하나를 거르고 묶는 일이다
4부

내 파이프라인으로
돌아오면

"최근 5분 p75를 15초마다"를 ClickHouse로 짠다면
4부 · 어디가 다른가

데이터가 보이는 시점은 비슷하다. 다른 건 조회 쪽이다

① 수집브라우저에서 Kafka ② 처리Flink ③ 적재sink에서 DB ④ 조회와 알림DB에서 alert ①②는 저장소와 무관하게 같다
③ 적재: 큰 차이 없음
BigQuery도 Storage Write API의 committed stream이면 쓰기가 승인되는 즉시 조회된다. "ClickHouse가 더 빨리 보인다"는 근거가 약하다.
④ 조회: 여기가 다르다
같은 작은 집계를 15초마다 반복할 때, 읽을 양을 미리 줄여 두는 장치(정렬 키, 증분 MV)가 ClickHouse 쪽에 있다.
4부 · 비교

반복되는 작은 집계는 ClickHouse 쪽 설계가 맞물린다

관점BigQueryClickHouse
최근 5분 찾기partition과 clustering으로 줄인다정렬 키와 희소 index로 granule 단위까지 줄인다
p75를 매번쿼리마다 원본을 다시 집계하거나, 사전 집계를 따로 설계한다증분 MV가 INSERT 때 t-digest 상태를 만들어 둔다
15초 주기 비용실행마다 스캔량이나 slot을 쓴다. 주기와 비용을 같이 본다상태 table 몇천 행만 읽는다. 서버는 이미 떠 있다
운영서버리스. 운영할 것이 거의 없다직접 띄우면 서버, 복제, merge, 디스크를 직접 본다
설계상 기대되는 차이다. 같은 워크로드로 응답 시간을 재기 전까지는 가설로 둔다
4부 · 설계

원본 table 하나, 10초 단위 상태 table 하나

CREATE TABLE web_perf_raw (
  event_id UUID, event_time DateTime64(3),
  metric LowCardinality(String), page LowCardinality(String), value Float64
) ENGINE = MergeTree PARTITION BY toDate(event_time)
  ORDER BY (metric, page, event_time) TTL toDateTime(event_time) + INTERVAL 7 DAY;

CREATE TABLE web_perf_10s (
  metric LowCardinality(String), bucket DateTime, page LowCardinality(String),
  p75 AggregateFunction(quantileTDigest(0.75), Float64), n AggregateFunction(count)
) ENGINE = AggregatingMergeTree ORDER BY (metric, bucket, page) TTL bucket + INTERVAL 30 DAY;

CREATE MATERIALIZED VIEW web_perf_10s_mv TO web_perf_10s AS
SELECT metric, toStartOfInterval(event_time, INTERVAL 10 SECOND) AS bucket, page,
       quantileTDigestState(0.75)(value) AS p75, countState() AS n
FROM web_perf_raw GROUP BY metric, bucket, page;
정렬 키는 같음 조건(metric) 다음에 범위 조건(시간). 원본은 크고 상세 분석용이라 7일, 상태는 작고 추세 비교용이라 30일 둔다
4부 · 알림과 적재

알림은 상태를 합치기만 하고, sink는 서버에 묶음을 맡긴다

-- 15초마다 평가. 기준은 web.dev "good" 경계
SELECT metric, page,
  quantileTDigestMerge(0.75)(p75) AS p75_ms,
  countMerge(n) AS n,
  transform(metric, ['LCP','INP'], [2500,200], 0) AS threshold_ms
FROM web_perf_10s
WHERE metric IN ('LCP','INP')
  AND bucket >= now() - INTERVAL 5 MINUTE
GROUP BY metric, page
HAVING n >= 100 AND p75_ms > threshold_ms
-- Flink JDBC sink 연결 설정
async_insert = 1
wait_for_async_insert = 1
async_insert_busy_timeout_ms = 1000
Flink batch를 크게 못 키워도 서버가 모아서 part를 만든다. wait를 켜야 버퍼에 들어간 게 아니라 part로 쓰인 뒤에 성공을 받는다.
p75 기준 LCP 2.5초, INP 200ms. bucket이 10초라 5분 창은 경계에서 최대 10초 어긋난다. 표본 100개 미만 page는 거른다
4부 · 알림 정책

두 번 연속 넘어야 알린다. Grafana에는 비율로 넘긴다

-- Grafana 알림 규칙의 쿼리
SELECT metric, page,
  quantileTDigestMerge(0.75)(p75)
    / transform(metric, ['LCP','INP'], [2500,200], 1)
    AS ratio
FROM web_perf_10s
WHERE metric IN ('LCP','INP')
  AND bucket >= now() - INTERVAL 5 MINUTE
GROUP BY metric, page
HAVING countMerge(n) >= 100
항목값
thresholdratio > 1
평가 주기15초
pending period15초: 첫 평가는 pending, 다음 평가도 넘으면 firing
metric과 page는 label이 되어 행 하나가 알림 하나. 공식 ClickHouse 플러그인이 Grafana-managed alerting을 지원한다.
로컬 실습에서 /item만 느리게 보내자 LCP와 INP 모두 pending을 거쳐 15초 뒤 firing이 됐다
4부 · 늦게 도착한 이벤트

늦은 이벤트는 지난 bucket에 그대로 더해진다

event_time으로 bucket을 정한다
2분 늦은 이벤트는 2분 전 bucket에 상태가 하나 더 생기고, 조회나 merge 때 합쳐진다. 상태는 합치는 순서와 무관하다. 직접 넣어 보니 다음 평가에 바로 잡혔다.
5분보다 늦으면
알림 창 밖이라 지난 평가는 다시 하지 않는다. 대시보드와 추세에는 반영된다.
가장 최근 bucket
아직 다 도착하지 않아 표본이 적다. n 조건으로 거르고, 지연이 크면 창 끝을 now() - 10초로 당긴다.
Flink에서 watermark로 창을 닫는 방식과 달리, 늦은 이벤트를 버리지 않고 받는다
4부 · 직접 확인

같은 p75를 내는 데 읽은 행이 3,700분의 1로 줄었다

쿼리읽은 행읽은 바이트전체 대비
원본, 시간 조건 없이5,200,00049.6 MiB1
원본, 최근 5분 (정렬 키로 pruning)238,6504.1 MiB1/22
10초 상태 table, 최근 5분1,410195.5 KiB1/3,688
로컬 docker ClickHouse 24.8, 합성 이벤트 520만 건(최근 5분 20만 건). 응답 시간이 아니라 system.query_log의 read_rows
4부 · 아직 부족한 점

MV에 한 번 더해진 중복은 되돌릴 수 없다

무엇이 문제인가
MV는 INSERT 시점에 계산한다. 원본을 나중에 Replacing merge로 중복 제거해도 이미 더해진 상태는 그대로다. Flink가 재시작해 다시 보낸 행이 다른 batch에 섞이면 part hash도 달라 걸러지지 않는다.
기본값도 확인할 것
복제 없는 MergeTree는 non_replicated_deduplication_window = 0이라 같은 batch 재전송도 안 거른다. async insert는 async_insert_deduplicate도 기본 0이다.
1010의 결론이 여기서도 맞다: 정확성은 sink에서 정한다
p75 알림은 약간의 중복에 둔감하니 그대로 두고, 정확한 개수가 필요한 지표만 Flink에서 event_id로 거르거나 checkpoint마다 insert_deduplication_token을 고정한다.
4부 · 같은 걸 BigQuery로

BigQuery는 매번 원본을 집계한다

CREATE TABLE my_dataset.web_perf_raw (...)
PARTITION BY DATE(event_time)
CLUSTER BY metric, event_time
OPTIONS (partition_expiration_days = 7);

SELECT metric, page,
  APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75_ms,
  COUNT(*) AS n
FROM my_dataset.web_perf_raw
WHERE metric IN ('LCP','INP')
  AND event_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 5 MINUTE)
GROUP BY metric, page
HAVING n >= 100 AND p75_ms > CASE metric
  WHEN 'LCP' THEN 2500 WHEN 'INP' THEN 200 END;
갓 들어온 데이터
아직 clustering이 정리되지 않았을 수 있다. 최근 5분에서 실제로 얼마나 줄어드는지는 재 봐야 안다.
최소 10MB
on-demand는 table마다 최소 10MB를 청구한다. 15초 주기면 하루 5,760번.
사전 집계
INSERT 때 분위수 상태를 쌓는 장치를 그대로 대응시키기 어렵다. 따로 설계해야 한다.
이 SQL은 직접 실행해 보지 않았다. 파일: lab/bigquery.sql
4부 · 어떻게 나눌까

고르기보다 역할을 나눈다

BigQuery
장기 보관과 폭넓은 분석. join이 많은 탐색, 운영 부담이 거의 없는 쪽.
ClickHouse
자주 호출되는 실시간 집계와 알림. 같은 Flink 출력을 sink 두 개로 보낸다.
단, 둘 다 운영할 가치가 있는지는
조회 지연, 비용, 유지보수 부담을 실제로 재 본 뒤에 정한다. 중복, 늦은 이벤트, 집계 정확성도 함께 감당해야 서비스가 된다.
마무리

ClickHouse는 읽을 양을
쓰는 순간에 줄여 둔다

"빠른 조회는 빠른 scan보다, 안 읽는 데서 나온다."
정렬해서 쓰고, merge로 정리하고, MV로 미리 집계해 둔다. 대신 수정과 일관성은 나중으로 미룬다