어제 부하테스트를 마친 뒤에도 ClickHouse의 초당 병합 행 수가 3~4시간 동안 늘었다가 떨어지기를 반복했습니다. 원인을 보기 전에 ClickHouse가 데이터를 읽고 쓰는 방식부터 정리했습니다.
ClickHouse 동작 과정
INSERT가 part를 쓰고 병합하는 순서
- INSERT 한 번마다 행을
ORDER BY순서로 정렬하고 압축해 level 0 part 디렉토리 하나로 쓴다. - part 안의 행은 8,192행씩 granule로 나뉜다.
primary.idx에는 granule마다 첫 행의 키를,.mrk2에는 granule이.bin의 어느 압축 블록에서 시작하는지를 기록한다. - 백그라운드 스레드가 같은 파티션에서 블록 번호가 이어진 part를 골라 큰 part 하나로 다시 쓴다. level은 병합한 part 중 가장 높은 level에 1을 더한 값이다.
- 병합 전 part는 비활성 상태가 되고, 기본 480초 뒤 삭제된다.
SELECT가 데이터를 읽는 순서
- 파티션 키의 최솟값과 최댓값으로 조건에 맞지 않는 part를 뺀다.
primary.idx로 조건에 맞는 granule을 고른다..mrk2로 granule이.bin의 어느 압축 블록에서 시작하는지 찾고, 필요한 컬럼의 블록만 풀어 읽는다.- 읽은 블록을 코어별로 집계하고, 마지막에 결과를 합친다.
ClickHouse가 집계 쿼리를 빠르게 처리하는 방식
쿼리에 쓴 컬럼 파일만 읽는다
- 행 저장(MySQL, PostgreSQL): 한 행의 컬럼이 붙어 저장돼, 컬럼 2개만 써도 행 전체를 읽는다.
- 컬럼 저장(ClickHouse): 컬럼마다 파일이 따로 있어,
country와amount파일만 읽는다. - 압축: 같은 컬럼의 비슷한 값이 이어서 저장돼 압축률이 높다.
정렬 키 인덱스로 읽을 granule을 고른다
- 인덱스 크기: 10억 행이어도 항목이 약 12만 개라 메모리에 올라간다.
- 조회:
primary.idx를 이진 탐색해 조건에 맞는 granule만 읽는다.
블록 단위로 여러 코어에서 집계한다
- 벡터화 실행: 값을 한 행씩 계산하지 않고, 블록 단위로 묶어 SIMD 명령으로 계산한다.
- 병렬 처리: 블록을 코어마다 나눠 부분 집계를 만들고, 마지막에 합친다.
SIMD(Single Instruction, Multiple Data)는 CPU가 명령 하나로 값 여러 개를 동시에 계산하는 방식입니다. 컬럼 파일을 풀면 같은 타입의 값이 연속으로 나와 SIMD로 바로 계산할 수 있습니다.
MergeTree가 INSERT를 저장하고 병합하는 방식
part 이름에 병합 level이 기록된다
- 이름:
{파티션}_{최소 블록 번호}_{최대 블록 번호}_{level}형식이다. 블록 번호는 INSERT마다 1씩 늘어나는 번호이고, level 0은 병합되지 않은 part다.202610_1_3_1은 블록 1~3을 한 번 병합한 part다. - 병합 대상: 같은 파티션에서 블록 번호가 이어진 part를 고른다. level이 같지 않아도 되고, 병합한 part의 level은 가장 높은 level에 1을 더한 값이다.
202610_1_3_1과202610_4_4_0~202610_9_9_0을 병합하면202610_1_9_2가 된다.
부하가 없을 때도 병합 행 수가 3~4시간 주기로 늘었다
병합 행 수는 0에서 3~4시간 동안 1분 rate 최대 256만 행/초까지 늘었다가 0 근처로 떨어졌습니다.
병합한 테이블은 loadtest.events가 아니라 ClickHouse가 자체 지표를 쌓는 system.asynchronous_metric_log였습니다.
- 적재:
system.asynchronous_metric_log에 7초마다 6,146행짜리 part가 생긴다.system.part_log에 기록이 남은 22:28까지loadtest.events의 병합은 3번이었다. - level:
202610_11409_18375_1970part는 INSERT 6,967번 분량인데 level이 1970이다. 같은 level끼리만 병합했다면 블록 수의 log₂인 약 13이다. 큰 part 하나가 새 level 0 part와 약 2,000번 병합됐다. - 병합 행 수: 병합할 때마다 큰 part 전체를 다시 써서, part가 커지는 만큼 병합 행 수가 늘었다. 이 part는 4,283만 행까지 커졌다. 새 part가 항상 기존 part와 병합되면 병합 작업량은 데이터 크기의 제곱에 비례한다(SimpleMergeSelector 주석).
- 떨어진 시점: 큰 part가 병합 대상에서 빠지면 병합 행 수가 0 근처로 떨어진다. 빠지는 조건은 확인하지 못했다.
테이블을 잘못 설계하면 생기는 문제
| 문제 | 정의 | 확인 방법 |
|---|---|---|
| part 과다 | 파티션의 활성 part가 1,000개를 넘으면 INSERT가 지연되고, 3,000개를 넘으면 실패한다 | system.parts에서 파티션별 활성 part 수 |
| 잘게 나눈 파티션 | INSERT 한 번에 파티션 수만큼 part가 생기고, 파티션끼리는 병합되지 않아 작은 part가 남는다 | system.parts에서 파티션 수와 파티션별 크기 |
| ORDER BY 키 순서 | 필터 컬럼이 키 앞쪽에 없으면 조건에 맞는 행이 흩어진 granule을 모두 읽는다 | EXPLAIN indexes = 1에서 선택된 granule 수 |
part가 병합보다 빨리 늘면 INSERT가 지연되거나 실패한다
- 원인: INSERT마다 part가 1개씩 늘고, 병합이 끝나야 줄어든다. 작은 INSERT를 자주 보내면 병합보다 part가 빨리 는다.
- 대처: 클라이언트에서 행을 묶어 INSERT 횟수를 줄이거나,
async_insert=1로 서버가 INSERT를 모아 쓰게 한다.
파티션을 잘게 나누면 작은 part가 병합되지 않고 남는다
- INSERT: 한 번에 들어온 행이 여러 파티션에 걸치면 파티션마다 part가 생긴다.
- 대처: 파티션은 보관 기간을 관리하는 단위로 보고 월 단위처럼 크게 잡는다. ClickHouse는 테이블의 파티션 수를 1,000~10,000개 아래로 권장한다. 조회 범위는
ORDER BY로 줄인다.
ORDER BY 키 순서가 필터와 맞지 않으면 granule을 모두 읽는다
- 첫 번째 키로 거를 때: 같은 값이 연속된 granule에 모여 그 구간만 읽는다.
- 두 번째 이후 키로 거를 때: 앞 컬럼의 카디널리티가 낮으면 일부 granule을 뺄 수 있다. 앞 컬럼의 카디널리티가 높으면 granule마다 앞 컬럼 값이 달라 뒤 컬럼의 범위를 알 수 없다.
- 대처: 자주 거르는 컬럼을 넣고, 카디널리티가 낮은 컬럼부터 둔다. 기존 컬럼의 순서를 바꾸려면 테이블을 다시 만들어 데이터를 옮겨야 한다.