ClickHouse의 압축
ClickHouse의 압축 (Compression in ClickHouse)
ClickHouse 쿼리 성능의 비결 중 하나는 압축이에요. 디스크에 데이터가 적으면 I/O가 줄고 쿼리와 삽입이 빨라져요. CPU 관점에서 어떤 압축 알고리즘의 오버헤드도 대부분의 경우 I/O 감소가 상쇄해요. 따라서 ClickHouse 쿼리를 빠르게 만들기 위해 노력할 때 데이터 압축을 개선하는 것이 첫 번째 초점이 되어야 해요.
출처: 문서
본문
ClickHouse가 데이터를 그렇게 잘 압축하는 이유는 이 문서를 읽어보시길 권장해요. 요약하면, 우리의 컬럼 지향 데이터베이스는 값을 컬럼 순서로 씁니다. 이 값들이 정렬되면 동일한 값들이 서로 인접하게 위치하고, 압축 알고리즘은 데이터의 연속 패턴을 활용해요. 게다가 ClickHouse에는 압축을 더 쉽게 튜닝할 수 있는 코덱과 세분화된 데이터 타입이 있어요.
ClickHouse의 압축은 3가지 주요 요소의 영향을 받아요.
- 정렬 키 (ordering key)
- 데이터 타입
- 사용되는 코덱
이 모든 것은 스키마를 통해 구성돼요.
압축을 최적화할 올바른 데이터 타입 선택
Stack Overflow 데이터셋을 예로 들어 볼게요. posts 테이블에 대한 다음 스키마들의 압축 통계를 비교해 볼게요.
posts— 타입 최적화가 없고 정렬 키가 없는 스키마.posts_v3— 각 컬럼에 적절한 타입과 비트 크기를 사용하고 정렬 키(PostTypeId, toDate(CreationDate), CommentCount)를 가진 타입 최적화 스키마.
다음 쿼리들로 각 컬럼의 현재 압축 및 비압축 크기를 측정할 수 있어요. 정렬 키가 없는 초기 최적화 스키마 posts의 크기를 살펴볼게요.
SELECT name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'posts'
GROUP BY name
┌─name──────────────────┬─compressed_size─┬─uncompressed_size─┬───ratio────┐
│ Body │ 46.14 GiB │ 127.31 GiB │ 2.76 │
│ Title │ 1.20 GiB │ 2.63 GiB │ 2.19 │
│ Score │ 84.77 MiB │ 736.45 MiB │ 8.69 │
│ Tags │ 475.56 MiB │ 1.40 GiB │ 3.02 │
│ ParentId │ 210.91 MiB │ 696.20 MiB │ 3.3 │
│ Id │ 111.17 MiB │ 736.45 MiB │ 6.62 │
│ AcceptedAnswerId │ 81.55 MiB │ 736.45 MiB │ 9.03 │
│ ClosedDate │ 13.99 MiB │ 517.82 MiB │ 37.02 │
│ LastActivityDate │ 489.84 MiB │ 964.64 MiB │ 1.97 │
│ CommentCount │ 37.62 MiB │ 565.30 MiB │ 15.03 │
│ OwnerUserId │ 368.98 MiB │ 736.45 MiB │ 2 │
│ AnswerCount │ 21.82 MiB │ 622.35 MiB │ 28.53 │
│ FavoriteCount │ 280.95 KiB │ 508.40 MiB │ 1853.02 │
│ ViewCount │ 95.77 MiB │ 736.45 MiB │ 7.69 │
│ LastEditorUserId │ 179.47 MiB │ 736.45 MiB │ 4.1 │
│ ContentLicense │ 5.45 MiB │ 847.92 MiB │ 155.5 │
│ OwnerDisplayName │ 14.30 MiB │ 142.58 MiB │ 9.97 │
│ PostTypeId │ 20.93 MiB │ 565.30 MiB │ 27 │
│ CreationDate │ 314.17 MiB │ 964.64 MiB │ 3.07 │
│ LastEditDate │ 346.32 MiB │ 964.64 MiB │ 2.79 │
│ LastEditorDisplayName │ 5.46 MiB │ 124.25 MiB │ 22.75 │
│ CommunityOwnedDate │ 2.21 MiB │ 509.60 MiB │ 230.94 │
└───────────────────────┴─────────────────┴───────────────────┴────────────┘
compact와 wide 파트에 대한 참고
compressed_size나 uncompressed_size 값이 0으로 보인다면, 파트의 타입이 wide가 아니라 compact이기 때문일 수 있어요 (system.parts의 part_type 설명 참조). 파트 포맷은 min_bytes_for_wide_part와 min_rows_for_wide_part 설정으로 제어되는데, 삽입된 데이터로 만들어진 파트가 앞서 언급한 설정 값들을 초과하지 않으면 파트가 wide가 아니라 compact가 되어 compressed_size나 uncompressed_size 값을 볼 수 없어요. 데모를 보여드릴게요.
Query
-- Create a table with compact parts
CREATE TABLE compact (
number UInt32
)
ENGINE = MergeTree()
ORDER BY number
AS SELECT * FROM numbers(100000); -- Not big enough to exceed default of min_bytes_for_wide_part = 10485760
-- Check the type of the parts
SELECT table, name, part_type from system.parts where table = 'compact';
-- Get the compressed and uncompressed column sizes for the compact table
SELECT name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'compact'
GROUP BY name;
-- Create a table with wide parts
CREATE TABLE wide (
number UInt32
)
ENGINE = MergeTree()
ORDER BY number
SETTINGS min_bytes_for_wide_part=0
AS SELECT * FROM numbers(100000);
-- Check the type of the parts
SELECT table, name, part_type from system.parts where table = 'wide';
-- Get the compressed and uncompressed sizes for the wide table
SELECT name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'wide'
GROUP BY name;
Response
┌─table───┬─name──────┬─part_type─┐
1. │ compact │ all_1_1_0 │ Compact │
└─────────┴───────────┴───────────┘
┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 0.00 B │ 0.00 B │ nan │
└────────┴─────────────────┴───────────────────┴───────┘
┌─table─┬─name──────┬─part_type─┐
1. │ wide │ all_1_1_0 │ Wide │
└───────┴───────────┴───────────┘
┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 392.31 KiB │ 390.63 KiB │ 1 │
└────────┴─────────────────┴───────────────────┴───────┘
여기서 압축 크기와 비압축 크기를 모두 보여드렸어요. 둘 다 중요해요. 압축 크기는 쿼리 성능(및 저장 비용)을 위해 최소화하고 싶은, 디스크에서 읽어야 할 양에 해당해요. 이 데이터는 읽기 전에 압축을 풀어야 해요. 이 비압축 크기와 압축 크기의 차이는 이 경우 사용되는 데이터 타입에 따라 달라져요. 이 크기를 최소화하면 쿼리의 메모리 오버헤드와 쿼리가 처리해야 하는 데이터 양이 줄어들어, 캐시 활용이 개선되고 궁극적으로 쿼리 시간이 개선돼요.
위 쿼리는 system 데이터베이스의
columns테이블에 의존해요. 이 데이터베이스는 ClickHouse가 관리하며, 쿼리 성능 메트릭부터 백그라운드 클러스터 로그까지 유용한 정보의 보고예요. 호기심 많은 독자에게는 “System Tables and a Window into the Internals of ClickHouse”와 관련 글[1][2]을 권장해요. 테이블의 총 크기를 요약하려면 위 쿼리를 단순화할 수 있어요.
SELECT formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'posts'
┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 50.16 GiB │ 143.47 GiB │ 2.86 │
└─────────────────┴───────────────────┴───────┘
타입과 정렬 키가 최적화된 테이블 posts_v3에 대해 이 쿼리를 반복하면, 비압축과 압축 크기가 크게 줄어드는 것을 볼 수 있어요.
SELECT
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'
┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 25.15 GiB │ 68.87 GiB │ 2.74 │
└─────────────────┴───────────────────┴───────┘
전체 컬럼 내역을 보면, 압축 전에 데이터를 정렬하고 적절한 타입을 사용함으로써 Body, Title, Tags, CreationDate 컬럼에서 상당한 절감이 이루어진 것을 확인할 수 있어요.
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'
GROUP BY name
┌─name──────────────────┬─compressed_size─┬─uncompressed_size─┬───ratio─┐
│ Body │ 23.10 GiB │ 63.63 GiB │ 2.75 │
│ Title │ 614.65 MiB │ 1.28 GiB │ 2.14 │
│ Score │ 40.28 MiB │ 227.38 MiB │ 5.65 │
│ Tags │ 234.05 MiB │ 688.49 MiB │ 2.94 │
│ ParentId │ 107.78 MiB │ 321.33 MiB │ 2.98 │
│ Id │ 159.70 MiB │ 227.38 MiB │ 1.42 │
│ AcceptedAnswerId │ 40.34 MiB │ 227.38 MiB │ 5.64 │
│ ClosedDate │ 5.93 MiB │ 9.49 MiB │ 1.6 │
│ LastActivityDate │ 246.55 MiB │ 454.76 MiB │ 1.84 │
│ CommentCount │ 635.78 KiB │ 56.84 MiB │ 91.55 │
│ OwnerUserId │ 183.86 MiB │ 227.38 MiB │ 1.24 │
│ AnswerCount │ 9.67 MiB │ 113.69 MiB │ 11.76 │
│ FavoriteCount │ 19.77 KiB │ 147.32 KiB │ 7.45 │
│ ViewCount │ 45.04 MiB │ 227.38 MiB │ 5.05 │
│ LastEditorUserId │ 86.25 MiB │ 227.38 MiB │ 2.64 │
│ ContentLicense │ 2.17 MiB │ 57.10 MiB │ 26.37 │
│ OwnerDisplayName │ 5.95 MiB │ 16.19 MiB │ 2.72 │
│ PostTypeId │ 39.49 KiB │ 56.84 MiB │ 1474.01 │
│ CreationDate │ 181.23 MiB │ 454.76 MiB │ 2.51 │
│ LastEditDate │ 134.07 MiB │ 454.76 MiB │ 3.39 │
│ LastEditorDisplayName │ 2.15 MiB │ 6.25 MiB │ 2.91 │
│ CommunityOwnedDate │ 824.60 KiB │ 1.34 MiB │ 1.66 │
└───────────────────────┴─────────────────┴───────────────────┴─────────┘
올바른 컬럼 압축 코덱 선택
컬럼 압축 코덱으로 각 컬럼의 인코딩·압축에 사용되는 알고리즘(및 설정)을 바꿀 수 있어요. 인코딩과 압축은 같은 목표(데이터 크기 줄이기)를 가진 채 약간 다르게 동작해요. 인코딩은 데이터 타입의 특성을 활용해 함수 기반으로 값을 변환하는 매핑을 적용해요. 반면 압축은 바이트 수준에서 데이터를 압축하는 일반적인 알고리즘을 사용해요. 일반적으로 인코딩이 먼저 적용되고 그 다음 압축이 사용돼요. 서로 다른 인코딩·압축 알고리즘은 서로 다른 값 분포에 효과적이므로, 데이터를 이해해야 해요. ClickHouse는 많은 수의 코덱과 압축 알고리즘을 지원해요. 다음은 중요도 순서로 나열한 권장 사항이에요.
| 권장 사항 | 근거 |
|---|---|
ZSTD 전부 사용 |
ZSTD 압축이 가장 좋은 압축률을 제공해요. ZSTD(1)이 대부분의 일반 타입에 기본값이어야 해요. 숫자 값을 수정해 더 높은 압축률을 시도할 수 있어요. 압축 비용(삽입이 느려짐) 증가에 비해 3보다 높은 값에서는 충분한 이점이 거의 없어요. |
날짜·정수 시퀀스에 Delta |
Delta 기반 코덱은 단조 시퀀스나 연속 값에서 작은 델타가 있을 때 잘 동작해요. 더 구체적으로 Delta 코덱은 파생값이 작은 수를 만들 때 잘 동작해요. 그렇지 않다면 DoubleDelta를 시도해 볼 만해요 (일반적으로 Delta의 1차 파생값이 이미 매우 작다면 별로 도움이 되지 않아요). 단조 증가가 균일한 시퀀스(예: DateTime 필드)는 더 잘 압축돼요. |
Delta가 ZSTD를 개선 |
ZSTD는 델타 데이터에 효과적인 코덱이에요. 반대로 delta 인코딩은 ZSTD 압축을 개선할 수 있어요. ZSTD가 있으면 다른 코덱이 추가 개선을 제공하는 경우는 드물어요. |
가능하면 LZ4를 ZSTD보다 선호 |
LZ4와 ZSTD 사이 압축이 비슷하다면, 해제가 더 빠르고 CPU가 덜 필요하므로 전자를 선호해요. 그러나 대부분의 경우 ZSTD가 LZ4를 상당한 차이로 능가해요. 이 코덱들 중 일부는 코덱이 없는 ZSTD와 비슷한 압축을 제공하면서 LZ4와 결합하면 더 빠르게 동작할 수도 있어요. 하지만 이것은 데이터 특성에 따라 달라져서 테스트가 필요해요. |
희소·작은 범위에 T64 |
T64는 희소 데이터나 블록의 범위가 작을 때 효과적일 수 있어요. 임의 숫자에는 T64를 피하세요. |
알 수 없는 패턴에는 Gorilla와 T64? |
데이터에 알 수 없는 패턴이 있다면 Gorilla와 T64를 시도해 볼 만해요. |
게이지 데이터에 Gorilla |
Gorilla는 부동소수점 데이터, 특히 게이지 수치(임의의 스파이크)를 나타내는 데이터에 효과적일 수 있어요. |
더 많은 옵션은 여기를 참고하세요. 아래에서는 Id, ViewCount, AnswerCount에 Delta 코덱을 지정했어요. 이것들이 정렬 키와 선형으로 상관되어 Delta 인코딩의 혜택을 받을 것이라고 가정했어요.
CREATE TABLE posts_v4
(
`Id` Int32 CODEC(Delta, ZSTD),
`PostTypeId` Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
`AcceptedAnswerId` UInt32,
`CreationDate` DateTime64(3, 'UTC'),
`Score` Int32,
`ViewCount` UInt32 CODEC(Delta, ZSTD),
`Body` String,
`OwnerUserId` Int32,
`OwnerDisplayName` String,
`LastEditorUserId` Int32,
`LastEditorDisplayName` String,
`LastEditDate` DateTime64(3, 'UTC'),
`LastActivityDate` DateTime64(3, 'UTC'),
`Title` String,
`Tags` String,
`AnswerCount` UInt16 CODEC(Delta, ZSTD),
`CommentCount` UInt8,
`FavoriteCount` UInt8,
`ContentLicense` LowCardinality(String),
`ParentId` String,
`CommunityOwnedDate` DateTime64(3, 'UTC'),
`ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = MergeTree
ORDER BY (PostTypeId, toDate(CreationDate), CommentCount)
이 컬럼들의 압축 개선은 아래와 같아요.
SELECT
`table`,
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (name IN ('Id', 'ViewCount', 'AnswerCount')) AND (`table` IN ('posts_v3', 'posts_v4'))
GROUP BY
`table`,
name
ORDER BY
name ASC,
`table` ASC
┌─table────┬─name────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ posts_v3 │ AnswerCount │ 9.67 MiB │ 113.69 MiB │ 11.76 │
│ posts_v4 │ AnswerCount │ 10.39 MiB │ 111.31 MiB │ 10.71 │
│ posts_v3 │ Id │ 159.70 MiB │ 227.38 MiB │ 1.42 │
│ posts_v4 │ Id │ 64.91 MiB │ 222.63 MiB │ 3.43 │
│ posts_v3 │ ViewCount │ 45.04 MiB │ 227.38 MiB │ 5.05 │
│ posts_v4 │ ViewCount │ 52.72 MiB │ 222.63 MiB │ 4.22 │
└──────────┴─────────────┴─────────────────┴───────────────────┴───────┘
6 rows in set. Elapsed: 0.008 sec
ClickHouse Cloud의 압축
ClickHouse Cloud에서는 기본적으로 ZSTD 압축 알고리즘(기본값 1)을 사용해요. 이 알고리즘의 압축 속도는 압축 레벨(높을수록 느림)에 따라 달라질 수 있지만, 해제는 일관되게 빠르다는(약 20% 변동) 장점이 있고 병렬화의 혜택도 받아요. 역사적인 테스트에서도 이 알고리즘은 종종 충분히 효과적이며, 코덱과 결합된 LZ4를 능가할 수도 있음을 시사해요. 대부분의 데이터 타입과 정보 분포에 효과적이므로, 합리적인 범용 기본값이며 최적화 없이도 초기의 압축이 이미 훌륭한 이유예요.