首页
/ 解密Apache Doris执行计划:从性能谜题到优化实战指南

解密Apache Doris执行计划:从性能谜题到优化实战指南

2026-04-05 09:32:48作者:范垣楠Rhoda

在数据驱动决策的时代,每一秒的查询延迟都可能影响业务决策的及时性。作为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问

  1. 是否使用了分区过滤?
  2. 是否使用了分桶过滤?
  3. 过滤条件是否可以下推到存储层?
  4. 是否存在不必要的列扫描?
  5. 统计信息是否最新?
  6. 是否使用了合适的索引?
  7. 表的存储格式是否高效?
  8. 数据压缩率是否合理?
  9. 是否存在小文件问题?
  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算子诊断清单

  1. 是否启用了部分聚合?
  2. 聚合函数是否可以下推?
  3. GROUP BY的列是否合理?
  4. 是否存在COUNT(DISTINCT)的优化空间?
  5. 聚合结果集的大小是否合理?

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算子优化清单

  1. 是否选择了合适的JOIN算法?
  2. 是否使用小表作为驱动表?
  3. JOIN条件是否包含索引列?
  4. 是否可以通过过滤条件减少JOIN前的数据量?
  5. 是否存在笛卡尔积风险?

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算子检查清单

  1. 是否必须进行数据重分布?
  2. 选择的重分布策略是否合适?
  3. 数据传输量是否最小化?
  4. 是否存在数据倾斜问题?
  5. 是否可以通过调整分区/分桶减少数据传输?

四、进阶技巧:新一代优化器Nereids Planner

4.1 Nereids Planner vs Legacy Planner

Apache Doris提供了两种查询优化器:传统优化器(Legacy Planner)和新一代优化器(Nereids Planner)。Nereids Planner基于Cascades框架实现,具有更强大的优化能力和更准确的代价估算。

🔍 重点:Nereids Planner的优势

  1. 更准确的代价估算:基于统计信息的精确代价模型,能够选择更优的执行计划。
  2. 更丰富的算子选择:支持更多类型的连接算法和聚合方式。
  3. 更灵活的执行策略:能够根据数据分布动态调整执行计划。

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

  1. 数据读取优化

    • [ ] 使用分区过滤
    • [ ] 使用分桶过滤
    • [ ] 避免全表扫描
    • [ ] 选择合适的索引
  2. 数据处理优化

    • [ ] 启用部分聚合
    • [ ] 优化JOIN顺序
    • [ ] 选择合适的JOIN算法
    • [ ] 减少不必要的列和行
  3. 数据传输优化

    • [ ] 避免不必要的EXCHANGE
    • [ ] 减少数据倾斜
    • [ ] 优化数据重分布策略
  4. 优化器选择

    • [ ] 尝试使用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/

登录后查看全文
热门项目推荐
相关项目推荐