
一年多前我接了一个线上复杂查询的P0工单。SQL长得不算吓人一张小的维表、一个聚合子查询、一条关联条件可它偏偏在凌晨跑批时从平时的两分钟涨到二十多分钟整个批处理窗口差点崩掉。当时我把执行计划拉出来一看问题立刻清楚了聚合子查询把所有区域的明细行全部算了一遍外层连接条件等子查询结果出来之后才做过滤连接算子的输入量级和输出量级完全不成比例。说白了过滤发生得太晚了。这个场景背后的核心机制就是标题里那三个词复杂查询、连接条件下推Join Condition Pushdown、基于代价优化。简单讲当一条复杂查询里存在多层子查询派生表、视图、CTE、聚合、窗口函数时优化器如果能证明外层JOIN条件可以安全地穿透内层子树提前在扫描或聚合之前把无关数据丢掉中间结果就能小一个量级甚至几个量级。但也不是所有条件下推都划算更不是所有条件下推都合法最终要由代价模型来判断推下去赚不赚、值不值。这篇文章我想把这件事从头到尾掰开为什么连接条件晚评估会引发性能灾难哪些场景能下推、哪些推了会改语义代价上怎么量化收益以及我自己的实战复盘中踩过的坑。适合读的人主要三类天天跟慢SQL搏斗的DBA、做SQL性能调优的工程师、以及正在写优化器规则的内核开发。1. 慢查询根因连接条件评估得太晚中间结果白白膨胀1.1 明明有索引为什么还是慢先把我那个P0的SQL简化脱敏一下。它大体长这样SELECT r.r_name, agg.total_amount, agg.order_cnt FROM region r JOIN ( SELECT c_nation, SUM(o_totalprice) AS total_amount, COUNT(*) AS order_cnt FROM customer c JOIN orders o ON c.cust_key o.cust_key WHERE o.order_date DATE 2023-01-01 AND o.order_date DATE 2024-01-01 GROUP BY c_nation ) agg ON agg.c_nation r.r_name WHERE r.r_region ASIA AND agg.total_amount 100000000;表结构上orders有order_date的普通索引customer有cust_key的主键索引region是一张只有几十行的维表。第一眼看过去连接是等值连接过滤字段也有索引怎么看都不该跑二十分钟。但EXPLAIN ANALYZE的结果让我有点尴尬customer和orders在2023年内关联出来的明细行数超过一亿子查询老老实实把这一亿多行做了一次全量Hash Join和Group Aggregate输出几十个国家的汇总行然后才跟region做连接最后才执行r_regionASIA和total_amount 100000000这两个过滤条件。索引全用上了可真正有价值的过滤条件全被放在了最外侧——它们根本没参与减少子查询内部计算量这件事。1.2 中间结果膨胀的本质过滤时机与行数爆炸我常说一句话SQL优化里决定查询快慢的往往不是某个索引而是每一级算子到底吞吐了多少行。执行计划是一根管道上一级算子的输出行数直接决定下一级算子的工作量。如果你在管道最末端才放一个高选择率的过滤条件那这个条件只帮你减少了最后输出的行数前面的扫描、连接、排序、聚合全部白干。上面的例子就是一个教科书式的过滤时机错误r_regionASIA在逻辑上能推导出c_nation必须是亚洲国家但物理计划把region表放在最外层导致customer和orders的全部相关数据都要在子查询内部完成连接和聚合再由外层JOIN去筛选。中间结果膨胀的本质就在这里——连接条件被当成了一道安装在最末端的闸门而不是安装在水源处的过滤器。我把这种问题总结成一句排查口诀拿到一个慢的复杂查询先看执行计划里有没有输入很大、输出很小的算子。一旦发现某个算子吃进去几千万行、吐出来只有几行说明它上游有一道本该更早执行的过滤条件被推迟了。1.3 连接条件下推的两层含义连接条件下推这个词在不同场合有不同侧重点实际执行中有两层含义都值得理解。第一层是狭义的规则变换把Join节点上的连接条件通常是等值连接相关的谓词作为过滤条件下推到输入子树中。比如agg.c_nation r.r_name配合上r.r_regionASIA可以推导出c_nation IN (中国, 日本, ...)这样的谓词然后把这个谓词push到聚合子查询内部的customer扫描上方甚至一路推到GROUP BY之前。第二层是广义的过滤传播基于连接关系把一棵子树过滤后得到的关键字集合动态传播给另一棵子树。最典型的就是MPP数据库里的动态分区裁剪、动态过滤以及普通数据库里连接顺序调整后驱动表产生的半连接/物化结果反过来限制被驱动表的扫描范围。两层本质是一样的让数据在管道更早的位置被丢弃。理解了这一点你就知道优化器为什么愿意为了这么一条简单的规则花费大量代价评估的时间。2. 下推之前先过合法性检查规则红线连接条件下推不是机械地把谓词搬到子查询内部那么简单。移动位置意味着改变算子的执行边界稍不留神就会改变查询语义。我见过不少优化完结果错了的事故基本都是没做合法性检查。下面几条红线是我实践中最常遇到的。2.1 分组键上的谓词可下推聚合值上的不可这是一个最基础也最容易混淆的边界。如果谓词只引用GROUP BY列比如c_nation 中国可以安全地下推到聚合的输入侧。为什么安全因为聚合是把输入行按分组键折叠成输出行一个组是否满足分组键等于某个值这个条件在输入侧过滤等价组内其他行的数据完全不受影响。过滤掉不满足条件的输入行只是砍掉了一些本来就不会在最终结果里出现的分组罢了。但如果谓词引用的是SUM、COUNT、AVG这类聚合结果比如agg.total_amount 100000000就不能下推了。原因更直白聚合输入侧根本不存在聚合值这个东西SUM是多个行折叠之后才产生的把它下推到明细行上没有任何语义对应。这里有一个常见误区需要提醒WHERE salary 10000这种谓词即使外层查询里有GROUP BY dept_id也不能随便下推——因为它过滤的是组内参与聚合的行会影响组内COUNT、SUM的结果只有作用在分组键本身上的谓词才允许推。2.2 窗口函数的分区锁与排序锁窗口函数比聚合更麻烦因为它是在完整结果集上做ROW_NUMBER、RANK、SUM OVER之类的计算。谓词能不能穿透窗口算子取决于它作用在哪一类列上作用在PARTITION BY列上可以下推相当于砍掉整个分区窗口内部的相对计算不受影响作用在其他普通列上不能随便下推因为窗口函数尤其是排序窗口、滑动窗口的结果依赖同一分区内其他行的存在和顺序作用在窗口计算结果上比如外层过滤WHERE rn 1绝对不能穿透窗口算子只能在窗口计算完之后执行。我在实际业务里见过一个典型场景想取每个用户最近一笔订单再join商品表于是先写子查询ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) rn外层WHERE rn 1。这个rn1永远推不进窗口内部因为每个窗口里的行号是算完才知道的。能做的只有确保PARTITION BY列上的外部过滤条件尽早生效同时让order_time的排序尽可能走索引减少窗口算子的重排代价。2.3 外连接语义ON条件与WHERE条件待遇不同外连接的谓词下推是全公司最容易出事故的地方之一。核心规则很简单ON子句里的内表谓词可以下推WHERE子句里的内表谓词不能随便下推除非先把外连接转换成内连接。我用一张表把这个区别总结清楚谓词位置例子能否下推原因ON子句内表侧a LEFT JOIN b ON a.idb.id AND b.x10可以ON条件在NULL扩展之前评估先过滤b再连接语义一致WHERE子句内表侧a LEFT JOIN b ON a.idb.id WHERE b.x10必须先把外连接转成内连接WHERE在NULL扩展之后过滤b侧为NULL的行会被过滤掉语义上已经等价于内连接WHERE子句外表侧a LEFT JOIN b ON a.idb.id WHERE a.y5可以先过滤外表再去做外连接扩展不改变保行语义连接条件本身a LEFT JOIN b ON a.idb.id一般不下推连接条件本身连接条件参与外连接的空值扩展逻辑直接移动容易改变左右边语义许多优化器在实现时确实会做外连接退化检测到WHERE子句对右表列有非空过滤时把LEFT JOIN安全地转换为INNER JOIN后再下推。但DBA手工改写时一定要自己确认否则结果集一夜之间少了行哭都来不及。2.4 易变函数与NULL语义的边界还有一个偏冷门但必须提的点连接条件里如果出现了非确定性函数无条件禁止下推。比如ON t1.a t2.b AND t1.c random()random是volatile函数每次调用可能返回不同值。把这条条件下推到子查询内部等于让它在两个不同的执行位置重新求值结果可能和原始SQL不一致。类似还有now()、uuid这类时间戳或随机生成函数。判断标准很简单这条谓词如果被复制到另一个位置执行值是否一定相同不确定就不能推。NULL语义方面大部分人以为col ! x下推安全实际上它确实安全因为NULL既不等于也不等于任何值无论在内层还是外层NULL行都会被排除。真正要小心的是和IS NULL、IS NOT NULL配合的外连接场景以及谓词下推后是否让原本能产生的NULL扩展行被提前过滤掉——这就是上一节外连接的锅。保守做法是遇到NULL语义不确定的谓词宁可让优化器别推。3. 基于代价的收益评估不是能推就推就能赢3.1 决策模型选择率、行数与物化成本合法性检查通过之后下一个问题是推了到底赚不赚。优化器不会无条件下推它会基于统计信息估算下推前后的计划代价然后选择更便宜的那棵计划树。决策过程大致分四步估算谓词的选择率。这一步依赖表统计信息行数、NDV、直方图、NULL比例。比如c_nation IN (中国, 日本, 韩国)的选择率如果估算为0.18说明子查询输入侧能提前砍掉82%的数据分别计算下推前和下推后整棵计划树的代价。代价模型通常包括IO成本、CPU成本、内存成本和网络传输成本比较两棵计划树的代价差记住不是只看谓词过滤减少的行数还要考虑下推是否破坏原有索引、是否让某些算子从流水线变成阻塞式、是否改变并行度如果目标是物化边界CTE、派生表被多次引用需要额外估算下推导致同段子树被多次物化的开销。选择率的估算方式也算个知识点多个谓词同时作用时优化器普遍假设各列独立用乘法合并选择率。但这个假设在高相关的列上经常翻车。比如c_nation中国 AND c_city北京两列本来就强相关独立假设会严重高估过滤效果导致优化器以为下推后只剩一点点数据实际还剩很多或者反过来低估。所以统计信息里的直方图、相关列统计extended statistics在这个决策里特别重要。3.2 带数字的推演从千万行到百万行为了让收益更直观我给刚才那个P0案例编一组接近真实的估算数字抽象代价单位只用来理解数量级。假设customer表全球3000万行orders表在2023全年有1.2亿行客户分布全球约30个国家其中亚洲国家客户占全量约18%但亚洲国家客户产生的订单占全量约18%。先把不下推的路径A算一遍扫描orders的order_date范围得到约8000万行订单并非均匀分布下半年多于一季度关联customer之后参与聚合的明细行约8000万行然后GROUP BY聚合输出约30行代价大头全部集中在8000万行明细的Hash Join 8000万行聚合这一层。再算下推后的路径B先扫描region表r_regionASIA过滤后得到约8个r_name通过c_nation r.r_name推导出c_nation IN (8个国家)选择率约0.18customer只需扫描约540万行orders只需要join这些客户对应的订单如果订单量也同比例下降参与聚合的明细行从8000万压到约1500万行聚合后输出8行左右后续JOIN和过滤成本几乎可以忽略。我用一张表来对比这两个路径的中间结果规模阶段算子不下推路径A下推后路径B降幅customer扫描行数3000万约540万约82%参与聚合的明细行数约8000万约1500万约81%子查询聚合输出行数308约73%外层JOIN输出行数约10约640%从这张表能清楚看到一个反直觉的点外层JOIN最终只输出几行好像推不推差别不大但真正的收益全部发生在子查询内部那8000万行的聚合上。这也是很多DBA容易忽略的地方——只看最终结果行数以为查询本身不复杂没意识到中间过程已经跑飞了。3.3 三种收益为负的典型情况连接条件下推不是银弹。如果代价模型不完善或者统计信息不准有时候推下去反而更慢。我实际工作中遇到过至少三种典型情况第一种子查询内部本身就已经被索引或分区裁剪压得很小。比如内层子查询已经有order_date的分区裁剪只扫一个月的数据外层再推一个选择率没那么高的国家过滤条件收益很小反而增加优化器搜索时间。第二种下推破坏了可用的索引访问路径。比如某个连接条件下推后在子查询内部产生的谓词是substr(nation, 1, 1) C这种函数表达式原来subquery里的查询可以走nation列上的普通索引现在索引失效优化器只能选全表扫描净效果可能是负的。第三种同一条子查询被多个地方引用。CTE或派生表被引用两次以上时下推可能要求优化器对同一段子树生成多个不同版本一次带过滤条件A一次带过滤条件B一次不带。如果优化器选择先物化CTE再复用下推就无法生效如果选择分别实例化可能要多扫几遍源表临时表空间占用成倍上涨。这种场景的取舍必须真正走到代价模型里去算不能拍脑袋。4. 实战复盘多级子查询的连接条件下推全链路4.1 原始SQL与基线计划回到那个P0。我先把原始执行计划的骨架整理出来简化版Filter: (agg.total_amount 100000000) - Hash Join (agg.c_nation region.r_name) - Seq Scan region, filter: r_region ASIA - Subquery Scan agg - Finalize GroupAggregate (group by c_nation) - Hash Join (customer.cust_key orders.cust_key) - Seq Scan customer (3千万行) - Index Scan orders on order_date (8千万行)这个计划里子查询内部是全量customer和orders的JOIN外层才做地区和金额过滤。我当时的处理不是直接加索引而是走一套固定的排查流程先拉EXPLAIN ANALYZE再看每一级算子的实际行数与估算行数最后定位输入很大、输出很小的算子。果然Hash Join那一层的实际输入是几千万行输出是明细行数百万行然后GroupAggregate直接压成30行。这就是典型的过滤时机错误。4.2 锁定可下推谓词与污染源我把执行计划里所有谓词列了一个清单逐个做合法性检查和收益估算r.r_regionASIA作用于region表选择率约0.3可以直接下推到region扫描上方连接条件agg.c_nation r.r_name它不能单独下推但它可以把region过滤后得到的r_name集合传播给agg侧传播后的候选谓词c_nation IN (亚洲国家列表)c_nation是GROUP BY列合法可下推且选择率约0.18收益显著agg.total_amount 100000000引用聚合结果不可下推留在最外层。这里有一个关键点在优化器没有自动推导的情况下这条链路需要?我自己把谓词写出来。当时用的数据库优化器确实没有把这组条件下推干净所以解决路径有两条要么升级优化器规则要么手工重写SQL。4.3 手工改写与优化器自动重写对比手工改写后的SQL长这样SELECT r.r_name, agg.total_amount, agg.order_cnt FROM (SELECT r_name FROM region WHERE r_region ASIA) r JOIN ( SELECT c_nation, SUM(o_totalprice) AS total_amount, COUNT(*) AS order_cnt FROM customer c JOIN orders o ON c.cust_key o.cust_key WHERE c.c_nation IN (SELECT r_name FROM region WHERE r_region ASIA) AND o.order_date DATE 2023-01-01 AND o.order_date DATE 2024-01-01 GROUP BY c_nation ) agg ON agg.c_nation r.r_name WHERE agg.total_amount 100000000;这次改写的本质是把原本在JOIN之后由外层连接条件隐式完成的过滤显式地写进子查询内部的WHERE子句让优化器可以把它一路下推到customer扫描和Hash Join之前。如果优化器支持自动推导原始SQL也能生成等价计划但至少在那个版本上显式改写是最稳妥的方式。改完以后有两个细节必须强调。第一SELECT r_name FROM region WHERE r_regionASIA这个子查询的成本极低region只有几十行几乎不影响整体代价。第二这也是带外连接或带NULL语义时要格外小心的写法如果原始SQL是LEFT JOIN且过滤条件在ON子句而非WHERE子句改写时绝不能简单把条件搬进内层必须回到第2.3节的规则重新确认。当时我为了保险把改写前后的SQL在测试环境各跑了几轮用集合差比较结果集确认完全一致才上生产。4.4 结果验证与后续监控上生产后的效果很明显执行时间从20分钟降到40秒左右中间过程的核心变化就是ORDER参与聚合的行数从几千万降到了数百万。更重要的是我把这个查询加进了每天的慢查询监控和计划巡检。原因很简单连接条件下推是否生效高度依赖统计信息而统计信息是会过期的——如果orders表的数据分布发生突变或者region维表的国家归属发生了变化优化器有可能重新选择原来的低效计划。我的监控手段也很朴素每天看一次这个SQL的EXPLAIN摘要重点盯Hash Join两侧的估算行数如果发现估算行数比值出现十倍以上的变化就触发统计信息刷新和计划对比。5. 场景延伸与稳定性提醒5.1 统计信息过期会误导代价决策连接条件下推的收益本质上是对选择率的押注。如果统计信息过期押注就会押错方向。我遇到过一件特别典型的事某个维表的直方图很长时间没更新优化器以为某个国家过滤条件的选择率是30%实际上因为业务停止运营实际选择率已经降到0.5%。优化器基于30%的估算觉得下推收益不大就选择了其他连接路径结果查询性能雪崩。这类问题最常见的解法是定期ANALYZE或自动收集统计信息但更关键的是建立估算行数 vs 实际行数的偏差监控。偏差超过一定阈值就自动触发统计信息更新甚至直接手动固定计划。对复杂的批处理查询我一般建议跑批前做一次受控的ANALYZE确保优化器手里的牌是最新的。5.2 并行计划与条件下推的相互作用连接条件下推还可能影响并行执行计划的形态。下推后的谓词让子查询内部输入行数骤降有时候反而会让优化器觉得数据不够多不值得开并行从而把计划从并行扫描降级成串行扫描。这在高并发跑批场景里不一定是坏事但如果是核心报表串行计划可能比并行计划多几倍延迟。另一个容易被忽略的问题是条件下推到聚合内部后如果聚合前端需要做Gather操作Gather的位置、并行度、数据倾斜都可能变化。我处理过的一个案例是下推后聚合的某个分组键在亚洲区域分布极不均匀导致某几个并行worker处理了80%的数据其他worker空闲整体耗时反而没降多少。这时候就需要关注数据分布和倾斜键必要时再加一层二级聚合或者重新设计分组粒度。5.3 参数化查询与Prepare语句的额外风险还有一类场景是参数化查询比如JDBC的PreparedStatement。语句在prepare阶段优化器只能基于用户提供或默认的参数值做通用估算下推决策也基于这个估算。但真实执行的参数可能有完全不同的选择率prepare时假设c_nation中国的选择率是0.05实际执行时传入的是某个只有几千行的小国家选择率只有0.0005。计划可能因为参数不同而明显次优。对此我的经验是对这类关键查询要么走固定的执行计划绑定要么在SQL里使用自适应执行或动态参数嗅探机制让优化器在每次执行时根据实际参数重新估算。如果数据库不支持这些特性只能老老实实为常见参数值做专门的SQL模板。5.4 一点关于优化器信任的经验最后分享一个这些年积累的切身体会不要迷信优化器应该能搞定。连接条件下推是一个看似简单、实现起来却需要大量合法性检查和代价计算的优化很多数据库产品在特定版本里只是部分实现了它。你写的复杂查询很可能在某个版本上就是这个优化的漏网之鱼。所以我现在拿到复杂查询的第一反应已经不是加索引或者改SQL写法了而是先拉执行计划找那些吃进去几千万行、吐出来几行的算子然后顺着这个算子的输入向上找看是不是有连接条件或过滤条件被延迟评估了。只要找到十有八九就是连接条件下推的发挥空间。条件允许就让优化器自动重写优化器不配合就手工改写SQL把谓词显式放进内层子查询。做完之后再核对一遍结果集确认语义没变。这条链路我已经用了很多次绝大多数复杂查询的性能问题都能在这个思路下找到突破口。