一、问题背景
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 时,通常会采用如下执行策略:
- 完整执行子查询:首先扫描基表,执行
DISTINCT、UNION、窗口函数等复杂操作,生成一个大的中间结果集。 - 再与外层表进行 JOIN:将外层表的连接条件和过滤条件应用于这个中间结果集,执行连接和过滤操作。
这一策略的致命问题在于:外层的高选择性 JOIN/WHERE 条件,无法反向约束子查询的扫描范围。当子查询本身计算复杂、数据量大时,这种执行路径几乎必然成为性能瓶颈。优化器往往无法及时识别出可以将外层谓词推入子查询的机会,导致查询执行效率低下。
三、金仓数据库基于代价的连接条件下推设计
为了解决上述问题,金仓数据库在最新的 V009R002C014 版本中,引入了一套“等价性 + 代价模型”双重约束的连接条件下推机制。整体思路可以概括为两步:
3.1 能不能推:等价性判定(Equivalence)
在这一阶段,优化器的目标不是“尽可能多地下推”,而是只识别绝对安全的下推机会。具体步骤包括:
- 分析子查询结构:深入分析子查询的逻辑结构,判断其是否满足语义等价条件。例如,检查子查询中是否包含
GROUP BY、WINDOW、DISTINCT等可能阻碍下推的运算。 - 约束性判定:对包含复杂运算(如聚集、窗口、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;性能对比:
- 未下推时:
- 多个子查询对基表进行全量扫描。
- 生成多个巨大的中间结果集。
- 最终 JOIN 成为性能瓶颈。
- 下推后:
- JOIN 条件提前参与子查询扫描,多个子查询由“全量扫描”转为“选择性扫描”。
- 中间结果规模显著缩减,整体执行时间降至0.23ms。
图3:复杂场景下不同策略执行时间对比(对数刻度)
通过上述复杂场景下的 SQL 可以看出,当连接条件不下推时,数据库需要先处理内部的 UNION 查询,对基表进行两次全量扫描和去重,生成一个巨大的中间结果集,再与外层表连接。而实现将连接条件推入子查询后,可以在扫描阶段就利用连接条件对数据进行筛选,大幅减少中间结果,从而提升性能。
五、总结
在复杂查询优化中,连接条件下推并不是一个简单的规则改写问题,而是一个典型的成本驱动型优化问题:
- 只做规则,不看代价,可能带来灾难性性能回退;
- 只看代价,不保证等价,会直接破坏 SQL 语义。
通过“等价性保障 + 基于代价的决策”的组合设计,优化器可以在安全前提下最大化 JOIN 条件的过滤能力,显著减少子查询阶段的数据扫描与中间结果规模,在复杂 SQL 场景中获得数量级的性能提升。这类优化对于