상황에 맞게 데이터 스킵핑 인덱스 사용하기
상황에 맞게 데이터 스킵핑 인덱스 사용하기
데이터 스킵핑 인덱스는 특히 프라이머리 키가 특정 필터 조건에 도움이 되지 않을 때 쿼리 중 스캔되는 데이터 양을 크게 줄여줍니다. 이 문서에서는 스킵 인덱스가 어떻게 동작하는지, 언제 사용해야 하는지, 실제 예제를 통해 설명할게요.
출처: 문서
본문
데이터 스킵핑 인덱스는 이전 모범 사례들 — 타입 최적화, 좋은 프라이머리 키 선택, 머티리얼라이즈드 뷰 활용 — 을 따른 다음에 고려해야 합니다. 스킵핑 인덱스가 처음이라면 이 가이드가 시작하기 좋은 곳입니다. 이런 유형의 인덱스는 어떻게 동작하는지 이해하고 신중하게 사용하면 쿼리 성능을 가속화할 수 있습니다. ClickHouse는 데이터 스킵핑 인덱스(data skipping indices) 라는 강력한 메커니즘을 제공하는데, 특히 프라이머리 키가 특정 필터 조건에 도움이 되지 않을 때 쿼리 실행 중 스캔되는 데이터 양을 극적으로 줄여줍니다. 행 기반 보조 인덱스(B-트리 등)에 의존하는 전통적인 데이터베이스와 달리, ClickHouse는 컬럼 스토어이므로 그러한 구조를 지원하는 방식으로 행 위치를 저장하지 않습니다. 대신 스킵 인덱스를 사용하여 쿼리 필터링 조건과 확실히 일치하지 않는 데이터 블록을 읽지 않도록 합니다. 스킵 인덱스는 min/max 값, 값 집합, 또는 Bloom 필터 표현 같은 데이터 블록에 대한 메타데이터를 저장하고, 쿼리 실행 중 이 메타데이터를 사용해 어떤 데이터 블록을 완전히 건너뛸 수 있는지 결정합니다. 이들은 MergeTree 계열 테이블 엔진에만 적용되며, 표현식, 인덱스 타입, 이름, 그리고 각 인덱스 블록의 크기를 정의하는 그래뉼러리티(granularity)로 정의됩니다. 이 인덱스들은 테이블 데이터와 함께 저장되며, 쿼리 필터가 인덱스 표현식과 일치할 때 참조됩니다. 각각 다른 유형의 쿼리와 데이터 분포에 적합한 여러 종류의 데이터 스킵핑 인덱스가 있습니다:
- minmax: 블록당 표현식의 최솟값과 최댓값을 추적합니다. 느슨하게 정렬된 데이터에 대한 범위 쿼리에 이상적입니다.
- set(N): 각 블록에 대해 지정된 크기 N까지 값의 집합을 추적합니다. 블록당 카디널리티가 낮은 컬럼에 효과적입니다.
- text: 토큰화된 문자열 데이터에 대해 역인덱스를 구축하여 효율적이고 결정적인 전문(full-text) 검색을 가능하게 합니다. 정확한 토큰 조회와 확장 가능한 다중 용어 검색이 필요한 자연어 또는 대규모 자유 형식 텍스트 컬럼에, 근사 Bloom 필터 기반 접근 대신 권장됩니다.
- bloom_filter: 값이 블록에 존재하는지 확률적으로 판단하여 집합 멤버십에 대한 빠른 근사 필터링을 허용합니다. 양성 일치가 필요한 "건초 더미에서 바늘 찾기" 쿼리를 최적화하는 데 효과적입니다.
- tokenbf_v1 / ngrambf_v1: (더 이상 사용되지 않음) 문자열에서 토큰이나 문자 시퀀스를 검색하도록 설계된 특수 Bloom 필터 변형입니다 — 특히 로그 데이터나 텍스트 검색 사용 사례에 유용합니다. ClickHouse 버전 >= 26.2에서 text 인덱스를 위해 더 이상 사용되지 않습니다.
스킵 인덱스는 강력하지만 신중하게 사용해야 합니다. 의미 있는 수의 데이터 블록을 제거할 때만 이점을 제공하며, 쿼리나 데이터 구조가 맞지 않으면 실제로 오버헤드를 도입할 수 있습니다. 블록에 일치하는 값이 하나라도 있으면 그 전체 블록을 여전히 읽어야 합니다. 효과적인 스킵 인덱스 사용은 종종 인덱스된 컬럼과 테이블의 프라이머리 키 사이의 강한 상관관계에, 또는 유사한 값을 함께 그룹화하는 방식으로 데이터를 삽입하는 것에 의존합니다. 일반적으로 데이터 스킵핑 인덱스는 적절한 프라이머리 키 설계와 타입 최적화를 확보한 후에 적용하는 것이 가장 좋습니다. 특히 다음에 유용합니다:
- 전체 카디널리티는 높지만 블록 내 카디널리티는 낮은 컬럼.
- 검색에 중요한 희귀 값(예: 오류 코드, 특정 ID).
- 국지적 분포를 가진 비(非)프라이머리 키 컬럼에서 필터링이 발생하는 경우.
항상 다음을 하세요:
- 실제 데이터와 현실적인 쿼리로 스킵 인덱스를 테스트하세요. 다른 인덱스 타입과 그래뉼러리티 값을 시도해 보세요.
send_logs_level='trace'와EXPLAIN indexes=1같은 도구를 사용해 인덱스 효과를 평가하세요.- 항상 인덱스 크기와 그래뉼러리티가 그것에 미치는 영향을 평가하세요. 그래뉼러리티 크기를 줄이면 종종 어느 지점까지는 성능이 개선되어 필터링되고 스캔되어야 하는 그래뉼이 더 많아집니다. 그러나 더 낮은 그래뉼러리티로 인덱스 크기가 커지면 성능도 저하될 수 있습니다. 다양한 그래뉼러리티 데이터 포인트에 대해 성능과 인덱스 크기를 측정하세요. 특히 bloom filter 인덱스에서 관련이 있습니다.
적절히 사용하면 스킵 인덱스는 상당한 성능 향상을 제공하지만, 맹목적으로 사용하면 불필요한 비용을 추가합니다. 데이터 스킵핑 인덱스에 대한 더 자세한 가이드는 여기를 참고하세요.
예제
다음의 최적화된 테이블을 고려해 보세요. 게시물당 한 행의 Stack Overflow 데이터를 담고 있습니다.
CREATE TABLE stackoverflow.posts
(
`Id` Int32 CODEC(Delta(4), ZSTD(1)),
`PostTypeId` Enum8('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
`AcceptedAnswerId` UInt32,
`CreationDate` DateTime64(3, 'UTC'),
`Score` Int32,
`ViewCount` UInt32 CODEC(Delta(4), ZSTD(1)),
`Body` String,
`OwnerUserId` Int32,
`OwnerDisplayName` String,
`LastEditorUserId` Int32,
`LastEditorDisplayName` String,
`LastEditDate` DateTime64(3, 'UTC') CODEC(Delta(8), ZSTD(1)),
`LastActivityDate` DateTime64(3, 'UTC'),
`Title` String,
`Tags` String,
`AnswerCount` UInt16 CODEC(Delta(2), ZSTD(1)),
`CommentCount` UInt8,
`FavoriteCount` UInt8,
`ContentLicense` LowCardinality(String),
`ParentId` String,
`CommunityOwnedDate` DateTime64(3, 'UTC'),
`ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = MergeTree
PARTITION BY toYear(CreationDate)
ORDER BY (PostTypeId, toDate(CreationDate))
이 테이블은 게시물 타입과 날짜로 필터링하고 집계하는 쿼리에 최적화되어 있습니다. 2009년 이후 발행되고 조회 수가 10,000,000개를 초과하는 게시물의 수를 세고 싶다고 가정해 보겠습니다.
SELECT count()
FROM stackoverflow.posts
WHERE (CreationDate > '2009-01-01') AND (ViewCount > 10000000)
┌─count()─┐
│ 5 │
└─────────┘
1 row in set. Elapsed: 0.720 sec. Processed 59.55 million rows, 230.23 MB (82.66 million rows/s., 319.56 MB/s.)
이 쿼리는 프라이머리 인덱스를 사용해 일부 행(과 그래뉼)을 제외할 수 있습니다. 하지만 위 응답과 다음 EXPLAIN indexes = 1이 보여주듯 대부분의 행은 여전히 읽어야 합니다:
EXPLAIN indexes = 1
SELECT count()
FROM stackoverflow.posts
WHERE (CreationDate > '2009-01-01') AND (ViewCount > 10000000)
LIMIT 1
┌─explain──────────────────────────────────────────────────────────┐
│ Expression ((Project names + Projection)) │
│ Limit (preliminary LIMIT (without OFFSET)) │
│ Aggregating │
│ Expression (Before GROUP BY) │
│ Expression │
│ ReadFromMergeTree (stackoverflow.posts) │
│ Indexes: │
│ MinMax │
│ Keys: │
│ CreationDate │
│ Condition: (CreationDate in ('1230768000', +Inf)) │
│ Parts: 123/128 │
│ Granules: 8513/8545 │
│ Partition │
│ Keys: │
│ toYear(CreationDate) │
│ Condition: (toYear(CreationDate) in [2009, +Inf)) │
│ Parts: 123/123 │
│ Granules: 8513/8513 │
│ PrimaryKey │
│ Keys: │
│ toDate(CreationDate) │
│ Condition: (toDate(CreationDate) in [14245, +Inf)) │
│ Parts: 123/123 │
│ Granules: 8513/8513 │
└──────────────────────────────────────────────────────────────────┘
25 rows in set. Elapsed: 0.070 sec.
간단한 분석은 ViewCount가 예상대로 CreationDate(프라이머리 키)와 상관관계가 있음을 보여줍니다 — 게시물이 오래 존재할수록 조회될 시간이 많기 때문입니다.
SELECT toDate(CreationDate) AS day, avg(ViewCount) AS view_count FROM stackoverflow.posts WHERE day > '2009-01-01' GROUP BY day
따라서 이것은 데이터 스킵핑 인덱스의 논리적 선택이 됩니다. 숫자 타입을 고려하면 minmax 인덱스가 합리적입니다. 다음 ALTER TABLE 명령으로 인덱스를 추가합니다 — 먼저 추가한 다음 "materialize" 합니다.
ALTER TABLE stackoverflow.posts
(ADD INDEX view_count_idx ViewCount TYPE minmax GRANULARITY 1);
ALTER TABLE stackoverflow.posts MATERIALIZE INDEX view_count_idx;
이 인덱스는 초기 테이블 생성 중에 추가할 수도 있었습니다. minmax 인덱스가 DDL의 일부로 정의된 스키마:
CREATE TABLE stackoverflow.posts
(
`Id` Int32 CODEC(Delta(4), ZSTD(1)),
`PostTypeId` Enum8('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
`AcceptedAnswerId` UInt32,
`CreationDate` DateTime64(3, 'UTC'),
`Score` Int32,
`ViewCount` UInt32 CODEC(Delta(4), ZSTD(1)),
`Body` String,
`OwnerUserId` Int32,
`OwnerDisplayName` String,
`LastEditorUserId` Int32,
`LastEditorDisplayName` String,
`LastEditDate` DateTime64(3, 'UTC') CODEC(Delta(8), ZSTD(1)),
`LastActivityDate` DateTime64(3, 'UTC'),
`Title` String,
`Tags` String,
`AnswerCount` UInt16 CODEC(Delta(2), ZSTD(1)),
`CommentCount` UInt8,
`FavoriteCount` UInt8,
`ContentLicense` LowCardinality(String),
`ParentId` String,
`CommunityOwnedDate` DateTime64(3, 'UTC'),
`ClosedDate` DateTime64(3, 'UTC'),
INDEX view_count_idx ViewCount TYPE minmax GRANULARITY 1 --인덱스 위치
)
ENGINE = MergeTree
PARTITION BY toYear(CreationDate)
ORDER BY (PostTypeId, toDate(CreationDate))
다음 애니메이션은 예제 테이블에 대해 minmax 스킵핑 인덱스가 어떻게 구축되는지, 테이블의 각 행 블록(그래뉼)에 대해 최소 및 최대 ViewCount 값을 추적하는지 보여줍니다. 이전 쿼리를 반복하면 상당한 성능 개선이 나타납니다. 스캔되는 행 수가 줄어든 것을 주목하세요:
SELECT count()
FROM stackoverflow.posts
WHERE (CreationDate > '2009-01-01') AND (ViewCount > 10000000)
┌─count()─┐
│ 5 │
└─────────┘
1 row in set. Elapsed: 0.012 sec. Processed 39.11 thousand rows, 321.39 KB (3.40 million rows/s., 27.93 MB/s.)
EXPLAIN indexes = 1이 인덱스 사용을 확인해 줍니다. 이제 스캔된 그래뉼이 크게 줄었고, minmax 인덱스가 ViewCount > 10,000,000 조건에 대한 일치를 절대 포함할 수 없는 모든 행 블록을 가지치기합니다.