LabHub
学习 学习路径 课程

ClickHouse — 열 지향 분석 DB 를 속까지 · 건너뛰기 인덱스와 프로젝션 · 讲解

건너뛰기 인덱스와 프로젝션 — 정렬 키 밖의 조건을 빠르게

在 LabHub 中继续学习

한 줄 요약

건너뛰기 인덱스는 행의 위치를 가리키는 B-트리가 아니라 그래뉼 묶음마다 붙인 요약(최솟값·최댓값, 값 집합, 블룸 필터) 이고, "여기엔 절대 없다" 고 말할 수 있을 때만 그 묶음을 건너뛴다. 프로젝션은 파트 안에 숨겨 둔 다른 정렬(또는 미리 집계한) 사본이다. 둘 다 이미 있는 파트에는 MATERIALIZE 해야 생기고, 둘 다 디스크로 값을 치른다.

概念图: 그래뉼 묶음마다 붙인 요약(최솟값·최댓값, 값 집합, 블룸 필터) · 파트 안에 숨겨 둔 다른 정렬(또는 미리 집계한) 사본 · 읽지 않아도 되는 그래뉼을 더 많이 알아내는 것 · 다른 정렬 키를 가진 사본을 하나 더 두는 것

왜 이게 필요했나

앞 모듈들에서 정렬 키가 거의 모든 것을 정한다는 것을 봤다. 그런데 정렬 키는 하나다. 로그 표를 시각(ts)으로 정렬해 두면 "어제 오후" 는 빠르지만, 장애 조사에서 "이 trace_id 한 줄" 을 찾을 때는 100만 행을 다 읽는다. 행 지향 DB 라면 보조 B-트리 인덱스를 하나 더 만들 일이다. 공식 문서는 그 방법이 열 지향 저장에서는 통하지 않는다고 설명한다 — 디스크에 행 단위로 가리킬 "행" 이 따로 없기 때문이다.

ClickHouse 가 내놓은 답은 두 갈래다. 하나는 읽지 않아도 되는 그래뉼을 더 많이 알아내는 것(건너뛰기 인덱스), 다른 하나는 다른 정렬 키를 가진 사본을 하나 더 두는 것(프로젝션, 또는 앞 모듈의 구체화 뷰). 어느 쪽이 맞는지는 자료의 분포가 정한다.

어떻게 동작하나

위에는 정렬 키 ts 순서로 놓인 그래뉼 여섯 칸이 있고, 칸마다 minmax 인덱스가 적어 둔 seq 의 최솟값·최댓값과 user_id 의 최솟값·최댓값이 있다. seq 는 시간과 함께 커져서 칸마다 범위가 좁고 겹치지 않아 WHERE seq BETWEEN 조건이 한 칸만 남긴다. user_id 는 고루 퍼져서 모든 칸의 범위가 0부터 49999 까지라 한 칸도 건너뛰지 못한다. 블룸 필터 칸은 trace_id 가 없다고 확답할 수 있는 칸을 버리고 거짓 양성인 칸 몇 개만 남긴다. 아래에는 파트 디렉터리 안에 user_id 로 다시 정렬한 p_user 사본과 서비스·시간별로 미리 집계한 p_svc_hour 사본이 하위 디렉터리로 들어 있고, 쿼리는 원래 표를 향하지만 옵티마이저가 읽을 행이 가장 적은 쪽을 고른다

인덱스 하나는 네 가지로 정의된다 — 이름, 식, 종류(TYPE), GRANULARITY. GRANULARITY 는 그래뉼 몇 개를 한 묶음으로 요약할지다. 1 이면 8192행마다, 4 면 32768행마다 요약 하나. 문서가 소개하는 종류는 이렇다.

| 종류 | 묶음마다 적는 것 | 잘 맞는 경우 |
| --- | --- | --- |
| minmax | 식의 최솟값·최댓값 | 정렬 키와 함께 대략 커지는 열의 범위 조건 |
| set(N) | 서로 다른 값 N개까지(넘치면 비움) | 전체로는 다양하지만 묶음 안에서는 몇 개뿐인 열 |
| bloom_filter | 값 집합의 블룸 필터(거짓 양성 기본 0.025) | 드문 값 하나를 찾는 "건초더미 속 바늘" |
| ngrambf_v1·tokenbf_v1 | n-그램·토큰의 블룸 필터 | 문자열 부분 검색 — 26.2 부터 폐기 예정, text 인덱스 권장 |

이 파드(26.8)에서 100만 행·그래뉼 123개 표에 ADD INDEX idx_trace trace_id TYPE bloom_filter GRANULARITY 1 을 치면 EXPLAIN indexes = 1 에 Skip 칸이 나타나지만 Granules: 123/123 이다. 인덱스는 정의만 생겼고 기존 파트에는 인덱스 파일이 없다(system.data_skipping_indices 의 크기 0). 문서대로 새로 들어오는 자료에만 적용되기 때문이다. ALTER TABLE ... MATERIALIZE INDEX idx_trace 는 기존 파트에 파일을 만드는 뮤테이션이라 파트 이름이 all_1_1_1 에서 all_1_1_1_2 로 바뀌고, 그 뒤에는 Granules: 3/123, 읽은 행 24,576 이 된다. 찾는 행은 한 줄이지만 블룸 필터의 거짓 양성으로 그래뉼 둘을 더 읽었다.

같은 minmax 라도 결과는 극과 극이다. 시간과 함께 커지는 seq 의 범위 조건은 1/123 그래뉼만 남겼고, 시간과 무관하게 고루 퍼진 user_id 는 어느 그래뉼이나 최솟값 근처와 최댓값 근처가 다 들어 있어 123/123, 즉 하나도 건너뛰지 못했다. 문서의 표현대로 쓸모 있는 건너뛰기 인덱스는 대개 정렬 키와 대상 열 사이의 강한 상관을 필요로 한다. 값이 묶음 안에 한 번만 있어도 그 묶음 전체를 읽어야 하므로, 상관이 없으면 인덱스는 계산 비용만 더한다.

그럴 때 쓰는 것이 프로젝션이다. ADD PROJECTION p_user (SELECT * ORDER BY user_id)MATERIALIZE PROJECTION 을 하면 파트마다 user_id 순으로 다시 정렬한 사본이 생긴다. 쿼리는 여전히 skp.logs 를 향하고, 옵티마이저가 읽을 양이 가장 적은 쪽을 고른다. 이 파드에서 WHERE user_id = 4242 는 100만 행에서 8,192행으로 줄었고, system.query_logprojections 열에 skp.logs.p_user 가 남았다. GROUP BY 가 든 프로젝션은 숨은 AggregatingMergeTree 가 되어 미리 집계한 사본이 된다. 서비스·시간별 count·avg 프로젝션은 4,320행이었고, 서비스별 집계 쿼리가 원본 대신 그것만 읽었다.

값은 디스크로 치른다. 이 파드에서 p_user 사본은 약 24.6MB 로, 사본을 포함한 파트 전체(약 52.9MB)의 절반 가까이였다. 집계 프로젝션은 약 36KB, 블룸 필터 인덱스는 약 1MB, minmax 인덱스는 1KB 남짓이었다. 프로젝션에는 제약도 있다 — 문서가 적듯 원본과 다른 TTL 을 줄 수 없고, 연쇄할 수 없고, 정의에 WHERE·JOIN 을 쓸 수 없으며, 프로젝션이 있는 표에 경량 DELETE 를 치면 기본 설정(lightweight_mutation_projection_mode = throw)에서 거절된다(파드에서 오류 코드 344 확인).

마지막으로 잴 때의 함정 하나. 이 파드의 26.8 에서 기본으로 켜져 있는 쿼리 조건 캐시(use_query_condition_cache)는 "이 조건에 맞는 행이 없던 그래뉼" 을 기억해 둔다. 같은 조건의 쿼리를 두 번째 돌리니 읽은 행이 100만에서 0 으로 떨어졌다. 인덱스 효과를 비교할 때는 use_query_condition_cache = 0 을 주고 잰다.

현장에서 만나는 모습

가장 흔한 실수는 "자주 거르는 열이니 인덱스를 걸자" 로 고루 퍼진 열에 minmax 나 set 을 거는 것이다. EXPLAIN 의 Skip 칸이 123/123 이면 그 인덱스는 비용만 있다. 걸기 전에 대상 열이 정렬 키와 상관있는지부터 본다.

두 번째는 운영 중인 큰 표에 ADD INDEX 만 하고 "왜 안 빨라지지?" 하는 것이다. 새 파트에만 생기므로 기존 자료는 MATERIALIZE 해야 하는데, 그것은 모든 파트를 다시 쓰는 뮤테이션이다 — 한가한 시간에, 진행을 system.mutations 로 보면서 한다.

세 번째는 trace_id·request_id 같은 고유 값 조회다. 블룸 필터가 잘 맞는 거의 유일한 자리이고, 문자열 부분 검색이라면 26.x 에서는 text 인덱스를 먼저 검토한다. 프로젝션은 "두 번째 정렬 키가 꼭 필요하고 디스크를 두 배 써도 된다" 는 판단이 선 뒤에 쓴다.

다음 실습에서 할 것

skp.logs 에 100만 행을 넣어 파트 하나로 합친다. 블룸 필터 인덱스를 ADD 만 한 상태의 EXPLAIN 을 저장하고, MATERIALIZE 한 뒤 읽은 행을 잰다. seq 와 user_id 에 minmax 를 걸어 효과를 비교하고, user_id 프로젝션과 서비스·시간 집계 프로젝션을 만들어 query_log 로 확인한 뒤, 마지막으로 이 모든 것이 쓴 디스크를 system 표에서 읽는다.