PREWHERE 최적화

PREWHERE 최적화 (How does the PREWHERE optimization work?)

PREWHERE 절은 ClickHouse의 쿼리 실행 최적화입니다. 불필요한 데이터 읽기를 피하고, 필터되지 않은 컬럼을 디스크에서 읽기 전에 관련 없는 데이터를 걸러내 I/O를 줄이고 쿼리 속도를 개선합니다.

출처: 문서

본문

PREWHERE 절은 ClickHouse의 쿼리 실행 최적화입니다. 불필요한 데이터 읽기를 피하고, 주요 필터가 아닌 컬럼을 디스크에서 읽기 전에 관련 없는 데이터를 걸러내 I/O를 줄이고 쿼리 속도를 개선합니다.

이 가이드는 PREWHERE가 어떻게 동작하는지, 그 영향을 어떻게 측정하는지, 최상의 성능을 위해 어떻게 조정하는지 설명합니다.

PREWHERE 최적화 없는 쿼리 처리

먼저 uk_price_paid_simple 테이블에 대한 쿼리가 PREWHERE를 사용하지 않고 어떻게 처리되는지 설명하겠습니다.

① 쿼리는 테이블의 기본 키의 일부인, 따라서 기본 인덱스의 일부이기도 한 town 컬럼에 대한 필터를 포함합니다.

② 쿼리를 가속화하기 위해 ClickHouse는 테이블의 기본 인덱스를 메모리에 로드합니다.

③ 인덱스 엔트리를 스캔해 town 컬럼에서 조건과 일치하는 행을 포함할 수 있는 granule을 식별합니다.

④ 이 잠재적으로 관련 있는 granule들은 쿼리에 필요한 다른 컬럼의 위치 정렬된 granule들과 함께 메모리에 로드됩니다.

⑤ 나머지 필터는 쿼리 실행 중에 적용됩니다.

보이듯이, PREWHERE가 없으면 실제로 일치하는 행이 몇 개뿐이어도 모든 잠재적으로 관련 있는 컬럼이 필터링 전에 로드됩니다.

PREWHERE가 쿼리 효율을 개선하는 방법

다음 애니메이션은 모든 쿼리 조건에 PREWHERE 절이 적용된 채 위 쿼리가 어떻게 처리되는지 보여 줍니다.

첫 세 가지 처리 단계는 이전과 같습니다:

① 쿼리는 테이블의 기본 키의 일부인 — 따라서 기본 인덱스의 일부이기도 한 — town 컬럼에 대한 필터를 포함합니다.

② PREWHERE 절 없는 실행과 비슷하게, 쿼리를 가속화하기 위해 ClickHouse는 기본 인덱스를 메모리에 로드합니다.

③ 그런 다음 인덱스 엔트리를 스캔해 town 컬럼에서 조건과 일치하는 행을 포함할 수 있는 granule을 식별합니다.

이제 PREWHERE 절 덕분에 다음 단계가 달라집니다: 모든 관련 컬럼을 먼저 읽는 대신 ClickHouse는 컬럼을 하나씩 필터링하며, 진짜 필요한 것만 로드합니다. 이것은 특히 넓은(wide) 테이블에서 I/O를 극적으로 줄입니다.

각 단계에서 이전 필터를 통과(즉 일치)한 행을 하나 이상 포함하는 granule만 로드합니다. 결과적으로 각 필터에 대해 로드하고 평가할 granule 수는 단조 감소합니다:

단계 1: town으로 필터링 ClickHouse는 ① town 컬럼에서 선택된 granule을 읽고 어느 것이 London과 일치하는 행을 실제로 포함하는지 확인하며 PREWHERE 처리를 시작합니다.

우리 예제에서는 선택된 모든 granule이 일치하므로, ② 다음 필터 컬럼 — date — 의 위치 정렬된 granule들이 처리를 위해 선택됩니다:

단계 2: date로 필터링 다음으로 ClickHouse는 ① 선택된 date 컬럼 granule을 읽어 필터 date > '2024-12-31'을 평가합니다.

이 경우 세 granule 중 두 개가 일치하는 행을 포함하므로, ② 다음 필터 컬럼 — price — 에서 그들의 위치 정렬된 granule만 추가 처리를 위해 선택됩니다:

단계 3: price로 필터링 마지막으로 ClickHouse는 ① price 컬럼에서 선택된 두 granule을 읽어 마지막 필터 price > 10_000을 평가합니다.

두 granule 중 하나만 일치하는 행을 포함하므로 ② SELECT 컬럼 — street — 의 위치 정렬된 granule만 추가 처리를 위해 로드하면 됩니다:

최종 단계까지 일치하는 행을 포함하는 최소한의 컬럼 granule 집합만 로드됩니다. 이것은 더 낮은 메모리 사용, 더 적은 디스크 I/O, 더 빠른 쿼리 실행으로 이어집니다.

PREWHERE는 읽는 데이터를 줄이며 처리되는 행은 줄이지 않음 ClickHouse는 PREWHERE 버전과 비-PREWHERE 버전의 쿼리 모두에서 같은 수의 행을 처리함을 주의하세요. 그러나 PREWHERE 최적화가 적용되면 처리되는 모든 행에 대해 모든 컬럼 값을 로드할 필요가 없습니다.

PREWHERE 최적화는 자동으로 적용됨

PREWHERE 절은 위 예제처럼 수동으로 추가할 수 있습니다. 그러나 수동으로 PREWHERE를 작성할 필요는 없습니다. optimize_move_to_prewhere 설정(기본 true)이 활성화되면 ClickHouse는 WHERE에서 PREWHERE로 필터 조건을 자동으로 이동하며, 읽기 양을 가장 많이 줄일 것들을 우선시합니다.

아이디어는 작은 컬럼이 스캔에 더 빠르고, 큰 컬럼이 처리될 때쯤 대부분의 granule이 이미 필터링되었다는 것입니다. 모든 컬럼이 같은 수의 행을 가지므로 컬럼의 크기는 주로 데이터 타입에 의해 결정됩니다. 예를 들어 UInt8 컬럼은 일반적으로 String 컬럼보다 훨씬 작습니다.

ClickHouse는 버전 23.2부터 기본적으로 이 전략을 따르며, 다단계 처리를 위해 PREWHERE 필터 컬럼을 압축되지 않은 크기 오름차순으로 정렬합니다.

버전 23.11부터 선택적 컬럼 통계가 컬럼 크기뿐 아니라 실제 데이터 선택도에 기반해 필터 처리 순서를 선택해 이것을 더 개선할 수 있습니다.

PREWHERE 영향 측정

PREWHERE가 쿼리에 도움이 되는지 검증하려면 optimize_move_to_prewhere 설정을 활성화/비활성화하고 쿼리 성능을 비교할 수 있습니다.

먼저 optimize_move_to_prewhere 설정을 비활성화한 채 쿼리를 실행합니다:

SELECT
    street
FROM
   uk.uk_price_paid_simple
WHERE
   town = 'LONDON' AND date > '2024-12-31' AND price < 10_000
SETTINGS optimize_move_to_prewhere = false;
   ┌─street──────┐
1. │ MOYSER ROAD │
2. │ AVENUE ROAD │
3. │ AVENUE ROAD │
   └─────────────┘

3 rows in set. Elapsed: 0.056 sec. Processed 2.31 million rows, 23.36 MB (41.09 million rows/s., 415.43 MB/s.)
Peak memory usage: 132.10 MiB.

ClickHouse는 쿼리에 대해 231만 행을 처리하면서 23.36 MB의 컬럼 데이터를 읽었습니다.

다음으로 optimize_move_to_prewhere 설정을 활성화한 채 쿼리를 실행합니다(이 설정은 기본으로 활성화되어 있으므로 선택사항임에 주의):

SELECT
    street
FROM
   uk.uk_price_paid_simple
WHERE
   town = 'LONDON' AND date > '2024-12-31' AND price < 10_000
SETTINGS optimize_move_to_prewhere = true;
   ┌─street──────┐
1. │ MOYSER ROAD │
2. │ AVENUE ROAD │
3. │ AVENUE ROAD │
   └─────────────┘

3 rows in set. Elapsed: 0.017 sec. Processed 2.31 million rows, 6.74 MB (135.29 million rows/s., 394.44 MB/s.)
Peak memory usage: 132.11 MiB.

같은 수의 행(231만)이 처리되었지만, PREWHERE 덕분에 ClickHouse는 컬럼 데이터를 3배 이상 적게 읽었습니다 — 23.36 MB 대신 단지 6.74 MB — 전체 실행 시간을 3배 줄였습니다.

ClickHouse가 PREWHERE를 내부적으로 어떻게 적용하는지 더 깊이 보려면 EXPLAIN과 트레이스 로그를 사용하세요.

EXPLAIN 절로 쿼리의 논리적 계획을 검사합니다:

EXPLAIN PLAN actions = 1
SELECT
    street
FROM
   uk.uk_price_paid_simple
WHERE
   town = 'LONDON' and date > '2024-12-31' and price < 10_000;
...
Prewhere info                                                                                                                                                                                                                                          
  Prewhere filter column: 
    and(greater(__table1.date, '2024-12-31'_String), 
    less(__table1.price, 10000_UInt16), 
    equals(__table1.town, 'LONDON'_String)) 
...

여기서 계획 출력 대부분을 생략합니다. 상당히 장황하기 때문입니다. 본질적으로 세 컬럼 조건이 모두 자동으로 PREWHERE로 이동했음을 보여 줍니다.

직접 재현하면 쿼리 계획에서 이 조건들의 순서가 컬럼의 데이터 타입 크기에 기반한다는 것도 보게 됩니다. 컬럼 통계를 활성화하지 않았으므로 ClickHouse는 PREWHERE 처리 순서를 결정하기 위한 대체값으로 크기를 사용합니다.

더 심층적으로 들어가려면 쿼리 실행 중 모든 test-레벨 로그 엔트리를 반환하도록 ClickHouse에 지시해 각 개별 PREWHERE 처리 단계를 관찰할 수 있습니다:

SELECT
    street
FROM
   uk.uk_price_paid_simple
WHERE
   town = 'LONDON' AND date > '2024-12-31' AND price < 10_000
SETTINGS send_logs_level = 'test';
...
<Trace> ... Condition greater(date, '2024-12-31'_String) moved to PREWHERE
<Trace> ... Condition less(price, 10000_UInt16) moved to PREWHERE
<Trace> ... Condition equals(town, 'LONDON'_String) moved to PREWHERE
...
<Test> ... Executing prewhere actions on block: greater(__table1.date, '2024-12-31'_String)
<Test> ... Executing prewhere actions on block: less(__table1.price, 10000_UInt16)
...

핵심 요점

  • PREWHERE는 나중에 필터링될 컬럼 데이터를 읽지 않아 I/O와 메모리를 절약합니다.
  • optimize_move_to_prewhere가 활성화(기본)되면 자동으로 동작합니다.
  • 필터링 순서가 중요합니다: 작고 선택적인 컬럼이 먼저 와야 합니다.
  • EXPLAIN과 로그를 사용해 PREWHERE가 적용되는지 확인하고 그 효과를 이해하세요.
  • PREWHERE는 넓은 테이블과 선택적 필터가 있는 대규모 스캔에서 가장 효과적입니다.

더 알아보기 (Learn more)