ClickHouse 데이터 스킵 인덱스 이해
ClickHouse 데이터 스킵 인덱스 이해 (Understanding ClickHouse data skipping indexes)
ClickHouse 쿼리 성능에 영향을 주는 핵심 요소는 WHERE 절 조건 평가 시 기본 키를 사용할 수 있는지입니다. 하지만 어떤 기본 키도 모든 사용 사례를 효율적으로 쓰지 못하며, 스킵 인덱스는 일치하는 값이 없음이 보장된 큰 데이터 덩어리를 건너뛰어 특정 상황에서 쿼리 속도를 크게 개선합니다.
출처: 문서
본문
소개 (Introduction)
많은 요인이 ClickHouse 쿼리 성능에 영향을 줍니다. 대부분 시나리오의 핵심 요소는 ClickHouse가 쿼리 WHERE 절 조건을 평가할 때 기본 키를 사용할 수 있는지입니다. 따라서 가장 흔한 쿼리 패턴에 적용되는 기본 키를 선택하는 것이 효과적인 테이블 설계에 필수적입니다.
그럼에도 얼마나 신중하게 기본 키를 조정해도 그것을 효율적으로 쓸 수 없는 쿼리 사용 사례가 반드시 생깁니다. 사용자는 흔히 시계열 유형 데이터에 ClickHouse를 사용하지만, 같은 데이터를 고객 id, 웹사이트 URL, 제품 번호 같은 다른 비즈니스 차원으로 분석하고 싶어 하기도 합니다. 그 경우 WHERE 절 조건을 적용하기 위해 각 컬럼 값을 전체 스캔해야 할 수 있어 쿼리 성능이 상당히 나빠질 수 있습니다. ClickHouse는 그러한 상황에서도 여전히 상대적으로 빠르지만, 수백만·수십억 개의 개별 값을 평가하면 "인덱스되지 않은" 쿼리가 기본 키 기반 쿼리보다 훨씬 느리게 실행됩니다.
전통적인 관계형 데이터베이스에서는 이 문제에 대한 한 가지 접근 방식이 테이블에 하나 이상의 "보조(secondary)" 인덱스를 붙이는 것입니다. 이것은 데이터베이스가 디스크에서 일치하는 모든 행을 O(n) 시간(테이블 스캔) 대신 O(log(n)) 시간에 찾을 수 있게 하는 b-트리 구조입니다. 여기서 n은 행 수입니다. 그러나 이런 유형의 보조 인덱스는 ClickHouse(또는 다른 컬럼 지향 데이터베이스)에서는 동작하지 않습니다. 인덱스에 추가할 개별 행이 디스크에 없기 때문입니다.
대신 ClickHouse는 특정 상황에서 쿼리 속도를 크게 개선할 수 있는 다른 유형의 인덱스를 제공합니다. 이 구조들은 일치하는 값이 없음이 보장된 큰 데이터 덩어리를 읽는 것을 건너뛸 수 있게 해 주기 때문에 "스킵(Skip)" 인덱스라고 불립니다.
기본 연산
데이터 스킵 인덱스는 MergeTree 계열 테이블에서만 사용할 수 있습니다. 각 데이터 스킵 인덱스는 네 가지 주요 인자를 가집니다:
- 인덱스 이름(Index name). 인덱스 이름은 각 파티션에 인덱스 파일을 만드는 데 사용됩니다. 또한 인덱스를 삭제하거나 구체화할 때 파라미터로 필요합니다.
- 인덱스 표현식(Index expression). 인덱스 표현식은 인덱스에 저장된 값의 집합을 계산하는 데 사용됩니다. 컬럼, 단순 연산자, 및/또는 인덱스 타입에 의해 결정된 함수의 부분집합의 조합일 수 있습니다.
- TYPE. 인덱스의 타입은 각 인덱스 블록을 건너뛰어 읽고 평가할 수 있는지 결정하는 계산을 제어합니다.
- GRANULARITY. 각 인덱스 블록은 GRANULARITY개의 granule로 구성됩니다. 예를 들어 기본 테이블 인덱스의 입자(granularity)가 8192행이고 인덱스 입자가 4라면 각 인덱스 "블록"은 32768행이 됩니다.
사용자가 데이터 스킵 인덱스를 만들면 테이블의 각 데이터 파트 디렉터리에 추가 파일 두 개가 생깁니다.
skp_idx_{index_name}.idx— 정렬된 표현식 값 포함skp_idx_{index_name}.mrk2— 연관된 데이터 컬럼 파일의 해당 오프셋 포함
쿼리를 실행하고 관련 컬럼 파일을 읽을 때 WHERE 절 필터링 조건의 일부가 스킵 인덱스 표현식과 일치하면, ClickHouse는 인덱스 파일 데이터를 사용해 각 관련 데이터 블록을 처리해야 하는지 건너뛸 수 있는지 결정합니다(블록이 기본 키 적용으로 이미 제외되지 않았다고 가정). 매우 단순화된 예제를 사용하기 위해 예측 가능한 데이터가 채워진 다음 테이블을 고려해 보세요.
CREATE TABLE skip_table
(
my_key UInt64,
my_value UInt64
)
ENGINE MergeTree primary key my_key
SETTINGS index_granularity=8192;
INSERT INTO skip_table SELECT number, intDiv(number,4096) FROM numbers(100000000);
기본 키를 사용하지 않는 단순 쿼리를 실행하면 my_value 컬럼의 1억 개 엔트리가 모두 스캔됩니다:
SELECT * FROM skip_table WHERE my_value IN (125, 700)
┌─my_key─┬─my_value─┐
│ 512000 │ 125 │
│ 512001 │ 125 │
│ ... | ... |
└────────┴──────────┘
8192 rows in set. Elapsed: 0.079 sec. Processed 100.00 million rows, 800.10 MB (1.26 billion rows/s., 10.10 GB/s.
이제 매우 기본적인 스킵 인덱스를 추가합니다:
ALTER TABLE skip_table ADD INDEX vix my_value TYPE set(100) GRANULARITY 2;
보통 스킵 인덱스는 새로 삽입된 데이터에만 적용되므로, 인덱스만 추가해서는 위 쿼리에 영향이 없습니다.
이미 존재하는 데이터를 인덱싱하려면 이 문을 사용하세요:
ALTER TABLE skip_table MATERIALIZE INDEX vix;
새로 만든 인덱스로 쿼리를 다시 실행합니다:
SELECT * FROM skip_table WHERE my_value IN (125, 700)
┌─my_key─┬─my_value─┐
│ 512000 │ 125 │
│ 512001 │ 125 │
│ ... | ... |
└────────┴──────────┘
8192 rows in set. Elapsed: 0.051 sec. Processed 32.77 thousand rows, 360.45 KB (643.75 thousand rows/s., 7.08 MB/s.)
ClickHouse는 800메가바이트의 1억 행을 처리하는 대신 360킬로바이트의 32,768행만 읽고 분석했습니다 — 각 8192행의 granule 네 개입니다.
더 시각적인 형태로, my_value가 125인 4096행이 어떻게 읽히고 선택되었는지, 그리고 디스크에서 읽지 않고 이후 행들이 어떻게 건너뛰었는지 다음과 같습니다:
쿼리를 실행할 때 트레이스를 활성화하면 스킵 인덱스 사용에 대한 자세한 정보에 접근할 수 있습니다. clickhouse-client에서 send_logs_level을 설정하세요:
SET send_logs_level='trace';
이것은 쿼리 SQL과 테이블 인덱스를 조정하려 할 때 유용한 디버깅 정보를 제공합니다. 위 예제에서 디버그 로그는 스킵 인덱스가 두 granule을 제외한 모두를 버렸음을 보여 줍니다:
<Debug> default.skip_table (933d4b2c-8cea-4bf9-8c93-c56e900eefd1) (SelectExecutor): Index `vix` has dropped 6102/6104 granules.
스킵 인덱스 타입
minmax
이 경량 인덱스 타입은 파라미터가 필요 없습니다. 각 블록에 대해 인덱스 표현식의 최솟값과 최댓값을 저장합니다(표현식이 튜플이면 튜플 요소의 각 멤버에 대해 값을 별도로 저장). 이 타입은 값으로 느슨하게 정렬되는 경향이 있는 컬럼에 이상적입니다. 이 인덱스 타입은 보통 쿼리 처리 중 적용 비용이 가장 낮습니다.
이 인덱스 타입은 스칼라 또는 튜플 표현식에서만 올바르게 동작합니다 — 배열이나 맵 데이터 타입을 반환하는 표현식에는 결코 적용되지 않습니다.
set
이 경량 인덱스 타입은 블록당 값 집합의 max_size라는 단일 파라미터를 받습니다(0은 무한한 수의 서로 다른 값 허용). 이 집합은 블록의 모든 값을 포함합니다(값 수가 max_size를 초과하면 비어 있음). 이 인덱스 타입은 각 granule 집합 내에서 낮은 카디널리티를 가진("뭉쳐 있는") 컬럼에 잘 동작하지만 전체적으로는 더 높은 카디널리티를 가집니다.
이 인덱스의 비용, 성능, 효과는 블록 내 카디널리티에 의존합니다. 각 블록이 많은 수의 고유 값을 포함하면, 큰 인덱스 집합에 대해 쿼리 조건을 평가하는 것이 매우 비싸거나, max_size를 초과해 인덱스가 비어 적용되지 않을 수 있습니다.
text
자연어 또는 자유 형식 텍스트 검색(예: 큰 텍스트 컬럼에서 단어나 구 검색)을 포함하는 워크로드에 ClickHouse는 text 인덱스(실제 역인덱스)를 제공합니다. Text 인덱스는 효율적인 전체 텍스트 검색 의미론과 토큰화된 룩업을 지원합니다. hasAnyToken, hasAllTokens 같은 검색 함수에 대한 결정적 토큰 인덱싱과 더 나은 성능을 제공하므로 전체 텍스트 검색 쿼리에 권장되는 선택입니다. 또한 모든 일반적인 텍스트 검색 함수도 최적화합니다.
자세한 내용은 여기 text 인덱스 문서를 참고하세요.
Bloom filter 타입
Bloom filter는 약간의 오탐(false positive) 가능성을 대가로 집합 멤버십을 공간 효율적으로 테스트할 수 있게 하는 데이터 구조입니다. 오탐은 스킵 인덱스의 경우 큰 문제가 아닙니다. 유일한 단점은 몇 개의 불필요한 블록을 읽는 것뿐이기 때문입니다. 그러나 오탐 가능성은 인덱스된 표현식이 참일 것으로 예상되어야 함을 의미합니다. 그렇지 않으면 유효한 데이터가 건너뛸 수 있습니다.
블룸 필터는 많은 수의 서로 다른 값을 테스트하는 것을 더 효율적으로 처리할 수 있으므로, 더 많은 테스트 값을 만드는 조건부 표현식에 적합할 수 있습니다. 특히 블룸 필터 인덱스는 배열에 적용될 수 있는데, 배열의 모든 값이 테스트되고, mapKeys 또는 mapValues 함수로 키나 값을 배열로 변환해 맵에도 적용될 수 있습니다.
블룸 필터 기반의 데이터 스킵 인덱스 타입은 세 가지가 있습니다:
-
기본 bloom_filter은 0과 1 사이의 허용된 "오탐" 비율이라는 단일 선택적 파라미터를 받습니다(지정하지 않으면 .025 사용).
-
특수한 tokenbf_v1(폐기됨). 사용된 블룸 필터 조정과 관련된 세 가지 파라미터를 받습니다: (1) 필터의 바이트 크기(더 큰 필터는 오탐이 적지만 스토리지 비용이 있음), (2) 적용되는 해시 함수 수(역시 더 많은 해시 필터가 오탐을 줄임), (3) 블룸 필터 해시 함수의 시드. 이 파라미터들이 블룸 필터 기능에 어떻게 영향을 주는지 더 자세한 내용은 여기 계산기를 참고하세요. 이 인덱스는 String, FixedString, Map 데이터 타입에서만 동작합니다. 입력 표현식은 영숫자가 아닌 문자로 구분된 문자 시퀀스로 나뉩니다. 예를 들어 컬럼 값
This is a candidate for a "full text" search는 토큰Thisisacandidateforfulltextsearch를 포함합니다. 이것은 더 긴 문자열 안의 단어와 다른 값에 대한 LIKE, EQUALS, IN, hasToken() 및 유사 검색에 쓰도록 설계되었습니다. 예를 들어 자유 형식 애플리케이션 로그 라인의 컬럼에서 적은 수의 클래스 이름이나 줄 번호를 검색하는 것이 한 가지 가능한 용도입니다. -
특수한 ngrambf_v1(폐기됨). 이 인덱스는 토큰 인덱스와 같은 방식으로 동작합니다. 블룸 필터 설정 앞에 인덱싱할 ngram의 크기라는 추가 파라미터 하나를 받습니다. ngram은 길이
n의 임의 문자로 구성된 문자열이므로, ngram 크기 4인 문자열A short string은 다음과 같이 인덱싱됩니다:
'A sh', ' sho', 'shor', 'hort', 'ort ', 'rt s', 't st', ' str', 'stri', 'trin', 'ring'
이 인덱스는 중국어처럼 단어 경계가 없는 언어의 텍스트 검색에도 유용할 수 있습니다.
전체 텍스트 검색 워크로드에는 폐기된
tokenbf_v1또는ngrambf_v1인덱스보다 전용 text 인덱스(전체 텍스트 검색용 Text index 참고)를 권장합니다. text 인덱스는 더 나은 검색 성능, 더 예측 가능한 동작, 토큰 기반 블룸 필터 인덱스보다 더 큰 유연성과 성능을 가진 실제 역인덱스를 제공합니다.
스킵 인덱스 함수
데이터 스킵 인덱스의 핵심 목적은 인기 있는 쿼리가 분석하는 데이터의 양을 제한하는 것입니다. ClickHouse 데이터의 분석적 특성상 그 쿼리들의 패턴은 대부분의 경우 함수 표현식을 포함합니다. 따라서 스킵 인덱스는 효율적이기 위해 공통 함수와 올바르게 상호작용해야 합니다. 이것은 다음 경우에 발생할 수 있습니다:
- 데이터가 삽입되고 인덱스가 기능적 표현식으로 정의될 때(표현식의 결과가 인덱스 파일에 저장됨), 또는
- 쿼리가 처리되고 표현식이 저장된 인덱스 값에 적용되어 블록을 제외할지 결정할 때.
각 스킵 인덱스 타입은 여기 나열된 인덱스 구현에 적합한 사용 가능한 ClickHouse 함수의 부분집합에서 동작합니다. 일반적으로 set 인덱스와 블룸 필터 기반 인덱스(또 다른 set 인덱스 유형)는 둘 다 순서가 없으므로 범위와는 동작하지 않습니다. 대조적으로 minmax 인덱스는 범위가 교차하는지 결정하는 것이 매우 빠르므로 범위에 특히 잘 동작합니다. LIKE, startsWith, endsWith, hasToken 같은 부분 일치 함수의 효율은 사용된 인덱스 타입, 인덱스 표현식, 특정 데이터 모양에 의존합니다.
스킵 인덱스 설정
스킵 인덱스에 적용되는 두 가지 설정이 있습니다.
- use_skip_indexes (0 또는 1, 기본 1). 모든 쿼리가 스킵 인덱스를 효율적으로 사용할 수 있는 것은 아닙니다. 특정 필터링 조건이 대부분의 granule을 포함할 가능성이 높으면 데이터 스킵 인덱스 적용은 불필요하고 때로는 상당한 비용을 발생시킵니다. 어떤 스킵 인덱스도 이점을 얻지 못할 가능성이 높은 쿼리에는 값을 0으로 설정하세요.
- force_data_skipping_indices (콤마로 구분된 인덱스 이름 목록). 이 설정은 어떤 종류의 비효율적인 쿼리를 방지하는 데 사용할 수 있습니다. 스킵 인덱스를 사용하지 않으면 테이블 쿼리가 너무 비싼 상황에서, 이 설정을 하나 이상의 인덱스 이름과 함께 사용하면 나열된 인덱스를 사용하지 않는 쿼리에 예외를 반환합니다. 이것은 잘못 작성된 쿼리가 서버 리소스를 소비하는 것을 방지합니다.
스킵 인덱스 모범 사례
스킵 인덱스는 직관적이지 않습니다. 특히 RDBMS 영역의 행 기반 보조 인덱스나 문서 스토어의 역인덱스에 익숙한 사람들에게요. 이점을 얻으려면 ClickHouse 데이터 스킵 인덱스 적용이 인덱스 계산 비용을 상쇄할 만큼 충분한 granule 읽기를 피해야 합니다. 결정적으로, 값이 인덱스된 블록에 한 번만 나타나도 블록 전체를 메모리로 읽고 평가해야 하며 인덱스 비용이 무의미하게 발생했습니다.
다음 데이터 분포를 고려해 보세요:
기본/정렬 키가 timestamp이고 visitor_id에 인덱스가 있다고 가정합니다. 다음 쿼리를 고려해 보세요:
SELECT timestamp, url FROM table WHERE visitor_id = 1001`
전통적인 보조 인덱스는 이런 종류의 데이터 분포에서 매우 유리할 것입니다. 요청된 visitor_id를 가진 5행을 찾기 위해 32,768행을 모두 읽는 대신, 보조 인덱스는 다섯 개의 행 위치만 포함하고 디스크에서 그 다섯 행만 읽힙니다. ClickHouse 데이터 스킵 인덱스에는 정반대가 적용됩니다. visitor_id 컬럼의 32,768개 값 모두가 스킵 인덱스 타입과 무관하게 테스트됩니다.
따라서 키 컬럼에 인덱스를 간단히 추가해 ClickHouse 쿼리를 빠르게 하려는 자연스러운 충동은 종종 잘못됩니다. 이 고급 기능은 기본 키 수정(How to Pick a Primary Key 참고), 프로젝션 사용, 머티얼라이즈드 뷰 사용 같은 다른 대안을 조사한 후에만 사용해야 합니다. 데이터 스킵 인덱스가 적절한 경우에도 인덱스와 테이블 모두를 신중하게 조정해야 하는 경우가 많습니다.
대부분의 경우 유용한 스킵 인덱스는 기본 키와 타깃이 되는 비기본 컬럼/표현식 사이의 강한 상관이 필요합니다. 상관이 없다면(위 다이어그램처럼) 수천 개의 값 블록의 행 중 적어도 하나가 필터링 조건을 충족할 가능성이 높고 거의 블록이 건너뛰지 않습니다. 대조적으로 기본 키의 값 범위(하루 중 시간)가 잠재적 인덱스 컬럼의 값(텔레비전 시청자 연령)과 강하게 연관되어 있다면 minmax 유형 인덱스가 유용할 가능성이 높습니다. 데이터 삽입 시 정렬/ORDER BY 키에 추가 컬럼을 포함하거나, 기본 키와 연관된 값이 삽입 시 그룹핑되도록 배치 삽입하면 이 상관을 높일 수 있음을 주의하세요. 예를 들어 특정 site_id의 모든 이벤트가 수집 프로세스에 의해 함께 그룹핑되어 삽입될 수 있습니다. 기본 키가 많은 사이트의 이벤트를 포함하는 타임스탬프라도요. 이것은 몇 개의 site id만 포함하는 많은 granule을 만들어, 특정 site_id 값으로 검색할 때 많은 블록을 건너뛸 수 있게 합니다.
스킵 인덱스의 또 다른 좋은 후보는 어떤 값 하나가 데이터에서 상대적으로 희소한 high-cardinality 표현식입니다. 한 가지 예는 API 요청의 오류 코드를 추적하는 관측 가능성 플랫폼일 수 있습니다. 특정 오류 코드는 데이터에서 드물지만 검색에는 특히 중요할 수 있습니다. error_code 컬럼의 set 스킵 인덱스는 오류를 포함하지 않는 대부분의 블록을 우회할 수 있게 해 오류 중심 쿼리를 크게 개선합니다.
마지막으로 핵심 모범 사례는 테스트, 테스트, 또 테스트입니다. 다시 한번, b-트리 보조 인덱스나 문서 검색용 역인덱스와 달리 데이터 스킵 인덱스 동작은 쉽게 예측되지 않습니다. 테이블에 추가하면 데이터 수집과, 어떤 이유로든 인덱스에서 이점을 얻지 못하는 쿼리 모두에 의미 있는 비용이 발생합니다. 항상 실제 세계 유형의 데이터로 테스트해야 하며, 테스트에는 타입, 입자 크기, 다른 파라미터의 변형이 포함되어야 합니다. 테스트는 생각 실험만으로는 명확하지 않은 패턴과 함정을 종종 드러냅니다.