본문으로 바로가기
본문으로 바로가기

ClickHouse 데이터 스키핑 인덱스 이해하기

소개

ClickHouse 쿼리 성능에는 여러 요소가 영향을 줍니다. 대부분의 시나리오에서 핵심 요소는 ClickHouse가 쿼리의 WHERE 절 조건을 평가할 때 기본 키(primary key)를 사용할 수 있는지 여부입니다. 따라서 가장 일반적인 쿼리 패턴에 적용할 수 있는 기본 키를 선택하는 것은 효과적인 테이블 설계를 위해 필수적입니다.

그러나 기본 키를 아무리 신중하게 튜닝하더라도 이를 효율적으로 사용할 수 없는 쿼리 사용 사례는 필연적으로 발생합니다. 사용자는 보통 시계열(time series) 유형의 데이터에 ClickHouse를 사용하지만, 동일한 데이터를 고객 ID, 웹사이트 URL, 제품 번호와 같은 다른 비즈니스 차원에 따라 분석하고자 하는 경우가 많습니다. 이러한 경우 WHERE 절 조건을 적용하기 위해 각 컬럼의 전체 값을 스캔해야 할 수 있으므로 쿼리 성능이 상당히 저하될 수 있습니다. 이러한 상황에서도 ClickHouse는 여전히 상대적으로 빠르지만, 수백만 또는 수십억 개의 개별 값을 평가해야 하므로 기본 키를 기반으로 한 쿼리에 비해 「인덱스가 없는(non-indexed)」 쿼리는 훨씬 더 느리게 실행됩니다.

전통적인 관계형 데이터베이스에서는 이 문제에 대한 한 가지 접근 방식으로 테이블에 하나 이상의 「보조(secondary)」 인덱스를 추가합니다. 이는 데이터베이스가 디스크에서 일치하는 모든 행을 O(n) 시간(테이블 스캔)이 아닌 O(log(n)) 시간에 찾을 수 있게 해 주는 b-tree 구조이며, 여기서 n은 행(row)의 개수입니다. 그러나 이 유형의 보조 인덱스는 디스크에 인덱스에 추가할 개별 행이 존재하지 않기 때문에 ClickHouse(또는 다른 컬럼 지향 데이터베이스)에는 적합하지 않습니다.

대신 ClickHouse는 특정 상황에서 쿼리 속도를 크게 향상시킬 수 있는 다른 유형의 인덱스를 제공합니다. 이러한 구조는 ClickHouse가 일치하는 값이 없다는 것이 보장되는 대량의 데이터 청크를 건너뛸 수 있게 해 주기 때문에 「스킵(Skip)」 인덱스라고 부릅니다.

기본 동작

데이터 스키핑 인덱스는 MergeTree 계열 테이블에만 사용할 수 있습니다. 각 데이터 스키핑 인덱스에는 네 가지 주요 인자가 있습니다.

  • 인덱스 이름. 인덱스 이름은 각 파티션에 인덱스 파일을 생성하는 데 사용됩니다. 또한 인덱스를 삭제하거나 구체화할 때 매개변수로 필요합니다.
  • 인덱스 표현식. 인덱스 표현식은 인덱스에 저장되는 값의 집합을 계산하는 데 사용됩니다. 컬럼, 단순 연산자 및/또는 인덱스 유형에 의해 결정되는 함수의 부분 집합을 조합하여 사용할 수 있습니다.
  • TYPE. 인덱스 유형은 각 인덱스 블록을 읽고 평가하는 작업을 건너뛸 수 있는지 여부를 결정하는 계산 방식을 제어합니다.
  • GRANULARITY. 각 인덱스 블록은 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.)

800메가바이트에 해당하는 1억 행을 처리하는 대신, ClickHouse는 360킬로바이트에 불과한 32,768행만 읽고 분석했습니다 -- 각 8,192행으로 이루어진 4개의 그래뉼입니다.

좀 더 시각적으로 표현하면, my_value 값이 125인 4,096행이 어떻게 읽히고 선택되었는지, 그리고 이어지는 행들이 디스크에서 읽히지 않고 어떻게 건너뛰어졌는지를 다음과 같이 보여 줍니다:

Simple Skip

쿼리를 실행할 때 트레이스를 활성화하면 skip 인덱스 사용에 대한 자세한 정보를 확인할 수 있습니다. clickhouse-client에서 send_logs_level을 설정하십시오:

SET send_logs_level='trace';

이는 쿼리 SQL과 테이블 인덱스를 조정할 때 유용한 디버깅 정보를 제공합니다. 위 예에서 디버그 로그는 스킵 인덱스가 두 개의 그래뉼을 제외한 모든 그래뉼을 제거했음을 보여 줍니다.

<Debug> default.skip_table (933d4b2c-8cea-4bf9-8c93-c56e900eefd1) (SelectExecutor): Index `vix` has dropped 6102/6104 granules.

스킵 인덱스의 종류

minmax

이 경량 인덱스 유형은 별도의 매개변수가 필요하지 않습니다. 인덱스 표현식의 최소값과 최대값을 각 블록마다 저장합니다(표현식이 튜플인 경우, 튜플의 각 요소에 대한 값을 개별적으로 저장합니다). 이 유형은 값 기준으로 대략적으로 정렬되는 경향이 있는 컬럼에 이상적입니다. 이 인덱스 유형은 일반적으로 쿼리 처리 중 적용 비용이 가장 낮은 편에 속합니다.

이 인덱스 유형은 스칼라 또는 튜플 표현식에 대해서만 올바르게 동작하며, 배열이나 맵 데이터 타입을 반환하는 표현식에는 인덱스가 전혀 적용되지 않습니다.

Set

이 경량 인덱스 유형은 블록당 값 집합의 크기 상한을 나타내는 max_size라는 단일 파라미터를 받습니다(0은 서로 다른 값의 개수에 제한이 없음을 의미합니다). 이 Set에는 블록 안의 모든 값이 포함되며, 값 개수가 max_size를 초과하는 경우에는 Set이 비게 됩니다. 이 인덱스 유형은 각 그래뉼 집합 내에서 컬럼의 낮은 카디널리티(사실상 「덩어리로 뭉쳐 있는」 상태)에는 잘 동작하지만, 전체적으로는 카디널리티가 더 높은 경우에 적합합니다.

이 인덱스의 비용, 성능 및 효과는 블록 내부의 카디널리티에 따라 달라집니다. 각 블록에 고유한 값이 매우 많이 포함되어 있는 경우, 큰 인덱스 Set에 대해 쿼리 조건을 평가하는 데 매우 큰 비용이 들거나, max_size를 초과하여 인덱스가 비어 있으므로 인덱스가 적용되지 않을 수 있습니다.

Bloom filter types

Bloom filter는 집합 포함 여부를 공간 효율적으로 검사하는 자료 구조로, 약간의 오탐(false positive) 가능성을 허용하는 대신 메모리 사용량을 줄입니다. 스킵 인덱스의 경우 오탐은 몇 개의 불필요한 블록을 추가로 읽는 정도만 단점이기 때문에 크게 문제가 되지 않습니다. 하지만 오탐 가능성이 있다는 것은 인덱싱된 표현식이 참일 것으로 예상되는 경우에 사용해야 한다는 의미이며, 그렇지 않으면 실제로는 유효한 데이터가 건너뛰어질 수 있습니다.

Bloom filter는 많은 수의 이산(discrete) 값에 대한 검사를 보다 효율적으로 처리할 수 있기 때문에, 검사할 값이 많아지는 조건식에 적합합니다. 특히 Bloom filter 인덱스는 배열에 적용할 수 있으며, 배열의 모든 값을 검사합니다. 또한 mapKeys 또는 mapValues 함수를 사용하여 키나 값 중 하나를 배열로 변환함으로써 맵에도 적용할 수 있습니다.

Bloom filter에 기반한 Data Skipping Index 유형은 세 가지입니다:

  • 기본 bloom_filter — 허용되는 "false positive" 비율을 0과 1 사이 값으로 나타내는 단일 선택적 파라미터를 받습니다(지정하지 않으면 0.025가 사용됩니다).

  • 특수화된 tokenbf_v1 — Bloom filter 튜닝과 관련된 세 가지 파라미터를 받습니다: (1) 필터의 크기(바이트 단위, 필터가 클수록 오탐이 줄어들지만 저장 공간이 더 필요함), (2) 적용할 해시 함수의 개수(마찬가지로 해시 함수가 많을수록 오탐이 줄어듦), (3) Bloom filter 해시 함수에 사용할 시드 값입니다. 이러한 파라미터가 Bloom filter 동작에 어떤 영향을 주는지에 대한 자세한 내용은 여기 계산기를 참조하십시오. 이 인덱스는 String, FixedString, Map 데이터 타입에서만 동작합니다. 입력 표현식은 영숫자 문자가 아닌 문자로 구분되는 문자 시퀀스로 분할됩니다. 예를 들어, 컬럼 값이 This is a candidate for a "full text" search인 경우, This is a candidate for full text search 토큰을 포함하게 됩니다. 긴 문자열 내에서 단어나 기타 값을 대상으로 하는 LIKE, EQUALS, IN, hasToken() 및 유사한 검색에 사용하도록 설계되었습니다. 예를 들어, 자유 형식 애플리케이션 로그 라인으로 이루어진 컬럼에서 적은 수의 클래스 이름이나 라인 번호를 검색하는 용도로 사용할 수 있습니다.

  • 특수화된 ngrambf_v1 — 이 인덱스는 token 인덱스와 동일한 방식으로 동작합니다. Bloom filter 설정 전에 하나의 파라미터를 추가로 받는데, 인덱싱할 ngram의 크기입니다. ngram은 길이가 n인 임의의 문자 시퀀스로, 문자열 A short string에 대해 ngram 크기가 4라면 다음과 같이 인덱싱됩니다:

    'A sh', ' sho', 'shor', 'hort', 'ort ', 'rt s', 't st', ' str', 'stri', 'trin', 'ring'
    

이 인덱스는 특히 중국어와 같이 단어 경계가 없는 언어의 텍스트 검색에 유용할 수 있습니다.

스킵 인덱스 함수

데이터 스키핑 인덱스의 핵심 목적은 자주 사용되는 쿼리가 분석하는 데이터 양을 줄이는 것입니다. ClickHouse 데이터의 분석적 특성상, 대부분의 쿼리 패턴에는 함수 표현식이 포함됩니다. 따라서 스킵 인덱스가 효율적이려면 일반적인 함수들과 올바르게 상호작용해야 합니다. 이는 다음과 같은 경우에 발생합니다.

  • 데이터를 삽입할 때 인덱스를 함수 표현식으로 정의하고(표현식의 결과를 인덱스 파일에 저장하는 경우),
  • 쿼리를 처리할 때 저장된 인덱스 값에 표현식을 적용하여 해당 블록을 제외할지 여부를 결정하는 경우

각 스킵 인덱스 유형은 해당 인덱스 구현에 적합한, 사용 가능한 ClickHouse 함수 중 일부에 대해서만 동작하며, 이에 대한 목록은 여기에 정리되어 있습니다. 일반적으로 집합형 인덱스와 블룸 필터 기반 인덱스(또 다른 유형의 집합형 인덱스)는 순서가 없는 구조이므로 범위 조건과 함께 사용할 수 없습니다. 반대로, minmax 인덱스는 범위와 특히 잘 동작하는데, 범위가 교차하는지 여부를 판단하는 작업이 매우 빠르기 때문입니다. 부분 일치 함수 LIKE, startsWith, endsWith, hasToken의 효율성은 사용된 인덱스 유형, 인덱스 표현식, 그리고 데이터의 구체적인 분포 형태에 따라 달라집니다.

Skip index 설정

skip index에 적용되는 설정은 두 가지가 있습니다.

  • use_skip_indexes (0 또는 1, 기본값 1). 모든 쿼리가 skip index를 효율적으로 사용할 수 있는 것은 아닙니다. 특정 필터링 조건이 대부분의 그래뉼을 포함할 가능성이 높은 경우, data skipping index를 적용하면 불필요하고 때로는 상당한 비용이 발생합니다. 어떤 skip index에도 이점이 없을 것으로 예상되는 쿼리에는 값을 0으로 설정합니다.
  • force_data_skipping_indices (인덱스 이름을 쉼표로 구분한 목록). 이 설정은 비효율적인 유형의 쿼리를 방지하는 데 사용할 수 있습니다. skip index를 사용하지 않으면 테이블에 대한 쿼리 비용이 너무 큰 상황에서는, 하나 이상의 인덱스 이름과 함께 이 설정을 사용하면 나열된 인덱스를 사용하지 않는 모든 쿼리에 대해 예외를 발생시킵니다. 이는 잘못 작성된 쿼리가 서버 리소스를 소모하는 것을 방지합니다.

스킵 인덱스 모범 사례

스킵 인덱스는 직관적이지 않습니다. 특히 RDBMS 영역의 보조 행 기반 인덱스나 문서 저장소의 역인덱스에 익숙한 경우에 그렇습니다. 이점을 얻으려면 ClickHouse 데이터 스킵 인덱스를 적용했을 때, 인덱스를 계산하는 비용을 상쇄할 수 있을 정도로 충분한 그래뉼(granule) 단위 읽기를 피해야 합니다. 특히 인덱싱된 블록에서 어떤 값이 한 번이라도 등장하면 해당 블록 전체를 메모리로 읽어와 평가해야 하며, 이때 인덱스 계산 비용이 불필요하게 발생합니다.

다음과 같은 데이터 분포를 가정합니다:

좋지 않은 스킵 인덱스 예시

기본/ORDER BY 키가 timestamp이고, visitor_id에 인덱스가 있다고 가정합니다. 다음 쿼리를 살펴보십시오:

SELECT timestamp, url FROM table WHERE visitor_id = 1001`

이러한 종류의 데이터 분포에서는 전통적인 보조 인덱스가 매우 유리합니다. 요청된 visitor_id 값을 가진 5개의 행을 찾기 위해 32768개의 모든 행을 읽는 대신, 보조 인덱스는 단지 5개의 행 위치만 포함하고 그 5개의 행만 디스크에서 읽게 됩니다. ClickHouse 데이터 스키핑 인덱스의 경우는 정확히 반대입니다. 스킵 인덱스의 유형과 상관없이 visitor_id 컬럼의 32768개 모든 값이 검사됩니다.

따라서 키 컬럼에 단순히 인덱스를 추가하여 ClickHouse 쿼리를 빠르게 만들려는 자연스러운 시도는 종종 잘못된 판단입니다. 이 고급 기능은 기본 키를 수정하는 것(기본 키 선택 방법 참조), 프로젝션 사용, materialized views 사용과 같은 다른 대안을 조사한 이후에만 사용해야 합니다. 데이터 스키핑 인덱스를 사용하는 것이 적절한 경우에도, 인덱스와 테이블 모두에 대한 신중한 튜닝이 자주 필요합니다.

대부분의 경우 유용한 스킵 인덱스를 사용하려면 기본 키와 대상이 되는 비기본 컬럼/식 사이에 강한 상관관계가 필요합니다. 상관관계가 없으면(위 그림과 같이), 수천 개 값이 있는 블록에서 최소 하나의 행이 필터링 조건을 만족할 가능성이 높고, 건너뛰어지는 블록은 거의 없게 됩니다. 반대로, 기본 키 값의 범위(예: 하루 중 시간)가 잠재적인 인덱스 컬럼의 값(예: TV 시청자 나이)과 강하게 연관되어 있는 경우, minmax 유형의 인덱스는 유리할 가능성이 높습니다. 데이터 삽입 시 추가 컬럼을 정렬/ORDER BY 키에 포함하거나, 기본 키와 연관된 값들이 삽입 시 함께 그룹되도록 배치 삽입을 수행하여 이 상관관계를 높일 수 있는 경우도 있습니다. 예를 들어, 특정 site_id에 대한 모든 이벤트를, 기본 키가 다수 사이트의 이벤트를 포함하는 타임스탬프이더라도, 수집 프로세스가 그룹화하여 한 번에 삽입할 수 있습니다. 이렇게 하면 소수의 site_id만 포함하는 많은 그래뉼이 생성되어, 특정 site_id 값으로 검색할 때 많은 블록을 건너뛸 수 있습니다.

또 다른 좋은 스킵 인덱스 후보는 고유값 개수가 많고(고카디널리티), 개별 값이 데이터 전체에서 상대적으로 희소한 표현식입니다. 한 가지 예로, API 요청의 에러 코드를 추적하는 관측성(observability) 플랫폼을 들 수 있습니다. 특정 에러 코드는 데이터에서 드물게 나타나지만, 검색 측면에서는 특히 중요할 수 있습니다. error_code 컬럼에 대한 Set 스킵 인덱스를 사용하면 에러를 포함하지 않는 대부분의 블록을 우회할 수 있어, 에러 중심 쿼리를 크게 가속할 수 있습니다.

마지막으로 핵심 모범 사례는 테스트, 테스트, 또 테스트하는 것입니다. 다시 말해, b-tree 보조 인덱스나 문서 검색을 위한 역인덱스와 달리, 데이터 스키핑 인덱스의 동작은 쉽게 예측하기 어렵습니다. 이를 테이블에 추가하면 데이터 수집과, 어떤 이유에서든 인덱스로부터 이득을 얻지 못하는 쿼리 모두에 상당한 비용이 발생합니다. 항상 실제 유형의 데이터에서 테스트해야 하며, 인덱스 유형, 그래뉼 크기 및 기타 파라미터의 변형을 포함하여 테스트해야 합니다. 테스트를 통해 사고 실험만으로는 분명하지 않은 패턴과 함정을 발견하는 경우가 많습니다.