ClickHouse — A Columnar Analytics Database from the Inside
Store the Same Values with Different Types and Codecs, and Measure the Size
한국어 원문으로 표시합니다.
목표
기본 타입·기본 코덱의 표와, 같은 값을 타입이나 코덱만 바꿔 담은 비교대 표를 만들어 system.columns 의 압축 후 바이트로 어느 선택이 이기는지 확인합니다. 마지막에 운영 중인 표의 코덱을 바꾸면 크기가 언제 바뀌는지 봅니다.
왜 중요한가
분석 DB 의 디스크·메모리·읽기 비용은 결국 바이트입니다. 같은 자료라도 타입과 코덱에 따라 크기가 몇 배씩 달라지는데, 어느 코덱이 이기는지는 자료의 모양이 정합니다 — 이 실습에서도 전문 코덱 하나는 오히려 손해를 봅니다. 그래서 비교는 늘 같은 자료·같은 정렬에서 한 가지만 바꿔 해야 합니다. 이 실습의 비교대 표가 그 방법입니다. 채점기는 여러분이 적은 숫자를 믿지 않고 system.columns 를 직접 읽어 대조하며, 판정은 속도가 아니라 바이트로 합니다.
단계
- 데이터베이스
codecs와 표codecs.plain을 만드세요 — 열ts DateTime, host String, status String, cpu Float64, bytes_total UInt64, latency_ms UInt32, err_code Nullable(UInt16)(이 순서, 코덱 없이), 엔진MergeTree,ORDER BY (host, ts). /opt/lab/fixtures/codecs/metrics.sql을 한 번 실행해 100만 행을 넣고OPTIMIZE TABLE codecs.plain FINAL로 파트를 하나로 만드세요.- 표
codecs.status_bench (host String, ts DateTime, s_str String, s_lc LowCardinality(String), s_enum Enum8('ok' = 1, 'warn' = 2, 'error' = 3, 'timeout' = 4))를ORDER BY (host, ts)로 만들어 세 열 모두에 plain 의status를 넣고, 파트를 하나로 합친 뒤 세 열의 압축 후 바이트와 가장 작은 열 이름(smallest)을 /root/ch/codecs/types.json 에 적으세요. - 표
codecs.ts_bench (host String, ts DateTime, ts_zstd DateTime CODEC(ZSTD(1)), ts_delta DateTime CODEC(Delta, ZSTD(1)), ts_dd DateTime CODEC(DoubleDelta, ZSTD(1)))를ORDER BY (host, ts)로 만들어 네 시각 열 모두에 plain 의ts를 넣고, 파트를 하나로 합친 뒤 네 열의 압축 후 바이트와 가장 작은 열(best)을 /root/ch/codecs/time.json 에 적으세요. - 표
codecs.num_bench (host String, ts DateTime, cnt UInt64 CODEC(ZSTD(1)), cnt_delta UInt64 CODEC(Delta, ZSTD(1)), lat UInt32 CODEC(ZSTD(1)), lat_t64 UInt32 CODEC(T64, ZSTD(1)), cpu Float64 CODEC(ZSTD(1)), cpu_gorilla Float64 CODEC(Gorilla, ZSTD(1)))를ORDER BY (host, ts)로 만들어 짝마다 plain 의bytes_total·latency_ms·cpu를 넣고, 파트를 하나로 합친 뒤 여섯 열의 압축 후 바이트와, 짝보다 오히려 커진 전문 코덱 열 이름의 배열(codec_lost)을 /root/ch/codecs/numbers.json 에 적으세요. - 표
codecs.null_bench (host String, ts DateTime, err_null Nullable(UInt16), err_zero UInt16)를ORDER BY (host, ts)로 만들어err_null에는 plain 의err_code를,err_zero에는 NULL 을 0 으로 채운 값을 넣고 파트를 하나로 합치세요. NULL 인 행을 세는 /root/ch/codecs/q_isnull.sql 을 만들고,null_uncompressed(err_null 의 압축 전 바이트)·isnull_bytes_read(q_isnull.sql 의 bytes_read)·avg_null·avg_zero(두 열의 avg) 를 /root/ch/codecs/nullable.json 에 적으세요. - 표
codecs.tuned (ts DateTime CODEC(Delta, ZSTD(1)), host LowCardinality(String), status LowCardinality(String), cpu Float64 CODEC(ZSTD(1)), bytes_total UInt64 CODEC(Delta, ZSTD(1)), latency_ms UInt16 CODEC(T64, ZSTD(1)), err_code UInt16)를ORDER BY (host, ts)로 만들어 plain 의 행을 옮기고(NULL 은 0) 파트를 하나로 합친 뒤, 두 표의 압축 후 바이트 합을 /root/ch/codecs/tuned.json 에plain_total·tuned_total로 적으세요. codecs.legacy를 plain 과 같은 열·엔진으로 만들어 plain 의 행을 넣고 파트를 하나로 합친 뒤, ts 의 압축 후 바이트를 잽니다(before).ALTER TABLE codecs.legacy MODIFY COLUMN ts CODEC(Delta, ZSTD(1))직후에 다시 재고(after_alter),OPTIMIZE TABLE codecs.legacy FINAL뒤에 또 재서(after_optimize) 세 값을 /root/ch/codecs/alter.json 에 적으세요.
참고
- 서버는 파드가 뜰 때 이미 떠 있습니다.
clickhouse-client만 치면 붙습니다. 멈췄다면ch-up. - 열별 크기:
SELECT name, data_compressed_bytes, data_uncompressed_bytes FROM system.columns WHERE database = 'codecs' AND table = '...'. 코덱은 같은 표의compression_codec열에 보입니다(기본 코덱이면 빈 문자열). - 숫자를 JSON 에 따옴표 없이 받으려면
--output_format_json_quote_64bit_integers 0. JSONEachRow 한 줄을 그대로 파일로 저장해도 됩니다. - 크기를 재기 전에 늘
OPTIMIZE TABLE ... FINAL. 파트가 여럿이면 압축 블록 경계가 달라 숫자가 흔들리고, 채점기는 파트가 하나일 때만 받습니다(실습용 절차입니다 — 운영에서는 병합을 서버에 맡깁니다). - 흔한 실수: 비교대 표에 plain 을 두 번 넣는 것(200만 행). 그때는
TRUNCATE TABLE뒤 다시 넣습니다. 전문 코덱만 적고 범용 코덱을 빼면(CODEC(Delta)) 서버가 거절하거나 크기가 잘 줄지 않습니다. - 공식 문서: CODEC · Compression in ClickHouse · LowCardinality · system.columns
아무 생각 없이 만든 표
데이터베이스 codecs 와 표 codecs.plain 을 만드세요. 열은 ts DateTime, host String, status String, cpu Float64, bytes_total UInt64, latency_ms UInt32, err_code Nullable(UInt16) 순서(코덱 없이), 엔진은 MergeTree, 정렬 키는 ORDER BY (host, ts) 입니다.
원본 DB 스키마를 그대로 옮긴 모양을 일부러 만듭니다 — 문자열은 String, 빈 값은 Nullable, 코덱은 적지 않아 기본(LZ4) 입니다. 이 표가 뒤 단계 비교의 기준이 됩니다.
100만 행을 넣고 파트를 하나로
/opt/lab/fixtures/codecs/metrics.sql 을 한 번 실행해 codecs.plain 에 1,000,000행을 넣고, OPTIMIZE TABLE codecs.plain FINAL 로 활성 파트를 하나로 만드세요.
clickhouse-client 에 --queries-file 로 넘기면 됩니다. 스크립트는 서버 50대가 10초마다 보고한 지표를 해시로 만들어 몇 번을 돌려도 같은 행이 나옵니다. 크기를 비교하려면 파트가 하나여야 숫자가 흔들리지 않습니다.
String · LowCardinality · Enum8
codecs.status_bench (host String, ts DateTime, s_str String, s_lc LowCardinality(String), s_enum Enum8('ok' = 1, 'warn' = 2, 'error' = 3, 'timeout' = 4)) 를 ORDER BY (host, ts) 로 만들어 세 열 모두에 plain 의 status 를 넣고 파트를 하나로 합친 뒤, 세 열의 압축 후 바이트(s_str·s_lc·s_enum)와 가장 작은 열 이름(smallest)을 /root/ch/codecs/types.json 에 적으세요.
INSERT ... SELECT host, ts, status, status, status 처럼 같은 값을 세 번 넣으면 차이는 타입에서만 나옵니다. String 은 행마다 글자를, LowCardinality 는 사전과 번호를, Enum8 은 1바이트 번호를 저장합니다. system.columns 의 data_compressed_bytes 를 쓰세요 — data_uncompressed_bytes 가 아닙니다.
시각 열 — LZ4 · ZSTD · Delta · DoubleDelta
codecs.ts_bench (host String, ts DateTime, ts_zstd DateTime CODEC(ZSTD(1)), ts_delta DateTime CODEC(Delta, ZSTD(1)), ts_dd DateTime CODEC(DoubleDelta, ZSTD(1))) 를 ORDER BY (host, ts) 로 만들어 네 시각 열 모두에 plain 의 ts 를 넣고 파트를 하나로 합친 뒤, 네 열의 압축 후 바이트(ts·ts_zstd·ts_delta·ts_dd)와 가장 작은 열 이름(best)을 /root/ch/codecs/time.json 에 적으세요.
정렬 키가 (host, ts) 라서 한 서버 안에서는 시각이 10초씩 일정하게 늘어납니다. 범용 코덱은 계속 커지는 정수에서 반복을 찾지 못하지만, 전처리 코덱이 값을 이웃과의 차이로 바꿔 두면 사정이 달라집니다. 코덱 사슬은 왼쪽부터 적용되니 전처리를 먼저, 범용 압축을 나중에 적습니다.
전문 코덱이 늘 이기지는 않는다
codecs.num_bench (host String, ts DateTime, cnt UInt64 CODEC(ZSTD(1)), cnt_delta UInt64 CODEC(Delta, ZSTD(1)), lat UInt32 CODEC(ZSTD(1)), lat_t64 UInt32 CODEC(T64, ZSTD(1)), cpu Float64 CODEC(ZSTD(1)), cpu_gorilla Float64 CODEC(Gorilla, ZSTD(1))) 를 ORDER BY (host, ts) 로 만들어 cnt 짝에 bytes_total, lat 짝에 latency_ms, cpu 짝에 cpu 를 넣고 파트를 하나로 합친 뒤, 여섯 열의 압축 후 바이트와 짝(ZSTD 만 쓴 열)보다 오히려 커진 전문 코덱 열 이름의 배열 codec_lost 를 /root/ch/codecs/numbers.json 에 적으세요.
짝의 한쪽은 ZSTD 만, 다른 쪽은 전문 코덱 + ZSTD 라서 차이는 전문 코덱이 보탠 몫입니다. 누적 카운터는 늘 커지고, 지연 시간은 범위가 좁고, CPU 는 소수 둘째 자리까지의 부동소수입니다. 어느 것이 이길지 먼저 짐작해 적어 두고 숫자와 대 보세요. 커진 열이 없으면 빈 배열입니다.
Nullable 은 파일을 하나 더 둔다
codecs.null_bench (host String, ts DateTime, err_null Nullable(UInt16), err_zero UInt16) 를 ORDER BY (host, ts) 로 만들어 err_null 에 plain 의 err_code 를, err_zero 에 NULL 을 0 으로 채운 값을 넣고 파트를 하나로 합치세요. err_null 이 NULL 인 행을 세는 /root/ch/codecs/q_isnull.sql 을 만들고, null_uncompressed(err_null 의 data_uncompressed_bytes)·isnull_bytes_read(q_isnull.sql 의 statistics.bytes_read)·avg_null·avg_zero(두 열의 avg) 를 /root/ch/codecs/nullable.json 에 적으세요.
Nullable 열은 값 파일과 행마다 1바이트짜리 null 지도를 따로 둡니다. NULL 여부만 묻는 조건은 null 지도만 읽으니 읽은 바이트가 행 수와 같아야 합니다. 값을 비교하는 조건을 섞으면 값 파일까지 읽습니다. 평균은 NULL 을 빼고 내지만 0 은 넣습니다 — 두 avg 가 왜 다른지 설명할 수 있어야 합니다.
이긴 선택을 모은 표
codecs.tuned (ts DateTime CODEC(Delta, ZSTD(1)), host LowCardinality(String), status LowCardinality(String), cpu Float64 CODEC(ZSTD(1)), bytes_total UInt64 CODEC(Delta, ZSTD(1)), latency_ms UInt16 CODEC(T64, ZSTD(1)), err_code UInt16) 를 ORDER BY (host, ts) 로 만들어 plain 의 행을 옮기고(err_code 의 NULL 은 0) 파트를 하나로 합친 뒤, plain 과 tuned 의 압축 후 바이트 합을 /root/ch/codecs/tuned.json 에 plain_total·tuned_total 로 적으세요.
앞 단계의 결과를 모은 것입니다 — 타입(LowCardinality, 범위에 맞는 UInt16, NULL 대신 0)을 먼저 고르고 코덱은 그다음에 붙였습니다. Gorilla 는 앞 단계에서 졌으니 cpu 에는 ZSTD 만 씁니다. 두 표의 합은 system.columns 를 table 로 묶어 더하면 됩니다.
운영 표의 코덱을 바꾸면 언제 작아지나
codecs.legacy 를 plain 과 같은 열·엔진으로 만들어 plain 의 행을 넣고 파트를 하나로 합친 뒤, ts 의 압축 후 바이트를 재세요(before). ALTER TABLE codecs.legacy MODIFY COLUMN ts CODEC(Delta, ZSTD(1)) 직후 다시 재고(after_alter), OPTIMIZE TABLE codecs.legacy FINAL 뒤 또 재서(after_optimize) 세 값을 /root/ch/codecs/alter.json 에 적으세요.
CREATE TABLE ... AS 다른표 는 열·엔진을 그대로 복사합니다. 파트는 한 번 쓰이면 바뀌지 않으므로 ALTER 는 앞으로 쓸 파트의 규칙만 바꿉니다. 이미 있는 파트가 새 코덱으로 다시 쓰이는 때가 언제인지 생각해 보세요. 세 번 모두 같은 system.columns 쿼리로 잽니다.