深入浅出:复杂查询中基于代价的连接条件下推优化实战

0 阅读1分钟

一、问题背景

1.1 客户场景中的典型痛点

在实际业务系统中,SQL 查询往往比教科书示例复杂得多。随着业务复杂度的提升,CTE多层子查询窗口函数聚集计算等被大量用于组织逻辑。这类 SQL 在提高可读性的同时,也给查询优化器带来了巨大挑战。尤其是在 JOIN 条件无法有效提前过滤数据 的场景下,性能问题尤为突出。

例如,考虑以下典型查询模式:

-- 示例:复杂查询模式
SELECT *
FROM (SELECT DISTINCT *
      FROM s1) s
JOIN s2 ON s.s1a = s2.s2a
WHERE s2.b = 3;

从业务语义上看,上述 SQL 逻辑清晰:先对表 s1 去重,再与表 s2 关联,并筛选 s2.b = 3 的记录。然而,从执行角度看,却隐藏着严重的性能隐患:

  • 子查询全量扫描:子查询 s 需要对 s1 做全表扫描并去重,生成一个巨大的中间结果集。

  • 过滤条件未提前:外层 s2.b = 3 的高选择性条件无法影响子查询的扫描范围,导致子查询输出大量无用数据。

  • 后续操作数据量膨胀:JOIN、聚集等后续操作都发生在一个“大数据量”之上,性能急剧下降。

根本问题在于:过滤发生得不够早。如果能将外层的连接条件下推到子查询内部,提前过滤数据,就能显著减少中间结果规模,提升查询性能。

1.2 业界普遍面临的两大难点

JOIN 条件下推 到子查询内部,直观上看是一个有效的优化方向。但在数据库内核层面,这个问题远没有想象中简单,主要体现在以下两个方面:

1.2.1 语义安全性(Equivalence)

JOIN 条件下推 本质上是在改变谓词生效的位置。如果处理不当,很容易改变 SQL 的语义,导致查询结果错误。尤其是在以下场景中,谓词下推可能破坏原有语义:

  • 聚集(GROUP BY):将谓词推入聚集子查询可能改变分组结果。

  • 窗口函数(Window Function):窗口函数依赖完整的行集,谓词提前过滤可能影响窗口计算结果。

  • DISTINCT / UNION:对去重或合并结果集应用谓词,可能导致结果集元素缺失或重复。

  • 含有副作用或非确定性函数的表达式:例如包含随机函数或用户定义函数的谓词,下推后可能改变查询语义。

因此,并非所有 JOIN 条件都可以安全地下推,必须有严格的等价性判定。优化器必须确保:谓词下推前后,查询结果保持一致。

1.2.2 代价评估(Cost)

即使在语义上等价,下推也未必“划算”。优化器需要评估不同执行路径的代价,选择最优方案。主要考虑因素包括:

  • 下推后可能触发参数化执行:外层查询的每一行都会触发子查询重新执行,可能导致 参数化扫描

  • 外层基数较大时的性能风险:如果外层结果集非常大,子查询被重复执行多次,其开销可能超过全量扫描一次的成本。

  • 极端情况下性能回退:在某些极端场景下,谓词下推反而可能导致性能灾难性的下降。

这意味着:JOIN 条件下推不仅要“能推”,还要“值得推”。优化器必须在保证语义等价的前提下,基于代价模型决策是否执行下推。

二、传统方案的局限

传统数据库优化器在面对上述复杂 SQL 时,通常会采用如下执行策略:

  1. 完整执行子查询:首先扫描基表,执行 DISTINCTUNION、窗口函数等复杂操作,生成一个大的中间结果集。

  2. 再与外层表进行 JOIN:将外层表的连接条件和过滤条件应用于这个中间结果集,执行连接和过滤操作。

这一策略的致命问题在于:外层的高选择性 JOIN/WHERE 条件,无法反向约束子查询的扫描范围。当子查询本身计算复杂、数据量大时,这种执行路径几乎必然成为性能瓶颈。优化器往往无法及时识别出可以将外层谓词推入子查询的机会,导致查询执行效率低下。

三、金仓数据库基于代价的连接条件下推设计

为了解决上述问题,金仓数据库在最新的 V009R002C014 版本中,引入了一套 “等价性 + 代价模型”双重约束 的连接条件下推机制。整体思路可以概括为两步:

3.1 能不能推:等价性判定(Equivalence)

在这一阶段,优化器的目标不是“尽可能多地下推”,而是只识别绝对安全的下推机会。具体步骤包括:

  • 分析子查询结构:深入分析子查询的逻辑结构,判断其是否满足语义等价条件。例如,检查子查询中是否包含 GROUP BYWINDOWDISTINCT 等可能阻碍下推的运算。

  • 约束性判定:对包含复杂运算(如聚集、窗口、UNION 等)的子查询进行约束性分析,确定哪些谓词下推不会改变结果集。

  • 拆分 JOIN 条件:将连接条件拆分为 可参数化部分(依赖外层列)和 子查询内部列。只有前者可以考虑下推。

符合条件的 JOIN 谓词,会被改写为参数化过滤条件,注入到子查询的扫描或过滤阶段中。这一步确保:推下去之后,结果不会变

3.2 值不值推:代价模型(Cost)

在通过等价性校验后,并不会立刻选择下推,而是进入代价评估阶段。优化器会比较 下推前后的执行路径,决定是否执行下推:

  • 评估下推前后代价:计算两种方案的全局执行代价,包括子查询扫描行数、中间结果规模等。

  • 考虑参数化执行成本:如果下推导致子查询参数化执行,需要评估重复执行的开销。

  • 选择最优执行计划:综合比较,选择整体代价最低的执行计划。如果代价模型判断下推收益不足,甚至可能带来性能回退,则优化器会自动放弃下推,选择其他执行路径。

这一步确保:推下去之后,确实会更快

四、效果验证

4.1 最小化用例

查询语句

EXPLAIN ANALYZE
SELECT *
FROM (SELECT DISTINCT *
      FROM s3) s3, s1
WHERE s1.s1a = s3.s3a;

测试结果

  • 未下推:子查询全表扫描并去重,生成一个巨大的中间结果集,执行时间约 84ms

  • 下推后:子查询扫描阶段即可利用 JOIN 条件进行数据裁剪,大幅减少扫描行数,执行时间约 0.14ms

图1:最小化用例执行时间对比(对数刻度)

中间结果规模显著下降,性能提升数量级明显。同样,我们来观察 D 厂商(不支持下推)的表现:

EXPLAIN ANALYZE
SELECT /*+use_nl (s3 s1) */ *
FROM (SELECT DISTINCT *
      FROM s3) s3, s1
WHERE s1.s1a = s3.s3a;

执行时间约 1.62ms。由于无法下推谓词,D 厂商数据库选择了 Hash Join 策略,虽然也较快,但无法充分利用索引,且在更复杂场景下可能性能不佳。

图2:不同优化策略下执行时间对比

4.2 复杂场景验证

查询语句

EXPLAIN ANALYZE
SELECT *
FROM (SELECT *
      FROM (SELECT DISTINCT *
            FROM s3
            UNION
            SELECT DISTINCT *
            FROM s3) s3, s1
      WHERE s1.s1d = s3.s3a) s
JOIN (SELECT *
      FROM (SELECT s3a, SUM(s3b) OVER (PARTITION BY s3a) s3d
            FROM s3) s3, s1
      WHERE s1.s1a = s3.s3a) j
  ON s.s3d = j.s3a;

性能对比

  • 未下推时
  1. 多个子查询对基表进行全量扫描。

  2. 生成多个巨大的中间结果集。

  3. 最终 JOIN 成为性能瓶颈。

  • 执行时间约 1081ms

  • 下推后

  1. JOIN 条件提前参与子查询扫描,多个子查询由“全量扫描”转为“选择性扫描”。

  2. 中间结果规模显著缩减,整体执行时间降至 0.23ms

图3:复杂场景下不同策略执行时间对比(对数刻度)

通过上述复杂场景下的 SQL 可以看出,当连接条件不下推时,数据库需要先处理内部的 UNION 查询,对基表进行两次全量扫描和去重,生成一个巨大的中间结果集,再与外层表连接。而实现将连接条件推入子查询后,可以在扫描阶段就利用连接条件对数据进行筛选,大幅减少中间结果,从而提升性能。

五、总结

在复杂查询优化中,连接条件下推并不是一个简单的规则改写问题,而是一个典型的 成本驱动型优化问题

  • 只做规则,不看代价,可能带来灾难性性能回退;

  • 只看代价,不保证等价,会直接破坏 SQL 语义。

通过 “等价性保障 + 基于代价的决策” 的组合设计,优化器可以在 安全前提下最大化 JOIN 条件的过滤能力,显著减少子查询阶段的数据扫描与中间结果规模,在复杂 SQL 场景中获得数量级的性能提升。这类优化对于