很多做后端或数仓的朋友可能都遇到过这种场景:一条SQL单表查很快,一旦JOIN三张以上的大表,响应时间从几百毫秒涨到几十秒,甚至直接把数据库CPU打满。我这几年的工作基本都花在查询引擎的优化器上,“复杂查询性能优化”里有很大一部分精力是在跟多表连接和中间结果膨胀作斗争。今天想聊的“连接条件下推 + 代价模型”,就是这类问题里性价比最高的一套解法。
先解释一下这两个词。连接条件下推,简单说就是把WHERE过滤条件和JOIN的ON条件尽量挪到连接执行之前,让每张表先把自己不需要的数据砍掉,再进入连接。代价模型则是优化器用来判断“哪种执行方案更便宜”的计算体系,行数估计、CPU开销、内存占用、IO成本都会被折算成一个可比较的数值。两者结合起来,优化器就不再靠拍脑袋决定“要不要下推”,而是先枚举出候选计划,算清楚下推前后的代价差,最后挑一个真正快、还不会改变查询语义的执行计划。
这篇文章的重点不是堆概念,而是把代价模型怎么设计、连接条件下推怎么落地、实测中会踩哪些坑讲透。适合正在做查询引擎开发、大数据平台优化,或者玩过几天EXPLAIN后想更进一步的人。下面我会用真实项目里最容易遇到的三表连接场景来拆解。
1. 连接条件下推要解决的根本问题:中间结果膨胀
1.1 一个典型的慢查询长什么样
我先用一个很常见的业务SQL来开场,三张表分别是客户表customer、订单表orders和订单明细表order_items,目标是查出最近一年高价值客户的订单总金额和商品名称。
SELECT c.customer_no, SUM(o.amount) AS total_amount, i.item_name FROM customer c JOIN orders o ON c.customer_id = o.customer_id JOIN order_items i ON o.order_id = i.order_id WHERE c.customer_level = 'VIP' AND o.order_time >= DATE '2024-01-01' AND i.category = '3C数码';很多数据平台为了省事,会先把三张表JOIN出一个大宽表,再在结果上做WHERE过滤。假设customer有1000万行,orders有8000万行,order_items有1.2亿行,三个过滤条件的真实选择率分别是5%、25%和10%。如果不做下推,优化器大概率会先按连接键把三张表拼起来,生成一个行数可能膨胀到数亿的中间结果,然后才在最外层执行三条件过滤,这个查询在默认执行计划下能跑到几十秒甚至几分钟。
1.2 中间结果膨胀为什么这么致命
连接本身的复杂度表面上看是O(N+M),但真实执行时,每一行都需要做哈希计算、内存操作、可能的随机IO,中间结果的行数和字节数直接决定了三个层面上的开销。
第一是CPU开销:行数越多,哈希探测、投影、比较执行的次数越多。第二是内存开销:哈希连接需要把内表构建为哈希表,中间结果行数大意味着哈希表占用内存大,超过内存阈值后就要走落盘路径,磁盘读写一介入,性能立刻掉一个数量级。第三是后续算子开销:中间结果越大,传给排序、聚合、窗口函数等算子的输入就越大。以1亿行哈希表为例,每个哈希条目占用64字节左右,光这个表就要6.4GB内存,在常见的内存配额下几乎必然落盘。中间结果膨胀是整个执行计划性能恶化的根源,优化器最核心的任务之一就是尽早缩减这个膨胀量。
1.3 连接条件下推的本质:把过滤动作提前
连接条件下推要做的事情并不神秘。上面SQL里三个过滤条件中,c.customer_level='VIP'只依赖customer表,o.order_time >= '2024-01-01'只依赖orders表,i.category='3C数码'只依赖order_items表。它们都应该在各自表扫描完成之后、进入连接之前先执行过滤。
需要特别说明的是,连接条件下推并不等同于数据库里常说的“谓词下推”。谓词下推通常指把WHERE里的过滤条件挪到扫描层,而连接条件下推的范围更宽,还包括把JOIN的ON条件中那些“只依赖单侧表”的过滤条件也一并前移。比如ON t1.id = t2.id AND t2.status = 1,这里的t2.status = 1完全可以推到t2表扫描之后执行。把这两个动作都纳入下推范畴之后,收益才会完整。
1.4 一个带数字的收益对比
回到上面的场景,我们算一下下推前后的连接输入规模。
不下推时,三张表的原始行数都要进入连接网络,参与哈希构建和探测的总行数大约是1.2亿行订单明细,加上8000万行订单和1000万行客户,全部要在连接算子间流转。而下推之后,customer先过滤到50万行,orders过滤到2000万行,order_items过滤到1200万行。三表连接的顺序可以变成:order_items和orders先JOIN,输入是1200万 + 2000万;得到中间结果后再和customer JOIN,输入大约是1200万 + 50万。
前后一对比,参与连接的核心行数从数亿级别降到了几千万级别,哈希表内存占用可能从数GB降到几百MB。我见过一个线上案例,把i.category下推到订单明细表扫描阶段后,中间结果从1.2亿行骤降到300万行左右,同样的查询从80秒降到了6秒。这个收益不是靠某个复杂算子实现的,只是让数据在源头先瘦了身。
2. 代价模型设计:优化器如何“算这笔账”
2.1 代价不只是一个数字
优化器在做“是否下推”决策时,不能简单写成“能下推就一定下推”。因为有些条件下推之后反而更慢,比如过滤率本来就很低,下推引入的额外扫描成本比省下的连接开销还大;又比如下推导致原本能用的索引失效。所以我们必须把问题转化成“下推后的计划总代价”和“不下推的计划总代价”之间的比较,两者都用同一个代价模型来量化。
代价模型通常把查询执行的开销拆成CPU、IO、内存和网络四类。最朴素但实用的公式是这样:
TotalCost = ScanCost + JoinCost + PredicateCost + OutputCost其中ScanCost与扫描的表行数、访问方式有关;JoinCost与连接输入行数、连接算法有关;PredicateCost是过滤条件的执行开销;OutputCost是最终输出和传输的代价。每一类内部再细分,比如IO代价分顺序读和随机读,CPU代价分每行处理、哈希计算、比较运算。
2.2 把代价参数落到可计算的数值上
我习惯把代价模型拆成一张参数表,每项都对应一个可调整的常数。这里的数值不要求绝对准确,但相对关系要对,否则优化器会选出反直觉的计划。
| 代价组件 | 常用参数 | 典型初始值 | 说明 |
|---|---|---|---|
| 顺序扫描每页 | seq_page_cost | 1.0 | 以一次顺序读页为基准单位 |
| 随机扫描每页 | random_page_cost | 4.0 | 机械盘/SSD参数差异很大 |
| CPU每行处理 | cpu_tuple_cost | 0.01 | 处理一行基础开销 |
| CPU每条件判断 | cpu_operator_cost | 0.0025 | 谓词/表达式求值开销 |
| 哈希构建每行 | hash_build_cost | 0.01 | 哈希表的构建 |
| 哈希探测每行 | hash_probe_cost | 0.005 | 探测哈希表开销 |
| 网络传输每行 | network_cost | 0.1 | 分布式/MPP场景才有意义 |
这些数值用好了,计划选择就能区分出“扫描后再过滤”和“先JOIN再过滤”的差异。以“过滤一行”为例,如果一行在连接阶段要被处理3次,每次处理成本是cpu_tuple_cost + cpu_operator_cost,那么早过滤一行节省的代价大约是3×(0.01+0.0025)=0.0375。看起来很小,但乘以千万行级别就非常可观。
2.3 基数估计才是代价模型的“地基”
代价公式里的所有大项几乎都跟行数成正比,行数估计错,后面算得再精细也没有意义。这就是为什么“连接条件下推的代价模型”绕不开基数估计。
基数估计最常用的输入是直方图、采样统计和唯一值数量。每张表的每列最好都有统计信息,包括NULL比例、不同值数量、高频值。过滤条件的选择率可以用满足条件行数占总行数的比例来衡量。比如orders表有8000万行,order_time >= '2024-01-01'在直方图里覆盖最近两个季度,占比约0.25,那么过滤后的行数就是2000万行。
这里有个常见的坑:如果统计信息缺失,优化器往往采用默认选择率,比如0.1甚至0.01。我曾遇到一个场景,一张表实际过滤率是0.6,因为统计信息过期,优化器按0.01算,误以为条件下推能从1亿行降到100万行,实际只能降到6000万行。下推后检查发现效果远不及预期,但不推又明显更差,最终靠手动更新统计信息才恢复正常。
2.4 统计信息不可靠时的兜底策略
相关列是另一个容易踩的坑。比如customer表里customer_level和customer_city高度相关,如果优化器分别按两个过滤条件的选择率相乘,算出先过滤customer_level再到customer_city会只剩0.25%行,实际可能还剩30%。对连接条件下推来说,这种过度低估会诱使优化器选择非常激进的连接顺序,最终执行时行数暴涨,计划直接崩掉。
兜底策略我建议分三层。第一层是保证统计信息新鲜,定task持续更新直方图和高频值。第二层是引入多维统计或者轻量级采样,对高相关的过滤列组合做联合估计,避免选择率连乘。第三层是在代价模型里加“保守因子”,当谓词下推预估的过滤率低于某个阈值时,用最坏情况做二次校验。这里的阈值我们内部一般取0.1,过滤率预测低于10%时强制回退成更保守的估算。
3. 连接条件下推的关键实现环节
3.1 先判断条件下推是否合法
代价模型决定“划不划算”,但在算这笔账之前,必须确保条件下推不改变查询语义。这是整个实现里最容易出错的一步。
一个谓词能下推到某个连接子树之前,至少要满足两个条件:它只引用该子树内的表;并且把它的执行位置提前不会影响NULL值的产生和过滤语义。后者在LEFT JOIN、RIGHT JOIN、FULL JOIN面前尤其危险。
举个例子,在LEFT JOIN场景下,右表可能产生NULL补充行。如果把这个条件下推到右表扫描之后、连接之前,就会把那些本该被补充为NULL的行提前过滤掉,最终结果少行,语义就错了。判断逻辑上,我会先做依赖分析,收集谓词里涉及的所有列,再检查这些列是否都属于当前连接一侧的所有表。这个检查可以递归完成,伪代码大致是这样:
function canPushdown(predicate, children): cols = allReferencedColumns(predicate) for child in children: if cols ⊆ child.schema().columns(): return true return false对于外连接,还需要额外检查谓词不是来自“连接下推禁止区”。规则上我的经验是:内连接里的谓词几乎都能下推;LEFT JOIN右表的谓词不能下推到右表侧;WHERE子句中对右表列的过滤,可以转化为连接后再过滤,但一般不适合直接下推到右表扫描。
3.2 生成候选下推计划并计算代价
合法性的判断通过之后,优化器要做的是把“下推”当成一个可以枚举的物理变换。以我们实现的类Cascades优化器为例,连接条件下推的流程分四步。
第一步,遍历逻辑计划树,找出所有连接节点和连接树上方的Filter节点。第二步,为每个Filter谓词判断哪些子表满足下推条件,生成候选计划,即把Filter节点下移并拆分到对应表的扫描节点之上。第三步,用代价模型分别计算原计划和候选计划的总代价。第四步,保留代价更低的一方,如果多种下推组合都存在,就选代价最低的那个。
这个过程听起来简单,但真正的复杂度在于组合爆炸:三表连接有3!种连接顺序,每个谓词又有多种下推位置可选。因此实际工程里不会把每个组合都完整展开,而是用启发式规则先剪枝。比如过滤率低于某个阈值才考虑下推,或者只有谓词的列上有索引时才生成下推候选。
3.3 代价计算的下推收益公式
在实现代价模型时,我把下推收益拆成一个可以直接对比的式子。假设原计划中谓词P的执行位置在连接树上方的N个节点之后,满足P的行数比例为s,那么P下推之前,P在每行上的开销要计算一遍,同时这N个节点的输入还要包含被过滤掉的行。定义FilterCost为单行谓词判断成本,RowCost为每行经过一个连接/投影节点的平均成本,则下推省下的总代价约等于:
Savings ≈ TotalRows × (1 - s) × (N × RowCost + FilterCost)同时,下推也会引入额外代价,比如因为过滤条件可能改变访问路径,导致原本的索引扫描变成全表扫描,这部分要单独计算线性级或指数级代价差。只有当Savings大于AdditionalCost,才值得下推。
上面的示例里,orders表8000万行,s=0.25,N=2,RowCost按0.015算,FilterCost按0.0025算,下推省下的代价大约是8000万×0.75×(2×0.015+0.0025)=19.5万。而下推带来的额外扫描代价如果只有3万,那净收益就很明显。
3.4 下推后还要联动连接顺序重排
连接条件下推不能孤立运行,它和连接顺序的枚举是强耦合的。原因很简单:过滤后的表大小不同了,内表、外表的取舍也应该重新做。
还是上面那个例子,如果order_items被过滤到1200万行,orders被过滤到2000万行,那么让1200万行做哈希构建、2000万行做探测,显然比反过来更省内存、更省CPU。如果优化器在生成候选计划时没有把“过滤后的基数变化”回传给连接顺序枚举,下推收益就会被连接顺序优化抵消掉不少。我在工程上的做法是,先做谓词下推,再在Memo结构里重新触发连接顺序相关规则,确保下推后的行数估计能实时参与后续枚举。实测中这样联合处理后,典型的星型查询能再快10%到20%。
4. 实测中的问题与排查技巧实录
4.1 下推之后反而变慢的三种情况
理论上连接条件下推收益很大,实际项目里我却踩过不少“好心办坏事”的坑。
第一种是过滤率估计过度乐观。统计信息缺失或过期时,优化器按默认选择率0.01甚至更低去算,误以为下推能把中间结果砍到很小,实际过滤率只有0.7,下推省下的代价还没有多出来的扫描代价多。
第二种是下推导致访问路径退化。一个谓词下推后本该走二级索引,但因为谓词里包含函数或者类型转换,索引失效,只能全表扫描,慢到无法接受。举个典型例子,把WHERE date(order_time) = '2024-01-01'下推后,date函数包裹导致无法直接比较原始列,索引自然就废了。
第三种是下推出来的候选计划过多,优化时间本身爆炸。我调试过一个20表连接的大查询,光是枚举下推组合就花了40多秒,再快的执行计划也被优化时间拖垮了。这类问题在大宽表结构、多星型模型的数仓场景里尤其常见。
4.2 排查慢计划时的三条经验
遇到慢查询,我一般不急着调参数,先做三件事。
第一,看EXPLAIN的行数预估和最终执行行数是否一致,偏差超过一个数量级,先去查统计信息。第二,对比下推前后两版计划的代价总和,确认优化器选下推到底是因为“真的省”还是“参数设置导致假省钱”。第三,把谓词涉及的列和索引信息打印出来,检查是否有类型隐式转换、函数包裹导致索引失效。这三步做完,八成的问题原因都能定位到。
我还习惯在代码里给代价模型的每个模块加一个debug日志,输出类似“谓词P下推到orders扫描:估算行数8000万→2000万,节省成本19.5万,额外成本3万,选择下推”这样的记录。这样分析线上问题的时候,不再是黑盒,每一步决策都有据可查。
4.3 用EXPLAIN ANALYZE验证下推效果
在开发验证阶段,我最常用EXPLAIN ANALYZE来对比下推前后的实际执行。关注三个指标:启动时间、执行总时间、以及最内层节点的actual rows和estimated rows。
当EST ROWS与ACTUAL ROWS差异明显时,说明基数估计有问题。当某一个Scan节点的rows removed by filter数值很大,说明过滤确实在扫描层生效了。当HashJoin节点的hashtree维护时间下降,说明连接输入变小带来的收益是真实的。把这些指标对比起来看,就可以判断下推的收益到底是来自行数减少,还是来自索引命中等其他因素。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 下推后行数减少但执行更慢 | 索引失效,扫描路径退化 | 检查谓词列索引、隐式转换 |
| 预估行数与实际行数差一个量级 | 统计信息过期/缺失 | 更新统计信息,检查直方图 |
| 大查询优化时间过长 | 下推组合爆炸 | 增加启发式剪枝,限制候选数量 |
| 查询结果行数变少 | 外连接谓词被错误下推 | 检查LEFT JOIN下推合法性 |
| 下推对性能没有提升 | 过滤率太高,净收益为负 | 查看实际过滤率,提高下推阈值 |
| 多个高相关过滤列时计划突变 | 统计信息未建模相关列 | 引入联合统计或保守因子 |
这类问题在开发环境很难暴露,因为测试数据量小,过滤率又往往很整齐。我建议上线前准备一套专门用来“欺负优化器”的回归用例,包含低过滤率谓词、外连接+过滤、无统计信息表等场景。每次改动代价模型或下推规则,先在这套用例上跑一遍,能少踩很多坑。
5. 实践之后,我对代价模型的一点私人体会
多轮项目做下来,我最大的体会是:代价模型的参数宁可欠调,也不要一次调太多。连接条件下推的收益高度依赖基数估计,而基数估计在真实数据分布下总会有误差。把每个参数的物理含义写清楚,比给公式配一堆神秘系数重要得多。
另一个体会是,统计信息要当成一等公民来维护,而不是上线之后想起来才跑一次。凡是出现“同一个查询昨天快今天慢”的情况,先检查统计信息刷新,再看执行计划变更,大概率比调参数管用。
最后说下后续扩展。连接条件下推这条路走到后面,自然会遇到分布式查询里的Shuffle代价问题,那时代价模型还要再加一项网络传输与数据重分布的估算,公式体系不变,但参数和组合评估的复杂度会明显上升。如果你也在实际项目里遇到过下推决策导致计划劣化的情况,欢迎来聊聊具体场景,很多坑往往是类似的。