从一次不太好复现的问题说起
前些天排查一段迁移到金仓数据库后的 SQL,代码不长,问题却折腾了好几轮。这个案例看似只是两个函数的调用顺序不对,实际牵出了金仓数据库优化器的等价变换、条件调度以及函数属性等一连串问题。
开发人员的描述是:单独执行基本正常,放到应用里偶尔查不到数据;在同一个客户端里多执行几次,有时又自己"好了"。起初大家怀疑过连接池、事务隔离级别,也重新收集过统计信息。最后把范围缩到 WHERE 子句中的两个函数:一个往程序包变量里写值,另一个再把值取出来。
把业务名称去掉,大致是下面这样:
sql
SELECT id1, payload
FROM my_table
WHERE pkg_abc.set_id(10) = 1
AND id1 = pkg_abc.get_id();
写这段 SQL 的人想得很直接:set_id(10) 写在前面,数据库先把值设为 10;轮到下一行时,get_id() 自然就能取到 10。
如果这是过程代码,这个理解没有问题。可它偏偏写在 WHERE 里。
先给结论:普通筛选条件只说明一行数据要满足什么要求,并不承诺条件按照文本顺序依次执行。今天看到它先算左边,不代表换一个计划后仍然如此;在一台机器上连续跑十次都正常,也不能证明这种写法可靠。
一条 SQL 从文本到执行,中间经历了什么
我们写下的是条件,不是步骤
开发人员看到的是 SQL 文本。数据库拿到文本后,还要经过解析、语义检查、查询改写和成本估算,最后才形成执行计划。
这里有三个"顺序",很容易被混为一谈:
- 条件在 SQL 中的书写顺序;
- 优化器内部表达式的组织顺序;
- 执行器处理具体数据时的求值顺序。
第一种顺序主要方便人阅读。后两种才会影响实际执行,而且它们并不是由换行位置简单推导出来的。
举个常见例子:
sql
SELECT order_id, amount
FROM orders
WHERE check_payload(payload) = 1
AND status = 'VALID';
假设 status 上有合适的索引,而有效订单只占很少一部分。先借助索引找出有效订单,再调用 check_payload,通常比对全表逐行调用函数划算。优化器如果仍然机械地照着文本从上往下执行,反倒是在浪费资源。
这也是成本优化器存在的原因。它必须有调整空间。
优化器常做的几件事
实际执行计划里的变化很多,但与本文关系最密切的主要有以下几类。
条件重排。 对没有副作用的表达式来说,A AND B 和 B AND A 的结果相同。哪个条件过滤性更好、计算成本更低,优化器就可能优先利用哪个。
谓词下推。 外层查询的筛选条件可能被推入子查询、视图或连接分支,让无用数据尽早被过滤。例如:
sql
SELECT *
FROM (
SELECT id, status, amount
FROM orders
) t
WHERE status = 'VALID';
执行时通常没有必要先完整生成 t,再从外面过滤。把 status = 'VALID' 放到靠近表扫描的位置,会少处理很多行。
常量折叠。 amount > 10 * 100 可以提前化成 amount > 1000,1 = 1 这样的恒真条件也可以直接消掉。如果整个条件能够被证明恒假,底层扫描甚至可能不发生。
子查询转换。 IN、EXISTS 和部分相关子查询,可能被转换为半连接、反连接或其他连接形式。SQL 看上去是"先执行里面,再判断外面",最终计划却不一定按这个外形工作。
当然,优化器不是想怎么改就怎么改。外连接、聚合、窗口函数、集合运算等结构都有各自的语义限制。能否变换,先要过正确性这一关;在多个合法方案中选哪一个,才轮到统计信息和成本模型决定。
真正危险的不是重排,而是副作用
当筛选函数偷偷做了别的事
仍然看开头那两个函数。如果它们只是普通计算,调换先后一般不会影响结果。问题是 set_id 修改了会话状态,它已经不是一个单纯的筛选表达式。
sql
WHERE id1 = pkg_abc.get_id()
AND pkg_abc.set_id(10) = 1
假如 get_id() 先执行,它读到的可能是空值,也可能是上一次调用留下的值。假如前一个条件已经足以判定该行不符合要求,后面的函数还有可能不再执行。再往细处追,扫描一行和扫描一万行时,函数调用次数也未必一样。
不少人会拿测试结果反驳:"我这里确实是从左往右的。"这个现象完全可能存在,只是不能拿它当接口契约。版本升级、统计信息变化、索引增删、参数不同,都可能让优化器选出另一份计划。正确性如果建立在某份计划恰好长什么样上,迟早会出问题。
写日志、更新表、推进序列、读写程序包变量,都属于需要警惕的行为。它们藏在 WHERE、JOIN ON 或 HAVING 里时尤其难查,因为看 SQL 的人下意识会把这些位置当作"只判断,不做事"。
为什么新连接更容易暴露问题
开头提到的故障有一个特征:在同一会话中多执行几次,结果反而可能正常。
原因不复杂。程序包变量等会话级状态会在会话存续期间保留。前一次调用只要成功设置过值,后面的错误 SQL 就可能读到旧值,于是产生"问题自己消失了"的假象。应用连接池又会复用物理连接,不同请求拿到的连接状态未必一样,现象自然时好时坏。
排查时可以做个很朴素的对照:一组测试每次都新建会话,另一组固定复用同一会话。如果两边结果明显不同,就该优先检查会话变量、临时对象和初始化逻辑,而不是继续在执行计划里寻找一个固定的左右顺序。
金仓数据库中的函数属性要如实写
三类属性表达的是承诺
金仓数据库中的函数可以依据实际行为声明相应的易变性属性。不同版本的具体语法应以对应版本文档为准,这里只讨论它们表达的含义。
IMMUTABLE:相同参数始终产生相同结果,不依赖数据库内容、会话状态或当前时间等外部因素。STABLE:在一条语句执行期间,相同参数的结果可以视为稳定,但不同语句之间可能变化。VOLATILE:连续调用的结果可能不同,或者函数存在可观察的副作用。
这几个词看起来像调优选项,实际上更接近开发者写给优化器的保证书。优化器相信函数不可变,才可能提前计算、合并调用或复用结果。
因此,不要为了少调用几次函数,就把读取表数据、序列、时间或会话变量的函数标成 IMMUTABLE。短期内某个计划或许变快了,代价却可能是返回旧结果。带有写操作的函数更不能伪装成纯函数。
我更倾向于先按最保守、最符合真实行为的方式声明,确认函数确实满足更强约束后再调整。函数属性写错带来的问题,往往不是稳定报错,而是只有特定计划下才出现的结果偏差,这类故障最费时间。
换到其他数据库,结论会不同吗
具体做法有差别,使用边界很接近
数据库迁移时,经常有人根据原系统的运行现象总结出一条"规律",例如某类等式条件总是从左向右算。迁到新环境后顺序变了,就把它归为兼容性问题。这里需要谨慎:运行现象不等于产品承诺。
金仓数据库会根据查询语义、统计信息和成本选择计划。执行计划中看到的 Filter 可以帮助解释本次执行,却不能反过来约束以后每一次执行。
Oracle 会进行谓词重排、谓词传递、视图合并和子查询转换,访问谓词与过滤谓词还可能出现在不同位置。某些表达式确实能观察到短路行为,但普通 WHERE 条件的文本次序仍不适合作为过程控制方式。
MySQL 同样会利用索引、选择率和条件下推改变求值位置。一个很典型的误用是先检查字符串格式,再在另一个条件中做可能报错的类型转换,希望前一项永远充当"保护条件"。这与依赖 set_id 先执行,本质上是同一个问题。更稳妥的办法是约束数据类型、清理脏数据,或者使用产品支持的安全转换方式。
SQL Server 的执行计划里可能看到 Seek Predicate、Predicate、Compute Scalar 等节点或属性。它们能说明表达式在当前计划中的位置,却不提供源代码从左到右求值的保证。参数和数据分布改变后,计划本身也可能发生变化。
几种数据库的内部实现不能简单画等号,但工程结论是一致的:优化器负责寻找访问路径,业务动作的先后应由过程代码负责。不能因为某个产品当前版本"碰巧按这个顺序跑",就把巧合写进业务规则。
这类 SQL 应该怎么改
能传参数,就别借会话状态传话
如果 id 本来就是查询参数,最干净的办法是直接传入:
sql
SELECT id1, payload
FROM my_table
WHERE id1 = :target_id;
这样做看似普通,却一次解决了好几个问题:调用关系清楚,单元测试容易编写,连接池不会带出上一个请求的状态,失败重试也不用猜会话里还剩下什么。
确实存在先后关系,就把步骤写出来
如果业务确实要求先设置状态,再执行查询,应使用存储过程、过程化代码或应用事务明确表达顺序。例如:
sql
BEGIN;
SELECT pkg_abc.set_id(10);
SELECT id1, payload
FROM my_table
WHERE id1 = pkg_abc.get_id();
COMMIT;
上面只是说明拆分思路。实际项目还要根据 set_id 的返回值处理失败,并确认事务边界是否符合业务要求。不要把 BEGIN 和 COMMIT 原样套进所有代码,更不能忽略调用失败后的回滚。
需要精确处理错误时,过程代码会更直白:
sql
-- 伪代码,具体语法以实际版本为准
BEGIN
v_result := pkg_abc.set_id(10);
IF v_result <> 1 THEN
RAISE EXCEPTION 'set_id failed';
END IF;
SELECT ...;
END;
有人觉得这种写法"多绕了一步"。其实那一步原本就存在,只是以前藏在布尔表达式里,现在把它写明了。
排查时别急着猜执行顺序
一份更实用的检查清单
遇到查询偶发空结果、函数调用次数异常,或者新旧环境结果不一致,我一般按下面的顺序检查:
- 搜索
WHERE、JOIN ON、HAVING和查询列中的自定义函数; - 查看函数是否读取或修改程序包变量、表、序列、临时对象以及外部资源;
- 核对函数属性是否与代码行为一致;
- 分别在新会话和复用会话中测试,记录第一次执行的结果;
- 用
EXPLAIN查看估算计划,确有必要时再执行EXPLAIN ANALYZE; - 把副作用调用拆出查询,比较拆分前后的结果。
这里特别提醒一句:EXPLAIN ANALYZE 会真实执行语句。有写操作或副作用函数时,不要直接拿生产 SQL 试。先确认影响范围,必要时在隔离环境复现。
执行计划能告诉我们条件落在哪个节点、过滤掉多少行、耗时大致在哪里,但它未必完整展示表达式内部每一次函数调用的微观先后。看计划是为了确认数据访问过程,不是为了替一段危险 SQL 找到永远不变的排序技巧。
写在最后
判断这类 SQL 是否可靠,有一个简单办法:假设两个条件被交换,函数被提前调用、延后调用、多调用一次,甚至没有调用,查询结果还能不能保持正确?
如果答案是否定的,这段逻辑就不应该待在 WHERE 里。
优化器做等价变换不是故意和开发人员作对。恰恰相反,谓词下推、条件重排和连接转换,是数据库面对大数据量时仍能维持性能的重要手段。应用代码需要做的,是别让业务正确性依赖这些手段刚好没有发生。
最近金仓社区有一些偏实践的活动。手头有真实项目机会的,可以了解"同行者计划";在做监控、诊断或自动化运维工具的团队,可以看看2026 金仓数据库智能运维工具开发大赛和相关说明。如果已经整理过类似的迁移与排障案例,社区征文专题也值得关注。
写案例时,建议把函数定义、数据分布、会话是否复用以及关键执行计划一并附上。只写"调整条件顺序后恢复",读者很难判断问题是否真的解决;把复现条件交代清楚,经验才更有参考价值。