테이블 파티션(Table Partitions)
테이블 파티션(Table Partitions)
파티션은 MergeTree 테이블의 데이터 파츠를 논리적인 단위로 묶어 데이터 관리를 쉽게 해 줍니다. 이 문서에서는 PARTITION BY가 무엇인지, 디스크 구조는 어떻게 되는지, 그리고 파티션이 데이터 관리와 쿼리 최적화에 어떤 역할을 하는지 설명할게요.
출처: 문서
본문
ClickHouse의 테이블 파티션이란 무엇인가요?
파티션은 MergeTree 엔진 계열 테이블의 데이터 파츠를 체계적이고 논리적인 단위로 그룹화합니다. 이는 시간 범위, 카테고리, 또는 기타 핵심 속성 같은 특정 기준에 맞춰 개념적으로 의미 있게 데이터를 구성하는 방법입니다. 이러한 논리적 단위 덕분에 데이터를 더 쉽게 관리하고, 쿼리하고, 최적화할 수 있습니다.
PARTITION BY
파티셔닝은 테이블을 처음 정의할 때 PARTITION BY 절을 통해 활성화할 수 있습니다. 이 절은 어떤 컬럼에든 SQL 표현식을 포함할 수 있으며, 그 결과가 행이 어느 파티션에 속하는지 정의합니다. 이를 설명하기 위해 테이블 파츠란 무엇인가 예제 테이블에 PARTITION BY toStartOfMonth(date) 절을 추가하여 확장해 보겠습니다. 이 절은 부동산 판매 월을 기준으로 테이블의 데이터 파츠를 구성합니다:
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
ORDER BY (town, street)
PARTITION BY toStartOfMonth(date);
ClickHouse SQL Playground에서 이 테이블을 조회해 볼 수 있습니다.
디스크 구조
행 집합이 테이블에 삽입될 때마다 ClickHouse는 삽입된 전체 행을 담은 (최소한) 하나의 데이터 파트를 만드는 대신(여기에서 설명), 삽입된 행 중 고유한 파티션 키 값마다 새 데이터 파트를 하나씩 만듭니다. ClickHouse 서버는 먼저 위 다이어그램에 그려진 4개 행의 예제 삽입에 대한 행들을 파티션 키 값 toStartOfMonth(date)로 분할합니다. 그런 다음 식별된 각 파티션에 대해 행들을 평소처럼 여러 순차 단계(① 정렬, ② 컬럼 분할, ③ 압축, ④ 디스크 쓰기)로 처리합니다. 파티셔닝이 활성화되면 ClickHouse는 각 데이터 파트에 대해 MinMax 인덱스를 자동으로 생성한다는 점에 유의하세요. 이는 파티션 키 표현식에 사용된 각 테이블 컬럼에 대한 파일로, 데이터 파트 내 해당 컬럼의 최솟값과 최댓값을 담고 있습니다.
파티션별 머지
파티셔닝이 활성화되면 ClickHouse는 파츠를 파티션 내에서만, 파티션 간에는 머지하지 않습니다. 이를 위 예제 테이블로 그려 보겠습니다. 위 다이어그램처럼 서로 다른 파티션에 속한 파츠는 절대 머지되지 않습니다. 만약 고카디널리티 파티션 키를 선택하면 수천 개의 파티션에 걸쳐 퍼져 있는 파츠들은 결코 머지 후보가 될 수 없어, 미리 설정된 한도를 초과하고 골치 아픈 Too many parts 오류를 일으킵니다. 이 문제를 해결하는 방법은 간단합니다. 카디널리티가 1000~10000 미만인 합리적인 파티션 키를 선택하면 됩니다.
파티션 모니터링하기
가상 컬럼 _partition_value를 사용해 예제 테이블의 모든 고유 파티션 목록을 조회할 수 있습니다. 또는 ClickHouse는 system.parts 시스템 테이블에 모든 테이블의 모든 파츠와 파티션을 추적하며, 다음 쿼리는 예제 테이블에 대해 파티션별로 모든 파티션 목록, 현재 활성 파츠 수, 그리고 이 파츠들의 행 합계를 반환합니다.
테이블 파티션은 무엇에 사용되나요?
데이터 관리
ClickHouse에서 파티셔닝은 주로 데이터 관리 기능입니다. 파티션 표현식에 따라 데이터를 논리적으로 구성하면 각 파티션을 독립적으로 관리할 수 있습니다. 예를 들어 위 예제 테이블의 파티셔닝 방식은 TTL 규칙을 사용해 더 오래된 데이터를 자동으로 제거함으로써 기본 테이블에 최근 12개월의 데이터만 유지하는 시나리오를 가능하게 합니다(DDL 문의 마지막 추가 행 참고):
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
PARTITION BY toStartOfMonth(date)
ORDER BY (town, street)
TTL date + INTERVAL 12 MONTH DELETE;
테이블이 toStartOfMonth(date)로 파티셔닝되어 있으므로 TTL 조건에 맞는 전체 파티션(테이블 파츠의 집합)이 drop되며, 파츠를 다시 쓰지 않고도 정리 작업이 더 효율적으로 수행됩니다. 마찬가지로 오래된 데이터를 삭제하는 대신 더 비용 효율적인 스토리지 티어로 자동으로 효율적으로 옮길 수도 있습니다:
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
PARTITION BY toStartOfMonth(date)
ORDER BY (town, street)
TTL date + INTERVAL 12 MONTH TO VOLUME 'slow_but_cheap';
쿼리 최적화
파티션은 쿼리 성능에 도움을 줄 수 있지만, 이는 접근 패턴에 크게 의존합니다. 쿼리가 소수의 파티션(이상적으로는 하나)만 대상으로 한다면 성능이 잠재적으로 향상될 수 있습니다. 이는 파티셔닝 키가 프라이머리 키에 없고 그 키로 필터링할 때만 일반적으로 유용합니다. 아래 예제 쿼리처럼요. 이 쿼리는 위 예제 테이블에서 실행되며, 테이블의 파티션 키에 사용된 컬럼(date)과 프라이머리 키에 사용된 컬럼(town)(date는 프라이머리 키에 없음) 모두를 필터링하여 2020년 12월 런던에서 판매된 모든 부동산의 최고 가격을 계산합니다. ClickHouse는 이 쿼리를 처리할 때 일련의 가지치기(pruning) 기법을 적용해 관련 없는 데이터를 평가하지 않도록 합니다:
1 파티션 가지치기 (Partition pruning)
MinMax 인덱스를 사용해 테이블의 파티션 키에 사용된 컬럼에 대한 쿼리 필터와 논리적으로 일치할 수 없는 전체 파티션(파츠 집합)을 무시합니다.
2 그래뉼 가지치기 (Granule pruning)
파티션 가지치기 단계 이후 남은 데이터 파츠에 대해서는 그들의 프라이머리 인덱스를 사용해 테이블의 프라이머리 키에 사용된 컬럼에 대한 쿼리 필터와 논리적으로 일치할 수 없는 모든 그래뉼(행 블록)을 무시합니다.
이 데이터 가지치기 단계들은 위 예제 쿼리의 물리적 실행 계획을 EXPLAIN 절로 검사하여 관찰할 수 있습니다:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE date >= '2020-12-01'
AND date <= '2020-12-31'
AND town = 'LONDON';
┌─explain──────────────────────────────────────────────────────────────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple_partitioned) │
6. │ Indexes: │
7. │ MinMax │
8. │ Keys: │
9. │ date │
10. │ Condition: and((date in (-Inf, 18627]), (date in [18597, +Inf))) │
11. │ Parts: 1/436 │
12. │ Granules: 11/3257 │
13. │ Partition │
14. │ Keys: │
15. │ toStartOfMonth(date) │
16. │ Condition: and((toStartOfMonth(date) in (-Inf, 18597]), (toStartOfMonth(date) in [18597, +Inf))) │
17. │ Parts: 1/1 │
18. │ Granules: 11/11 │
19. │ PrimaryKey │
20. │ Keys: │
21. │ town │
22. │ Condition: (town in ['LONDON', 'LONDON']) │
23. │ Parts: 1/1 │
24. │ Granules: 1/11 │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
위 출력은 다음을 보여줍니다:
1 파티션 가지치기
위 EXPLAIN 출력의 7~18행은 ClickHouse가 먼저 date 필드의 MinMax 인덱스를 사용해 존재하는 436개의 활성 데이터 파츠 중 1개에 저장된 3,257개 그래뉼(행 블록) 중 11개를 식별한다는 것을 보여줍니다. 이 11개는 쿼리의 date 필터와 일치하는 행을 포함합니다.
2 그래뉼 가지치기
위 EXPLAIN 출력의 19~24행은 ClickHouse가 파티션 가지치기 단계에서 식별된 데이터 파츠의 (town 필드에 생성된) 프라이머리 인덱스를 사용해 (쿼리의 town 필터와 잠재적으로도 일치하는 행을 포함하는) 그래뉼 수를 11에서 1로 더 줄인다는 것을 나타냅니다. 이는 앞서 인쇄한 ClickHouse 클라이언트 출력에도 반영되어 있습니다:
... Elapsed: 0.006 sec. Processed 8.19 thousand rows, 57.34 KB (1.36 million rows/s., 9.49 MB/s.)
Peak memory usage: 2.73 MiB.
즉 ClickHouse는 쿼리 결과 계산을 위해 1개 그래뉼(8192행 블록)만 6밀리초 만에 스캔하고 처리했다는 뜻입니다.
파티셔닝은 주로 데이터 관리 기능입니다
모든 파티션을 가로질러 조회하는 것은 일반적으로 파티셔닝되지 않은 테이블에서 같은 쿼리를 실행하는 것보다 느리다는 점을 알아 두세요. 파티셔닝을 하면 데이터가 보통 더 많은 데이터 파츠에 분산되므로, ClickHouse가 더 많은 데이터 양을 스캔하고 처리하게 되는 경우가 많습니다. 이를 테이블 파츠란 무엇인가 예제 테이블(파티셔닝 없음)과 위의 현재 예제 테이블(파티셔닝 있음) 양쪽에 같은 쿼리를 실행해 확인할 수 있습니다. 두 테이블 모두 같은 데이터와 행 수를 담고 있습니다. 하지만 파티셔닝된 테이블은 더 많은 활성 데이터 파츠를 가집니다. 앞서 언급했듯이 ClickHouse는 파티션 내에서만, 파티션 간에는 파츠를 머지하지 않기 때문입니다. 위에서 보여주었듯이 파티셔닝된 테이블 uk_price_paid_simple_partitioned는 600개가 넘는 파티션을 가지므로 활성 데이터 파츠가 600개가 넘습니다. 반면 파티셔닝되지 않은 테이블 uk_price_paid_simple은 모든 초기 데이터 파츠가 백그라운드 머지로 단일 활성 파츠로 병합될 수 있었습니다.
파티셔닝된 테이블에서 town = 'LONDON'으로만 필터링하는 같은 쿼리를 실행하면, 아래 EXPLAIN 출력의 7~14행에서 date에 대한 MinMax/파티션 가지치기 조건이 true 갇혀 전혀 가지치기가 이루어지지 않음을 볼 수 있습니다:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE town = 'LONDON';
┌─explain─────────────────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple_partitioned) │
6. │ Indexes: │
7. │ MinMax │
8. │ Condition: true │
9. │ Parts: 436/436 │
10. │ Granules: 3257/3257 │
11. │ Partition │
12. │ Condition: true │
13. │ Parts: 436/436 │
14. │ Granules: 3257/3257 │
15. │ PrimaryKey │
16. │ Keys: │
17. │ town │
18. │ Condition: (town in ['LONDON', 'LONDON']) │
19. │ Parts: 431/436 │
20. │ Granules: 671/3257 │
└─────────────────────────────────────────────────────────────────┘
파티셔닝되지 않은 테이블에서 실행되는 같은 예제 쿼리의 물리적 실행 계획은 아래 출력의 11~12행에서 ClickHouse가 테이블의 단일 활성 데이터 파츠 내에 존재하는 3,083개 행 블록 중 241개가 쿼리 필터와 일치하는 행을 잠재적으로 포함하는 것으로 식별했다는 것을 보여줍니다:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple
WHERE town = 'LONDON';
┌─explain───────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple) │
6. │ Indexes: │
7. │ PrimaryKey │
8. │ Keys: │
9. │ town │
10. │ Condition: (town in ['LONDON', 'LONDON']) │
11. │ Parts: 1/1 │
12. │ Granules: 241/3083 │
└───────────────────────────────────────────────────────┘
파티셔닝된 테이블 버전에서 쿼리를 실행하면 ClickHouse는 671개 행 블록(약 550만 행)을 90밀리초 만에 스캔하고 처리합니다. 반면 파티셔닝되지 않은 테이블에서 쿼리를 실행하면 ClickHouse는 241개 행 블록(약 200만 행)을 12밀리초 만에 스캔하고 처리합니다. 이처럼 쿼리가 하나의 파티션 키 컬럼만 필터링할 때는 파티셔닝이 불리하게 작용할 수 있으므로, 파티셔닝은 주로 데이터 관리 기능으로 생각하는 것이 바람직합니다.