Skip to content
ClickHouse Docs
ClickHouse DocsClickHouse Docs

Как понять выполнение запросов с помощью анализатора

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

Давайте посмотрим, как каждый компонент работает в процессе выполнения запроса. Мы возьмем несколько запросов, а затем разберем их с помощью оператора EXPLAIN.

Парсер

Задача парсера — преобразовать текст запроса в AST (абстрактное синтаксическое дерево). Этот шаг можно наглядно представить с помощью 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               │
└────────────────────────────────────────────────────┘

Результатом является абстрактное синтаксическое дерево, которое можно визуализировать, как показано ниже:

graph TD
    swuq["SelectWithUnionQuery"] --> el_root["ExpressionList"]
    el_root --> sq["SelectQuery"]
    sq --> el_select["ExpressionList"]
    sq --> tisq["TablesInSelectQuery"]
    el_select --> fmin["Function min (alias minimum_date)"]
    el_select --> fmax["Function max (alias maximum_date)"]
    fmin --> el_min["ExpressionList"]
    el_min --> id_min["Identifier timestamp"]
    fmax --> el_max["ExpressionList"]
    el_max --> id_max["Identifier timestamp"]
    tisq --> tisqe["TablesInSelectQueryElement"]
    tisqe --> te["TableExpression"]
    te --> ti["TableIdentifier session_events"]

У каждого узла есть соответствующие дочерние элементы, а всё дерево в целом отражает общую структуру вашего запроса. Это логическая структура, помогающая обрабатывать запрос. С точки зрения конечного пользователя (если только его не интересует выполнение запроса) она не слишком полезна; этот инструмент в основном используется разработчиками.

Анализатор

В настоящее время в ClickHouse есть две архитектуры анализатора. Чтобы использовать старую архитектуру, задайте: enable_analyzer=0. Текущая архитектура включена по умолчанию, начиная с ClickHouse 24.3. Здесь мы будем описывать только текущую архитектуру, поскольку старая объявлена устаревшей и сохраняется только для обратной совместимости.

Анализатор — важный этап выполнения запроса. Он принимает 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 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                                                                                                                          │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Теперь вы можете видеть все используемые входные данные, функции, псевдонимы и типы данных. С некоторыми оптимизациями, которые планировщик будет применять, можно ознакомиться здесь.

Конвейер запроса

Конвейер запроса формируется на основе плана запроса. Он очень похож на план запроса, однако, в отличие от него, представляет собой не дерево, а граф. Конвейер наглядно показывает, как 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;
}
Вывод параллельного графа

Итак, исполнитель решил не выполнять операции параллельно, потому что объём данных был недостаточно большим. После добавления большего количества строк исполнитель решил использовать несколько потоков, как показано на графе.

Исполнитель

Наконец, завершающий этап выполнения запроса осуществляется исполнителем. Он берёт конвейер выполнения запроса и запускает его. Существуют разные типы исполнителей — в зависимости от того, выполняете ли вы SELECT, INSERT или INSERT SELECT.

Navigation