上周排查一条线上慢SQL的时候我差不多花了一个下午最后把锅扣在了“连接条件下推”上。这条SQL本身不复杂三张表做LEFT JOINWHERE里带一个日期范围数据形态也算正常可执行计划就是不走我期望的路径。真正让我想明白的是传统规则优化器里那条“能下推就下推”的启发式判断到了外连接和JOIN重排混合的场景就失灵了它需要的是基于代价的连接条件下推。这篇东西就围绕这个主题展开说说连接条件下推解决的痛点、为什么一定要引入代价估算以及我在一个具体案例里把它落到执行计划全过程的经历。如果你是做数据库内核、分布式SQL引擎优化或者日常要跟慢SQL缠斗的DBA这篇应该能对上你的胃口。1. 先从一条慢SQL说起连接条件下推到底解决什么问题1.1 一条几分钟跑不完的报表SQL先说案例。场景是典型的星型模型三张表customer客户表1000万行orders订单表5000万行lineitem订单明细表3亿行业务上要统计2024年1月每个客户下的订单总金额SQL长这样SELECT c.custkey, SUM(l.extendedprice) AS total_amount FROM customer c LEFT JOIN orders o ON c.custkey o.custkey LEFT JOIN lineitem l ON o.orderkey l.orderkey WHERE o.orderdate DATE 2024-01-01 AND o.orderdate DATE 2024-02-01 GROUP BY c.custkey;在没做任何优化的时候这条SQL能跑三分钟以上。我一开始以为是lineitem表太大导致的于是把注意力放在“是不是要加索引”“是不是要开并行”上。后来翻执行计划发现表扫描确实没扫描多少数据问题出在JOIN过程优化器先做了 customer LEFT JOIN orders把整个orders表都卷了进来又往外连接的结果上挂 lineitem中间结果集膨胀到了3亿多行最后才在最外层执行 WHERE 过滤。也就是说那个明明可以把订单表先滤掉的日期条件等到最后才生效白白把几亿行无用的NULL扩展行和大批不满足条件的订单明细都算了一遍。这类问题靠单表上的谓词下推解决不了它发生在运算符之间属于连接条件下的重排问题。1.2 Scan谓词下推与连接条件下推的区别很多DBA习惯说的“谓词下推”指的是Scan层的下推把o.orderdate DATE 2024-01-01这个条件尽量塞到存储引擎扫描阶段减少从磁盘读出来的行数。这个手段在单表查询里效果立竿见影但在多表JOIN场景里它只是第一步。真正的连接条件下推发生在优化器生成查询树之后。它要做的是把某个过滤条件从查询树的高层比如最外层的Filter节点搬到JOIN算子输入侧甚至改变JOIN的顺序和类型。回到上面的SQL最理想的执行顺序应该是先把orders表按日期过滤成小集合再跟lineitem和customer做连接。这样一来JOIN中游走的行数从3亿掉到几百万耗时自然下降。Scan谓词下推和连接条件下推的区别可以这么理解前者是在“源头少出水”后者是在“管道里改变水流路线”。现实系统里两者经常配合使用但机制完全不同。Scan下推靠的是存储层的过滤能力而连接条件下推会在优化器的等价计划搜索空间里动手术牵涉到JOIN的语义安全。1.3 启发式规则为什么在这种场景下会失效传统规则优化器RBO面对上面的SQL并不是完全无动于衷。它能看到 WHERE 条件引用了o.orderdate结果集最终会被这个条件过滤所以理论上可以把条件往JOIN的输入侧移动。但RBO的决策是静态的条件能用、能推就推下去不能推就放弃。它不会停下来问一个问题“把条件推下去之后JOIN的顺序是否也要跟着变若是把外连接改成内连接中间结果会不会更小”规则优化器失效的根源在于它缺少“代价”的视角。能下推和该下推是两个维度的判断。条件是否可下推靠语义分析就能确定但条件下推之后到底能省多少行、会不会引入更差的连接顺序、要不要顺带做外连接消除这必须用统计信息和代价模型来回答。2. 语义红线与代价盲区规则优化器的两块铁板2.1 外连接场景的语义陷阱ON与WHERE并不等价先给个最容易被忽视的例子。同样的过滤条件写在ON里和写在WHERE里在LEFT JOIN下可能是完全不同的语义。-- 写法A过滤条件在ON里 SELECT c.custkey, o.orderkey FROM customer c LEFT JOIN orders o ON c.custkey o.custkey AND o.orderdate DATE 2024-01-01; -- 写法B过滤条件在WHERE里 SELECT c.custkey, o.orderkey FROM customer c LEFT JOIN orders o ON c.custkey o.custkey WHERE o.orderdate DATE 2024-01-01;写法A的语义是LEFT JOIN时只匹配2024年1月之后的订单没有满足条件的订单时客户仍然保留订单列为NULL。写法B的语义完全不同先做LEFT JOIN再把订单列为NULL的扩展行全部过滤掉最终输出的是“至少下过一单2024年1月订单”的客户。换句话说写法B里的LEFT JOIN实质上变成了INNER JOIN。如果优化器不理解这个差别盲目把WHERE条件下推到orders表的扫描阶段相当于把写法B悄悄变成了写法A结果集里会凭空多出一堆客户NULL行。这就是连接条件下推最大的坑语义变了但SQL不报错业务数据悄悄出错最危险。2.2 Null-Rejecting谓词能否下推的判断依据判断WHERE条件下推到外连接是否安全有一个非常实用的概念叫 Null-Rejecting 谓词。一个谓词是Null-Rejecting的意思是当它引用的某一侧表达式结果为NULL时整个谓词的结果为假或者不确定但最终会被过滤掉。比如o.orderdate DATE 2024-01-01如果o.orderdate是NULL这个表达式返回NULLSQL三值逻辑里的unknownWHERE会把它过滤掉。所以它是Null-Rejecting谓词。反过来o.orderdate IS NULL就不是Null-Rejecting因为o.orderdate为NULL时这个谓词反而返回真。对于 LEFT JOIN 来说右表在无匹配时扩展出来的列全部是NULL。因此WHERE中引用右表列且是Null-Rejecting谓词 → 下推通常安全甚至可以把LEFT JOIN消除成INNER JOIN。WHERE中引用右表列但不是Null-Rejecting谓词 → 不能下推否则那些靠“无匹配”而保留下来的NULL行会提前消失。ON中只引用右表列的条件 → 下推到右表扫描侧通常安全因为这等价于让右表提前过滤掉不可能匹配的行。这个检查是一票否决项。我做这个功能时第一步永远不是写代价模型而是先写语义安全检查器把所有谓词按“否能安全下推”打标然后再谈代价。2.3 规则看不到数据分布与连接顺序代价驱动接棒即便语义上安全规则优化器仍然可能给出错误建议。典型场景是某个过滤条件虽然能下推但选择率selectivity特别差比如o.orderdate DATE 1900-01-01几乎过滤不掉任何行。这种情况下把条件下推并不会缩小中间结果反而可能让优化器产生“右表很小”的错觉进而选择错误的连接顺序比如用小表驱动大表时恰好需要全量构建哈希表或者触发笛卡尔积。数据分布的影响光靠语法树是看不到的。同样的o.custkey c.custkey如果customer表里某些custkey占了绝大多数行等值JOIN的输出规模会被少数热点键严重放大。此时是否下推某个过滤条件会显著改变JOIN两侧的行数比进而影响最优连接顺序。这些判断都需要统计信息和基数估算来支撑也就是转到代价驱动CBO的范畴。3. 基于代价的连接条件下推核心决策逻辑3.1 从规则驱动到代价驱动候选计划与补偿表达式代价驱动和规则驱动最大的区别不是“删除规则”而是把规则的输出从“唯一推荐计划”改成“一组候选计划”然后用代价模型在里面选一个。具体到连接条件下推优化器会围绕同一个逻辑表达式生成多个等价物理候选原始计划保持外连接结构过滤在最外层执行。全下推计划把Null-Rejecting条件下推到右表扫描层并把外连接消除为内连接。部分下推计划只把条件下推到JOIN的输入侧但保留外连接结构用于语义不允许消除连接的场景。与JOIN重排组合的计划下推后重新选择驱动表和连接顺序。如果下推会引入语义偏差优化器还需要插入“补偿表达式”。比如对某些不能直接消除的FULL OUTER JOIN场景要在对应分支补一个过滤或NULL判定保证结果集合与原SQL一致。这一步在工程实现上比规则本身麻烦得多但它是CBO搜索空间扩大的基础。RBO与CBO在连接条件下推上的差异可以简化成下表对比维度规则驱动RBO代价驱动CBO决策依据谓词结构、引用列归属统计信息与代价估算是否能下推分析谓词安全性分析安全性后还要估算收益是否消除外连接按固定规则触发按连接结果行数、构建代价综合判断对统计信息的依赖低高结果可解释性容易解释需要用Explain和分析日志解释3.2 基数估计一切代价计算的地基代价估算的地基是基数估计Cardinality Estimation也就是预测“某个算子会输出多少行”。行数估错了后面所有代价计算全是空中楼阁。目前主流数据库的做法是结合直方图、唯一值数量NDV、NULL值比例来做估算。以我上面那条SQL为例orders表总共5000万行。统计信息里orderdate的直方图显示2024年1月的数据约有100万行。所以o.orderdate DATE 2024-01-01 AND o.orderdate DATE 2024-02-01的选择率约为 100万 / 5000万 2%。过滤后的orders表预估行数为100万。JOIN输出行数的估算相比之下更粗糙常见做法是inner_join_rows left_rows * right_rows / max(ndv(left_join_col), ndv(right_join_col))lineitem表按orderkey与orders表连接orders表过滤后100万行每个订单平均6个明细行那么JOIN输出约为100万 × 6 600万行。真实优化器还会考虑分布倾斜、NULL不匹配、直方图重叠等因素但核心逻辑就是这个量级。基数估计决定了“下推收益”是否能被看见。如果统计信息缺失或者过期优化器可能把1月数据估成1000万行此时下推与否的代价差距变小CBO就可能放弃下推这就是很多“统计信息过期后SQL变慢”的真相之一。3.3 一条SQL的两套候选计划代价对比全过程把上面的案例套进两个候选计划里看中间结果的变化。计划A不下推保持外连接顺序customer 1000万行 LEFT JOIN orders 5000万行 - 中间结果约5100万行含无匹配的客户NULL扩展行 LEFT JOIN lineitem 3亿行 - 中间结果约3.01亿行 WHERE orderdate过滤 - 只剩约600万行 GROUP BY计划B下推订单日期条件消除外连接并重排JOIN顺序orders 5000万行 WHERE orderdate过滤 - 100万行 JOIN lineitem - 600万行 JOIN customer - 600万行 GROUP BY计划B的最大中间结果只有600万行比计划A的3.01亿行缩小了50倍。这种量级的差异已经不需要精确的代价公式就能看出谁更优。但代价模型不能只“看行数”它还要把扫描代价、哈希表构建代价、探针代价、网络传输代价加总。一个简化的本地代价公式可以写成Cost C_scan * scan_rows C_build * build_rows C_probe * probe_rows C_emit * output_rowsC_scan从存储层读取一行数据的IO与解码成本。C_build在哈希连接里构建一侧哈希表的CPU与内存成本。C_probe用另一侧数据探测哈希表的成本。C_emit把结果行输出给上层算子的成本。按这个公式去套计划A和计划B计划A在“探针”和“输出”两项上要承担的3亿行级别开销而计划B只需要承担600万行。即便C_emit很小行数差距摆在那里最终的代价排序也会非常明显。3.4 什么场景适合启用这条规则基于代价的连接条件下推不是任何时候都该触发我总结了几条实战判断过滤条件的选择率很低比如低于10%时下推收益大值得优先尝试。过滤条件引用了外连接内侧表且是Null-Rejecting谓词这时候可以同时做外连接消除。过滤条件的选择率接近1过滤不掉什么数据时下推的意义不大反而会浪费优化器时间保守处理更稳。谓词里包含非确定性函数比如random()、now()、自定义易变函数禁止下推下推会改变函数被调用的次数导致结果不一致。统计信息缺失或明显过期时CBO的基数估计不可信需要先 ANALYZE否则代价对比就是盲人摸象。4. 优化器落地实现从可下推判断到规则编码4.1 谓词的可下推性检查清单在实际优化器里写这个规则第一步不是做代价而是做安全检查。我通常按这个清单逐项过谓词引用了哪些表的列如果谓词里的列引用跨多张表不能直接下推到单表扫描层。该谓词是否引用当前JOIN内侧表的列是的话需要判断是否Null-Rejecting。该谓词是否在ON子句里ON子句里只引用一侧表的条件通常可以下推到那一侧同时要保留JOIN结构。该谓词是否包含非确定性函数或用户自定义函数包含则禁止下推。该谓词是否被外层查询的聚合、窗口函数、DISTINCT影响如果谓词在窗口函数之上下推会改变窗口计算的行集需要特别小心。这些检查最好是显式建模不要散落在规则代码的if else里。否则后面新增一种JOIN类型或者新的函数类型时很容易漏判。4.2 一个TransformRule的落地伪代码以Cascades风格的优化器为例连接条件下推通常是一个Transform Rule它匹配“Filter节点下挂着Join节点”的模式然后生成多个替换计划交给代价模型选择。伪代码如下def match(node): # 匹配 Filter 节点且子节点是 Join if not isinstance(node, Filter): return None child node.child if not isinstance(child, Join): return None return node def on_match(filter_node, context): join filter_node.child predicate filter_node.predicate alternatives [] # 候选1保持原始计划 alternatives.append(original_plan) # 候选2把条件下推到右表输入侧并尝试消除外连接 if is_null_rejecting(predicate, join.right_side): pushed push_down_filter(join.right_side, predicate) if join.join_type in (LEFT_JOIN, RIGHT_JOIN): # 可消除为内连接 new_join Join(INNER_JOIN, join.left_side, pushed) else: new_join Join(join.join_type, join.left_side, pushed) alternatives.append(Filter(join.left_side_condition, new_join)) # 候选3只下推但保留外连接语义 if is_safe_on_condition(predicate, join.right_side, join.join_type): pushed push_down_filter(join.right_side, predicate) alternatives.append(Join(join.join_type, join.left_side, pushed)) # 代价驱动选择 best min(alternatives, keylambda p: estimate_cost(p, context.statistics)) return best真实代码里还要处理Filter折叠、JOIN键上的谓词join condition里的等值条件与普通过滤谓词的区分以及和Join Reorder规则的交互。这个伪代码的核心思想是“不要只产出一种计划要让代价平滑地介入决策”。4.3 分布式数据库里的额外维度Shuffle代价如果只是本地单机数据库上面的本地代价公式基本够用。但在分布式SQL引擎里连接条件下推还会显著影响Shuffle的开销这是很多人在单机环境下意识不到的点。拿同样一条SQL举例orders表如果按custkey分区存储customer表也按custkey分区那么它们之间的连接可以走本地连接几乎不需要网络Shuffle。可是lineitem表通常按orderkey分区要和orders连接就得把orders表的数据按orderkey重新分发或者在lineitem侧做广播。此时如果先对orders表做日期过滤参与Shuffle的数据量就会从5000万行降到100万行网络传输量下降两个数量级。这个收益在本地代价模型里看不到但在分布式集群里往往是最大的收益来源。反过来也有坑下推之后原本可以直接做Broadcast Join的小表因为过滤后行数变化优化器可能改成Shuffle Hash Join反而增大网络开销。所以分布式环境里的代价函数必须把Shuffle字节数、网络带宽、并发度纳入计算不能只看本地CPU和IO。4.4 支撑这套机制的基础设施没有统计信息CBO就是一个瞎猜的过程。落地基于代价的连接条件下推至少需要这几样基础设施列级直方图用于估算范围过滤条件的选择率。NDV统计用于估算等值连接的输出行数。NULL值比例用于修正外连接下推时的行数估计。分区级统计信息在分区表场景下让过滤条件下推能联合分区裁剪一起生效。Explain分析工具能输出算子级的估算行数、实际行数、代价明细方便排查“为什么没下推”。我见过不少系统直接跳过统计信息建设去写代价模型最后的结果是优化器偶尔聪明、偶尔犯傻线上SQL忽快忽慢。这类功能是典型的数据驱动决策地基没打好上层做得越复杂越危险。5. 实战回放一次11倍提速的完整过程5.1 实验环境与数据从来不是“理想情况”为了验证方案我用TPC-H风格的数据做了一组实验。集群环境是3台16核64GB的机器SQL引擎支持CBO和基础的分区裁剪。数据量如下表行数备注customer1000万custkey为主键orders5000万custkey有索引orderdate有直方图lineitem3亿orderkey分布均匀每订单约6个明细测试SQL就是开头那条报表SQL。我先用旧优化器规则模式下推跑了一遍再把基于代价的连接条件下推规则打开重新跑一遍看执行计划和实际耗时。5.2 下推前后执行计划对比旧优化器给出的计划简化大概长这样Aggregate - Filter (orderdate in 2024-01) - HashJoin LeftOuter - HashJoin LeftOuter | - SeqScan customer | - HashJoin Inner | - SeqScan orders | - SeqScan lineitem - Result可以看到日期过滤在最外层两个HashJoin全程处理3亿行的中间结果。开启基于代价的连接条件下推后计划变成Aggregate - HashJoin Inner - HashJoin Inner | - Filter (orderdate in 2024-01) | | - SeqScan orders | - SeqScan lineitem - SeqScan customerorders先做过滤再与lineitem连接最后与customer连接外连接被消除中间结果显著缩小。两个计划的实测数据指标下推前下推后orders扫描行数5000万5000万过滤后100万最大中间结果行数约3.01亿约600万Shuffle数据量约4.2GB约380MB查询耗时342秒31秒耗时从342秒降到31秒提速约11倍。这个案例直观说明了连接条件下推和“Scan谓词下推”不是一个段位的事情后者顶多让orders表读得快一点前者直接把整条执行路径的长度缩短了一个数量级。5.3 实测中的三个大坑与对应解法第一个坑统计信息过期导致下推被拒绝。上线前我把orders表灌了大量历史数据但没重新收集统计信息优化器手里的直方图还是旧版本错估1月订单占比为30%。代价模型一对比发现下推后收益不明显就把下推计划拒了。解法很朴素跑ANALYZE TABLE刷新统计信息再看执行计划。第二个坑一个非确定性函数导致数据不一致。线上某条SQL的过滤条件里有自定义函数is_vip(c.custkey)它内部查了缓存表严格来说是易变函数。优化器检查函数确定性时没拦住把条件下推到了JOIN右侧导致函数调用次数和时机全变了结果集出现偶发性偏差。排查半天才发现是函数元数据里少了deterministictrue的标记。这个坑提醒我任何下推规则都要在函数层面做确定性校验别信别处的假设。第三个坑下推后JOIN重排引发笛卡尔积。下推把orders过滤成了很小的集合优化器认为它适合做驱动表结果和另一个没有直接等值连接条件的表做连接时生成了笛卡尔积中间结果爆炸。最后我给这个场景加了约束只有在JOIN两侧存在等值条件时才允许把下推后的表作为驱动表参与重排。这也是CBO规则落地时必须保留的“逃生舱”不能放任搜索空间无限变大。6. 常见问题速查表6.1 外连接谓词下推决策矩阵平时排查SQL时可以直接对照下面这张表。连接类型谓词位置是否Null-Rejecting建议LEFT JOIN右表的ON条件不适用可下推到右表输入侧保留外连接LEFT JOIN右表的WHERE条件是建议下推代价合适时可消除为INNER JOINLEFT JOIN右表的WHERE条件否禁止下推下推会改变语义RIGHT JOIN左表的WHERE条件是建议下推参考LEFT JOIN对称处理FULL JOIN任一表的WHERE条件是通常不能直接消除需补偿表达式谨慎判断SEMI JOIN子查询内部过滤条件不适用可下推到子查询侧但要防止破坏SEMI语义ANTI JOIN子查询内部过滤条件不适用下推需非常小心可能把应该保留的非匹配行提前滤掉这张表的核心逻辑就一句话下推前先确认“被过滤的行是不是原本就该在最终结果里出现的行”。6.2 现场排查五步法遇到“同一个SQL换了数据量就变慢”的问题我一般按下面几步排查用EXPLAIN ANALYZE看看实际扫描行数和算子输出行数找到中间结果膨胀的位置。检查JOIN两侧的过滤条件是否被放到最外层执行如果Filter在Join上层说明连接条件下推没生效。检查谓词里有没有引用外连接内侧表并确认它是否属于Null-Rejecting谓词。查看统计信息收集时间确认直方图、NDV是否过期必要时手动 ANALYZE。如果确认条件可以下推但优化器没下推去查规则开关和代价日志看是安全校验拦截了还是代价模型认为不划算。我自己在使用中的体会是连接条件下推这个规则激进与保守之间差距巨大。最稳的做法是把它做成“默认开启、允许关闭、有日志可查”的三段式开关线上出问题随时能降级。另一个值得养成的小习惯改动这类规则前先把线上几条慢SQL的执行计划和行数估计导出来做基线改完后逐条diff。哪怕结果行数一致也要看NULL值分布是否变化。我靠这个习惯躲过至少两次语义翻车各位如果准备动优化器里的这类规则强烈建议也这么干。