数据库优化器到底在想什么:一个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的时候可能正好变量有值,跑通了,等到了生产又不行了。来回折腾好几次才找到根因。
下面这张时序图清楚地展示了两种不同的执行路径,以及会话污染导致测试假象的过程:
优化器到底在想什么
说白了,数据库优化器的任务就是:找一条最便宜的路径把数据捞出来。
你写SQL的时候脑子里想的是"先做A再做B",优化器根本不管这一套。它关心的只有三样东西:哪个条件能过滤掉更多数据、哪个条件算起来更快、哪个条件能用索引跳过大量扫描。
它不按你的代码顺序干活,它按它的代价公式干活。
下面是优化器内部决策的流程图,展示了它如何评估和选择执行策略:
各家数据库是怎么处理这个问题的
Oracle的做法:WHERE里的等值条件从左到右执行,但优化器有权调整顺序。尤其是等式和不等式混在一起的时候,优化器会优先算过滤率高的条件。好处是性能好,坏处是行为不太可预测。
金仓的做法:WHERE里的函数条件从左到右执行。这个承诺比Oracle更明确。但即使执行顺序是稳定的,依赖"先赋值后取值"这种逻辑仍然不安全。因为优化器可能在版本升级后改变行为,或者同一个SQL在不同连接里的表现不一样。
MySQL:有套"条件优化"机制,会把WHERE里的条件重新排序,优先执行过滤效果好的。对纯查询条件是好事,但如果条件里带了自定义函数,结果就很难预测了。
SQL Server:基本按书写顺序执行,但函数如果标记了DETERMINISTIC(确定性),优化器就可以自由重排。没标记的就保留原顺序。
下面这张架构图展示了不同数据库优化器的策略差异:
优化器设计上的取舍
从这几个数据库的设计能看出来,优化器设计有个核心矛盾:
想帮用户省事吧,就可能改变执行顺序,导致依赖顺序的逻辑出问题。不帮用户省事吧,有些SQL的性能就上不去。
Oracle选的是"帮用户省事",它假设用户写SQL的时候没有隐含的顺序依赖。这个假设在大部分场景下成立,但碰到了set_id/get_id这种写法就翻车了。
金仓选的是"给用户确定性",WHERE里的函数条件从左到右执行,让用户知道数据库会怎么跑。这个设计对迁移场景特别友好。从Oracle迁过来,至少行为是明确的,不会出现同一条SQL今天能跑明天崩的情况。
但金仓的文档里也说了,即使执行顺序稳定,也不建议依赖它来做业务逻辑。这句话说得挺实在的。
下面是条件执行调度的详细流程图,展示了优化器如何判断能否重排条件:
后来我学到的
那次踩坑之后,我给自己定了几条规矩,不管用哪个数据库都管用:
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之外,让查询只做查询的事。这是最稳妥的做法。
📢 对了,金仓社区最近有几个活动,感兴趣的可以看看:
-
荐商机·赢好礼------金仓社区"同行者计划"开启 ,分享经验、推荐方案都有机会拿奖励:bbs.kingbase.com.cn/forumDetail...
-
2026金仓数据库智能运维工具开发大赛 正在报名中,对运维工具开发感兴趣的话值得关注:bbs.kingbase.com.cn/forumDetail... 或 bbs.kingbase.com.cn/forumDetail...
-
还有征文大赛 ,分享数据库使用过程中的经验、踩坑经历,欢迎投稿:bbs.kingbase.com.cn/web-api/for...
欢迎来社区转转,一起交流。