写 SQL 的人默认自己在描述"做什么",但很少有人认真想过,从语句提交到结果返回之间,那条语句被改成了什么样子。

绝大多数情况下不需要想。直到某天执行计划变了、迁移换了内核、或者一条依赖求值顺序的语句开始时好时坏,才会发现自己一直站在一层假设上面:以为写下去的东西就是执行的东西。

这篇拆一下中间这一层。优化器做等价变换的依据是什么,它凭什么敢改;条件的求值顺序由谁决定;以及在这些问题上,Oracle、MySQL、SQL Server 和 KES 各自选了什么路,代价是什么。

一、"等价"只保证结果集,不保证过程

查询变换里的"等价",指的是变换前后返回的结果集等价。多重集意义上的等价,行数和内容一致。

没被包含在这个承诺里的东西比想象中多:每个表达式被求值几次,条件按什么顺序检查,某个函数会不会因为短路而完全不执行,运行时错误在哪个时刻抛出,甚至结果的返回顺序——只要没写 ORDER BY,顺序从来不在保证范围内。

SQL Server 上有一个流传很广的例子:

SELECT CAST(col AS INT)
FROM t
WHERE ISNUMERIC(col) = 1;

按书写顺序理解,先过滤出能转成数字的行,再做转换,逻辑无懈可击。实际执行时可能报转换失败,因为投影列的计算标量可以被优化器调度到过滤之前,而结果集在没有错误发生的前提下是等价的——优化器的推理里,不包含"这次转换会抛错"这件事。

这条规则解释了很多看起来毫无道理的现象。把有副作用的函数放进 WHERE、靠条件顺序控制执行流程、指望某个函数每行只算一次,这些写法之所以不安全,不是因为哪个数据库实现得不好,而是它们要求的保证从一开始就不在契约里。

理解了这一点,再看下面这些变换,才知道该怕什么。

二、等价变换的几个主要家族

各家的变换清单加起来有几十条,但真正需要理解的不是清单,而是每条变换成立的前提。前提被破坏时它不做,前提被误判时它出错。

常量折叠与表达式预处理。 编译期能算出来的表达式提前算掉,WHERE a = 1 + 2 变成 WHERE a = 3。这里的门槛就是函数的易变性标记,以 KES 为例分三档:IMMUTABLE 的函数可以在生成计划时直接求值,STABLE 的可以在一条语句内只算一次,VOLATILE 的必须每行都算。声明错了不会报错,只会让优化器在错误的前提下做决策——把一个读表的函数标成 IMMUTABLE,结果被折叠成常量,问题要等到某次计划变化才浮出来。

谓词下推与等价类传递。 t1.a = t2.a AND t1.a = 5 可以推出 t2.a = 5,这个新条件让 t2 也能用上索引。成熟的优化器通常把这件事做成一套显式的等价类机制,把所有已知相等的表达式收进同一个集合,既用来派生新的过滤条件,也用来推导排序键——如果已知 t1.a = t2.a,那么按 t1.a 有序的数据流同时也按 t2.a 有序,上层的排序节点就可以省掉。同一套结构服务两个目的,是优化器设计里比较漂亮的思路。

下推有明确的边界。聚合之上的条件不能随便推到聚合之下,除非它只涉及分组列;外连接可空侧的条件不能推过连接;含 VOLATILE 函数的条件不能跨节点移动。这些边界在实现里都是显式的检查,不是靠运气。

外连接化简。 这条最容易被误用。

SELECT *
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE p.status = 'PAID';

写的人以为自己在做外连接,实际上因为 WHERE 里有一个对可空侧的严格条件(NULL 输入必然产生 NULL 或 false 的条件),所有补 NULL 的行都会被过滤掉,这个 LEFT JOIN 与 INNER JOIN 完全等价。优化器会做这个化简,执行计划里直接显示 Inner Join。变换本身没错,错的是写的人以为自己保留了左表全集。条件应该写在 ON 上还是 WHERE 上,语义完全不同,这不是风格问题。

子查询展开与去关联。 IN 和 EXISTS 子查询通常被转成半连接,NOT EXISTS 转成反连接,转换之后就能参与连接顺序和连接算法的选择,代价可能相差几个数量级。

问题出在 NOT IN 上。a NOT IN (SELECT b FROM t) 在 b 可能为 NULL 时,不等价于反连接:只要子查询里出现一个 NULL,整个结果就是空集,而反连接会照常返回行。面对这个语义难题,各家分成了两派:

保守的一派,除非能证明列不可空,否则不做这个转换,NOT IN 就保持子查询计划执行,大数据量下性能塌方。这也是不少团队在 SQL 规范里要求用 NOT EXISTS 替代 NOT IN 的原因。

Oracle 从 11g 起支持 null-aware anti join,让反连接自己处理 NULL 语义,转换照做,性能不掉。

同一个语义难题,一派选择不做,一派选择把执行算子改造得能处理它。前者实现简单、行为可预测,代价是把优化的责任推回给写 SQL 的人;后者对使用者友好,代价是执行器复杂度上升。这是这篇里第一个典型的取舍。

连接消除。 查询里连了一张表,但既不取它的列,连接也不改变结果行数,这个连接就可以删掉。

LEFT JOIN 的消除,前提是内侧有唯一性证明(唯一索引或主键)且外部没有引用它的列;内连接的消除要求更强的证明,Oracle 和 SQL Server 都会基于外键约束来做。能消除到什么程度,取决于优化器手里有多少证明材料。

这里有一个对开发很有用的推论:约束不只是数据校验工具,它同时是提供给优化器的证明材料。 NOT NULL 决定了 NOT IN 能不能转反连接,唯一索引决定了连接能不能被消除,外键决定了行数估算准不准。很多团队在迁移时为了导数据顺利,把约束全部去掉,事后只补数据校验不补约束,等于永久性地拿走了优化器的推理依据。

三、条件执行调度:在哪一层算,谁先算

条件被放在哪里执行,比它写在哪里重要得多。

一条过滤条件最终可能落在三个位置。一是作为索引访问条件,直接决定扫描的起止范围,能减少 I/O;二是作为索引层过滤,在索引项上判断,减少回表次数;三是回表之后在数据行上过滤,此时 I/O 已经发生,只是少往上层传几行。三者的成本差着量级。

各家都在执行计划里暴露了这个分层。KES 用 Index Cond 与 Filter 区分,配合 Rows Removed by Filter 能直接看出过滤发生在哪一层、白读了多少行;Oracle 的计划里有 access() 和 filter() 两个谓词段;MySQL 的 Extra 里出现 Using index condition 表示条件被下推到了存储引擎层,也就是索引条件下推。

同一层内部的多个条件,谁先算?

答案通常不是书写顺序。优化器会按自己估算的代价和过滤能力给条件重排,便宜的、过滤性强的先跑,贵的往后放。重排依据的是统计信息和代价模型,两者都会随数据和版本变化,所以这个顺序对使用者既不可见,也不稳定。这也解释了为什么"把便宜条件写在前面"这类手工微调通常没什么意义——优化器自己会排,真正该维护的是让它排得准的统计信息。

各家在"要不要承诺求值顺序"上的立场差别很大。

Oracle 的文档明确不承诺 WHERE 条件的求值顺序。更值得一提的是历史:Oracle 早年提供过 ORDERED_PREDICATES 提示来强制按书写顺序求值,在 10g 里被废弃,理由是谓词顺序应该交给代价模型统一决策。这是一次很清楚的方向选择,把控制权从开发者手里收回给优化器。

MySQL 和 SQL Server 同样不承诺。SQL Server 那个 ISNUMERIC 的例子已经成了教学材料。

KES 在这件事上选了另一条路。作为需要承接 Oracle 存量系统的产品,它在兼容性设计上给出了确定的行为:WHERE 子句中的函数条件按出现的先后顺序从左到右求值。代价是放弃了这部分重排带来的优化空间,换来的是行为可预测、迁移过来的老代码不会因为顺序变化而失效。对一个以替换存量系统为主要场景的数据库来说,这个交换是理性的——但它解决的是"顺序不确定",解决不了"依赖顺序的写法本身有问题"。第一篇里那条靠 WHERE 条件顺序传值的 SQL,即使求值顺序确定,仍然会因为会话状态残留而时好时坏。

顺带说一个可移植的结论:SQL 标准里明确保证求值顺序的结构是 CASE 表达式。需要严格控制先后,用 CASE 包起来,或者干脆拆成两条语句,不要指望 AND 的书写顺序。

四、四条取舍轴

把前面的差异归拢一下,主流数据库在优化器上的分歧集中在四个方向。

变换是规则驱动还是代价驱动。 不少优化器把变换做成启发式规则,判定能做就做,不去评估做完是不是真的更快,好处是规划阶段本身足够快,适合高并发的短查询负载。Oracle 从 10g 起有一套基于代价的查询变换,会把变换后的语句真正估一遍代价,甚至对多种变换组合做取舍。后者能避免"变换了反而更慢"的情况,代价是解析开销显著上升,所以 Oracle 才需要在共享池和游标管理上投入那么多机制。这两条路没有优劣,服务的负载形态不同。

语义处理的激进程度。 NOT IN 是个典型样本,聚合下推是另一个:把聚合提前到连接之前执行,可以大幅减少参与连接的行数,Oracle 有这类变换,不少数据库至今没有。保守派的好处是行为容易预测、边界条件不容易出错,激进派的好处是用户不用懂那么多也能拿到好计划。

允许多大程度的人工干预。 这在数据库圈是个长期争论。反对提示的理由很硬:提示会掩盖优化器和统计信息的真实问题,并且写死的提示会随数据变化而过期,所以应该只留粗粒度开关。Oracle 和 SQL Server 站在另一边,提供了完整的提示体系,把权衡交给使用者。

KES 的处理值得单独说。它内置了 HINT 能力,覆盖扫描方式、连接方式、连接顺序、行数修正、并行和参数设置等类别,由 enable_hint 参数控制,并且支持通过 HINT 表把提示与语句做映射,在不改应用 SQL 的前提下干预计划。这不是理念上的摇摆,是现实约束的结果:接手一套跑了十几年、源码可能都找不全的系统,"不改 SQL 也能改计划"是必须具备的能力。理念上反对提示的论点都成立,工程上国产库没有拒绝的余地。

计划稳定性怎么保障。 Oracle 有 SQL 计划管理,能把已验证的计划固定下来,新计划必须证明更好才允许启用;SQL Server 的查询存储可以强制计划并做自动纠正。这也是各家国产数据库近年重点投入的方向之一。对生产系统来说,一个略慢但稳定的计划,通常比一个平均更快但偶尔翻车的计划更有价值。

五、落到日常怎么用

理解这些机制之后,有几件事的做法会变。

把约束当成优化器的输入。 该有的 NOT NULL、唯一索引、外键要补齐,尤其是迁移过程中为了导数方便临时去掉的那些。这些信息决定了一大批变换能不能成立,而不只是拦住脏数据。

用执行计划验证变换是否发生,而不是靠推理。 几个高价值的观察点:子查询有没有被展开成半连接或反连接,还是仍以子计划形式存在;LEFT JOIN 有没有因为 WHERE 上的条件退化成 Inner Join;条件落在 Index Cond 还是 Filter,Rows Removed by Filter 是不是大得离谱。这些在计划里都是明写的,看一眼比争论半天有用。

需要顺序就显式表达。 用 CASE,或者拆成多条语句,或者把逻辑移到应用层。任何"靠条件书写顺序控制流程"的写法,都应该在代码评审里直接打回。

迁移评估时,重点看变换差异而不是语法差异。 语法不兼容会在编译期暴露,属于便宜问题。真正贵的是那些语法完全合法、迁移工具一路绿灯,但因为两边优化器的变换能力不同而性能塌方的语句——大量 NOT IN、深层嵌套的标量子查询、依赖外键做连接消除的宽视图,都属于这一类。把核心 SQL 在两边各跑一次执行计划做对比,比看兼容率报告实在得多。

六、写在最后

优化器是数据库里少数几个"你不理解它也能用,但理解了会完全改变写法"的组件。它每天都在悄悄改写你的语句,依据是一套写在文档和实现里的规则,不是善意,也不是玄学。你给它的信息越准确,它的推理就越大胆;你依赖它没有承诺过的行为,它迟早会在某次版本升级或者数据分布变化时收走那份运气。

这类知识很难靠读文档一次性掌握,更多是靠反复看执行计划、反复被打脸攒出来的。也正因为如此,把这些判断沉淀成工具比留在个人经验里更有价值——一条"这条 SQL 依赖了未承诺的求值顺序"或者"这个 NOT IN 在当前内核下不会被转成反连接"的检查规则,写成代码之后就能反复使用。

电科金仓目前在办的 2026 金仓数据库智能运维工具开发大赛,方向正好落在智能运维和数据库诊断工具上,社区里提供了参赛开发指南和 BIC-QA 诊断工具的开发指导,具体赛程和评审标准以社区赛事指南帖为准:

https://bbs.kingbase.com.cn/activityDetail?activityId=915ccd26c9e895ebea389b2479880712

最后提醒一句,本文涉及的实现细节都会随版本变化,尤其是各家优化器的具体行为。文中结论适合用来建立判断框架,落到具体环境时,还是要以所用版本的官方文档和实际执行计划为准。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐