☰
连接条件下推与代价模型:深入查询优化器的核心优化策略
2026/10/2 9:24:45 网站建设 项目流程

1. 从一条慢查询说起:为什么要做连接条件下推

1.1 慢查询现场:一个真实的执行计划

前阵子帮团队优化一条报表查询,SQL本身不长,三张表做连接,核心逻辑大概长这样:

SELECT o.user_id, u.city, p.product_name FROM orders o JOIN users u ON o.user_id = u.id JOIN products p ON o.product_id = p.id WHERE u.register_date >= '2024-01-01' AND o.status = 'PAID' AND p.category = '3C';

听起来很常规对吧?但这条SQL在生产环境跑了整整12秒,每天凌晨定时任务都要被它拖住。拿到执行计划后我一眼就看出问题了:优化器选择了先做两个表连接、再做第三表连接,然后在连接完成的中间结果上才执行WHERE条件过滤。orders表有2800万行,users和products也都有上百万行,等三个表全连接完再过滤,中间结果集膨胀到几千万行,内存和CPU全被吃满。

我当时的判断是:连接条件下推没有生效。也就是说,那些原本可以在扫描阶段就过滤掉的数据,比如u.register_date >= '2024-01-01',被放到了连接操作的后面,导致上游的无效行数被放大。后来手动调整了连接顺序、把条件改成在子查询里内层过滤,执行时间直接降到0.8秒。同一个业务结果,差距接近15倍。

这个案例让我重新审视了一个基础但容易被忽略的优化点:连接条件下推。它不像索引调优或分区裁剪那样名声在外,但几乎所有复杂查询的性能问题,都多多少少跟它有关。

1.2 连接条件下推到底推的是什么

用大白话说,连接条件下推就是“把过滤动作尽量往下沉,让数据在进入连接操作之前就变少”。

那“连接条件”具体指什么?这里要分清楚两个概念:

  • join condition:写在ON后面的关联条件,比如o.user_id = u.id,它决定了两个表怎么配对。
  • filter condition:写在WHERE后面的过滤条件,比如u.register_date >= '2024-01-01',它决定哪些行要保留。

在很多执行计划里,这两类条件会被统一抽象成“谓词”(predicate),而“下推”动作就是把这些谓词从上层算子(比如Join、Aggregate)移动到下层算子(比如TableScan、IndexScan)。

打个比方:你要把两堆不同颜色的积木合在一起,然后从混合后的积木里挑出红色的,显然不好。更好的做法是先各自把非红色的积木扔掉,再合并。连接条件下推就是这个“先扔掉再合并”的思路。

对于orders和users做连接,如果能把u.register_date >= '2024-01-01'下推到 users 表的扫描节点,那么参与连接的 right side 就从百万行缩到十几万行。这一步做与不做,直接影响连接算法的选择:数据量小可能走 Hash Join 的小表建哈希表,数据量大就只能在磁盘上做落盘排序,性能天差地别。

但问题来了:是不是所有条件都无脑下推就最好?不是。我见过不少下推之后反而变慢的例子。原因很简单,下推是有代价的——比如下推导致索引失效,或者下推的表达式本身计算成本极高。这时候就需要引入“代价”的概念,用数据说话,而不是靠经验拍板。

2. 代价模型:下推判断的底层逻辑

2.1 代价估算为什么不是拍脑袋

很多刚接触优化器的人会问:下推条件这么明显的收益,为什么还要算代价?因为数据库和查询引擎里的每个算子都有真实的开销:

  • 表扫描要读磁盘页,有IO代价。
  • 过滤条件对每一行做判断,有CPU代价。
  • 连接算子要建哈希表或比较键值,有内存和CPU代价。
  • 中间结果如果超过内存阈值,还会溢写到磁盘,代价成倍增长。

而“下推”本质上是在做一个权衡:把过滤运算提前,用扫描阶段的CPU/IO开销,换取连接阶段的数据量缩减。如果过滤条件本身很便宜、选择性很高(能滤掉大部分行),下推稳赚;但如果过滤条件里挂了一个重量级的用户自定义函数(UDF),每一行都要执行一次换算,下推后扫描阶段要处理的行数可能跟原来差不多,反而多付出了计算成本。

所以真正可靠的连接条件下推,必须基于代价估算,而不是“只要条件能下推就下推”。这里的核心参数是每个谓词的选择率(selectivity),也就是这个条件过滤后剩余行数占总行数的比例。选择率越低,过滤效果越好,下推的收益越大。

2.2 从统计信息到连接顺序:三个关键参数

要算下推前后的代价,至少需要三样东西:基础统计信息、谓词选择率、估算行数。

先看统计信息。表扫描算子需要知道当前表有多少行(row_count)、每列有多少不同值(distinct_count)、最大值最小值(min/max)、有没有直方图(histogram)。这些信息一般在数据库的 catalog 或元数据服务里。没有统计信息,优化器就只能瞎猜,比如默认选择率是 5%,那就会出现严重误判。

假设 orders 表有 2800 万行,status = 'PAID'这个条件的选择率为 0.2,意味着过滤后剩 560 万行。如果不下推,这 2800 万行会全部参与连接;下推之后,扫描阶段先过滤,只有 560 万行进连接。在 Hash Join 场景里,右表想要 build 成哈希表,我们当然希望 build 侧越小越好。

再看连接结果行数估算。两表连接后的行数大约是left_rows * right_rows / max(distinct_left_key, distinct_right_key)。这在代价模型中很重要,因为它决定了下一层算子的输入规模。

我遇到过一种情况:下推条件后,右表行数确实变少了,但连接键的 distinct 数量也变了。比如右表过滤后剩下的大部分都是同一个 key,那么中间结果的膨胀倍数反而更大。如果只看行数不下推,就会掉坑。

所以我在实际优化器规则里,会用一个统一的形式计算下推候选的收益:

下推收益 = (原始连接输入行数 × 连接代价单位) - (过滤后输入行数 × 连接代价单位 + 过滤算子自身代价)

只有当收益大于某个阈值时,才真正执行下推。这里的“代价单位”是引擎内部抽象出来的权重,不必太纠结数值,关键是要把不同算子之间的相对关系表达出来。

3. 两种主流实现:静态规则与代价驱动

3.1 静态下推的局限

很多开源引擎早期都采用“规则优化”(RBO,Rule-Based Optimization),里面有一条铁律:Filter 下压到 TableScan 之前,Join 条件下推。这听起来没毛病,因为它符合“尽早过滤”的直觉。

但我在实际业务中真遇到过反例。有个查询在order_time列上建了索引,过滤条件写成date(order_time) = '2024-11-11'。静态规则一看,这是过滤条件,可以下推,就把它压到索引扫描节点上。结果数据库没法用普通索引,因为索引键是原始order_time,对函数值建不了索引,于是优化器被迫从 Index Range Scan 退化成 Full Index Scan,扫描量反而比不下推还要大。

这种场景里,正确做法是判断谓词是否可以被索引匹配,或者把date(order_time) = '2024-11-11'自动改写成order_time >= '2024-11-11 00:00:00' AND order_time < '2024-11-12 00:00:00',但这已经超出了“无脑下推”的范畴。

静态规则还有一个问题:它完全不看数据分布。同一个谓词,在数据均匀分布的表上和严重倾斜的表上,下推收益完全不同。静态规则只会回答“能不能下推”,不会回答“应不应该下推”。

3.2 基于代价的动态下推改怎么写

要解决上述问题,就得把下推从“规则”变成“候选选择”。我在引擎里实现时做了三步:

第一步,枚举所有可能的下推位置。一个条件不是只有“下推”和“不下推”两种状态,它还可以选择下推到 Join 的某个输入侧,或者多层下推。例如WHERE t1.a = 1 AND t2.b = 2,两个条件分别来自不同表,就应该分别下推到各自表的扫描节点。

第二步,对每个候选方案做代价估算。这一步需要调用统计信息模块,获取每个表扫描的原始行数、过滤后的估算行数,以及这个过滤算子本身的执行开销。这部分不要做得太复杂,先用线性模型就够用。

第三步,生成新的执行计划并对比。如果下推方案的总代价比原始方案低,就保留下推;否则维持原样。

伪代码大致是:

def optimize_with_cost(plan): filter_nodes = collect_filters(plan) candidates = [] for f in filter_nodes: for target in possible_pushdown_targets(f): new_plan = pushdown(plan, f, target) if cost(new_plan) < cost(plan): candidates.append((cost, new_plan)) return min(candidates, key=lambda x: x[0])[1] if candidates else plan

这里面的cost()函数就是核心:它得能反映“过滤下推后,连接输入行数减小带来的收益”和“过滤算子本身的计算代价”之间的平衡。说句实在话,真正决定效果的不是伪代码逻辑,而是代价模型的参数标定。

4. 实战:在查询引擎里落地代价下推

4.1 改造前的准备:算子接口与统计信息

如果你是在自研查询引擎里做这个优化,建议先确认三件事。

第一,你的计划节点有没有提供accept或者transform接口。如果压根没有树结构的 rewrite 能力,那代价下推无从谈起。我一般会有一个PlanRewriter基类,负责遍历逻辑计划树,递归调用规则。

第二,统计信息能不能拿到表和列的 card。如果表扫描节点没有暴露row_count,代价模型只能靠猜。建议至少实现一个StatsProvider接口,能从 catalog 里查到每张表的元数据。

第三,谓词表达式能不能被拆开分析。我们需要判断一个条件里涉及哪张表的哪些列,比如o.status = 'PAID'依赖的是 orders 表的列,那它就可以下推到 orders 的扫描节点。如果表达式同时涉及两张表,比如o.user_id = u.id,那它是连接条件,不能单纯下推。

改造前先写好这三个基础模块,后面就水到渠成。我自己第一次做的时候跳过了第二点,直接默认表行数是 100 万,结果上线后大批查询的估算行数全错了,后来花了一整天重新接统计信息。

4.2 完整实现步骤与核心代码

下面给出一套简化但可落地的实现过程,语言用 Java 风格,因为大多数数据库内核是 Java 或 C++ 写的,关键是思路。

步骤一:定义代价估算接口

public interface CostEstimator { Cost estimateScan(TableScanNode scan, Stats stats); Cost estimateFilter(FilterNode filter, Cost inputCost, Stats stats); Cost estimateJoin(JoinNode join, Cost leftCost, Cost rightCost); }

每个算子返回的Cost至少包含 CPU 和 IO 两个分量,内部做加权。我习惯用totalCost = ioCost * 1.0 + cpuCost * 0.5,数值本身不重要,重要的是相对大小。

步骤二:重写 Filter 节点的下推逻辑

这段是整个优化的心脏:

class PushDownFilterRule extends PlanRewriter { @Override public PlanNode visitFilter(FilterNode filter, PlanContext ctx) { PlanNode child = rewrite(filter.getChild()); List<Expression> predicates = splitConjunction(filter.getPredicate()); PlanNode bestPlan = filter.withChild(child); Cost bestCost = ctx.estimate(bestPlan); for (Expression pred : predicates) { if (!ctx.canPushDown(pred, child)) continue; // 构造下推后的计划:过滤子节点 -> 再把剩余条件保留 PlanNode newChild = new FilterNode(child, pred); PlanNode candidate = new FilterNode(newChild, removePredicate(predicates, pred)); Cost candidateCost = ctx.estimate(candidate); if (candidateCost.lessThan(bestCost)) { bestPlan = candidate; bestCost = candidateCost; } } return bestPlan; } }

这里有个容易被忽略的细节:下推后,原来的 Filter 节点不能直接删除。因为一个WHERE里可能有多个条件,有的能下推,有的不能。比如o.status = 'PAID' AND o.total > 100 AND o.user_id IN (SELECT user_id FROM vip_users),第三个是子查询相关条件,不一定能下推,所以要保留在原来的 Filter 节点里。

步骤三:在表扫描节点上计算下推后的代价

Cost estimateScanWithFilter(TableScanNode scan, Expression filter, Stats stats) { double selectivity = stats.estimateSelectivity(filter); long rowsAfterFilter = (long) (stats.getRowCount() * selectivity); Cost filterCost = new Cost(rowsAfterFilter * CPU_UNIT, 0); Cost scanCost = estimateScan(scan, stats); Cost joinSaved = estimateJoinInputCost(rowsAfterFilter); return filterCost.plus(scanCost).minus(joinSaved); }

这一步的estimateSelectivity需要直方图或唯一值统计。如果引擎数据湖里没有统计信息,我会在会话级打开“动态采样”,跑一个SELECT COUNT(*)的近似查询来估算选择率。代价略高,但比瞎猜稳。

步骤四:注册到优化器管线

把上面的规则插到 CBO 规则链的前端,注意别和谓词合并且、列裁剪等规则冲突。一般放在谓词合并之前,因为先合并再下推会少走很多弯路。我踩过一次坑:先做列裁剪,结果把下推条件里要用的列裁剪掉了,后面怎么都推不下去。调整规则顺序后问题消失。

4.3 参数调优与场景验证

代价模型里有几个权重系数,不是越高越好,需要针对你的引擎硬件和典型查询调。

第一个参数是“下推收益阈值”。我一般设置成“下推后连接输入行数降低 10% 以上才执行”。如果只降低 1%,下推带来的计划重建成本和可能的索引失效风险就不划算了。这个阈值在测试环境可以调高,在生产环境要保守一点。

第二个参数是“连接估算膨胀系数”。真实连接结果往往比公式算出来的大,因为数据倾斜。我会在代价模型里给连接结果乘以一个 1.2 的修正系数,避免优化器低估连接开销,从而过度依赖下推。

验证阶段不要只测一两条 SQL,要拿上一周的慢查询日志做回放。我当时整理了一个 30 条查询的回归集,包括:

  • 单表过滤 + 两表连接;
  • 三表连接,且过滤条件分布在不同表上;
  • 过滤条件中包含 UDF;
  • 过滤条件在索引列上,下推后可以走索引;
  • 过滤条件在非索引列上,下推后只能全表扫。

然后对比启用代价下推前后的执行时间和扫描行数。执行时间需要跑三次取中位数,因为缓存会影响结果。这里有个小技巧:在每轮测试之间执行DISCARD PLAN CACHE(PostgreSQL)或ALTER SYSTEM CLEAR QUERY CACHE,防止计划缓存掩盖优化效果。

5. 典型案例:三表连接下的下推决策

5.1 案例一:过滤性极强的条件下推

场景是用户维表关联订单维表,再关联商品维表。业务要求统计某个城市、某个商品分类在指定时间段的支付订单量。SQL 里city、category、register_date三个条件分布在三张表上。

这三个条件的选择率分别是:城市条件过滤后剩 2% 的行,商品分类过滤后剩 15%,注册时间过滤后剩 30%。如果三个条件都不下推,先做连接,那么参与连接的行数是全量三表数据,数量级约2800万 × 190万 × 30万的笛卡尔空间,再经过等值连接后中间结果可能有几百万行。

我们的代价下推优化器把所有组合都算了一遍,最终选择把三个条件分别下推到各自的表扫描节点。此时 orders 表只剩约 56 万行(2800万 × 2%),users 表剩约 28.5 万行(190万 × 15%),products 表剩约 9 万行(30万 × 30%)。连接顺序也变成了从小到大的products -> users -> orders,整个执行计划变成一个“先缩表再连接”的结构,实际执行 0.8 秒左右。

5.2 案例二:下推反而更慢的反直觉场景

另一个场景让人印象深刻:表order_logs有 5000 万行,连接条件是一个 JSON 字段里的某个属性,JSON_EXTRACT(extra, '$.source') = 'app'。直觉上下推后应该更小,但代价模型给出的结论是“不下推”。

原因出在JSON_EXTRACT这个函数上。它在 PostgreSQL 里是非低成本函数,对每行都要做一次 JSON 解析和字段提取,CPU 开销比普通等值比较高出百倍。如果下推,扫描阶段要对 5000 万行全部执行 JSON_EXTRACT;不下推的话,可以先和其他表做连接,连接后的行数可能因为另一张表的过滤条件降到只有 300 万行,然后再对 300 万行做 JSON_EXTRACT,总计算量反而小了一个数量级。

这个案例告诉我们,代价下推真正关注的是“过滤掉的行”和“为过滤付出的成本”之间的比值。如果过滤代价太大,即使过滤效果好,也可能划算不过来。优化器里必须把表达式自身的 cost 纳入模型,不能默认所有谓词都是等价的。

5.3 效果对比数据

我把两个典型场景的执行数据整理成下表,方便直观感受:

场景是否下推表扫描行数连接输入行数执行时间内存使用
多条件三表连接下推orders 56万约 56 万0.8s1.2GB
多条件三表连接不下推orders 2800万约 560 万12.4s6.8GB
JSON字段过滤连接下推order_logs 5000万约 5000 万23.7s9.1GB
JSON字段过滤连接不下推order_logs 5000万约 300 万5.2s2.3GB

第一行和第三行都是基于代价模型的决策结果,一个选择下推,一个选择不下推,两种决策都带来了最优性能。这也再次说明,代价驱动不是“越多越好”,而是“合适就好”。

6. 排查与避坑:我在生产中遇到的三个问题

6.1 统计信息过期导致的误判

代价下推最怕的是统计信息不准。我遇到过一张订单表,因为批量任务每天更新大量数据,表行数在 1000 万到 5000 万之间波动,但统计信息还是 24 小时前的 1200 万。优化器根据旧统计,认为某个过滤条件能滤掉 90% 的行,于是把条件下推,实际上当天数据分布完全变了,过滤后只滤掉 5%,下推后的 Hash Join 反而因为右表太大而频繁溢写磁盘。

排查方法很简单:EXPLAIN 后看预估行数和实际行数的偏差。如果偏差超过三倍,优先刷新统计信息再谈优化。但在生产环境刷新统计信息也有成本,我后来养成了一个习惯——对核心大表配置周期性的采样统计,每隔 15 分钟做一次 APPROX_COUNT_DISTINCT 采样。这样不但让代价下推更准,还能顺带稳住其他 CBO 优化规则。

6.2 表达式下推的边界处理

不是所有表达式都能安全下推。第一次做代价下推时,我把random()和now()也当成普通谓词处理,结果同一个查询每次执行计划都不一样,缓存直接失效。

正确做法是把表达式分为三类:

  • 确定性纯函数:a + 1、substr(name, 1, 3),这类可以下推。
  • 确定性但依赖列统计:col1 = col2,需要看两个列的相关性,可以下推但要额外评估。
  • 非确定性函数:random()、now(),每次调用结果不同,绝对不能下推,否则扫描阶段的过滤结果不可重复,语义就错了。

实现上可以在表达式树上加一个isDeterministic()标志,递归判断所有叶子节点。这也是我们在 code review 时最容易忽略的点。

6.3 子查询与相关谓词的陷阱

还有一种情况:过滤条件里带子查询,比如WHERE o.user_id IN (SELECT id FROM vip_users)。理论上这个条件可以下推吗?不能直接下推。因为vip_users表的数据可能随着执行而变化,相关子查询下推到扫描节点后,语义可能变成逐行执行子查询,性能反而爆炸。

正确步骤是先做子查询去相关化(uncorrelated subquery unnesting),把IN子查询改写成一个半连接(Semi Join),再决定过滤条件下推的时机。很多引擎的优化器规则里,去相关化和条件下推是两条独立规则,顺序很重要。我的经验是:先去相关化,再应用代价下推。否则代价模型会把子查询当成一个黑盒谓词,估算出来的选择率毫无参考价值。

另外还要留意标量子查询(Scalar Subquery)和 EXISTS 子查询,它们往往会被错误地下推。至少要满足“子查询内不引用外层表的列”这个条件,才允许做下推尝试。

7. 最后的小经验

在我把代价下推真正落地到引擎之后,有几个体会特别深。第一,代价模型不需要一开始就做得很复杂,线性模型跑通之后,再用真实统计去校准权重,比直接上一套神经网络靠谱得多。第二,规则顺序比规则本身更重要,代价下推和谓词合并、去相关化、列裁剪之间的依赖关系要先理清楚,否则会互相抵消。第三,每次修改代价模型,都记得保留旧计划的 fallback 开关,一句set enable_expensive_pushdown = off就能让你在线上出问题时快速回滚,不至于三更半夜手忙脚乱。

最后再分享一个小技巧:在支持 hint 的引擎里,可以先放下/*+ no_pushdown_pred(...) */这种 hint,手动干扰优化器,观察执行时间变化,用来反推代价模型对某个谓词的估算偏差。这个办法我用了很多次,屡试不爽。优化器是工具,你才是那个最终拍板的人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询