数据库优化器到底在想什么:一个SQL今天能跑明天崩的背后

数据库优化器到底在想什么:一个SQL今天能跑明天崩的背后

这事得从一条"薛定谔"的SQL说起

去年做Oracle迁移金仓的时候碰上个怪事。

有个查询在测试环境跑得好好的,上生产就抽风了。同一条SQL,同一个用户,有时候能出结果,有时候返回空集。最离谱的是,在同一个会话里连续执行两次,结果都能不一样。

我当时就觉得不对劲,把SQL单独拉出来跑了一遍:

sql 复制代码
SELECT * FROM my_table 
WHERE id1 = pkg_abc.get_id() 
  AND pkg_abc.set_id(10) = 1;

开发的思路很简单:先用set_id存个值,再用get_id取出来查数据。在Oracle里测试的时候,这SQL跑得挺顺。因为Oracle的优化器算了一笔账,觉得set_id这个条件是常量,计算代价低,先跑了它,然后get_id拿到了值,查询正常。

到了生产环境,数据量变了,统计信息也变了,优化器重新算了笔账:这次它觉得get_id更便宜,先跑了get_id。结果get_id跑的时候变量还是空的,条件不成立,set_id根本轮不到执行。

所以同一句SQL,优化器换了个决定,结果就全变了。

更坑的是会话污染。如果当前会话之前执行过赋值操作,变量里已经存了值,后面这句SQL不管怎么跑都能出数据。开发测试的时候碰巧遇上了这种情况,就以为SQL没问题。等新连接进来,变量是空的,马上就崩了。

这种bug最难查,因为它不是每次都出现。你debug的时候可能正好变量有值,跑通了,等到了生产又不行了。来回折腾好几次才找到根因。

下面这张时序图清楚地展示了两种不同的执行路径,以及会话污染导致测试假象的过程:

sequenceDiagram participant Dev as 开发人员 participant App as 应用会话 participant Session as 数据库会话 participant Optimizer as 优化器 participant Executor as 执行引擎 Note over Dev,Executor: 场景一:测试环境跑通(set_id先执行) Dev->>App: 执行SQL App->>Session: 建立连接 Session->>Optimizer: 解析SQL,估算代价 Note over Optimizer: set_id(10)=1 判定为常量条件<br/>计算代价低,排到前面 Optimizer->>Executor: 执行顺序:先set_id,再get_id Executor->>Executor: 第1步:set_id(10)=1(变量赋值为10) Executor->>Executor: 第2步:get_id()(变量已赋值,返回10) Executor->>Executor: 第3步:查询id1=10 Executor-->>Session: 返回结果 Session-->>App: ✅ 查询成功 Note over Dev,Executor: 开发看到&#34;能跑通&#34;,误以为SQL没问题 Note over Dev,Executor: 场景二:生产环境翻车(优化器选了不同顺序) Dev->>App: 执行SQL App->>Session: 建立新连接(变量初始为空) Session->>Optimizer: 解析SQL,估算代价 Note over Optimizer: 统计信息变了,get_id()代价更低<br/>排到前面 Optimizer->>Executor: 执行顺序:先get_id,再set_id Executor->>Executor: 第1步:get_id()(变量为空,返回NULL) Note over Executor: NULL判断为false,触发短路 Executor->>Executor: 第2步:set_id(10)=1(未执行!) Executor-->>Session: 返回空集 Session-->>App: ❌ 返回空数据 Note over Dev,Executor: 场景三:会话污染造成的假象 Dev->>App: 执行SQL(同一会话) App->>Session: 复用已有连接 Note over Session: 变量里还残留着之前赋的值 Session->>Optimizer: 解析SQL Optimizer->>Executor: 执行顺序:先get_id,再set_id Executor->>Executor: 第1步:get_id()(变量有残留值,返回10!) Executor->>Executor: 第2步:set_id(10)=1(执行但已不重要) Executor->>Executor: 第3步:查询id1=10 Executor-->>Session: 返回结果 Session-->>App: ✅ 查询成功(但靠的是残留值!) Note over Dev,Executor: 开发再次以为SQL没问题<br/>实际上换一个会话就崩

优化器到底在想什么

说白了,数据库优化器的任务就是:找一条最便宜的路径把数据捞出来。

你写SQL的时候脑子里想的是"先做A再做B",优化器根本不管这一套。它关心的只有三样东西:哪个条件能过滤掉更多数据、哪个条件算起来更快、哪个条件能用索引跳过大量扫描。

它不按你的代码顺序干活,它按它的代价公式干活。

下面是优化器内部决策的流程图,展示了它如何评估和选择执行策略:

flowchart TD Start[接收SQL语句] --> Parse[语法解析] Parse --> Rewrite[逻辑重写] Rewrite --> GeneratePaths[生成多种执行路径] GeneratePaths --> Eval1[评估路径1<br/>外连接消除检查] GeneratePaths --> Eval2[评估路径2<br/>条件重排序检查] GeneratePaths --> Eval3[评估路径3<br/>索引扫描检查] Eval1 --> Calc1[计算代价:IO+CPU] Eval2 --> Calc2[计算代价:IO+CPU] Eval3 --> Calc3[计算代价:IO+CPU] Calc1 --> Compare[比较各路径代价] Calc2 --> Compare Calc3 --> Compare Compare --> SelectPath[选择代价最小的路径] SelectPath --> CheckOrder[检查函数执行顺序] CheckOrder --> Decide{是否可调换顺序?} Decide -->|可调换且更便宜| Reorder[调整执行顺序] Decide -->|不可调换| KeepOrder[保留原顺序] Reorder --> Execute[执行查询] KeepOrder --> Execute Execute --> Output[返回结果集] style Start fill:#e1f5fe style Output fill:#c8e6c9 style Decide fill:#fff9c4 style Reorder fill:#ffccbc

各家数据库是怎么处理这个问题的

Oracle的做法:WHERE里的等值条件从左到右执行,但优化器有权调整顺序。尤其是等式和不等式混在一起的时候,优化器会优先算过滤率高的条件。好处是性能好,坏处是行为不太可预测。

金仓的做法:WHERE里的函数条件从左到右执行。这个承诺比Oracle更明确。但即使执行顺序是稳定的,依赖"先赋值后取值"这种逻辑仍然不安全。因为优化器可能在版本升级后改变行为,或者同一个SQL在不同连接里的表现不一样。

MySQL:有套"条件优化"机制,会把WHERE里的条件重新排序,优先执行过滤效果好的。对纯查询条件是好事,但如果条件里带了自定义函数,结果就很难预测了。

SQL Server:基本按书写顺序执行,但函数如果标记了DETERMINISTIC(确定性),优化器就可以自由重排。没标记的就保留原顺序。

下面这张架构图展示了不同数据库优化器的策略差异:

flowchart TB subgraph Oracle[&#34;Oracle 优化器架构&#34;] O1[SQL输入] --> O2[代价估算模块] O2 --> O3{条件重排决策} O3 -->|代价低| O4[调整执行顺序] O3 -->|代价高| O5[保持原顺序] O4 --> O6[执行引擎] O5 --> O6 Note1[特点:激进优化<br/>性能优先,行为难预测] end subgraph KES[&#34;金仓 KES 优化器架构&#34;] K1[SQL输入] --> K2[代价估算模块] K2 --> K3[解析用户指定顺序] K3 --> K4{函数条件?} K4 -->|是| K5[严格从左到右] K4 -->|否| K6[基于代价重排] K5 --> K7[执行引擎] K6 --> K7 Note2[特点:确定性优先<br/>可预期,适合迁移场景] end subgraph MySQL[&#34;MySQL 优化器架构&#34;] M1[SQL输入] --> M2[条件优化模块] M2 --> M3{条件过滤效果评估} M3 -->|过滤率高| M4[提到前面执行] M3 -->|过滤率低| M5[放到后面执行] M4 --> M6[执行引擎] M5 --> M6 Note3[特点:过滤率优先<br/>纯查询友好,函数需谨慎] end subgraph SQLServer[&#34;SQL Server 优化器架构&#34;] S1[SQL输入] --> S2[函数确定性检查] S2 --> S3{标记为DETERMINISTIC?} S3 -->|是| S4[可自由重排] S3 -->|否| S5[保留原顺序] S4 --> S6[执行引擎] S5 --> S6 Note4[特点:基于函数标记<br/>确定性vs非确定性] end style Oracle fill:#e3f2fd style KES fill:#fff3e0 style MySQL fill:#f3e5f5 style SQLServer fill:#e8f5e9

优化器设计上的取舍

从这几个数据库的设计能看出来,优化器设计有个核心矛盾:

想帮用户省事吧,就可能改变执行顺序,导致依赖顺序的逻辑出问题。不帮用户省事吧,有些SQL的性能就上不去。

Oracle选的是"帮用户省事",它假设用户写SQL的时候没有隐含的顺序依赖。这个假设在大部分场景下成立,但碰到了set_id/get_id这种写法就翻车了。

金仓选的是"给用户确定性",WHERE里的函数条件从左到右执行,让用户知道数据库会怎么跑。这个设计对迁移场景特别友好。从Oracle迁过来,至少行为是明确的,不会出现同一条SQL今天能跑明天崩的情况。

但金仓的文档里也说了,即使执行顺序稳定,也不建议依赖它来做业务逻辑。这句话说得挺实在的。

下面是条件执行调度的详细流程图,展示了优化器如何判断能否重排条件:

flowchart TD Start[WHERE子句中的多个条件] --> Scan[扫描所有条件] Scan --> Check1{条件类型判断} Check1 -->|常量条件| Const[标记为常量<br/>计算代价极低] Check1 -->|索引列条件| Index[标记为索引可用<br/>通常过滤率高] Check1 -->|函数条件| Func[标记为函数调用<br/>需额外评估] Check1 -->|子查询条件| SubQ[标记为子查询<br/>通常代价最高] Const --> Analyze[进入重排决策] Index --> Analyze Func --> Analyze SubQ --> Analyze Analyze --> FilterRate{评估过滤率} FilterRate -->|高| High[过滤率高<br/>条件靠前] FilterRate -->|低| Low[过滤率低<br/>条件靠后] High --> CheckSideEffects{检查副作用} Low --> CheckSideEffects CheckSideEffects -->|有副作用| Safe[保持原顺序<br/>不能重排] CheckSideEffects -->|无副作用| Reorder[可以重排<br/>按代价排序] Safe --> Execute[执行查询] Reorder --> Execute subgraph 实际执行顺序示例 Execute --> Step1[第1步:常量条件<br/>计算代价最低] Step1 --> Step2[第2步:高过滤率条件<br/>能快速缩小数据集] Step2 --> Step3[第3步:普通条件<br/>在已过滤集上计算] Step3 --> Step4[第4步:代价高的函数<br/>只在小数据集上调用] end style Start fill:#e1f5fe style CheckSideEffects fill:#fff9c4 style Safe fill:#c8e6c9 style Reorder fill:#ffccbc style Execute fill:#e8f5e9

后来我学到的

那次踩坑之后,我给自己定了几条规矩,不管用哪个数据库都管用:

WHERE里不能放有副作用的函数。什么叫副作用?修改变量、改数据、改状态的都算。这类函数应该单独执行,和查询分开。先CALL set_value(),再SELECT ...,两步走,永远不出错。

纯查询函数该声明就声明。如果函数只是读数据,告诉优化器它不会变,用STABLE或者IMMUTABLE标记一下。这样优化器可以放心优化,不会因为执行顺序的问题搞出幺蛾子。

看执行计划的时候盯着Filter顺序。跑EXPLAIN ANALYZE,看各条件的实际执行顺序。如果发现顺序和你预期的不一致,赶紧查原因。有时候是统计信息不准,有时候是优化器算错了账。

下面是执行计划查看的示例代码:

sql 复制代码
-- 查看执行计划,重点关注Filter的顺序
EXPLAIN ANALYZE 
SELECT * FROM orders 
WHERE status = 1 
  AND amount > 100 
  AND pkg_order.check_valid(order_id) = 1;

执行计划输出示例:

sql 复制代码
QUERY PLAN
----------------------------------------------------------------------------------------------------------
Seq Scan on orders  (cost=0.00..354.50 rows=12 width=68) (actual time=0.032..2.145 rows=15 loops=1)
  Filter: ((status = 1) AND (amount > 100) AND (pkg_order.check_valid(order_id) = 1))
  Rows Removed by Filter: 9985
  Filter顺序(实际执行):
    1. status = 1   -- 过滤掉60%,先执行
    2. amount > 100 -- 再过滤掉30%
    3. pkg_order.check_valid(order_id) = 1 -- 最后才调用函数,减少函数执行次数
Planning Time: 0.185 ms
Execution Time: 2.201 ms

正确的写法:把副作用的函数移出WHERE

sql 复制代码
-- 错误写法(依赖执行顺序)
SELECT * FROM my_table 
WHERE id1 = pkg_abc.get_id() 
  AND pkg_abc.set_id(10) = 1;

-- 正确写法1:分两步
CALL pkg_abc.set_id(10);
SELECT * FROM my_table WHERE id1 = pkg_abc.get_id();

-- 正确写法2:如果函数只是纯查询,标记为STABLE
CREATE OR REPLACE FUNCTION get_user_level(p_user_id INT)
RETURNS INT
STABLE  -- 告诉优化器这个函数在同一个查询中不会变
AS $$
BEGIN
    RETURN (SELECT level FROM users WHERE id = p_user_id);
END;
$$ LANGUAGE plpgsql;

-- 然后可以放心使用
SELECT * FROM orders WHERE user_level = get_user_level(1001);

再说两句

回过头看,外连接消除和函数执行顺序这俩问题的本质是一样的:优化器在帮你做决定,但你未必知道它做了这个决定。

金仓在这块走的路相对谨慎。它的外连接消除虽然存在,但开发可以主动避开。它的函数执行顺序有明确承诺,让开发至少有个可预期的行为。

那次迁移之后,我反复跟团队说一件事:写SQL的时候,别假设数据库会按你期望的顺序干活。把逻辑放在SQL之外,让查询只做查询的事。这是最稳妥的做法。

📢 对了,金仓社区最近有几个活动,感兴趣的可以看看:

  1. 荐商机·赢好礼------金仓社区"同行者计划"开启 ,分享经验、推荐方案都有机会拿奖励:bbs.kingbase.com.cn/forumDetail...

  2. 2026金仓数据库智能运维工具开发大赛 正在报名中,对运维工具开发感兴趣的话值得关注:bbs.kingbase.com.cn/forumDetail...bbs.kingbase.com.cn/forumDetail...

  3. 还有征文大赛 ,分享数据库使用过程中的经验、踩坑经历,欢迎投稿:bbs.kingbase.com.cn/web-api/for...

欢迎来社区转转,一起交流。

相关推荐
xianjixiance_2 小时前
HarmonyOS应用开发实战:萌宠日记 - 整体架构设计与技术选型解析
后端
b130538100492 小时前
HarmonyOS应用开发实战:萌宠日记 - 回调函数模式
后端
Conan在掘金2 小时前
鸿蒙报错速查:Cannot find name 'image',忘 import 编译就炸,根因 + 真解法
后端
雪隐2 小时前
个人电脑玩AI-13让5060 Ti给你打工——我用 0.9B 小模型终结了"谁来记会议纪要"这个世纪难题
前端·人工智能·后端
无名之辈J2 小时前
Ai开发
后端
Conan在掘金2 小时前
�鸿蒙报错速查:arkts-strict-typing 函数返回值类型必须显式,忘标就炸,根因 + 真解法
后端
半个落月2 小时前
用 LangChain JS 做可控写作实验:理解温度参数、提示词与异步调用
javascript·人工智能·后端
爱勇宝2 小时前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
用户208046804562 小时前
Python3 条件控制新手实战指南
后端