2. Join查询计划解读:看懂Doris在背后做了什么
很多同学拿到EXPLAIN输出就头疼——一堆缩进、大写单词、数字,完全不知道Doris在表达什么。我刚开始接触Doris时也一样,看着那几屏文本,心里只有一个想法:这玩意儿能看懂?
但说实话,看懂查询计划是性能调优的第一步。你连Doris打算怎么执行你的SQL都不知道,怎么优化?今天我就带你把这层窗户纸捅破。
核心观点:EXPLAIN输出不是给DBA看的装饰品,它是你调优的导航地图。读懂了它,你就知道瓶颈在哪、数据怎么流动、Join怎么执行的。
2.1 EXPLAIN输出结构:从整体到局部
我们先跑一个最简单的例子。假设有两张表:orders(订单表)和customers(客户表),我们要做关联查询。
EXPLAIN SELECT o.order_id, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date > '2024-01-01';
输出大概长这样(我简化了部分细节):
+----------------------------------------------------------------------+
| Explain String |
+----------------------------------------------------------------------+
| PLAN FRAGMENT 0 |
| OUTPUT EXPRS: `o`.`order_id` | `c`.`customer_name` |
| PARTITION: UNPARTITIONED |
| HAS_COLO_PLAN_NODE: true |
| STREAM DATA SINK |
| EXCHANGE ID: 01 |
| UNPARTITIONED |
| |
| 1:VEXCHANGE |
| offset: 0 |
| |
| PLAN FRAGMENT 1 |
| OUTPUT EXPRS: |
| PARTITION: HASH_PARTITIONED: `o`.`customer_id` |
| STREAM DATA SINK |
| EXCHANGE ID: 04 |
| HASH_PARTITIONED: `c`.`customer_id` |
| |
| 4:VHASH JOIN |
| | join op: INNER JOIN (BROADCAST) |
| | equal join conjunct: `o`.`customer_id` = `c`.`customer_id` |
| | runtime filters: RF000[in] -> `o`.`customer_id` |
| | |
| |--2:VEXCHANGE |
| | offset: 0 |
| | |
| |--3:VOLAP_SCAN_NODE(`customers`) |
| | TABLE: customers |
| | PREAGGREGATION: ON |
| | partitions=1/1 |
| | cardinality=100000 |
| | |
| 2:VOLAP_SCAN_NODE(`orders`) |
| TABLE: orders |
| PREAGGREGATION: ON |
| partitions=3/3 |
| cardinality=5000000 |
+----------------------------------------------------------------------+
嗯,信息量不小。我们拆开看。
2.2 Plan Node与Plan Fragment:Doris的执行单元
Plan Fragment是Doris分布式执行的基本单位。每个Fragment可以在不同BE上并行执行。你可以把它理解成「一个可以在多台机器上跑的子任务」。
Plan Node则是Fragment内部的执行步骤。比如扫描数据、做Join、聚合等。
上面那个例子有两个Fragment:
- Fragment 0:负责最终结果输出,只有一个EXCHANGE节点(接收数据)。
- Fragment 1:真正的干活单元,包含Scan和Hash Join。
我个人习惯先看Fragment数量。如果发现Fragment特别多(比如十几个),那就要警惕了——可能产生了过多的数据交换。
小技巧:Fragment之间的数据交换是通过EXCHANGE节点完成的。EXCHANGE越多,网络开销越大。理想情况下,大表Scan和小表Join应该尽量在同一个Fragment内完成。
2.3 Join Operator:三种Join方式
Doris支持三种Join算子。我按使用频率排个序:
| Join类型 | 适用场景 | 内存消耗 | 我见过的坑 |
|---|---|---|---|
| HASH JOIN | 大表Join小表,等值连接 | 较高(需要构建Hash表) | 小表不够小,Hash表撑爆内存 |
| MERGE JOIN | 两表都已排序,或非等值连接 | 较低 | 排序开销被忽略,反而更慢 |
| NESTED LOOP JOIN | 小表Join小表,或连接条件复杂 | 极低 | 数据量稍大就慢到怀疑人生 |
HASH JOIN是最常用的。它的逻辑很简单:把右表(通常是较小的表)加载到内存,建一个Hash表,然后遍历左表去匹配。我在项目中遇到过一个问题——右表明明只有几百万行,但每行数据特别宽(几百个字段),结果Hash表建出来好几个G,直接把BE搞OOM了。后来我强制做了列裁剪,只保留Join需要的字段,问题就解决了。
MERGE JOIN呢?它要求两表都按Join key排好序。如果数据本身有序(比如模型里用了Sorted Key),Merge Join就非常快。但如果你没排序硬要用,Doris会先排序再Merge,那开销就大了。
NESTED LOOP JOIN——说实话,我很少主动用它。它就是一个双重循环:对左表的每一行,去右表里找匹配。数据量一上去,复杂度就是O(n*m)。我曾经接手过一个慢查询,Doris自动选择了Nested Loop Join,两个表各100万行,跑了半小时没出结果。改成Hash Join后,3秒搞定。
避坑指南:如果你在EXPLAIN输出里看到NESTED LOOP JOIN,而且两个表的数据量都超过10万行,那大概率是执行计划选错了。检查一下Join条件,是不是缺少等值连接?或者统计信息不准?
2.4 数据分布与Shuffle策略
这是多表关联性能的命门。数据分布不对,再好的Join算子也白搭。
Doris有三种Shuffle策略:
- BROADCAST:把小表复制到所有节点。适合小表Join大表。
- SHUFFLE:两表都按Join key重新分区。适合两表都大。
- COLOCATE:两表数据已经按相同方式分布,无需移动。这是最理想的情况。
回到刚才的EXPLAIN输出,注意这一行:
| join op: INNER JOIN (BROADCAST) |
Doris选择了BROADCAST,把customers表(10万行)广播到所有节点。为什么?因为Doris判断customers足够小,广播比Shuffle更划算。
那什么时候用SHUFFLE?我举个例子:两个大表各1亿行,Join key是user_id。如果广播,1亿行数据复制,网络直接打满。这时候Doris会选SHUFFLE,两表都按user_id哈希分区,每个节点只处理自己那一份。
至于COLOCATE Join,这是Doris的杀手锏。如果两张表在建表时用了相同的DISTRIBUTED BY HASH和BUCKETS,并且数据分布一致,那Join时数据就在本地,零网络开销。我在做实时大宽表场景时,就靠Colocate Join把查询延迟从秒级降到了毫秒级。
关键判断:看EXPLAIN输出里的HASH_PARTITIONED或BROADCAST。如果发现大表被广播了,或者Shuffle的key跟Join key不一致,那就要手动干预了。
2.5 一张图看懂Join查询计划
下面这张SVG图,把整个流程串起来了。你可以对照着看,加深理解。
2.6 实战:如何快速定位问题
拿到一个慢查询的EXPLAIN输出,我一般按这个顺序排查:
- 看Join类型:是不是HASH JOIN?如果不是,为什么?
- 看Shuffle策略:大表有没有被广播?小表有没有被Shuffle?
- 看数据量:
cardinality字段显示的行数对不对?如果统计信息不准,执行计划可能选错。 - 看Runtime Filter:有没有生成?能不能下推到Scan阶段?
我曾经遇到一个案例:EXPLAIN显示Doris选择了BROADCAST,但被广播的表有500万行。结果每个BE都收到了完整的500万行数据,内存直接爆了。后来我查了一下,发现是统计信息没更新,Doris以为这张表只有5万行。跑一次ANALYZE TABLE后,执行计划就正常了。
我的建议:养成习惯,每次上线新查询前,先跑EXPLAIN看一眼。花30秒读一下执行计划,可能帮你省下几小时的排障时间。
好了,关于Join查询计划的解读就到这里。记住,EXPLAIN不是天书,它是Doris在跟你对话。你读懂了它,就知道该怎么调优了。