Перейти к основному содержанию
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.

Парсер

Задача парсера — преобразовать текст запроса в 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               │
└────────────────────────────────────────────────────┘
Результатом является абстрактное синтаксическое дерево, которое можно визуализировать, как показано ниже: У каждого узла есть соответствующие дочерние элементы, а всё дерево в целом отражает общую структуру вашего запроса. Это логическая структура, помогающая обрабатывать запрос. С точки зрения конечного пользователя (если только его не интересует выполнение запроса) она не слишком полезна; этот инструмент в основном используется разработчиками.

Анализатор

В настоящее время в ClickHouse есть две архитектуры анализатора. Чтобы использовать старую архитектуру, задайте: enable_analyzer=0. Новая архитектура включена по умолчанию. Здесь мы будем описывать только новую архитектуру, поскольку старая будет объявлена устаревшей, когда новый анализатор станет общедоступным.
Новая архитектура должна обеспечить более качественную основу для повышения производительности ClickHouse. Однако, поскольку это фундаментальный компонент процесса обработки запросов, она также может негативно повлиять на некоторые запросы, и у нее есть известные несовместимости. Вы можете вернуться к старому анализатору, изменив настройку enable_analyzer на уровне запроса или пользователя.
Анализатор — важный этап выполнения запроса. Он принимает 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
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Теперь вы можете видеть все используемые входные данные, функции, псевдонимы и типы данных. С некоторыми оптимизациями, которые планировщик будет применять, можно ознакомиться здесь.

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 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=""];
}
Затем скопируйте этот вывод и вставьте его сюда — будет сгенерирован следующий граф: Белый прямоугольник соответствует узлу конвейера, серый прямоугольник — шагам плана запроса, а символ 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.
Последнее изменение 10 июня 2026 г.