쿼리 실행을 analyzer로 이해하기
쿼리 실행을 analyzer로 이해하기
ClickHouse는 쿼리를 매우 빠르게 처리하지만, 쿼리 실행은 단순한 이야기가 아니에요. SELECT 쿼리가 어떻게 실행되는지 이해해볼게요. 설명을 위해 ClickHouse 테이블에 데이터를 좀 추가할게요:
CREATE TABLE session_events(
clientId UUID,
sessionId UUID,
pageId UUID,
timestamp DateTime,
type String
) ORDER BY (timestamp);
INSERT INTO session_events SELECT * FROM generateRandom('clientId UUID,
sessionId UUID,
pageId UUID,
timestamp DateTime,
type Enum(\'type1\', \'type2\')', 1, 10, 2) LIMIT 1000;
이제 ClickHouse에 데이터가 있으니, 몇 가지 쿼리를 실행하고 그 실행을 이해해볼게요. 쿼리 실행은 많은 단계로 분해돼요. 쿼리 실행의 각 단계는 해당하는 EXPLAIN 쿼리로 분석하고 문제를 해결할 수 있어요. 이 단계들은 아래 차트에 요약되어 있어요.
쿼리 실행 중 각 개체를 살펴볼게요. 몇 가지 쿼리를 가져와서 EXPLAIN 문으로 검사할 거예요.
출처: 문서
본문
Parser
파서의 목표는 쿼리 텍스트를 AST(Abstract Syntax Tree)로 변환하는 것이에요. 이 단계는 EXPLAIN AST로 시각화할 수 있어요:
EXPLAIN AST SELECT min(timestamp) AS minimum_date, max(timestamp) AS maximum_date FROM session_events;
┌─explain────────────────────────────────────────────┐
│ SelectWithUnionQuery (children 1) │
│ ExpressionList (children 1) │
│ SelectQuery (children 2) │
│ ExpressionList (children 2) │
│ Function min (alias minimum_date) (children 1) │
│ ExpressionList (children 1) │
│ Identifier timestamp │
│ Function max (alias maximum_date) (children 1) │
│ ExpressionList (children 1) │
│ Identifier timestamp │
│ TablesInSelectQuery (children 1) │
│ TablesInSelectQueryElement (children 1) │
│ TableExpression (children 1) │
│ TableIdentifier session_events │
└────────────────────────────────────────────────────┘
출력은 Abstract Syntax Tree로, 아래처럼 시각화할 수 있어요. 각 노드는 해당하는 자식을 가지며 전체 트리는 쿼리의 전체 구조를 나타내요. 이는 쿼리를 처리하는 데 도움이 되는 논리적 구조예요. 최종 사용자 관점에서(쿼리 실행에 관심이 없다면) 그다지 유용하지 않아요; 이 도구는 주로 개발자가 사용해요.
Analyzer
analyzer는 ClickHouse 24.3부터 기본으로 활성화되었고, 26.9부터는 유일한 쿼리 분석이며 enable_analyzer는 더 이상 비활성화할 수 없는 구식 설정이 되었어요.
analyzer는 쿼리 처리 단계의 근본적인 구성 요소이며, 일부 쿼리에는 부정적 영향을 미칠 수 있어요: 그것을 위해 업데이트해야 할 쿼리는 알려진 비호환성을 참고하세요.
analyzer는 쿼리 실행의 중요한 단계예요. AST를 가져와서 쿼리 트리로 변환해요. AST에 대한 쿼리 트리의 주요 이점은 저장소 같은 많은 구성 요소가 해석된다는 점이에요. 어떤 테이블에서 읽을지도 알고, 별칭도 해석되며, 트리는 사용되는 다양한 데이터 타입을 알아요. 이러한 이점 덕분에 analyzer는 최적화를 적용할 수 있어요. 이 최적화는 "패스(pass)"를 통해 동작해요. 각 패스는 서로 다른 최적화를 찾을 거예요. 모든 패스를 여기에서 볼 수 있는데, 이전 쿼리로 실전에서 살펴볼게요:
EXPLAIN QUERY TREE passes=0 SELECT min(timestamp) AS minimum_date, max(timestamp) AS maximum_date FROM session_events;
┌─explain────────────────────────────────────────────────────────────────────────────────┐
│ QUERY id: 0 │
│ PROJECTION │
│ LIST id: 1, nodes: 2 │
│ FUNCTION id: 2, alias: minimum_date, function_name: min, function_type: ordinary │
│ ARGUMENTS │
│ LIST id: 3, nodes: 1 │
│ IDENTIFIER id: 4, identifier: timestamp │
│ FUNCTION id: 5, alias: maximum_date, function_name: max, function_type: ordinary │
│ ARGUMENTS │
│ LIST id: 6, nodes: 1 │
│ IDENTIFIER id: 7, identifier: timestamp │
│ JOIN TREE │
│ IDENTIFIER id: 8, identifier: session_events │
└────────────────────────────────────────────────────────────────────────────────────────┘
EXPLAIN QUERY TREE passes=20 SELECT min(timestamp) AS minimum_date, max(timestamp) AS maximum_date FROM session_events;
┌─explain───────────────────────────────────────────────────────────────────────────────────┐
│ QUERY id: 0 │
│ PROJECTION COLUMNS │
│ minimum_date DateTime │
│ maximum_date DateTime │
│ PROJECTION │
│ LIST id: 1, nodes: 2 │
│ FUNCTION id: 2, function_name: min, function_type: aggregate, result_type: DateTime │
│ ARGUMENTS │
│ LIST id: 3, nodes: 1 │
│ COLUMN id: 4, column_name: timestamp, result_type: DateTime, source_id: 5 │
│ FUNCTION id: 6, function_name: max, function_type: aggregate, result_type: DateTime │
│ ARGUMENTS │
│ LIST id: 7, nodes: 1 │
│ COLUMN id: 4, column_name: timestamp, result_type: DateTime, source_id: 5 │
│ JOIN TREE │
│ TABLE id: 5, alias: __table1, table_name: default.session_events │
└───────────────────────────────────────────────────────────────────────────────────────────┘
두 실행 사이에 별칭과 프로젝션의 해석을 볼 수 있어요.
Planner
planner는 쿼리 트리를 가져와서 쿼리 계획을 만든답니다. 쿼리 트리는 특정 쿼리로 무엇을 하고 싶은지 알려주고, 쿼리 계획은 그것을 어떻게 할 것인지 알려줘요. 쿼리 계획의 일부로 추가 최적화가 수행돼요. 쿼리 계획을 보려면 EXPLAIN PLAN 또는 EXPLAIN을 사용할 수 있어요(EXPLAIN은 EXPLAIN PLAN을 실행해요).
EXPLAIN PLAN WITH
(
SELECT count(*)
FROM session_events
) AS total_rows
SELECT type, min(timestamp) AS minimum_date, max(timestamp) AS maximum_date, count(*) /total_rows * 100 AS percentage FROM session_events GROUP BY type
┌─explain──────────────────────────────────────────┐
│ Expression ((Projection + Before ORDER BY)) │
│ Aggregating │
│ Expression (Before GROUP BY) │
│ ReadFromMergeTree (default.session_events) │
└──────────────────────────────────────────────────┘
이것도 어느 정도 정보를 주지만, 더 얻을 수 있어요. 예를 들어 프로젝션이 필요로 하는 컬럼의 이름을 알고 싶을 수도 있어요. 쿼리에 헤더를 추가할 수 있어요:
EXPLAIN header = 1
WITH (
SELECT count(*)
FROM session_events
) AS total_rows
SELECT
type,
min(timestamp) AS minimum_date,
max(timestamp) AS maximum_date,
(count(*) / total_rows) * 100 AS percentage
FROM session_events
GROUP BY type
┌─explain──────────────────────────────────────────┐
│ Expression ((Projection + Before ORDER BY)) │
│ Header: type String │
│ minimum_date DateTime │
│ maximum_date DateTime │
│ percentage Nullable(Float64) │
│ Aggregating │
│ Header: type String │
│ min(timestamp) DateTime │
│ max(timestamp) DateTime │
│ count() UInt64 │
│ Expression (Before GROUP BY) │
│ Header: timestamp DateTime │
│ type String │
│ ReadFromMergeTree (default.session_events) │
│ Header: timestamp DateTime │
│ type String │
└──────────────────────────────────────────────────┘
이제 마지막 Projection(minimum_date, maximum_date, percentage)을 위해 생성해야 할 컬럼 이름을 알게 됐어요. 하지만 실행해야 할 모든 액션의 세부사항도 원할 수 있어요. actions=1로 설정하면 그렇게 할 수 있어요.
EXPLAIN actions = 1
WITH (
SELECT count(*)
FROM session_events
) AS total_rows
SELECT
type,
min(timestamp) AS minimum_date,
max(timestamp) AS maximum_date,
(count(*) / total_rows) * 100 AS percentage
FROM session_events
GROUP BY type
┌─explain────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Expression ((Projection + Before ORDER BY)) │
│ Actions: INPUT :: 0 -> type String : 0 │
│ INPUT : 1 -> min(timestamp) DateTime : 1 │
│ INPUT : 2 -> max(timestamp) DateTime : 2 │
│ INPUT : 3 -> count() UInt64 : 3 │
│ COLUMN Const(Nullable(UInt64)) -> total_rows Nullable(UInt64) : 4 │
│ COLUMN Const(UInt8) -> 100 UInt8 : 5 │
│ ALIAS min(timestamp) :: 1 -> minimum_date DateTime : 6 │
│ ALIAS max(timestamp) :: 2 -> maximum_date DateTime : 1 │
│ FUNCTION divide(count() :: 3, total_rows :: 4) -> divide(count(), total_rows) Nullable(Float64) : 2 │
│ FUNCTION multiply(divide(count(), total_rows) :: 2, 100 :: 5) -> multiply(divide(count(), total_rows), 100) Nullable(Float64) : 4 │
│ ALIAS multiply(divide(count(), total_rows), 100) :: 4 -> percentage Nullable(Float64) : 5 │
│ Positions: 0 6 1 5 │
│ Aggregating │
│ Keys: type │
│ Aggregates: │
│ min(timestamp) │
│ Function: min(DateTime) → DateTime │
│ Arguments: timestamp │
│ max(timestamp) │
│ Function: max(DateTime) → DateTime │
│ Arguments: timestamp │
│ count() │
│ Function: count() → UInt64 │
│ Arguments: none │
│ Skip merging: 0 │
│ Expression (Before GROUP BY) │
│ Actions: INPUT :: 0 -> timestamp DateTime : 0 │
│ INPUT :: 1 -> type String : 1 │
│ Positions: 0 1 │
│ ReadFromMergeTree (default.session_events) │
│ ReadType: Default │
│ Parts: 1 │
│ Granules: 1 │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
이제 사용되는 모든 입력, 함수, 별칭, 데이터 타입을 볼 수 있어요. planner가 적용할 몇 가지 최적화를 여기에서 볼 수 있어요.
쿼리 파이프라인 (Query pipeline)
쿼리 파이프라인은 쿼리 계획에서 생성돼요. 쿼리 파이프라인은 쿼리 계획과 매우 유사하지만, 트리가 아니라 그래프라는 점이 달라요. 이것은 ClickHouse가 쿼리를 어떻게 실행할 것인지와 어떤 리소스가 사용될 것인지 강조해요. 쿼리 파이프라인을 분석하는 것은 입출력 측면에서 병목이 어디인지 보는 데 매우 유용해요. 이전 쿼리를 가져와서 쿼리 파이프라인 실행을 살펴볼게요:
EXPLAIN PIPELINE
WITH (
SELECT count(*)
FROM session_events
) AS total_rows
SELECT
type,
min(timestamp) AS minimum_date,
max(timestamp) AS maximum_date,
(count(*) / total_rows) * 100 AS percentage
FROM session_events
GROUP BY type;
┌─explain────────────────────────────────────────────────────────────────────┐
│ (Expression) │
│ ExpressionTransform × 2 │
│ (Aggregating) │
│ Resize 1 → 2 │
│ AggregatingTransform │
│ (Expression) │
│ ExpressionTransform │
│ (ReadFromMergeTree) │
│ MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread) 0 → 1 │
└────────────────────────────────────────────────────────────────────────────┘
괄호 안은 쿼리 계획 단계이고, 그 옆은 프로세서예요. 이는 훌륭한 정보이지만, 이것이 그래프이므로 그렇게 시각화하면 좋겠어요. graph 설정을 1로 설정하고 출력 형식을 TSV로 지정할 수 있어요:
EXPLAIN PIPELINE graph=1 WITH
(
SELECT count(*)
FROM session_events
) AS total_rows
SELECT type, min(timestamp) AS minimum_date, max(timestamp) AS maximum_date, count(*) /total_rows * 100 AS percentage FROM session_events GROUP BY type FORMAT TSV;
digraph
{
rankdir="LR";
{ node [shape = rect]
subgraph cluster_0 {
label ="Expression";
style=filled;
color=lightgrey;
node [style=filled,color=white];
{ rank = same;
n5 [label="ExpressionTransform × 2"];
}
}
subgraph cluster_1 {
label ="Aggregating";
style=filled;
color=lightgrey;
node [style=filled,color=white];
{ rank = same;
n3 [label="AggregatingTransform"];
n4 [label="Resize"];
}
}
subgraph cluster_2 {
label ="Expression";
style=filled;
color=lightgrey;
node [style=filled,color=white];
{ rank = same;
n2 [label="ExpressionTransform"];
}
}
subgraph cluster_3 {
label ="ReadFromMergeTree";
style=filled;
color=lightgrey;
node [style=filled,color=white];
{ rank = same;
n1 [label="MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread)"];
}
}
}
n3 -> n4 [label=""];
n4 -> n5 [label="× 2"];
n2 -> n3 [label=""];
n1 -> n2 [label=""];
}
이 출력을 복사해서 여기에 붙여넣으면 다음 그래프가 생성돼요. 흰 사각형은 파이프라인 노드에 해당하고, 회색 사각형은 쿼리 계획 단계에 해당하며, 숫자가 뒤따르는 x는 사용되는 입출력의 수에 해당해요. 그것들을 컴팩트한 형태로 보고 싶지 않으면 compact=0을 추가할 수 있어요:
EXPLAIN PIPELINE graph = 1, compact = 0
WITH (
SELECT count(*)
FROM session_events
) AS total_rows
SELECT
type,
min(timestamp) AS minimum_date,
max(timestamp) AS maximum_date,
(count(*) / total_rows) * 100 AS percentage
FROM session_events
GROUP BY type
FORMAT TSV
digraph
{
rankdir="LR";
{ node [shape = rect]
n0[label="MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread)"];
n1[label="ExpressionTransform"];
n2[label="AggregatingTransform"];
n3[label="Resize"];
n4[label="ExpressionTransform"];
n5[label="ExpressionTransform"];
}
n0 -> n1;
n1 -> n2;
n2 -> n3;
n3 -> n4;
n3 -> n5;
}
ClickHouse가 여러 스레드로 테이블에서 읽지 않는 이유가 뭘까요? 테이블에 데이터를 더 추가해볼게요:
INSERT INTO session_events SELECT * FROM generateRandom('clientId UUID,
sessionId UUID,
pageId UUID,
timestamp DateTime,
type Enum(\'type1\', \'type2\')', 1, 10, 2) LIMIT 1000000;
이제 EXPLAIN 쿼리를 다시 실행해볼게요:
EXPLAIN PIPELINE graph = 1, compact = 0
WITH (
SELECT count(*)
FROM session_events
) AS total_rows
SELECT
type,
min(timestamp) AS minimum_date,
max(timestamp) AS maximum_date,
(count(*) / total_rows) * 100 AS percentage
FROM session_events
GROUP BY type
FORMAT TSV
digraph
{
rankdir="LR";
{ node [shape = rect]
n0[label="MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread)"];
n1[label="MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread)"];
n2[label="ExpressionTransform"];
n3[label="ExpressionTransform"];
n4[label="StrictResize"];
n5[label="AggregatingTransform"];
n6[label="AggregatingTransform"];
n7[label="Resize"];
n8[label="ExpressionTransform"];
n9[label="ExpressionTransform"];
}
n0 -> n2;
n1 -> n3;
n2 -> n4;
n3 -> n4;
n4 -> n5;
n4 -> n6;
n5 -> n7;
n6 -> n7;
n7 -> n8;
n7 -> n9;
}
실행기가 데이터 양이 충분히 많지 않아서 연산을 병렬화하지 않기로 결정했어요. 행을 더 추가하자 실행기는 그래프에 표시된 것처럼 여러 스레드를 사용하기로 결정했어요.
Executor
마지막으로 쿼리 실행의 마지막 단계는 executor가 수행해요. 쿼리 파이프라인을 가져와서 실행해요. SELECT를 하는지, INSERT를 하는지, INSERT SELECT를 하는지에 따라 서로 다른 유형의 executor가 있어요.