上一篇【第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/OEXPLAIN SYNTAX查看语法解析结果理解SQL实际解析形态基本语法格式EXPLAIN[PLAN|TREE|PIPELINE|ESTIMATE|SYNTAX][SETTINGS...]SELECT...;3. EXPLAIN PLAN传统执行计划输出3.1 基本语法EXPLAINPLANSELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregionORDERBYtotalDESCLIMIT10;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表FilterWHERE条件过滤有WHERE子句Expression表达式计算/列投影别名计算、类型转换Aggregating聚合计算GROUP BYAggregatingPartitions分区级聚合分布式聚合Sorting排序ORDER BYLimit结果截断LIMIT子句Join连接操作JOIN子句Distinct去重DISTINCTUnion合并UNION ALLSubquery子查询FROM中的子查询EXPLAIN PLAN的扩展选项-- 查看更详细的信息header、index、actionsEXPLAINPLANheader1,indexes1,actions1SELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregion;header 1显示每个节点输出的列信息indexes 1显示索引使用情况actions 1显示每个节点的详细操作4. EXPLAIN TREE树形执行计划4.1 更直观的树状结构EXPLAIN TREE以更详细的树形结构展示执行计划每个节点包含更多上下文信息EXPLAINTREESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15ANDamount100GROUPBYregionORDERBYtotalDESCLIMIT10;输出示例┌─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节点这是最关键的节点之一显示数据读取的详细信息EXPLAINTREESELECTcount()FROMhitsWHEREdate2024-01-15;输出中的关键信息包括Parts需要扫描的数据分区数Granules需要读取的Mark数Granule是ClickHouse的最小读取单位Partitions涉及的分区信息Aggregating节点聚合节点显示聚合的方式单阶段聚合或两阶段聚合EXPLAINTREESELECTregion,count()FROMordersGROUPBYregion;在分布式查询中聚合通常分为两阶段先在各分片本地聚合再在协调节点合并聚合结果。EXPLAIN TREE可以清晰展示这一过程。Join节点Join节点显示连接类型和连接条件EXPLAINTREESELECTo.*,u.nameFROMorders oJOINusers uONo.user_idu.user_id;输出会显示Join的类型HASH JOIN、MERGE JOIN等、连接键和右表构建方式。5. EXPLAIN PIPELINE查询管道5.1 QueryPipeline的可视化EXPLAIN PIPELINE是ClickHouse最具特色的执行计划查看方式。ClickHouse的查询执行引擎基于Pipeline模型数据在多个Processor之间以流式方式传递。EXPLAIN PIPELINE将这一过程可视化EXPLAINPIPELINESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregion;输出示例┌─explain──────────────────────────────────────────────────┐ │ (MergeTreeThread) │ │ (ExpressionTransform) │ │ (FilterTransform) │ │ (MergingAggregated) │ │ (AggregatingTransform) │ │ (ExpressionTransform) │ │ (MergeTreeThread) │ │ (ExpressionTransform) │ │ (FilterTransform) │ │ (SortingTransform) │ │ (MergingAggregated) │ │ (AggregatingTransform) │ │ (LimitTransform) │ └──────────────────────────────────────────────────────────┘5.2 理解数据流向Pipeline展示了数据的实际流动路径。每个Processor是一个数据处理单元数据从数据源如MergeTreeThread经过一系列Transform流向最终输出。关键概念Processor数据处理单元包括Source数据源、Transform数据变换和Sink数据输出PortProcessor之间的连接点数据通过Port在Processor之间传递Thread执行线程Pipeline中的Processor被分配到不同线程并行执行-- 查看Pipeline的详细图EXPLAINPIPELINE graph1SELECTcount()FROMhits;当graph 1时ClickHouse会输出Pipeline的DOT格式图描述可以用于生成可视化图形。5.3 并行度分析EXPLAIN PIPELINE可以直观地展示查询的并行度EXPLAINPIPELINESELECTcount()FROMlarge_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提供查询各阶段的行数和字节数估算EXPLAINESTIMATESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15ANDstatuscompletedGROUPBYregion;输出示例┌─database─┬─table──┬─parts─┬─rows─────┬─bytes───────┐ │ default │ orders │ 4 │ 1500000 │ 120000000 │ └──────────┴────────┴───────┴──────────┴─────────────┘各列含义列名含义database数据库名table表名parts涉及的数据分区数rows预估读取的行数bytes预估读取的字节数6.2 估算信息的用途EXPLAIN ESTIMATE的估算信息对优化决策具有重要参考价值判断分区裁剪效果-- 有分区裁剪EXPLAINESTIMATESELECT*FROMordersWHEREdate2024-01-15;-- parts: 1, rows: 500000-- 无分区裁剪EXPLAINESTIMATESELECT*FROMordersWHEREamount1000;-- parts: 90, rows: 45000000通过对比parts和rows的数量可以直观判断WHERE条件是否有效触发了分区裁剪。预估查询资源消耗rows和bytes的估算值可以帮助预估查询的资源消耗避免在生产环境执行资源消耗过大的查询。7. EXPLAIN SYNTAX语法检查7.1 查看SQL的实际解析结果EXPLAIN SYNTAX展示ClickHouse对SQL的实际解析和重写结果EXPLAINSYNTAXSELECTuser_idASuid,count()AScntFROMeventsWHEREuid1000GROUPBYuidORDERBYcntDESC;输出示例SELECTuser_idASuid,count()AScntFROMeventsWHEREuser_id1000-- 注意uid被展开为原始列名user_idGROUPBYuser_id-- 同样展开为原始列名ORDERBYcntDESC;7.2 别名展开与类型推导EXPLAIN SYNTAX能帮助我们看到ClickHouse内部对SQL做了哪些优化和重写-- 原始SQLEXPLAINSYNTAXSELECTtoDate(event_time)ASd,count()AScntFROMeventsWHEREd2024-01-15GROUPBYd;-- 实际解析结果别名被展开SELECTtoDate(event_time)ASd,count()AScntFROMeventsWHEREtoDate(event_time)2024-01-15-- d被展开GROUPBYtoDate(event_time);-- d被展开注意当WHERE条件中的别名被展开后分区裁剪可能失效因为ClickHouse的分区裁剪依赖于对分区列的直接比较如果分区列被函数包裹则无法利用分区索引。-- 好的写法直接使用分区列WHEREdate2024-01-15-- 不好的写法对分区列使用函数WHEREtoDate(event_time)2024-01-157.3 优化器重写ClickHouse的优化器会自动重写某些SQL模式-- 原始SQLEXPLAINSYNTAXSELECT*FROM(SELECTuser_id,count()AScntFROMeventsGROUPBYuser_id)WHEREcnt10;-- 可能的重写结果谓词下推SELECTuser_id,count()AScntFROMeventsGROUPBYuser_idHAVINGcount()10;8. 基础查询优化策略8.1 分区裁剪优化分区裁剪是ClickHouse最重要的优化手段之一。当查询的WHERE条件包含分区键时ClickHouse可以跳过不相关的分区大幅减少I/O。原理MergeTree引擎按PARTITION BY定义的分区键组织数据每个分区是独立的目录。查询时ClickHouse根据WHERE条件判断哪些分区需要扫描跳过不满足条件的分区。-- 按日期分区CREATETABLEorders(dateDate,order_id UInt64,amountDecimal(18,2),region String)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(date,order_id);-- 有效分区裁剪SELECT*FROMordersWHEREdate2024-01-15;-- 只扫描202401分区-- 有效分区裁剪范围查询SELECT*FROMordersWHEREdateBETWEEN2024-01-01AND2024-01-31;-- 只扫描202401分区-- 无效分区裁剪SELECT*FROMordersWHEREamount1000;-- 扫描所有分区分区裁剪验证-- 使用EXPLAIN ESTIMATE验证分区裁剪效果EXPLAINESTIMATESELECT*FROMordersWHEREdate2024-01-15;-- parts: 1只有一个分区被扫描EXPLAINESTIMATESELECT*FROMordersWHEREamount1000;-- parts: 24所有分区都被扫描8.2 索引选择优化ClickHouse的MergeTree引擎使用主键索引PRIMARY KEY / ORDER BY来加速数据查找。主键索引原理主键索引基于排序键构建数据按主键排序存储。每个Mark索引标记对应一个Granule8192行的数据范围。-- 合理的主键设计CREATETABLEuser_events(dateDate,user_id UInt64,event_type String,event_timeDateTime)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(user_id,date);-- 以user_id为首列支持按用户查询-- 不合理的主键设计CREATETABLEuser_events_bad(dateDate,user_id UInt64,event_type String,event_timeDateTime)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(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按regionuser_id查找-- 最佳ORDER BY: (region, user_id, date)8.3 列裁剪优化列式存储的核心优势在于只读取需要的列。避免SELECT *是最基本的优化手段。对比效果-- 100列的宽表-- 查询1SELECT * — 读取所有100列-- 查询2SELECT col1, col2, col3 — 只读取3列-- I/O差距约33倍-- 验证列裁剪效果EXPLAINESTIMATESELECT*FROMwide_tableWHEREdatetoday();-- bytes: 大EXPLAINESTIMATESELECTcol1,col2FROMwide_tableWHEREdatetoday();-- bytes: 小8.4 PREWHERE优化PREWHERE是ClickHouse特有的优化机制专门用于MergeTree引擎。它允许在读取完整列数据之前先读取过滤列的稀疏索引来跳过不满足条件的数据块。PREWHERE vs WHERE-- WHERE先读取所有需要的列再过滤SELECTuser_id,event_type,amountFROMeventsWHEREdate2024-01-15ANDamount1000;-- PREWHERE先读取amount列判断是否满足条件再决定是否读取其他列SELECTuser_id,event_type,amountFROMevents PREWHERE amount1000WHEREdate2024-01-15;使用场景PREWHERE适合过滤性强、列体积小的条件条件特征适合PREWHERE适合WHERE过滤性强过滤掉90%数据是否列体积小是否过滤性弱只过滤少量数据否是列体积大否是-- 自动PREWHERE优化-- ClickHouse在大多数情况下会自动将WHERE条件提升为PREWHERE-- 但对于复杂查询手动指定PREWHERE可能更优-- 手动PREWHERE示例SELECTuser_id,event_time,url,durationFROMweb_logs PREWHERE status_code200-- 先通过稀疏索引过滤status_code体积小WHEREduration5000-- 再在读取完整数据后过滤ANDdate2024-01-15;重要提示ClickHouse从较新版本开始已经能自动将WHERE条件优化为PREWHERE手动指定PREWHERE的需求已大幅减少。但在某些复杂查询中手动控制PREWHERE仍然有价值。8.5 GROUP BY优化GROUP BY是分析查询中最常用的操作之一其优化对整体查询性能影响显著。优化策略1减少GROUP BY的列数-- 不必要的列参与GROUP BYSELECTregion,city,count()FROMordersGROUPBYregion,city;-- 如果只需要按region统计SELECTregion,count()FROMordersGROUPBYregion;-- 减少GROUP BY列数降低哈希表复杂度优化策略2利用ORDER BY加速GROUP BY当GROUP BY的列与表的ORDER BY排序键一致时ClickHouse可以利用数据的有序性使用更高效的聚合算法-- 表的ORDER BY是(user_id, date)-- 当GROUP BY user_id时可以部分利用有序性SELECTuser_id,count()FROMeventsGROUPBYuser_id;优化策略3使用聚合组合器减少中间结果-- 使用-If组合器替代CASE WHEN-- 不推荐SELECTcount(CASEWHENstatussuccessTHEN1END)ASsuccess_count,count(CASEWHENstatuserrorTHEN1END)ASerror_countFROMeventsGROUPBYregion;-- 推荐SELECTcountIf(statussuccess)ASsuccess_count,countIf(statuserror)ASerror_countFROMeventsGROUPBYregion;优化策略4两阶段聚合ClickHouse在分布式查询中自动执行两阶段聚合先在各分片本地聚合再在协调节点合并。但单机查询也可以利用类似思路-- 大表聚合利用子查询实现多阶段聚合SELECTregion,sum(local_count)AStotal_count,sum(local_amount)AStotal_amountFROM(SELECTregion,date,count()ASlocal_count,sum(amount)ASlocal_amountFROMordersWHEREdate2024-01-01ANDdate2024-03-31GROUPBYregion,date-- 第一阶段细粒度聚合)ASdaily_statsGROUPBYregion;-- 第二阶段粗粒度聚合9. 执行计划分析实战识别慢查询的瓶颈9.1 案例背景某业务反馈以下查询执行缓慢耗时约30秒SELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;9.2 第一步使用EXPLAIN ESTIMATE评估扫描量EXPLAINESTIMATESELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;结果发现parts 365说明扫描了所有分区——分区裁剪失效了9.3 第二步使用EXPLAIN SYNTAX检查重写EXPLAINSYNTAXSELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;发现toDate(event_time) 2024-01-15未被优化——因为分区键是toYYYYMM(event_time)而查询使用的是toDate(event_time)两者不匹配导致分区裁剪失效。9.4 第三步优化查询优化1修正分区条件-- 原查询分区裁剪失效WHEREtoDate(event_time)2024-01-15-- 优化后利用分区键WHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00优化2添加PREWHERE-- status_code过滤性强且列体积小PREWHERE status_code200优化3避免SELECT*确保只查询需要的列原查询已经指定列名无需修改。优化后的完整查询SELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logs PREWHERE status_code200WHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;9.5 优化效果对比-- 优化前EXPLAINESTIMATE...-- parts: 365, rows: 1800000000, bytes: 540000000000-- 耗时约30秒-- 优化后EXPLAINESTIMATE...-- parts: 1, rows: 5000000, bytes: 1500000000-- 耗时约0.8秒指标优化前优化后提升倍数扫描分区数3651365×扫描行数18亿500万360×查询耗时30秒0.8秒37.5×9.6 使用EXPLAIN PIPELINE确认并行度EXPLAINPIPELINESELECTuser_id,count()ASevent_countFROMweb_logsWHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00GROUPBYuser_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聚合函数体系详解上——基础聚合与组合聚合
【Clickhouse从入门到精通】第39篇:ClickHouse SQL执行计划查看与查询优化基础
发布时间:2026/5/19 22:29:48
上一篇【第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/OEXPLAIN SYNTAX查看语法解析结果理解SQL实际解析形态基本语法格式EXPLAIN[PLAN|TREE|PIPELINE|ESTIMATE|SYNTAX][SETTINGS...]SELECT...;3. EXPLAIN PLAN传统执行计划输出3.1 基本语法EXPLAINPLANSELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregionORDERBYtotalDESCLIMIT10;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表FilterWHERE条件过滤有WHERE子句Expression表达式计算/列投影别名计算、类型转换Aggregating聚合计算GROUP BYAggregatingPartitions分区级聚合分布式聚合Sorting排序ORDER BYLimit结果截断LIMIT子句Join连接操作JOIN子句Distinct去重DISTINCTUnion合并UNION ALLSubquery子查询FROM中的子查询EXPLAIN PLAN的扩展选项-- 查看更详细的信息header、index、actionsEXPLAINPLANheader1,indexes1,actions1SELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregion;header 1显示每个节点输出的列信息indexes 1显示索引使用情况actions 1显示每个节点的详细操作4. EXPLAIN TREE树形执行计划4.1 更直观的树状结构EXPLAIN TREE以更详细的树形结构展示执行计划每个节点包含更多上下文信息EXPLAINTREESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15ANDamount100GROUPBYregionORDERBYtotalDESCLIMIT10;输出示例┌─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节点这是最关键的节点之一显示数据读取的详细信息EXPLAINTREESELECTcount()FROMhitsWHEREdate2024-01-15;输出中的关键信息包括Parts需要扫描的数据分区数Granules需要读取的Mark数Granule是ClickHouse的最小读取单位Partitions涉及的分区信息Aggregating节点聚合节点显示聚合的方式单阶段聚合或两阶段聚合EXPLAINTREESELECTregion,count()FROMordersGROUPBYregion;在分布式查询中聚合通常分为两阶段先在各分片本地聚合再在协调节点合并聚合结果。EXPLAIN TREE可以清晰展示这一过程。Join节点Join节点显示连接类型和连接条件EXPLAINTREESELECTo.*,u.nameFROMorders oJOINusers uONo.user_idu.user_id;输出会显示Join的类型HASH JOIN、MERGE JOIN等、连接键和右表构建方式。5. EXPLAIN PIPELINE查询管道5.1 QueryPipeline的可视化EXPLAIN PIPELINE是ClickHouse最具特色的执行计划查看方式。ClickHouse的查询执行引擎基于Pipeline模型数据在多个Processor之间以流式方式传递。EXPLAIN PIPELINE将这一过程可视化EXPLAINPIPELINESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15GROUPBYregion;输出示例┌─explain──────────────────────────────────────────────────┐ │ (MergeTreeThread) │ │ (ExpressionTransform) │ │ (FilterTransform) │ │ (MergingAggregated) │ │ (AggregatingTransform) │ │ (ExpressionTransform) │ │ (MergeTreeThread) │ │ (ExpressionTransform) │ │ (FilterTransform) │ │ (SortingTransform) │ │ (MergingAggregated) │ │ (AggregatingTransform) │ │ (LimitTransform) │ └──────────────────────────────────────────────────────────┘5.2 理解数据流向Pipeline展示了数据的实际流动路径。每个Processor是一个数据处理单元数据从数据源如MergeTreeThread经过一系列Transform流向最终输出。关键概念Processor数据处理单元包括Source数据源、Transform数据变换和Sink数据输出PortProcessor之间的连接点数据通过Port在Processor之间传递Thread执行线程Pipeline中的Processor被分配到不同线程并行执行-- 查看Pipeline的详细图EXPLAINPIPELINE graph1SELECTcount()FROMhits;当graph 1时ClickHouse会输出Pipeline的DOT格式图描述可以用于生成可视化图形。5.3 并行度分析EXPLAIN PIPELINE可以直观地展示查询的并行度EXPLAINPIPELINESELECTcount()FROMlarge_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提供查询各阶段的行数和字节数估算EXPLAINESTIMATESELECTregion,sum(amount)AStotalFROMordersWHEREdate2024-01-15ANDstatuscompletedGROUPBYregion;输出示例┌─database─┬─table──┬─parts─┬─rows─────┬─bytes───────┐ │ default │ orders │ 4 │ 1500000 │ 120000000 │ └──────────┴────────┴───────┴──────────┴─────────────┘各列含义列名含义database数据库名table表名parts涉及的数据分区数rows预估读取的行数bytes预估读取的字节数6.2 估算信息的用途EXPLAIN ESTIMATE的估算信息对优化决策具有重要参考价值判断分区裁剪效果-- 有分区裁剪EXPLAINESTIMATESELECT*FROMordersWHEREdate2024-01-15;-- parts: 1, rows: 500000-- 无分区裁剪EXPLAINESTIMATESELECT*FROMordersWHEREamount1000;-- parts: 90, rows: 45000000通过对比parts和rows的数量可以直观判断WHERE条件是否有效触发了分区裁剪。预估查询资源消耗rows和bytes的估算值可以帮助预估查询的资源消耗避免在生产环境执行资源消耗过大的查询。7. EXPLAIN SYNTAX语法检查7.1 查看SQL的实际解析结果EXPLAIN SYNTAX展示ClickHouse对SQL的实际解析和重写结果EXPLAINSYNTAXSELECTuser_idASuid,count()AScntFROMeventsWHEREuid1000GROUPBYuidORDERBYcntDESC;输出示例SELECTuser_idASuid,count()AScntFROMeventsWHEREuser_id1000-- 注意uid被展开为原始列名user_idGROUPBYuser_id-- 同样展开为原始列名ORDERBYcntDESC;7.2 别名展开与类型推导EXPLAIN SYNTAX能帮助我们看到ClickHouse内部对SQL做了哪些优化和重写-- 原始SQLEXPLAINSYNTAXSELECTtoDate(event_time)ASd,count()AScntFROMeventsWHEREd2024-01-15GROUPBYd;-- 实际解析结果别名被展开SELECTtoDate(event_time)ASd,count()AScntFROMeventsWHEREtoDate(event_time)2024-01-15-- d被展开GROUPBYtoDate(event_time);-- d被展开注意当WHERE条件中的别名被展开后分区裁剪可能失效因为ClickHouse的分区裁剪依赖于对分区列的直接比较如果分区列被函数包裹则无法利用分区索引。-- 好的写法直接使用分区列WHEREdate2024-01-15-- 不好的写法对分区列使用函数WHEREtoDate(event_time)2024-01-157.3 优化器重写ClickHouse的优化器会自动重写某些SQL模式-- 原始SQLEXPLAINSYNTAXSELECT*FROM(SELECTuser_id,count()AScntFROMeventsGROUPBYuser_id)WHEREcnt10;-- 可能的重写结果谓词下推SELECTuser_id,count()AScntFROMeventsGROUPBYuser_idHAVINGcount()10;8. 基础查询优化策略8.1 分区裁剪优化分区裁剪是ClickHouse最重要的优化手段之一。当查询的WHERE条件包含分区键时ClickHouse可以跳过不相关的分区大幅减少I/O。原理MergeTree引擎按PARTITION BY定义的分区键组织数据每个分区是独立的目录。查询时ClickHouse根据WHERE条件判断哪些分区需要扫描跳过不满足条件的分区。-- 按日期分区CREATETABLEorders(dateDate,order_id UInt64,amountDecimal(18,2),region String)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(date,order_id);-- 有效分区裁剪SELECT*FROMordersWHEREdate2024-01-15;-- 只扫描202401分区-- 有效分区裁剪范围查询SELECT*FROMordersWHEREdateBETWEEN2024-01-01AND2024-01-31;-- 只扫描202401分区-- 无效分区裁剪SELECT*FROMordersWHEREamount1000;-- 扫描所有分区分区裁剪验证-- 使用EXPLAIN ESTIMATE验证分区裁剪效果EXPLAINESTIMATESELECT*FROMordersWHEREdate2024-01-15;-- parts: 1只有一个分区被扫描EXPLAINESTIMATESELECT*FROMordersWHEREamount1000;-- parts: 24所有分区都被扫描8.2 索引选择优化ClickHouse的MergeTree引擎使用主键索引PRIMARY KEY / ORDER BY来加速数据查找。主键索引原理主键索引基于排序键构建数据按主键排序存储。每个Mark索引标记对应一个Granule8192行的数据范围。-- 合理的主键设计CREATETABLEuser_events(dateDate,user_id UInt64,event_type String,event_timeDateTime)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(user_id,date);-- 以user_id为首列支持按用户查询-- 不合理的主键设计CREATETABLEuser_events_bad(dateDate,user_id UInt64,event_type String,event_timeDateTime)ENGINEMergeTree()PARTITIONBYtoYYYYMM(date)ORDERBY(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按regionuser_id查找-- 最佳ORDER BY: (region, user_id, date)8.3 列裁剪优化列式存储的核心优势在于只读取需要的列。避免SELECT *是最基本的优化手段。对比效果-- 100列的宽表-- 查询1SELECT * — 读取所有100列-- 查询2SELECT col1, col2, col3 — 只读取3列-- I/O差距约33倍-- 验证列裁剪效果EXPLAINESTIMATESELECT*FROMwide_tableWHEREdatetoday();-- bytes: 大EXPLAINESTIMATESELECTcol1,col2FROMwide_tableWHEREdatetoday();-- bytes: 小8.4 PREWHERE优化PREWHERE是ClickHouse特有的优化机制专门用于MergeTree引擎。它允许在读取完整列数据之前先读取过滤列的稀疏索引来跳过不满足条件的数据块。PREWHERE vs WHERE-- WHERE先读取所有需要的列再过滤SELECTuser_id,event_type,amountFROMeventsWHEREdate2024-01-15ANDamount1000;-- PREWHERE先读取amount列判断是否满足条件再决定是否读取其他列SELECTuser_id,event_type,amountFROMevents PREWHERE amount1000WHEREdate2024-01-15;使用场景PREWHERE适合过滤性强、列体积小的条件条件特征适合PREWHERE适合WHERE过滤性强过滤掉90%数据是否列体积小是否过滤性弱只过滤少量数据否是列体积大否是-- 自动PREWHERE优化-- ClickHouse在大多数情况下会自动将WHERE条件提升为PREWHERE-- 但对于复杂查询手动指定PREWHERE可能更优-- 手动PREWHERE示例SELECTuser_id,event_time,url,durationFROMweb_logs PREWHERE status_code200-- 先通过稀疏索引过滤status_code体积小WHEREduration5000-- 再在读取完整数据后过滤ANDdate2024-01-15;重要提示ClickHouse从较新版本开始已经能自动将WHERE条件优化为PREWHERE手动指定PREWHERE的需求已大幅减少。但在某些复杂查询中手动控制PREWHERE仍然有价值。8.5 GROUP BY优化GROUP BY是分析查询中最常用的操作之一其优化对整体查询性能影响显著。优化策略1减少GROUP BY的列数-- 不必要的列参与GROUP BYSELECTregion,city,count()FROMordersGROUPBYregion,city;-- 如果只需要按region统计SELECTregion,count()FROMordersGROUPBYregion;-- 减少GROUP BY列数降低哈希表复杂度优化策略2利用ORDER BY加速GROUP BY当GROUP BY的列与表的ORDER BY排序键一致时ClickHouse可以利用数据的有序性使用更高效的聚合算法-- 表的ORDER BY是(user_id, date)-- 当GROUP BY user_id时可以部分利用有序性SELECTuser_id,count()FROMeventsGROUPBYuser_id;优化策略3使用聚合组合器减少中间结果-- 使用-If组合器替代CASE WHEN-- 不推荐SELECTcount(CASEWHENstatussuccessTHEN1END)ASsuccess_count,count(CASEWHENstatuserrorTHEN1END)ASerror_countFROMeventsGROUPBYregion;-- 推荐SELECTcountIf(statussuccess)ASsuccess_count,countIf(statuserror)ASerror_countFROMeventsGROUPBYregion;优化策略4两阶段聚合ClickHouse在分布式查询中自动执行两阶段聚合先在各分片本地聚合再在协调节点合并。但单机查询也可以利用类似思路-- 大表聚合利用子查询实现多阶段聚合SELECTregion,sum(local_count)AStotal_count,sum(local_amount)AStotal_amountFROM(SELECTregion,date,count()ASlocal_count,sum(amount)ASlocal_amountFROMordersWHEREdate2024-01-01ANDdate2024-03-31GROUPBYregion,date-- 第一阶段细粒度聚合)ASdaily_statsGROUPBYregion;-- 第二阶段粗粒度聚合9. 执行计划分析实战识别慢查询的瓶颈9.1 案例背景某业务反馈以下查询执行缓慢耗时约30秒SELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;9.2 第一步使用EXPLAIN ESTIMATE评估扫描量EXPLAINESTIMATESELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;结果发现parts 365说明扫描了所有分区——分区裁剪失效了9.3 第二步使用EXPLAIN SYNTAX检查重写EXPLAINSYNTAXSELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logsWHEREtoDate(event_time)2024-01-15ANDstatus_code200GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;发现toDate(event_time) 2024-01-15未被优化——因为分区键是toYYYYMM(event_time)而查询使用的是toDate(event_time)两者不匹配导致分区裁剪失效。9.4 第三步优化查询优化1修正分区条件-- 原查询分区裁剪失效WHEREtoDate(event_time)2024-01-15-- 优化后利用分区键WHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00优化2添加PREWHERE-- status_code过滤性强且列体积小PREWHERE status_code200优化3避免SELECT*确保只查询需要的列原查询已经指定列名无需修改。优化后的完整查询SELECTuser_id,count()ASevent_count,sum(duration)AStotal_duration,uniq(url)ASurl_countFROMweb_logs PREWHERE status_code200WHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00GROUPBYuser_idHAVINGevent_count100ORDERBYtotal_durationDESCLIMIT50;9.5 优化效果对比-- 优化前EXPLAINESTIMATE...-- parts: 365, rows: 1800000000, bytes: 540000000000-- 耗时约30秒-- 优化后EXPLAINESTIMATE...-- parts: 1, rows: 5000000, bytes: 1500000000-- 耗时约0.8秒指标优化前优化后提升倍数扫描分区数3651365×扫描行数18亿500万360×查询耗时30秒0.8秒37.5×9.6 使用EXPLAIN PIPELINE确认并行度EXPLAINPIPELINESELECTuser_id,count()ASevent_countFROMweb_logsWHEREevent_time2024-01-15 00:00:00ANDevent_time2024-01-16 00:00:00GROUPBYuser_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聚合函数体系详解上——基础聚合与组合聚合