SQL 复杂查询重构:EXPLAIN 执行计划看不懂时该怎么拆 SQL 复杂查询重构EXPLAIN 执行计划看不懂时该怎么拆一、承认吧大多数 EXPLAIN 确实难读大家好我是朱大喜。今天聊一个每个数据人都绕不开但都不太想面对的话题——看执行计划。说实话我第一次用EXPLAIN的时候满屏的 tree node 和 operator 看得我头皮发麻。什么HashAggregate、ShuffleExchange、BroadcastNestedLoopJoin光名字就透着一股劝退的气息。但后来我发现一个问题看不懂执行计划你其实不是在调优 SQL你是在猜。猜着改完快了也不知道为啥快慢了更不知道从哪下手。今天我不打算把执行计划的每个节点都给你讲一遍——那种文档 Spark 官方就有。我想分享的是一套拆招的思路当你面对一条复杂 SQL、EXPLAIN 输出上百行的时候怎么快速定位到真正的瓶颈。二、拆解执行计划的四步法我把读执行计划的套路总结成了一句话找最宽的、找最深的、找出现最多次的、找数据量最大的。第一步找到逻辑计划里最宽的那棵树。在 EXPLAIN 输出里缩进最深的地方通常是嵌套最多的子查询。子查询每多一层优化器的优化空间就少一块。我见过最夸张的 SQL一个 SELECT 里套了 7 层子查询每一层还带GROUP BY优化器看了都想罢工。第二步找物理执行计划里数据交换最多的节点。在分布式查询引擎里Exchange或者叫Shuffle是最贵的操作。数据要在网络上传要落盘排序每一步 Exchange 都意味着额外的时间和资源。执行计划里 Exchange 出现的次数基本上决定了这条 SQL 的上限有多慢。graph TD A[拿到执行计划] -- B{第一步找最宽} B --|嵌套超过3层| C[先拆子查询br/用CTE扁平化] B --|嵌套≤3层| D{第二步找Exchange} D --|Exchange≥2次| E[检查JOIN键br/数据倾斜] D --|Exchange≤1次| F{第三步找扫描量} F --|扫描量异常大| G[检查分区过滤br/是否下推] F --|扫描量合理| H{第四步找重复节点} H --|有重复SCAN| I[考虑物化中间结果] H --|无重复| J[瓶颈可能在数据量本身br/考虑预聚合]第三步检查 Scan 节点的分区过滤是否真的下推了。有时候你写了WHERE dt 2026-07-15但因为中间经过了一次子查询的列名变换过滤条件没能推到文件扫描层。结果就是全表扫完再过滤——你以为是精准打击实际上是大炮打蚊子。第四步找重复出现的 TableScan。如果同一张表在物理计划里被扫描了两次说明你的 SQL 里存在冗余的子查询。这种情况常见于WHERE EXISTS和WHERE IN (subquery)的组合写法——优化器不一定能聪明到把两次扫描合并。三、CTE公用表表达式是你的好朋友拿到执行计划发现嵌套太深怎么办拆用 CTE 把嵌套的子查询拎出来变成平铺的临时表。-- ❌ 反面教材4层嵌套优化器头皮发麻 SELECT user_id, SUM(amount) AS total_amount FROM ( SELECT user_id, amount FROM orders WHERE order_date ( SELECT MAX(order_date) - INTERVAL 30 DAY FROM orders ) AND status IN ( SELECT status_code FROM order_status WHERE is_valid 1 ) ) t WHERE user_id IN ( SELECT user_id FROM user_tags WHERE tag vip ) GROUP BY user_id; -- ✅ CTE重构后逻辑分层清晰优化器能逐个优化 WITH recent_date AS ( -- 第1层找出30天前的日期只算一次 SELECT MAX(order_date) - INTERVAL 30 DAY AS cutoff_date FROM orders ), valid_status AS ( -- 第2层找出有效状态一次扫描搞定 SELECT status_code FROM order_status WHERE is_valid 1 ), vip_users AS ( -- 第3层VIP用户列表独立的小表扫描 SELECT user_id FROM user_tags WHERE tag vip ), filtered_orders AS ( -- 第4层把三个条件平行组合不再嵌套 SELECT o.user_id, o.amount FROM orders o JOIN recent_date r ON o.order_date r.cutoff_date JOIN valid_status s ON o.status s.status_code JOIN vip_users v ON o.user_id v.user_id ) SELECT user_id, SUM(amount) AS total_amount FROM filtered_orders GROUP BY user_id;CTE 的好处不只是可读性。对于大多数现代查询引擎Spark SQL、Presto/Trino、ClickHouseCTE 会被当作内联视图处理优化器可以对每个 CTE 独立做谓词下推和列裁剪。更重要的是每个 CTE 的结果可以被多次引用而不需要重新计算——这在嵌套子查询里是做不到的。但也要注意 CTE 的一个坑如果 CTE 在引用时只用了极少数的列引擎一般能自动做列裁剪。但如果 CTE 被多次引用且每次的过滤条件不同可能触发多次物化。这时候要看执行计划里 CTE 被 Scan 了几次——多于一次的话考虑拆成两个 CTE。四、一个实际拆解案例背景我们团队有一条日报 SQL每天凌晨跑 25 分钟DBA 催了好几次让优化。SQL 大概长这样——统计各品类最近 7 天的 GMV、订单数、退款率还要跟去年同期做对比。EXPLAIN 出来 200 多行。我按四步法拆解找最宽SQL 里有一层子查询在算去年同期它内部又嵌套了两层。这部分先拎出来改成 CTE。找 Exchange三个 GROUP BY 操作各触发一次 Shuffle加上两个 JOIN 的 Shuffle一共 5 次数据重分布。太多了。找扫描量Scan 阶段没有分区过滤——WHERE dt xxx写在了子查询里没下推。找重复orders表被扫了两次今年一次、去年同期一次。优化动作也很直接把去年同期用 LAG 窗口函数一次扫出来而不是两次 JOIN把日期过滤提到最外层用 GROUPING SETS 把三个聚合合并成一次 Shuffle。最终耗时降到 4 分钟。-- 优化后的核心逻辑一次扫描 LAG窗口替代二次JOIN WITH daily_stats AS ( SELECT dt, category, SUM(gmv) AS daily_gmv, COUNT(DISTINCT order_id) AS order_cnt, SUM(refund_amount) AS refund_amount, -- LAG取去年同期一次扫描搞定不需要回表JOIN LAG(SUM(gmv), 365) OVER (PARTITION BY category ORDER BY dt) AS last_year_gmv FROM orders WHERE dt 2025-06-25 -- 过滤条件放在最外层确保分区裁剪生效 GROUP BY dt, category ) SELECT category, SUM(daily_gmv) AS week_gmv, SUM(order_cnt) AS week_orders, SUM(refund_amount) / NULLIF(SUM(daily_gmv), 0) AS refund_rate, SUM(last_year_gmv) AS last_year_week_gmv FROM daily_stats WHERE dt 2026-07-08 -- 取最近7天 GROUP BY category;五、总结EXPLAIN 不可怕可怕的是你把它当成天书直接放弃。记住四步法找最宽、找 Exchange、找扫描量、找重复——每次拿到执行计划就按这四个维度过一遍慢慢形成肌肉记忆。还有一个心态上的建议不要试图一眼看懂整个执行计划。先定位到最慢的那个 StageSpark UI 里通常能直接看到每个 Stage 的耗时然后只深挖那一个 Stage 的物理计划。把一个点吃透比泛泛看一屏要有用得多。最后送你一句话SQL 优化的终极奥义不是写出花哨的语法而是让引擎只扫描必要的数据、只做必要的计算。执行计划就是帮你验证这件事的眼睛。