跳至主要内容

使用分析器理解查询执行

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 query steps

让我们看看在查询执行过程中每个实体是如何工作的。我们将执行几个查询,然后使用 EXPLAIN 语句检查它们。

解析器

解析器的目标是将查询文本转换为 AST(抽象语法树)。可以使用 EXPLAIN AST 来可视化此步骤。

EXPLAIN AST SELECT min(timestamp), max(timestamp) 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 │
└────────────────────────────────────────────────────┘

输出是一个抽象语法树,可以将其可视化如下所示。

AST output

每个节点都有相应的子节点,整个树表示查询的整体结构。这是一种逻辑结构,有助于处理查询。从最终用户的角度来看(除非对查询执行感兴趣),它不是很有用;此工具主要供开发人员使用。

分析器

ClickHouse 目前有两个分析器架构。您可以通过设置 allow_experimental_analyzer=0 来使用旧架构。如果您想使用新架构,则应设置 allow_experimental_analyzer=1。鉴于旧分析器将在新分析器普遍可用后弃用,这里我们只描述新架构。

注意

新分析器处于 Beta 阶段。新架构应该为我们提供一个更好的框架来提高 ClickHouse 的性能。但是,鉴于它是查询处理步骤中的一个基本组件,它也可能对某些查询产生负面影响。迁移到新分析器后,您可能会看到性能下降、查询失败或查询给出意外结果。您可以通过在查询或用户级别更改 allow_experimental_analyzer 设置来恢复到旧分析器。请在 GitHub 上报告任何问题。

分析器是查询执行的重要步骤。它获取 AST 并将其转换为查询树。与 AST 相比,查询树的主要优势在于许多组件将被解析,例如存储。我们还知道要从哪个表读取,别名也会被解析,并且树知道使用的不同数据类型。凭借所有这些优势,分析器可以应用优化。这些优化是通过“传递”来实现的。每个传递都会查找不同的优化。您可以在 此处 查看所有传递,让我们在之前的查询中实际看一下。

EXPLAIN QUERY TREE passes=0 SELECT min(timestamp) AS minimum_date, max(timestamp) AS maximum_date FROM session_events SETTINGS allow_experimental_analyzer=1;

┌─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 │
│ SETTINGS allow_experimental_analyzer=1
└────────────────────────────────────────────────────────────────────────────────────────┘
EXPLAIN QUERY TREE passes=20 SELECT min(timestamp) AS minimum_date, max(timestamp) AS maximum_date FROM session_events SETTINGS allow_experimental_analyzer=1;

┌─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 │
│ SETTINGS allow_experimental_analyzer=1
└───────────────────────────────────────────────────────────────────────────────────────────┘

在两次执行之间,您可以看到别名和投影的解析。

计划器

计划器获取查询树并从中构建查询计划。查询树告诉我们想要对特定查询做什么,而查询计划告诉我们如何去做。作为查询计划的一部分,将进行其他优化。您可以使用 EXPLAIN PLANEXPLAIN 来查看查询计划(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 │
└──────────────────────────────────────────────────┘

因此,现在您知道需要为最后一个投影创建哪些列名称(minimum_datemaximum_datepercentage),但您可能还想了解需要执行的所有操作的详细信息。您可以通过设置 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
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

您现在可以看到正在使用的所有输入、函数、别名和数据类型。您可以看到计划器将应用的一些优化 此处

查询管道

从查询计划生成查询管道。查询管道与查询计划非常相似,区别在于它不是树,而是一个图。它突出显示了 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 12
│ AggregatingTransform │
(Expression)
│ ExpressionTransform │
(ReadFromMergeTree)
│ MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread) 01
└────────────────────────────────────────────────────────────────────────────┘

括号内是查询计划步骤,旁边是处理器。这是一个很好的信息,但鉴于这是一个图,最好将其可视化。我们有一个设置 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=""];
}

然后,您可以复制此输出并将其粘贴到 此处,这将生成以下图形。

Graph output

白色矩形对应于管道节点,灰色矩形对应于查询计划步骤,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;
}

Compact graph output

为什么 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;
}

Parallel graph

因此,执行器决定不并行化操作,因为数据量不够大。通过添加更多行,执行器随后决定使用多个线程,如图形所示。

执行器

最后,查询执行的最后一步由执行器完成。它将获取查询管道并执行它。执行器有不同类型,具体取决于您是在执行 SELECTINSERT 还是 INSERT SELECT