写 SQL 的人默认自己在描述"做什么",但很少有人认真想过,从语句提交到结果返回之间,那条语句被改成了什么样子。
绝大多数情况下不需要想。直到某天执行计划变了、迁移换了内核、或者一条依赖求值顺序的语句开始时好时坏,才会发现自己一直站在一层假设上面:以为写下去的东西就是执行的东西。
这篇拆一下中间这一层。优化器做等价变换的依据是什么,它凭什么敢改;条件的求值顺序由谁决定;以及在这些问题上,Oracle、PostgreSQL、MySQL、SQL Server 和 KES 各自选了什么路,代价是什么。
一、"等价"只保证结果集,不保证过程
查询变换里的"等价",指的是变换前后返回的结果集等价。多重集意义上的等价,行数和内容一致。
没被包含在这个承诺里的东西比想象中多:每个表达式被求值几次,条件按什么顺序检查,某个函数会不会因为短路而完全不执行,运行时错误在哪个时刻抛出,甚至结果的返回顺序------只要没写 ORDER BY,顺序从来不在保证范围内。
SQL Server 上有一个流传很广的例子:
sql
SELECT CAST(col AS INT)
FROM t
WHERE ISNUMERIC(col) = 1;
按书写顺序理解,先过滤出能转成数字的行,再做转换,逻辑无懈可击。实际执行时可能报转换失败,因为投影列的计算标量可以被优化器调度到过滤之前,而结果集在没有错误发生的前提下是等价的------优化器的推理里,不包含"这次转换会抛错"这件事。
这条规则解释了很多看起来毫无道理的现象。把有副作用的函数放进 WHERE、靠条件顺序控制执行流程、指望某个函数每行只算一次,这些写法之所以不安全,不是因为哪个数据库实现得不好,而是它们要求的保证从一开始就不在契约里。
理解了这一点,再看下面这些变换,才知道该怕什么。
二、等价变换的几个主要家族
各家的变换清单加起来有几十条,但真正需要理解的不是清单,而是每条变换成立的前提。前提被破坏时它不做,前提被误判时它出错。
常量折叠与表达式预处理。 编译期能算出来的表达式提前算掉,WHERE a = 1 + 2 变成 WHERE a = 3。这里的门槛就是函数的易变性标记:IMMUTABLE 的函数可以在生成计划时直接求值,STABLE 的可以在一条语句内只算一次,VOLATILE 的必须每行都算。声明错了不会报错,只会让优化器在错误的前提下做决策------把一个读表的函数标成 IMMUTABLE,结果被折叠成常量,问题要等到某次计划变化才浮出来。
谓词下推与等价类传递。 t1.a = t2.a AND t1.a = 5 可以推出 t2.a = 5,这个新条件让 t2 也能用上索引。PostgreSQL 把这件事做成了一套显式的等价类机制(EquivalenceClass),把所有已知相等的表达式收进同一个集合,既用来派生新的过滤条件,也用来推导排序键------如果已知 t1.a = t2.a,那么按 t1.a 有序的数据流同时也按 t2.a 有序,上层的排序节点就可以省掉。同一套结构服务两个目的,是 PG 优化器里设计得比较漂亮的部分。
下推有明确的边界。聚合之上的条件不能随便推到聚合之下,除非它只涉及分组列;外连接可空侧的条件不能推过连接;含 VOLATILE 函数的条件不能跨节点移动。这些边界在源码里都是显式的检查,不是靠运气。
外连接化简。 这条最容易被误用。
sql
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,整个结果就是空集,而反连接会照常返回行。两家的处理方式不同:
PostgreSQL 选择保守,除非能证明不可空,否则不做这个转换,NOT IN 就保持子查询计划执行,大数据量下性能塌方。这也是 PG 社区常年推荐用 NOT EXISTS 替代 NOT IN 的原因。
Oracle 从 11g 起支持 null-aware anti join,让反连接自己处理 NULL 语义,转换照做,性能不掉。
同一个语义难题,一家选择不做,一家选择把执行算子改造得能处理它。前者实现简单、行为可预测,代价是把优化的责任推回给写 SQL 的人;后者对使用者友好,代价是执行器复杂度上升。这是这篇里第一个典型的取舍。
连接消除。 查询里连了一张表,但既不取它的列,连接也不改变结果行数,这个连接就可以删掉。
PostgreSQL 做 LEFT JOIN 消除,条件是内侧有唯一性证明(唯一索引或主键)且外部没有引用它的列。有意思的是,PG 从 9.6 起会用外键约束改进连接选择率的估算,却不用外键做内连接消除。Oracle 和 SQL Server 都会基于外键做连接消除。
这里有一个对开发很有用的推论:约束不只是数据校验工具,它同时是提供给优化器的证明材料。 NOT NULL 决定了 NOT IN 能不能转反连接,唯一索引决定了连接能不能被消除,外键决定了行数估算准不准。很多团队在迁移时为了导数据顺利,把约束全部去掉,事后只补数据校验不补约束,等于永久性地拿走了优化器的推理依据。
三、条件执行调度:在哪一层算,谁先算
条件被放在哪里执行,比它写在哪里重要得多。
一条过滤条件最终可能落在三个位置。一是作为索引访问条件,直接决定扫描的起止范围,能减少 I/O;二是作为索引层过滤,在索引项上判断,减少回表次数;三是回表之后在数据行上过滤,此时 I/O 已经发生,只是少往上层传几行。三者的成本差着量级。
各家都在执行计划里暴露了这个分层。PostgreSQL 和 KES 用 Index Cond 与 Filter 区分,配合 Rows Removed by Filter 能直接看出过滤发生在哪一层、白读了多少行;Oracle 的计划里有 access() 和 filter() 两个谓词段;MySQL 的 Extra 里出现 Using index condition 表示条件被下推到了存储引擎层,也就是索引条件下推。
同一层内部的多个条件,谁先算?
PostgreSQL 的做法比较有代表性,可以直接读到源码逻辑:条件先按安全级别排序,同一安全级别内按估算代价升序排列,代价相同的保持书写顺序。这带来两个实际影响。
一个是代价可以被主动干预。PG 系里用户自定义函数有 COST 属性,过程语言函数的默认代价是内置操作符的一百倍,所以一个 PL 函数写在前面也会被排到廉价条件之后执行。如果某个函数实际很贵,把它的 COST 调高,它自然就会被排到最后;反过来,一个便宜但过滤率极高的函数,调低 COST 就能让它先跑。这是少见的、可以合法影响求值顺序的手段。
另一个是安全约束优先于代价。这块牵扯到 leakproof 属性:一个函数如果可能通过报错信息或副作用泄露参数内容,就不是 leakproof 的。在行级安全策略或者安全屏障视图下,用户自己写的条件不允许在安全条件之前求值,否则可以构造一个报错型函数把不该看到的行套出来。为此优化器必须放弃一部分本可以更快的排列。安全和优化在这里是直接冲突的,PG 选择了把安全做成显式属性、让优化器绕着走,而不是靠运气。
各家在"要不要承诺求值顺序"上的立场差别很大。
Oracle 的文档明确不承诺 WHERE 条件的求值顺序。更值得一提的是历史:Oracle 早年提供过 ORDERED_PREDICATES 提示来强制按书写顺序求值,在 10g 里被废弃,理由是谓词顺序应该交给代价模型统一决策。这是一次很清楚的方向选择,把控制权从开发者手里收回给优化器。
MySQL 和 SQL Server 同样不承诺。SQL Server 那个 ISNUMERIC 的例子已经成了教学材料。
KES 在这件事上选了另一条路。作为需要承接 Oracle 存量系统的产品,它在兼容性设计上给出了确定的行为:WHERE 子句中的函数条件按出现的先后顺序从左到右求值。代价是放弃了这部分重排带来的优化空间,换来的是行为可预测、迁移过来的老代码不会因为顺序变化而失效。对一个以替换存量系统为主要场景的数据库来说,这个交换是理性的------但它解决的是"顺序不确定",解决不了"依赖顺序的写法本身有问题"。第一篇里那条靠 WHERE 条件顺序传值的 SQL,即使求值顺序确定,仍然会因为会话状态残留而时好时坏。
顺带说一个可移植的结论:SQL 标准里明确保证求值顺序的结构是 CASE 表达式。需要严格控制先后,用 CASE 包起来,或者干脆拆成两条语句,不要指望 AND 的书写顺序。
四、四条取舍轴
把前面的差异归拢一下,主流数据库在优化器上的分歧集中在四个方向。
变换是规则驱动还是代价驱动。 PostgreSQL 的多数变换是启发式的,规则判定能做就做,不去评估做完是不是真的更快。Oracle 从 10g 起有一套基于代价的查询变换,会把变换后的语句真正估一遍代价,甚至对多种变换组合做取舍。后者能避免"变换了反而更慢"的情况,代价是解析开销显著上升,所以 Oracle 才需要在共享池和游标管理上投入那么多机制。PG 的选择是让规划本身足够快,接受错过一部分优化机会。这两条路没有优劣,服务的负载形态不同。
语义处理的激进程度。 NOT IN 是个典型样本,聚合下推是另一个:把聚合提前到连接之前执行,可以大幅减少参与连接的行数,Oracle 有这类变换,PG 至今没有进主干。保守派的好处是行为容易预测、边界条件不容易出错,激进派的好处是用户不用懂那么多也能拿到好计划。
允许多大程度的人工干预。 PostgreSQL 社区长期拒绝在内核里提供优化器提示,理由是提示会掩盖优化器和统计信息的真实问题,并且写死的提示会随数据变化而过期,内核只提供 enable_ 系列开关这类粗粒度手段。Oracle 和 SQL Server 走的是相反方向,提供了完整的提示体系。
KES 的处理值得单独说。它基于 PG 内核,但内置了 HINT 能力,覆盖扫描方式、连接方式、连接顺序、行数修正、并行和 GUC 设置等类别(早期由 sys_hint_plan 扩展提供,现已并入内核,用 enable_hint 参数控制),并且支持通过 HINT 表把提示与语句做映射,在不改应用 SQL 的前提下干预计划。这不是理念上的摇摆,是现实约束的结果:接手一套跑了十几年、源码可能都找不全的系统,"不改 SQL 也能改计划"是必须具备的能力。理念上 PG 社区是对的,工程上国产库没有拒绝的余地。
计划稳定性怎么保障。 Oracle 有 SQL 计划管理,能把已验证的计划固定下来,新计划必须证明更好才允许启用;SQL Server 的查询存储可以强制计划并做自动纠正;PostgreSQL 内核在这块基本是空白,只能观察不能约束。这也是各家国产数据库在 PG 内核之上补得最多的方向之一。对生产系统来说,一个略慢但稳定的计划,通常比一个平均更快但偶尔翻车的计划更有价值。
五、落到日常怎么用
理解这些机制之后,有几件事的做法会变。
把约束当成优化器的输入。 该有的 NOT NULL、唯一索引、外键要补齐,尤其是迁移过程中为了导数方便临时去掉的那些。这些信息决定了一大批变换能不能成立,而不只是拦住脏数据。
用执行计划验证变换是否发生,而不是靠推理。 几个高价值的观察点:子查询有没有被展开成半连接或反连接,还是仍以子计划形式存在;LEFT JOIN 有没有因为 WHERE 上的条件退化成 Inner Join;条件落在 Index Cond 还是 Filter,Rows Removed by Filter 是不是大得离谱。这些在计划里都是明写的,看一眼比争论半天有用。
需要顺序就显式表达。 用 CASE,或者拆成多条语句,或者把逻辑移到应用层。任何"靠条件书写顺序控制流程"的写法,都应该在代码评审里直接打回。
迁移评估时,重点看变换差异而不是语法差异。 语法不兼容会在编译期暴露,属于便宜问题。真正贵的是那些语法完全合法、迁移工具一路绿灯,但因为两边优化器的变换能力不同而性能塌方的语句------大量 NOT IN、深层嵌套的标量子查询、依赖外键做连接消除的宽视图,都属于这一类。把核心 SQL 在两边各跑一次执行计划做对比,比看兼容率报告实在得多。
六、写在最后
优化器是数据库里少数几个"你不理解它也能用,但理解了会完全改变写法"的组件。它每天都在悄悄改写你的语句,依据是一套写在文档和源码里的规则,不是善意,也不是玄学。你给它的信息越准确,它的推理就越大胆;你依赖它没有承诺过的行为,它迟早会在某次版本升级或者数据分布变化时收走那份运气。
这类知识很难靠读文档一次性掌握,更多是靠反复看执行计划、反复被打脸攒出来的。也正因为如此,把这些判断沉淀成工具比留在个人经验里更有价值------一条"这条 SQL 依赖了未承诺的求值顺序"或者"这个 NOT IN 在当前内核下不会被转成反连接"的检查规则,写成代码之后就能反复使用。
电科金仓目前在办的 2026 金仓数据库智能运维工具开发大赛,方向正好落在智能运维和数据库诊断工具上,社区里提供了参赛开发指南和 BIC-QA 诊断工具的开发指导,具体赛程和评审标准以社区赛事指南帖为准:
bbs.kingbase.com.cn/activityDet...
最后提醒一句,本文涉及的实现细节都会随版本变化,尤其是各家优化器的具体行为。文中结论适合用来建立判断框架,落到具体环境时,还是要以所用版本的官方文档和实际执行计划为准。