【Clickhouse从入门到精通】第39篇:ClickHouse SQL执行计划查看与查询优化基础
上一篇【第38篇】ClickHouse SELECT高级用法——表达式函数与DISTINCT/UNION ALL
下一篇ClickHouse聚合函数体系详解(上)——基础聚合与组合聚合
摘要
本文是《Clickhouse从入门到精通》系列博客的第39篇文章,聚焦ClickHouse的SQL执行计划查看与查询优化基础。文章从EXPLAIN命令体系入手,详细讲解EXPLAIN PLAN、EXPLAIN TREE、EXPLAIN PIPELINE、EXPLAIN ESTIMATE和EXPLAIN SYNTAX等五种执行计划查看方式的语法与输出解读;随后系统阐述分区裁剪、索引选择、列裁剪、PREWHERE优化和GROUP BY优化等基础查询优化策略;最后通过实战案例展示如何分析慢查询执行计划并定位性能瓶颈。
关键词:EXPLAIN、执行计划、查询优化、PIPELINE、分区裁剪
1. 引言
在上一篇文章中,我们深入探讨了ClickHouse中SELECT语句的高级用法,包括列表达式、DISTINCT去重、UNION ALL合并查询和子查询等技术。掌握了这些查询写法后,下一步就是确保查询能高效执行。在实际生产环境中,面对TB甚至PB级别的数据量,一个不经优化的查询可能从毫秒级退化到分钟甚至小时级别。
ClickHouse提供了丰富的EXPLAIN命令来查看SQL执行计划,帮助我们理解查询的内部执行机制,从而精准定位性能瓶颈并制定优化策略。本文将从EXPLAIN命令体系入手,逐步展开查询优化的基础方法论。
2. EXPLAIN命令概述
ClickHouse从21.x版本开始引入了EXPLAIN命令族,用于查看查询的执行计划。通过不同类型的EXPLAIN命令,我们可以从不同角度审视查询的执行方式。
EXPLAIN命令类型总览
| 命令 | 用途 | 适用场景 |
|---|---|---|
| EXPLAIN PLAN | 查看逻辑执行计划 | 理解查询逻辑步骤 |
| EXPLAIN TREE | 查看树形执行计划 | 更直观地理解执行层次 |
| EXPLAIN PIPELINE | 查看查询管道 | 理解数据流和并行度 |
| EXPLAIN ESTIMATE | 查看估算信息 | 预估数据量和I/O |
| EXPLAIN SYNTAX | 查看语法解析结果 | 理解SQL实际解析形态 |
基本语法格式:
EXPLAIN [PLAN | TREE | PIPELINE | ESTIMATE | SYNTAX] [SETTINGS ...]
SELECT ...;
3. EXPLAIN PLAN:传统执行计划输出
3.1 基本语法
EXPLAIN PLAN SELECT
region,
sum(amount) AS total
FROM orders
WHERE date = '2024-01-15'
GROUP BY region
ORDER BY total DESC
LIMIT 10;
3.2 输出格式解读
EXPLAIN PLAN输出的是逻辑执行计划的层次结构,每个节点代表一个执行步骤:
┌─explain─────────────────────────────────────────┐
│ Limit │
│ Sorting │
│ Aggregating │
│ Expression │
│ Filter │
│ Expression │
│ ReadFromMergeTree │
└────────────────────────────────────────────────┘
从输出中可以看到执行步骤从底向上:先读取数据(ReadFromMergeTree),然后经过表达式计算(Expression)、过滤(Filter)、聚合(Aggregating)、排序(Sorting),最后取前N行(Limit)。
3.3 关键节点类型解读
| 节点类型 | 含义 | 常见场景 |
|---|---|---|
| ReadFromMergeTree | 从MergeTree表读取数据 | 所有查询的起始节点 |
| ReadFromStorage | 从其他存储引擎读取 | 非MergeTree表 |
| Filter | WHERE条件过滤 | 有WHERE子句 |
| Expression | 表达式计算/列投影 | 别名计算、类型转换 |
| Aggregating | 聚合计算 | GROUP BY |
| AggregatingPartitions | 分区级聚合 | 分布式聚合 |
| Sorting | 排序 | ORDER BY |
| Limit | 结果截断 | LIMIT子句 |
| Join | 连接操作 | JOIN子句 |
| Distinct | 去重 | DISTINCT |
| Union | 合并 | UNION ALL |
| Subquery | 子查询 | FROM中的子查询 |
EXPLAIN PLAN的扩展选项
-- 查看更详细的信息:header、index、actions
EXPLAIN PLAN header = 1, indexes = 1, actions = 1
SELECT region, sum(amount) AS total
FROM orders
WHERE date = '2024-01-15'
GROUP BY region;
header = 1:显示每个节点输出的列信息indexes = 1:显示索引使用情况actions = 1:显示每个节点的详细操作
4. EXPLAIN TREE:树形执行计划
4.1 更直观的树状结构
EXPLAIN TREE以更详细的树形结构展示执行计划,每个节点包含更多上下文信息:
EXPLAIN TREE SELECT
region,
sum(amount) AS total
FROM orders
WHERE date = '2024-01-15' AND amount > 100
GROUP BY region
ORDER BY total DESC
LIMIT 10;
输出示例:
┌─explain──────────────────────────────────────────────┐
│ Limit (len 10) │
│ └── Sorting (ORDER BY total DESC) │
│ └── Aggregating │
│ └── Expression │
│ └── Filter (amount > 100) │
│ └── Expression │
│ └── ReadFromMergeTree (default.orders) │
│ Parts: 8 │
│ Granules: 120 │
└──────────────────────────────────────────────────────┘
4.2 各节点类型深度解读
ReadFromMergeTree节点
这是最关键的节点之一,显示数据读取的详细信息:
EXPLAIN TREE SELECT count() FROM hits WHERE date = '2024-01-15';
输出中的关键信息包括:
- Parts:需要扫描的数据分区数
- Granules:需要读取的Mark数(Granule是ClickHouse的最小读取单位)
- Partitions:涉及的分区信息
Aggregating节点
聚合节点显示聚合的方式(单阶段聚合或两阶段聚合):
EXPLAIN TREE SELECT region, count() FROM orders GROUP BY region;
在分布式查询中,聚合通常分为两阶段:先在各分片本地聚合,再在协调节点合并聚合结果。EXPLAIN TREE可以清晰展示这一过程。
Join节点
Join节点显示连接类型和连接条件:
EXPLAIN TREE SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.user_id;
输出会显示Join的类型(HASH JOIN、MERGE JOIN等)、连接键和右表构建方式。
5. EXPLAIN PIPELINE:查询管道
5.1 QueryPipeline的可视化
EXPLAIN PIPELINE是ClickHouse最具特色的执行计划查看方式。ClickHouse的查询执行引擎基于Pipeline模型,数据在多个Processor之间以流式方式传递。EXPLAIN PIPELINE将这一过程可视化:
EXPLAIN PIPELINE SELECT
region,
sum(amount) AS total
FROM orders
WHERE date = '2024-01-15'
GROUP BY region;
输出示例:
┌─explain──────────────────────────────────────────────────┐
│ (MergeTreeThread) │
│ (ExpressionTransform) │
│ (FilterTransform) │
│ (MergingAggregated) │
│ (AggregatingTransform) │
│ (ExpressionTransform) │
│ (MergeTreeThread) │
│ (ExpressionTransform) │
│ (FilterTransform) │
│ (SortingTransform) │
│ (MergingAggregated) │
│ (AggregatingTransform) │
│ (LimitTransform) │
└──────────────────────────────────────────────────────────┘
5.2 理解数据流向
Pipeline展示了数据的实际流动路径。每个Processor是一个数据处理单元,数据从数据源(如MergeTreeThread)经过一系列Transform流向最终输出。
关键概念
- Processor:数据处理单元,包括Source(数据源)、Transform(数据变换)和Sink(数据输出)
- Port:Processor之间的连接点,数据通过Port在Processor之间传递
- Thread:执行线程,Pipeline中的Processor被分配到不同线程并行执行
-- 查看Pipeline的详细图
EXPLAIN PIPELINE graph = 1 SELECT count() FROM hits;
当graph = 1时,ClickHouse会输出Pipeline的DOT格式图描述,可以用于生成可视化图形。
5.3 并行度分析
EXPLAIN PIPELINE可以直观地展示查询的并行度:
EXPLAIN PIPELINE SELECT count() FROM large_table;
输出中的数字表示各阶段的并行线程数:
┌─explain──────────────────────────────┐
│ (MergeTreeThread × 8) │ ← 8个线程并行读取
│ (ExpressionTransform × 8) │ ← 8个线程并行转换
│ (AggregatingTransform × 8) │ ← 8个线程并行聚合
│ (MergingAggregated × 1) │ ← 1个线程合并结果
│ (ExpressionTransform × 1) │ ← 1个线程输出
└──────────────────────────────────────┘
理解并行度有助于判断查询是否充分利用了CPU资源。如果某个阶段的并行度很低(例如最终合并阶段只有1个线程),这可能成为性能瓶颈。
6. EXPLAIN ESTIMATE:估算信息
6.1 行数和字节数估算
EXPLAIN ESTIMATE提供查询各阶段的行数和字节数估算:
EXPLAIN ESTIMATE SELECT
region,
sum(amount) AS total
FROM orders
WHERE date = '2024-01-15' AND status = 'completed'
GROUP BY region;
输出示例:
┌─database─┬─table──┬─parts─┬─rows─────┬─bytes───────┐
│ default │ orders │ 4 │ 1500000 │ 120000000 │
└──────────┴────────┴───────┴──────────┴─────────────┘
各列含义:
| 列名 | 含义 |
|---|---|
| database | 数据库名 |
| table | 表名 |
| parts | 涉及的数据分区数 |
| rows | 预估读取的行数 |
| bytes | 预估读取的字节数 |
6.2 估算信息的用途
EXPLAIN ESTIMATE的估算信息对优化决策具有重要参考价值:
判断分区裁剪效果
-- 有分区裁剪
EXPLAIN ESTIMATE SELECT * FROM orders WHERE date = '2024-01-15';
-- parts: 1, rows: 500000
-- 无分区裁剪
EXPLAIN ESTIMATE SELECT * FROM orders WHERE amount > 1000;
-- parts: 90, rows: 45000000
通过对比parts和rows的数量,可以直观判断WHERE条件是否有效触发了分区裁剪。
预估查询资源消耗
rows和bytes的估算值可以帮助预估查询的资源消耗,避免在生产环境执行资源消耗过大的查询。
7. EXPLAIN SYNTAX:语法检查
7.1 查看SQL的实际解析结果
EXPLAIN SYNTAX展示ClickHouse对SQL的实际解析和重写结果:
EXPLAIN SYNTAX SELECT
user_id AS uid,
count() AS cnt
FROM events
WHERE uid > 1000
GROUP BY uid
ORDER BY cnt DESC;
输出示例:
SELECT
user_id AS uid,
count() AS cnt
FROM events
WHERE user_id > 1000 -- 注意:uid被展开为原始列名user_id
GROUP BY user_id -- 同样展开为原始列名
ORDER BY cnt DESC;
7.2 别名展开与类型推导
EXPLAIN SYNTAX能帮助我们看到ClickHouse内部对SQL做了哪些优化和重写:
-- 原始SQL
EXPLAIN SYNTAX SELECT
toDate(event_time) AS d,
count() AS cnt
FROM events
WHERE d = '2024-01-15'
GROUP BY d;
-- 实际解析结果(别名被展开)
SELECT
toDate(event_time) AS d,
count() AS cnt
FROM events
WHERE toDate(event_time) = '2024-01-15' -- d被展开
GROUP BY toDate(event_time); -- d被展开
注意:当WHERE条件中的别名被展开后,分区裁剪可能失效!因为ClickHouse的分区裁剪依赖于对分区列的直接比较,如果分区列被函数包裹,则无法利用分区索引。
-- 好的写法:直接使用分区列
WHERE date = '2024-01-15'
-- 不好的写法:对分区列使用函数
WHERE toDate(event_time) = '2024-01-15'
7.3 优化器重写
ClickHouse的优化器会自动重写某些SQL模式:
-- 原始SQL
EXPLAIN SYNTAX SELECT * FROM (
SELECT user_id, count() AS cnt FROM events GROUP BY user_id
) WHERE cnt > 10;
-- 可能的重写结果:谓词下推
SELECT user_id, count() AS cnt
FROM events
GROUP BY user_id
HAVING count() > 10;
8. 基础查询优化策略
8.1 分区裁剪优化
分区裁剪是ClickHouse最重要的优化手段之一。当查询的WHERE条件包含分区键时,ClickHouse可以跳过不相关的分区,大幅减少I/O。
原理
MergeTree引擎按PARTITION BY定义的分区键组织数据,每个分区是独立的目录。查询时,ClickHouse根据WHERE条件判断哪些分区需要扫描,跳过不满足条件的分区。
-- 按日期分区
CREATE TABLE orders (
date Date,
order_id UInt64,
amount Decimal(18,2),
region String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, order_id);
-- 有效分区裁剪
SELECT * FROM orders WHERE date = '2024-01-15';
-- 只扫描202401分区
-- 有效分区裁剪:范围查询
SELECT * FROM orders WHERE date BETWEEN '2024-01-01' AND '2024-01-31';
-- 只扫描202401分区
-- 无效分区裁剪
SELECT * FROM orders WHERE amount > 1000;
-- 扫描所有分区
分区裁剪验证
-- 使用EXPLAIN ESTIMATE验证分区裁剪效果
EXPLAIN ESTIMATE SELECT * FROM orders WHERE date = '2024-01-15';
-- parts: 1(只有一个分区被扫描)
EXPLAIN ESTIMATE SELECT * FROM orders WHERE amount > 1000;
-- parts: 24(所有分区都被扫描)
8.2 索引选择优化
ClickHouse的MergeTree引擎使用主键索引(PRIMARY KEY / ORDER BY)来加速数据查找。
主键索引原理
主键索引基于排序键构建,数据按主键排序存储。每个Mark(索引标记)对应一个Granule(8192行)的数据范围。
-- 合理的主键设计
CREATE TABLE user_events (
date Date,
user_id UInt64,
event_type String,
event_time DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (user_id, date); -- 以user_id为首列,支持按用户查询
-- 不合理的主键设计
CREATE TABLE user_events_bad (
date Date,
user_id UInt64,
event_type String,
event_time DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, user_id); -- 以date为首列,按用户查询无法利用索引
索引选择原则
| 原则 | 说明 |
|---|---|
| 高基数列优先 | 将区分度高的列放在主键前面 |
| 查询频率优先 | 将WHERE中频繁使用的列放在主键前面 |
| 范围查询列靠后 | 等值查询列在前,范围查询列在后 |
| 主键长度控制 | 主键列不宜过多,建议3-5列 |
-- 查询模式1:按user_id精确查找
-- 最佳ORDER BY: (user_id, date)
-- 查询模式2:按date范围+user_id精确查找
-- 最佳ORDER BY: (date, user_id)
-- 查询模式3:按region+user_id查找
-- 最佳ORDER BY: (region, user_id, date)
8.3 列裁剪优化
列式存储的核心优势在于只读取需要的列。避免SELECT *是最基本的优化手段。
对比效果
-- 100列的宽表
-- 查询1:SELECT * — 读取所有100列
-- 查询2:SELECT col1, col2, col3 — 只读取3列
-- I/O差距:约33倍
-- 验证列裁剪效果
EXPLAIN ESTIMATE SELECT * FROM wide_table WHERE date = today();
-- bytes: 大
EXPLAIN ESTIMATE SELECT col1, col2 FROM wide_table WHERE date = today();
-- bytes: 小
8.4 PREWHERE优化
PREWHERE是ClickHouse特有的优化机制,专门用于MergeTree引擎。它允许在读取完整列数据之前,先读取过滤列的稀疏索引来跳过不满足条件的数据块。
PREWHERE vs WHERE
-- WHERE:先读取所有需要的列,再过滤
SELECT user_id, event_type, amount
FROM events
WHERE date = '2024-01-15' AND amount > 1000;
-- PREWHERE:先读取amount列判断是否满足条件,再决定是否读取其他列
SELECT user_id, event_type, amount
FROM events
PREWHERE amount > 1000
WHERE date = '2024-01-15';
使用场景
PREWHERE适合过滤性强、列体积小的条件:
| 条件特征 | 适合PREWHERE | 适合WHERE |
|---|---|---|
| 过滤性强(过滤掉90%+数据) | 是 | 否 |
| 列体积小 | 是 | 否 |
| 过滤性弱(只过滤少量数据) | 否 | 是 |
| 列体积大 | 否 | 是 |
-- 自动PREWHERE优化
-- ClickHouse在大多数情况下会自动将WHERE条件提升为PREWHERE
-- 但对于复杂查询,手动指定PREWHERE可能更优
-- 手动PREWHERE示例
SELECT
user_id,
event_time,
url,
duration
FROM web_logs
PREWHERE status_code = 200 -- 先通过稀疏索引过滤,status_code体积小
WHERE duration > 5000 -- 再在读取完整数据后过滤
AND date = '2024-01-15';
重要提示:ClickHouse从较新版本开始,已经能自动将WHERE条件优化为PREWHERE,手动指定PREWHERE的需求已大幅减少。但在某些复杂查询中,手动控制PREWHERE仍然有价值。
8.5 GROUP BY优化
GROUP BY是分析查询中最常用的操作之一,其优化对整体查询性能影响显著。
优化策略1:减少GROUP BY的列数
-- 不必要的列参与GROUP BY
SELECT region, city, count()
FROM orders
GROUP BY region, city;
-- 如果只需要按region统计
SELECT region, count()
FROM orders
GROUP BY region; -- 减少GROUP BY列数,降低哈希表复杂度
优化策略2:利用ORDER BY加速GROUP BY
当GROUP BY的列与表的ORDER BY(排序键)一致时,ClickHouse可以利用数据的有序性,使用更高效的聚合算法:
-- 表的ORDER BY是(user_id, date)
-- 当GROUP BY user_id时,可以部分利用有序性
SELECT user_id, count()
FROM events
GROUP BY user_id;
优化策略3:使用聚合组合器减少中间结果
-- 使用-If组合器替代CASE WHEN
-- 不推荐
SELECT
count(CASE WHEN status = 'success' THEN 1 END) AS success_count,
count(CASE WHEN status = 'error' THEN 1 END) AS error_count
FROM events
GROUP BY region;
-- 推荐
SELECT
countIf(status = 'success') AS success_count,
countIf(status = 'error') AS error_count
FROM events
GROUP BY region;
优化策略4:两阶段聚合
ClickHouse在分布式查询中自动执行两阶段聚合:先在各分片本地聚合,再在协调节点合并。但单机查询也可以利用类似思路:
-- 大表聚合:利用子查询实现多阶段聚合
SELECT
region,
sum(local_count) AS total_count,
sum(local_amount) AS total_amount
FROM (
SELECT
region,
date,
count() AS local_count,
sum(amount) AS local_amount
FROM orders
WHERE date >= '2024-01-01' AND date <= '2024-03-31'
GROUP BY region, date -- 第一阶段:细粒度聚合
) AS daily_stats
GROUP BY region; -- 第二阶段:粗粒度聚合
9. 执行计划分析实战:识别慢查询的瓶颈
9.1 案例背景
某业务反馈以下查询执行缓慢(耗时约30秒):
SELECT
user_id,
count() AS event_count,
sum(duration) AS total_duration,
uniq(url) AS url_count
FROM web_logs
WHERE toDate(event_time) = '2024-01-15'
AND status_code = 200
GROUP BY user_id
HAVING event_count > 100
ORDER BY total_duration DESC
LIMIT 50;
9.2 第一步:使用EXPLAIN ESTIMATE评估扫描量
EXPLAIN ESTIMATE SELECT
user_id,
count() AS event_count,
sum(duration) AS total_duration,
uniq(url) AS url_count
FROM web_logs
WHERE toDate(event_time) = '2024-01-15'
AND status_code = 200
GROUP BY user_id
HAVING event_count > 100
ORDER BY total_duration DESC
LIMIT 50;
结果发现parts = 365,说明扫描了所有分区——分区裁剪失效了!
9.3 第二步:使用EXPLAIN SYNTAX检查重写
EXPLAIN SYNTAX SELECT
user_id,
count() AS event_count,
sum(duration) AS total_duration,
uniq(url) AS url_count
FROM web_logs
WHERE toDate(event_time) = '2024-01-15'
AND status_code = 200
GROUP BY user_id
HAVING event_count > 100
ORDER BY total_duration DESC
LIMIT 50;
发现toDate(event_time) = '2024-01-15'未被优化——因为分区键是toYYYYMM(event_time),而查询使用的是toDate(event_time),两者不匹配导致分区裁剪失效。
9.4 第三步:优化查询
优化1:修正分区条件
-- 原查询:分区裁剪失效
WHERE toDate(event_time) = '2024-01-15'
-- 优化后:利用分区键
WHERE event_time >= '2024-01-15 00:00:00'
AND event_time < '2024-01-16 00:00:00'
优化2:添加PREWHERE
-- status_code过滤性强且列体积小
PREWHERE status_code = 200
优化3:避免SELECT*
确保只查询需要的列(原查询已经指定列名,无需修改)。
优化后的完整查询
SELECT
user_id,
count() AS event_count,
sum(duration) AS total_duration,
uniq(url) AS url_count
FROM web_logs
PREWHERE status_code = 200
WHERE event_time >= '2024-01-15 00:00:00'
AND event_time < '2024-01-16 00:00:00'
GROUP BY user_id
HAVING event_count > 100
ORDER BY total_duration DESC
LIMIT 50;
9.5 优化效果对比
-- 优化前
EXPLAIN ESTIMATE ... -- parts: 365, rows: 1800000000, bytes: 540000000000
-- 耗时约30秒
-- 优化后
EXPLAIN ESTIMATE ... -- parts: 1, rows: 5000000, bytes: 1500000000
-- 耗时约0.8秒
| 指标 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 扫描分区数 | 365 | 1 | 365× |
| 扫描行数 | 18亿 | 500万 | 360× |
| 查询耗时 | 30秒 | 0.8秒 | 37.5× |
9.6 使用EXPLAIN PIPELINE确认并行度
EXPLAIN PIPELINE SELECT
user_id,
count() AS event_count
FROM web_logs
WHERE event_time >= '2024-01-15 00:00:00'
AND event_time < '2024-01-16 00:00:00'
GROUP BY user_id;
确认各阶段的并行线程数合理,没有单线程瓶颈。
10. 总结与最佳实践
本文系统介绍了ClickHouse的EXPLAIN命令体系和基础查询优化策略。EXPLAIN是查询优化的核心工具,掌握其用法是性能调优的基础。
最佳实践建议:
- 养成EXPLAIN习惯:在执行新查询前,先使用EXPLAIN ESTIMATE评估扫描量,确认分区裁剪有效
- 分区键设计:确保分区键与常见查询模式匹配,WHERE条件直接使用分区列而非函数包裹
- 主键设计:将高基数、高频查询列放在ORDER BY前面,控制主键列数量
- **避免SELECT ***:始终明确指定列名,这是列式存储的基本优化
- PREWHERE优化:对过滤性强的小体积列使用PREWHERE,减少不必要的列数据读取
- EXPLAIN SYNTAX检查:用EXPLAIN SYNTAX确认SQL是否被优化器正确重写,避免别名展开导致的分区裁剪失效
- GROUP BY优化:减少GROUP BY列数,使用-If组合器替代CASE WHEN,考虑多阶段聚合
- 持续监控:使用system.query_log记录慢查询,定期用EXPLAIN分析并优化
上一篇【第38篇】ClickHouse SELECT高级用法——表达式函数与DISTINCT/UNION ALL
下一篇ClickHouse聚合函数体系详解(上)——基础聚合与组合聚合
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)