解密Apache Doris执行计划:从性能谜题到优化实战指南
在数据驱动决策的时代,每一秒的查询延迟都可能影响业务决策的及时性。作为Apache Doris的使用者,你是否曾遇到过这样的困境:一条看似简单的SQL查询却运行缓慢,服务器资源消耗异常,而你却找不到问题的根源?执行计划正是解开这些性能谜题的关键钥匙。本文将以"技术侦探"的视角,带你深入探索Apache Doris执行计划的奥秘,掌握从执行计划分析到性能优化的完整流程,让你轻松应对各类查询性能挑战。
一、问题引入:当电商数据分析遇到性能瓶颈
1.1 性能谜题:一条SQL的"慢动作"回放
某电商平台的数据分析师小李最近遇到了一个棘手的问题。在进行季度销售数据分析时,他执行了如下SQL查询:
SELECT
category,
COUNT(DISTINCT user_id) AS uv,
SUM(order_amount) AS total_sales
FROM
sales_fact
WHERE
order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY
category;
这条看似普通的聚合查询,在数据量增长到1亿条后,执行时间从原来的30秒飙升到了5分钟。小李检查了服务器资源,发现CPU利用率不到50%,内存也充足,这让他百思不得其解。
1.2 线索发现:执行计划的"犯罪现场"
🔍 重点:执行计划是SQL的"X光片"
执行计划就像SQL查询的"X光片",能够清晰地展示查询的内部执行流程。通过执行计划,我们可以看到数据是如何被读取、处理和聚合的,从而找到性能瓶颈的根源。在Apache Doris中,只需在SQL前添加EXPLAIN关键字,即可生成执行计划:
EXPLAIN
SELECT
category,
COUNT(DISTINCT user_id) AS uv,
SUM(order_amount) AS total_sales
FROM
sales_fact
WHERE
order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY
category;
1.3 初步诊断:执行计划的"健康检查"
小李执行了EXPLAIN命令后,得到了如下执行计划片段:
+-------------------------------------------------------------------------------------------------+
| ID | OPERATOR | NAME | EST. ROWS | EST. BYTES | PROPERTIES |
+-------------------------------------------------------------------------------------------------+
| 0 | EXCHANGE | UNPARTITION | 1000 | 40000 | TYPE: GATHER |
| 1 | AGGREGATE | FINAL | 1000 | 40000 | group by: category, count distinct: user_id, sum: order_amount |
| 2 | EXCHANGE | HASH | 100000 | 4000000 | HASH KEYS: category |
| 3 | AGGREGATE | PARTIAL | 100000 | 4000000 | group by: category, count distinct: user_id, sum: order_amount |
| 4 | SCAN | OLAP_TABLE | 10000000 | 400000000 | table: sales_fact, partitions: [], buckets: [] |
+-------------------------------------------------------------------------------------------------+
⚠️ 警告:全表扫描的危险信号
在SCAN算子的PROPERTIES中,partitions: []和buckets: []表示查询没有利用分区和分桶信息,进行了全表扫描。这正是导致查询缓慢的主要原因之一。
二、核心概念:执行计划的"解剖学"
2.1 执行计划的基本构成
执行计划由一系列执行算子(Operator)组成,这些算子按一定顺序排列,形成一个有向无环图(DAG)。数据从最底层的算子流入,经过逐层处理后,从最顶层的算子流出,形成最终结果。
💡 技巧:记住"自底向上"原则
阅读执行计划时,应从最底层的算子开始,逐步向上分析。最底层的算子通常是数据读取算子(如SCAN),最顶层的算子则是结果返回算子(如EXCHANGE GATHER)。
2.2 执行计划成本计算简化模型
执行计划的选择是基于成本的。虽然Apache Doris的优化器会进行复杂的成本计算,但我们可以使用一个简化模型来理解:
总成本 = I/O成本 + CPU成本 + 网络成本
- I/O成本:读取数据的成本,与数据量成正比
- CPU成本:处理数据的成本,与计算复杂度和数据量成正比
- 网络成本:数据在节点间传输的成本,与数据量和节点数成正比
例如,全表扫描会增加I/O成本,而没有分区过滤的查询会导致不必要的I/O开销。
2.3 3步看懂执行计划
步骤1:定位数据源头
找到最底层的SCAN算子,查看表名、分区和分桶信息,判断是否存在全表扫描。
步骤2:追踪数据流向
从SCAN算子开始,向上追踪数据经过的每个算子,注意观察数据量的变化(EST. ROWS)。如果数据量在某个算子处异常增加或减少,可能存在问题。
步骤3:分析算子类型
识别关键算子(如AGGREGATE、JOIN、EXCHANGE),检查其属性是否合理。例如,AGGREGATE算子是否使用了部分聚合,JOIN算子的连接方式是否合适。
三、实践指南:执行计划分析与优化
3.1 SCAN算子:数据读取的"第一道关卡"
SCAN算子负责从存储引擎读取数据,是执行计划的起点。常见的SCAN算子有OLAP_TABLE_SCAN、MYSQL_SCAN、HIVE_SCAN等。
🔍 重点:SCAN算子的关键属性
table:表名partitions:涉及的分区buckets:涉及的分桶predicates:过滤条件
反模式识别:全表扫描
低效执行计划:
PROPERTIES: {
"table": "sales_fact",
"partitions": [],
"buckets": [],
"predicates": ""
}
高效执行计划:
PROPERTIES: {
"table": "sales_fact",
"partitions": ["p202301", "p202302", "p202303"],
"buckets": ["1-10"],
"predicates": "order_date BETWEEN '2023-01-01' AND '2023-03-31'"
}
💡 技巧:利用分区和分桶过滤
对于分区表,确保查询条件中包含分区键过滤,如order_date BETWEEN '2023-01-01' AND '2023-03-31'。对于分桶表,可以通过分桶键过滤减少扫描的数据量。
SCAN算子健康度10问
- 是否使用了分区过滤?
- 是否使用了分桶过滤?
- 过滤条件是否可以下推到存储层?
- 是否存在不必要的列扫描?
- 统计信息是否最新?
- 是否使用了合适的索引?
- 表的存储格式是否高效?
- 数据压缩率是否合理?
- 是否存在小文件问题?
- 扫描的数据量是否与预期一致?
3.2 AGGREGATE算子:数据聚合的"加工厂"
AGGREGATE算子负责执行聚合操作,如COUNT、SUM、AVG等。常见的AGGREGATE算子有PARTIAL_AGGREGATE(部分聚合)和FINAL_AGGREGATE(最终聚合)。
🔍 重点:部分聚合的重要性
部分聚合可以在数据分片上进行初步聚合,减少后续的数据传输和计算量。例如,在分布式环境中,每个节点先对本地数据进行部分聚合,然后再将结果发送到 coordinator 节点进行最终聚合。
反模式识别:缺少部分聚合
低效执行计划:
+--------------------------------------------------+
| ID | OPERATOR | NAME | EST. ROWS | ... |
+--------------------------------------------------+
| 0 | AGGREGATE | FINAL | 1000 | ... |
| 1 | EXCHANGE | GATHER | 1000000 | ... |
| 2 | SCAN | OLAP_TABLE | 1000000 | ... |
+--------------------------------------------------+
高效执行计划:
+--------------------------------------------------+
| ID | OPERATOR | NAME | EST. ROWS | ... |
+--------------------------------------------------+
| 0 | AGGREGATE | FINAL | 1000 | ... |
| 1 | EXCHANGE | HASH | 10000 | ... |
| 2 | AGGREGATE | PARTIAL | 10000 | ... |
| 3 | SCAN | OLAP_TABLE | 1000000 | ... |
+--------------------------------------------------+
💡 技巧:强制启用部分聚合
如果优化器没有自动启用部分聚合,可以通过HINT强制启用:
SELECT /*+ PARTIAL_AGGREGATION() */
category,
COUNT(DISTINCT user_id) AS uv,
SUM(order_amount) AS total_sales
FROM
sales_fact
WHERE
order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY
category;
AGGREGATE算子诊断清单
- 是否启用了部分聚合?
- 聚合函数是否可以下推?
- GROUP BY的列是否合理?
- 是否存在COUNT(DISTINCT)的优化空间?
- 聚合结果集的大小是否合理?
3.3 JOIN算子:多表关联的"桥梁"
JOIN算子负责执行表连接操作,常见的JOIN算子有HASH_JOIN、MERGE_JOIN和NESTED_LOOP_JOIN。
🔍 重点:JOIN算法的选择
- HASH_JOIN:将小表构建成哈希表,然后扫描大表进行匹配。适用于大表和小表的连接。可以比喻为"图书馆按主题分类查找",先将图书按主题分类(构建哈希表),然后根据主题快速查找所需图书。
- MERGE_JOIN:要求两个表都按连接键排序,然后进行合并。适用于已排序的大表连接。
- NESTED_LOOP_JOIN:将小表作为外层循环,大表作为内层循环。适用于小表和大表的连接,且外层表结果集较小的情况。
反模式识别:大表作为驱动表
低效执行计划:
PROPERTIES: {
"join_type": "INNER JOIN",
"condition": "a.order_id = b.order_id",
"left_table": "order_detail", // 大表
"right_table": "user_info" // 小表
}
高效执行计划:
PROPERTIES: {
"join_type": "INNER JOIN",
"condition": "a.order_id = b.order_id",
"left_table": "user_info", // 小表
"right_table": "order_detail" // 大表
}
💡 技巧:使用小表作为驱动表
在HASH_JOIN中,应尽量使用小表作为驱动表(构建哈希表的表),以减少内存消耗和提高匹配效率。可以通过HINT指定JOIN顺序:
SELECT /*+ JOIN_ORDER(user_info, order_detail) */
u.user_name,
SUM(o.order_amount) AS total_sales
FROM
user_info u
JOIN
order_detail o ON u.user_id = o.user_id
GROUP BY
u.user_name;
JOIN算子优化清单
- 是否选择了合适的JOIN算法?
- 是否使用小表作为驱动表?
- JOIN条件是否包含索引列?
- 是否可以通过过滤条件减少JOIN前的数据量?
- 是否存在笛卡尔积风险?
3.4 EXCHANGE算子:数据传输的"高速公路"
EXCHANGE算子负责在不同节点间传输数据,实现数据重分布。常见的EXCHANGE算子有HASH_EXCHANGE、BROADCAST_EXCHANGE和GATHER_EXCHANGE。
🔍 重点:数据重分布策略
- HASH_EXCHANGE:根据哈希值将数据分发到不同节点,用于需要按特定键聚合或连接的场景。
- BROADCAST_EXCHANGE:将数据广播到所有节点,适用于小表与大表的连接。
- GATHER_EXCHANGE:将所有节点的数据收集到一个节点,用于最终结果的汇总。
反模式识别:不必要的数据重分布
低效执行计划:
+--------------------------------------------------+
| ID | OPERATOR | NAME | EST. ROWS | ... |
+--------------------------------------------------+
| 0 | EXCHANGE | GATHER | 1000 | ... |
| 1 | AGGREGATE | FINAL | 1000 | ... |
| 2 | EXCHANGE | HASH | 10000 | ... |
| 3 | AGGREGATE | PARTIAL | 10000 | ... |
| 4 | SCAN | OLAP_TABLE | 10000 | ... |
+--------------------------------------------------+
高效执行计划(单节点执行):
+--------------------------------------------------+
| ID | OPERATOR | NAME | EST. ROWS | ... |
+--------------------------------------------------+
| 0 | AGGREGATE | FINAL | 1000 | ... |
| 1 | SCAN | OLAP_TABLE | 10000 | ... |
+--------------------------------------------------+
💡 技巧:避免不必要的数据传输
如果查询可以在单个节点完成,应避免使用EXCHANGE算子进行数据重分布。可以通过合理设置分区和分桶,使数据在单个节点上完成处理。
EXCHANGE算子检查清单
- 是否必须进行数据重分布?
- 选择的重分布策略是否合适?
- 数据传输量是否最小化?
- 是否存在数据倾斜问题?
- 是否可以通过调整分区/分桶减少数据传输?
四、进阶技巧:新一代优化器Nereids Planner
4.1 Nereids Planner vs Legacy Planner
Apache Doris提供了两种查询优化器:传统优化器(Legacy Planner)和新一代优化器(Nereids Planner)。Nereids Planner基于Cascades框架实现,具有更强大的优化能力和更准确的代价估算。
🔍 重点:Nereids Planner的优势
- 更准确的代价估算:基于统计信息的精确代价模型,能够选择更优的执行计划。
- 更丰富的算子选择:支持更多类型的连接算法和聚合方式。
- 更灵活的执行策略:能够根据数据分布动态调整执行计划。
4.2 启用Nereids Planner
可以通过会话变量启用Nereids Planner:
-- 启用Nereids Planner
SET enable_nereids_planner = true;
-- 禁用Nereids Planner
SET enable_nereids_planner = false;
也可以在单个查询中通过HINT指定使用Nereids Planner:
EXPLAIN SELECT /*+ SET_VAR(enable_nereids_planner=true) */
category,
COUNT(DISTINCT user_id) AS uv,
SUM(order_amount) AS total_sales
FROM
sales_fact
WHERE
order_date BETWEEN '2023-01-01' AND '2023-03-31'
GROUP BY
category;
4.3 Nereids Planner执行计划优化案例
案例:复杂子查询优化
对于包含复杂子查询的SQL,Nereids Planner能够进行更有效的子查询重写和算子重排。例如,以下SQL:
SELECT
a.category,
a.total_sales,
b.avg_price
FROM
(SELECT category, SUM(order_amount) AS total_sales FROM sales_fact GROUP BY category) a
JOIN
(SELECT category, AVG(product_price) AS avg_price FROM product GROUP BY category) b
ON
a.category = b.category;
Nereids Planner可能会将其重写为更高效的执行计划,合并子查询或调整JOIN顺序,从而减少数据处理量。
💡 技巧:利用Nereids Planner的高级功能
Nereids Planner支持多种高级优化功能,如:
- 子查询重写
- 算子下推
- 动态规划选择最优连接顺序
- 自适应执行策略
通过充分利用这些功能,可以显著提升复杂查询的性能。
五、附录:执行计划速查表
5.1 常见算子速查表
| 算子类型 | 功能描述 | 优化要点 |
|---|---|---|
| SCAN | 数据读取 | 利用分区、分桶过滤,避免全表扫描 |
| AGGREGATE | 数据聚合 | 启用部分聚合,优化COUNT(DISTINCT) |
| JOIN | 表连接 | 选择合适的JOIN算法,使用小表作为驱动表 |
| EXCHANGE | 数据传输 | 减少不必要的数据重分布,避免数据倾斜 |
| PROJECT | 列投影 | 只选择需要的列,减少数据传输量 |
| FILTER | 数据过滤 | 尽早过滤数据,减少后续处理量 |
5.2 执行计划分析步骤流程图
图:执行计划分析步骤流程图
5.3 性能优化 checklist
-
数据读取优化
- [ ] 使用分区过滤
- [ ] 使用分桶过滤
- [ ] 避免全表扫描
- [ ] 选择合适的索引
-
数据处理优化
- [ ] 启用部分聚合
- [ ] 优化JOIN顺序
- [ ] 选择合适的JOIN算法
- [ ] 减少不必要的列和行
-
数据传输优化
- [ ] 避免不必要的EXCHANGE
- [ ] 减少数据倾斜
- [ ] 优化数据重分布策略
-
优化器选择
- [ ] 尝试使用Nereids Planner
- [ ] 利用HINT调整执行计划
通过以上步骤和技巧,你已经掌握了Apache Doris执行计划的分析和优化方法。记住,执行计划是SQL性能优化的基础,只有深入理解执行计划,才能真正做到有的放矢,解决各种性能问题。祝你在Apache Doris的使用之旅中,写出更高效的SQL,让数据查询如虎添翼!
官方文档:README.md 测试用例:pytest/qe/query_regression/sql/ 源码实现:fe/fe-core/src/main/java/org/apache/doris/planner/
GLM-5智谱 AI 正式发布 GLM-5,旨在应对复杂系统工程和长时域智能体任务。Jinja00
GLM-5.1GLM-5.1是智谱迄今最智能的旗舰模型,也是目前全球最强的开源模型。GLM-5.1大大提高了代码能力,在完成长程任务方面提升尤为显著。和此前分钟级交互的模型不同,它能够在一次任务中独立、持续工作超过8小时,期间自主规划、执行、自我进化,最终交付完整的工程级成果。Jinja00
LongCat-AudioDiT-1BLongCat-AudioDiT 是一款基于扩散模型的文本转语音(TTS)模型,代表了当前该领域的最高水平(SOTA),它直接在波形潜空间中进行操作。00- QQwen3.5-397B-A17BQwen3.5 实现了重大飞跃,整合了多模态学习、架构效率、强化学习规模以及全球可访问性等方面的突破性进展,旨在为开发者和企业赋予前所未有的能力与效率。Jinja00
HY-Embodied-0.5这是一套专为现实世界具身智能打造的基础模型。该系列模型采用创新的混合Transformer(Mixture-of-Transformers, MoT) 架构,通过潜在令牌实现模态特异性计算,显著提升了细粒度感知能力。Jinja00
FreeSql功能强大的对象关系映射(O/RM)组件,支持 .NET Core 2.1+、.NET Framework 4.0+、Xamarin 以及 AOT。C#00
