题外话
在写《StarRocks 查询探秘》系列文章,有同学疑惑是否有必要研究底层代码?在我看来是有必要的,第一,深入理解一门技术,理解其原理和底层代码,对各种特性深入理解,知道针对业务场景如何使用,才能发挥出该技术/框架的最大能力;第二,很多时候线上问题往往是使用姿势不对造成,某种意义上就是对其理解不深刻,即使是线上bug,也要求我们拥有强悍的 troubleshooting 能力,而这些就是通过阅读其源码锻炼出来的。
言归正传,如果单纯讲源码,确实有些无聊,甚至收获有限。本文将从 EXPLAIN 命令入手,深入剖析 StarRocks 如何生成和优化执行计划,并结合实际案例,通过执行计划优化业务 SQL,把一个线上十几分钟耗时的 SQL 优化到3分钟内。
EXPLAIN SQL:窥探执行计划的窗口
EXPLAIN 显示 SQL 的物理执行计划,支持的选项:EXPLAIN、EXPLAIN LOGICAL、EXPLAIN VERBOSE、EXPLAIN COSTS、EXPLAIN ANALYZE
EXPLAIN 的不同级别:
LOGICAL | 显示简要的执行计划。 |
|---|---|
VERBOSE | 显示详细的执行计划,包括数据类型,nullable 信息,优化策略等。 |
COSTS | 显示详细的执行计划,包括统计信息。 |
ANALYZE | 显示实际运行后的执行计划信息。 |
以下面这条 SQL 为例:
EXPLAIN SELECT a.id, uidFROM (SELECT id FROM hive_hms.db_xx.hive_table_xx ) aJOIN (SELECT regexp_extract(extend2, 'id:([0-9]+)', 1) AS id, click_uid AS uid FROM paimon_hms.db_xx.paimon_table_xx WHERE dt='20250807' AND hour='01' AND min='1' ) b ON a.id=b.idLIMIT 3;输出的执行计划以及计划分段如下:

通过这张图能更直观看出整个执行计划和计划分段,每个计划分段包含的 Operator

ExecPlan 是 SQL 的执行计划,包含多个 PlanFragment(计划片段),以及与查询执行相关的元数据和上下文信息。它是查询优化器和执行引擎之间的桥梁,负责组织查询计划的结构、跟踪节点和片段的分配、生成执行计划的解释信息等。
PlanFragment 是数据库查询执行引擎中用于表示查询计划的一个片段(Fragment)。查询计划通常被分解为多个片段,这些片段通过 ExchangeNode(交换节点)形成树状结构,共同完成查询的执行。每个片段负责执行一部分查询逻辑,并将结果输出到下一个片段或最终的查询结果。
那么 PlanFragment 的划分依据是啥呢?从源码看是以物理执行计划中的 PhysicalDistributionOperator 作为切分点,直观的理解就是需要数据进行 re-shuffle,需要节点跟节点之间交换数据,这时候可以认为可以切分成不同的 PlanFragment,单个 PlanFragmen 可以在一个节点内执行。
源码解析
在 StatementPlanner.java 中,经过 CBO 优化器后,逻辑计划树从 LogicalOperator 转换成 PhysicalOperator 以及相关重写后, 进入执行计划的构建。

PlanFragment
public class PlanFragment extends TreeNode<PlanFragment> { .... protected PlanNode planRoot; // exchange node to which this fragment sends its output private ExchangeNode destNode; // if null, outputs the entire row produced by planRoot protected ArrayList<Expr> outputExprs; // created in finalize() or set in setSink() protected DataSink sink; protected final DataPartition dataPartition; protected DataPartition outputPartition; .... protected int parallelExecNum = 1; protected int pipelineDop = 1; protected boolean dopEstimated = false; protected Map<Integer, RuntimeFilterDescription> buildRuntimeFilters = Maps.newHashMap(); protected Map<Integer, RuntimeFilterDescription> probeRuntimeFilters = Maps.newHashMap(); ....}通过 Visitor 模式递归遍历构建出 PlanFragment

下图中的红框圈中的 PhysicalDistributionOperator 就是作为 PlanFragment 的切分点

根据分布类型设置不同的 DataPartition,比如,ROUND_ROBIN:随机分发数据,dataPartition 设置为 RANDOM。将计算出的 dataPartition 设置到 exchangeNode 上,形成一个新的 PlanFragment。

线上 SQL 优化案例分析
线上一个复杂的 SQL,里面嵌套着大量的两两表 Join 关联查询,摘取其中的一个子句如下:
SELECT pid, uid FROM (SELECT pid, word FROM table_A WHERE dt='${date}' AND hour='${hour}' AND pid like '5%' ) a JOIN (SELECT uid, query FROM table_B WHERE dt='$date' AND hour='${hour}' ) b WHERE instr(b.query, a.word) > 0;一开始怀疑是不是两表进行 shuffle join 导致的性能差,通过 Explain Verbose 查看,发现是 instr(b.query, a.word) 这个非等值条件导致使用 NESTLOOP JOIN。NESTLOOP JOIN 会对于两个数据集(表 A 和表 B),对表 A 的每一行,遍历表 B 的所有行,检查 Join 条件是否满足。如果条件满足,则将匹配的行组合成结果。

进一步分析确认 CPU 开销所在

优先尝试看看能不能转换成等值Join,但是从业务角度是没有办法的;通过分析维度表,word 和 pid 是一对多的关系,于是先对维度表按 word 进行聚合操作:SELECT word, GROUP_CONCAT(pid) AS pid_list;
聚合后的数据量少了8倍左右,也就是说在 NESTLOOP JOIN 避免不了的情况下,匹配的数据量减少了8倍;然后再进行 JOIN 后再打平,改进后的 SQL 如下:
SELECT t.pid, b.uidFROM table_xx aJOIN search_expo bCROSS JOIN UNNEST(SPLIT(a.pid_list, ',')) AS t(pid)WHERE INSTR(b.query, a.word) > 0;优化后的结果,从原来的十几分钟耗时减少到 3 分钟内;

通过执行计划,我们可以分析:
在下一篇文章《StarRocks 查询探秘(七):执行计划分段 PlanFragment 的调度》中,深入分析 StarRocks 调度器 Coordinator 是如何进行 PlanFragment 的调度分发,并以线上SQL Scan 数据不均衡导致的耗时长,剖析 Selector 的计算逻辑,敬请期待!
更多大数据干货,欢迎关注我的微信公众号—BigData共享
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。