[Clickhouse] 저장공간 최적화
clickhouse는 스캔 효율성과 저장공간 절약에 목을 메는 DB다.
그래서 절약을 위한 데이터 저장 방식 옵션을 이것저것 지원하고, 선택에 따라서 실제 저장 효율이 크게 차이가 날 수도 있다.
여기서는 어떤 옵션들이 있는지를 정리해본다.
디스크 사용량 추적
디스크 최적화를 하려면, 당연히 어떤 녀석들이 디스크를 많이 먹는지부터 알아야 한다.
자세한 내용은 별도 포스트를 참조한다.
https://blog.naver.com/sssang97/224353439136
Strict한 타입 선택
String 같은 동적 타입은 건드릴 것이 없지만, 정수나 실수 같은 고정폭 크기의 타입들은 딱 필요한 만큼만 정밀도를 설정해서 사용하는 것이 좋다.
예를 들면, 가능하면 Uint64보다는 Int32를 선호하는 것이 효율적이며, DateTime64보다 DateTime이 효율적이다. 그리고 날짜만 포함한다면 DateTime보다 Date를 선호하는 것이 좋다.
Nullable 최소화하기
일반적인 DB들은 null을 쓰든 not null을 쓰든 실성능 차이가 극적이지 않지만, clickhouse는 null을 쓰는 것이 저장공간과 실성능 모두에 부정적인 영향을 준다.
null이 들어가면 메인 값 블럭과 별개로 null map이라는 영역을 또 따로 관리해야하기 때문이다.
그래서 가급적이면 zero value 기반으로 null에 대한 의미를 부여하는 것이 좋고, Nullable 자체를 직접적으로 사용하는 것은 추천하지 않는다.
실제로 nullable 여부로 얼마나 크기 차이가 벌어지는지 확인해보자.
CREATE TABLE default.test_logs
(
`Timestamp` DateTime64(9),
`TraceId` String,
`SeverityText` String,
`ServiceName` String,
`Body` String
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (
ServiceName,
Timestamp)
SETTINGS index_granularity = 8192,
ttl_only_drop_parts = 1,
enable_block_number_column = 1,
enable_block_offset_column = 1;
CREATE TABLE default.test_logs_nullable
(
`Timestamp` DateTime64(9),
`TraceId` Nullable(String),
`SeverityText` String,
`ServiceName` String,
`Body` Nullable(String)
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (
ServiceName,
Timestamp)
SETTINGS index_granularity = 8192,
ttl_only_drop_parts = 1,
enable_block_number_column = 1,
enable_block_offset_column = 1;
동일 데이터셋을 타입만 다르게 넣어봤다.

그럼 이 정도로 전후차이가 난다. 막 몇배 차이가 나고 그런건 아니지만, Body가 195MB에서 214MB로 확실하게 늘긴 늘었다.
기겁할 정도의 차이는 아니지만, 기본 데이터셋이 크다면 꽤 치명적일 수도 있다.
LowCardinality 활용
가장 중요한 옵션 중 하나다. 이게 clickhouse에서는 Enum보다 훨씬 유연하고 효율적이다.
카디널리티가 작은 값, 그러니까 값의 종류가 좁은 컬럼에 대해 최적화를 때려주는 마법같은 타입이다.
특히 문자열 타입에 효과적이다.
예를 들어, 다음과 같은 로그 테이블이 있다고 해보자.
CREATE TABLE default.test_logs
(
`Timestamp` DateTime64(9),
`TraceId` String,
`SeverityText` String,
`ServiceName` String,
`Body` String
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (
ServiceName,
Timestamp)
SETTINGS index_granularity = 8192,
ttl_only_drop_parts = 1,
enable_block_number_column = 1,
enable_block_offset_column = 1;
사실 여기서 ServiceName과 ServerityText 같은 값은 사실상의 enum에 가까운 값이다.
ServerityText는 해봤자 info, warn, error 같은 값만 들어오고, ServiceName은 아무리 많아도 100개가 안될 것이다.
그렇기 때문에 모든 값을 문자열 그대로 순진하게 저장하는 것은 저장공간의 낭비가 될 수 있다.
가장 효과적인 것은 저 문자열 엔트리를 별도의 매핑테이블로 정의하고 숫자값만을 넣는 것이겠지만, 전반적인 사용성이 크게 떨어지기 때문에 그렇게는 잘 하지 않는다.
방금 말한 이 최적화를 DB 내에서 알아서 처리해주는 것이 LowCardinality라는 기능이다.
LowCardinality 타입을 사용하면, 문자열을 넣어도 내부적으로 매핑테이블을 정의하고, 실제로는 UInt8, UInt16, UInt32, UInt64 중 하나로 키값을 저장한다.

그렇게 해서 부어보면

ServerityText는 4.68MB => 1.37MB
ServiceName은 215KB => 20KB로 크게 줄어든 것을 볼 수 있다.
할 수 있으면 적용하는게 좋다.
다만, 당연히 카디널리티가 높은 값에 대해서는 효율적이지 않다.
카디널리티가 수만개를 넘어서면 효율성이 급감하니, 잘 검토하고 적용하는 것을 권한다.
MergeTree: 정렬키 선택
정렬키를 선택하는 것도 꽤 큰 요인이 된다.
정렬 키의 선두컬럼에는 일반적으로 카디널리티가 지나치게 높지 않고, 동일하거나 유사한 값을 가진 행을 서로 가깝게 모을 수 있는 컬럼을 배치하는 것이 유리하다.
같은 정렬키 그룹에 속한 값일수록 나머지 컬럼의 값도 비슷한 패턴의 값이 모여있을 확률이 높고, 자연히 압축률도 높아지기 때문이다. (후술할 Delta 압축 논리 포함)
아무튼 핵심은, 성능을 중요시한다면 UUID 같은 카디널리티가 높은 값은 단일 정렬키로 쓰거나, 복합 정렬키의 선두로 써서는 안된다는 것이다. 복합 정렬키의 후순위 컬럼이라면 그건 크게 상관이 없다.
그래서 이런 식으로 Order By에 UUID 같은 카디널리티가 매우 높고 랜덤성이 높은 값을 넣는 것은 압축률을 저해한다.

잘 설계된 정렬키는 이런 모양이다.
동일 서비스를 기반으로 묶이기 때문에, 비슷한 데이터 분포가 발생할 확률이 높은 것이다.
그래서 동일한 값에 정렬키만 다르게 삽입해도, 디스크 사용 패턴이 크게 달라지는 것을 볼 수 있다.

TraceId 컬럼 자체의 크기는 줄어들었지만, Body 같은 대형 컬럼의 저장공간이 그보다 훨씬 늘어났다.
파티션 단위 설계
스토리지가 예쁘게 쌓이려면 결국 MERGE가 빡빡하게 잘 돌면서 압축이 돌아가야 한다.
근데 이 압축에 제동을 걸 수 있는 것이 파티션이다.
파티션이 시간이나 분 단위처럼 너무 잘게 쪼개져있다면, 파티션 내에서만 MERGE가 실행되기 때문에 압축 성능에 한계가 있을 수 있다.
그래서 이런 자잘한 파티션 구성은 상황에 따라서 비효율적일 수 있다.
PARTITION BY toStartOfHour(Timestamp)TTL 활용하기
clickhouse는 매우 다양한 형태로 TTL 옵션을 제공한다.
이런 식으로 행 단위 TTL을 걸 수도 있고
ALTER TABLE default.otel_logs
MODIFY TTL
toDateTime(Timestamp) + INTERVAL 30 DAY DELETE;
심지어 컬럼 단위로 TTL을 걸어서 컬럼만 날려버릴 수도 있다.
CREATE TABLE logs
(
Timestamp DateTime64(9),
ServiceName LowCardinality(String),
Body String
TTL toDateTime(Timestamp) + INTERVAL 7 DAY,
LogAttributes Map(String, String)
TTL toDateTime(Timestamp) + INTERVAL 14 DAY
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
TTL toDateTime(Timestamp) + INTERVAL 30 DAY;
혹은, TTL을 걸되, 삭제가 아니라 강하게 "재압축"을 하도록 할 수도 있다.
심지어 계층형으로도 가능하다.
CREATE TABLE logs
(
Timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
Body String CODEC(ZSTD(1))
)
ENGINE = MergeTree
ORDER BY Timestamp
TTL
toDateTime(Timestamp) + INTERVAL 7 DAY
RECOMPRESS CODEC(ZSTD(3)),
toDateTime(Timestamp) + INTERVAL 30 DAY
RECOMPRESS CODEC(ZSTD(6)),
toDateTime(Timestamp) + INTERVAL 90 DAY
DELETE;
이러면 7일 지나면 3단계 압축, 30일이 지나면 6단계 압축을 하고, 90일이 지나면 삭제를 한다.
TTL로 볼륨 이동(S3 백업)을 처리할 수도 있다.
CREATE TABLE logs
(
Timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
Body String CODEC(ZSTD(1))
)
ENGINE = MergeTree
ORDER BY Timestamp
TTL
toDateTime(Timestamp) + INTERVAL 7 DAY TO VOLUME 's3',
toDateTime(Timestamp) + INTERVAL 90 DAY DELETE
SETTINGS storage_policy = 'hot_and_cold';압축 코덱 - 압축 알고리즘
clickhouse는 다양한 압축 알고리즘을 지원하며, 심지어 컬럼마다 다른 압축 코덱을 적용할 수도 있다.
이걸 잘 활용하면 정말 컴팩트하게 데이터를 담아낼 수 있다.
다음은 otel_logs의 현란한 기본값이다.

코덱 LZ4 vs ZSTD
데이터 분포 패턴을 예측할 수 없는 일반적인 데이터들은 LZ4나 ZSTD 중 하나를 선택해서 저장할 수 있다.
따로 설정하지 않는다면 LZ4가 기본값으로 들어간다.
뭐가 다른걸까? 둘 중에 명백한 우열이 있지는 않다.
LZ4는 저장공간보다는 실행속도에 중점을 둔 코덱이고, ZSTD는 실행속도보다는 저장공간에 더 중점을 둔 코덱이다. ZSTD는 LZ4에 비하면 30% 정도 더 압축률이 높은 대신, 실행속도는 3-5배 느리다.
그래서 일반적인 경우에는 LZ4가 융통성있고 실용적인 선택지가 된다.
한 테이블에 ZSTD와 LZ4를 섞는 것도 좋은 방법이다.
실제로 쿼리에서 자주 참조되는 컬럼은 LZ4로 압축하고, 가끔씩만 참조되는 컬럼은 ZSTD로 넣는 식이다.
한번 비교해보자.
그냥 넣으면 가장 큰 Body 기준 190MB 정도를 먹지만
ZSTD로 바꾸기만 해도 127MB로 확 줄어든다.
약간 느려지긴 하지만 저장공간이 더 중요하다면 감안할만한 수준이다.
ZSTD와 압축률
ZSTD는 압축 정도를 지정할 수 있는 코덱이다.
기본값은 1(가장 낮음)이고, 22까지 설정할 수 있다.

근데 22는 이론적인 상한이고, 말도 안되게 느리기 때문에 최댓값을 쓰는 것은 사실상 불가능하다. 보통 공간이 정말 중요하면 5-11 정도에서 왔다갔다 하는 편이 합리적이다.
레벨 1과 레벨 11을 비교해보면, 대강 이렇다.

사실 큰 차이는 안난다.
근데 Level 11이 Level 1보다 5배는 더 느리다. 잘 생각하고 사용하길 바란다.
Delta 압축
Delta 코덱은 물리적으로 인접한 숫자 값 사이의 차이가 작거나 반복될 때 효과적인 코덱이다. 원본 값을 그대로 저장하는 대신, 앞선 값과 현재 값의 차이를 계산해 저장한다. 그래서 촘촘하게 분포되어있는 값을 효율적으로 압축할 수 있다.
1,2,3,4,5 처럼 촘촘한 간격으로 분포하거나, 1,1,1,2,3,3 처럼 같은 값이 많아도 좋다.
심지어 1,2,3,4,1000,1002,10003 처럼 중간에 이격이 있어도 좋다. 다만 값이 널뛰기하듯 분포되지만 않으면 된다. 중요한 것은 전체적인 인접성 분포다.
가장 대표적인 사용사례는 "시간"이나, incremantal integer다.
데이터의 생성 시간(created_at) 같은 값들은 매우 높은 확률로 몇분-몇초 간격으로 값이 분포되며, 증분 정수는 1,2,3,4 같은 촘촘한 증가가 보장된다.
사용법은 이렇다.
Delta 자체는 압축 의미론을 갖고 있는게 아니라서, 별도의 압축 코덱을 함께 지정해야 한다. 이건 전처리 코덱이다.
먹여보면 차이가 얼마나 날까?

꽤 크게 차이가 벌어지는 것을 볼 수 있다.
17MB => 11MB로 약 30-40% 정도의 압축 효과가 발생했다.
할 수 있으면 하는게 좋다.
참고로, Timestamp처럼 값의 변화폭이 일정하지 않을 때는 Delta가 좋지만, 변화량이 일정하다면 DoubleDelta가 더 효율적인 저장 구조를 가진다.
이외에도 마이너한 알고리즘들이 몇가지 더 있지만, 다 다루진 않는다.
별도 문서를 참조하길 바란다.
https://clickhouse.com/docs/ko/sql-reference/statements/create/table#column_compression_codec
참조
https://clickhouse.com/docs/sql-reference/data-types/lowcardinality
https://clickhouse.com/docs/best-practices/select-data-types
https://clickhouse.com/docs/ko/data-compression/compression-in-clickhouse
https://clickhouse.com/resources/engineering/database-compression
https://clickhouse.com/docs/ko/data-compression/compression-modes
https://clickhouse.com/docs/ko/sql-reference/statements/create/table#column_compression_codec