
在 WHERE 子句里放一个有副作用的函数,就像把状态变更藏进条件判断里:短期可能可用,长期很难维护和验证。
引言:一段看起来合理的代码
在一次代码评审中,我看到过这样一条 SQL:
sql
SELECT * FROM employees
WHERE get_department_id() = set_department_id('IT') + 0;
编写者的意图很明确:先调用 set_department_id('IT') 设置一个会话级变量,然后调用 get_department_id() 读取它,再用这个值过滤 employees 表。
这类写法的问题不在于语法,而在于它把查询结果建立在三个不稳定前提上:
- 依赖函数的执行顺序;
- 依赖函数副作用,例如修改全局或会话状态;
- 假设数据库版本、优化器策略和运行环境不会改变。
本文从执行顺序、会话状态和优化器改写三个角度,分析为什么不应在 WHERE 子句中依赖有副作用函数。
一、WHERE 中的函数执行顺序是否确定?
1.1 Oracle 的不确定性
在 Oracle 中,WHERE 子句中多个表达式或函数的求值顺序通常不应被视为稳定保证。优化器可能基于以下原因调整执行方式:
- 谓词重排:根据过滤率和代价,调整条件的求值顺序;
- 短路优化:当某个条件已经能确定布尔表达式结果时,可能跳过后续条件;
- 并行执行:不同执行片段可能以不同顺序处理数据。
因此,今天看似按书写顺序执行的 SQL,换一个执行计划、统计信息或版本后,行为可能发生变化。
1.2 KES 当前实现中的确定性
在部分 KES 版本中,WHERE 子句表达式会按书写顺序从左到右执行。这种行为降低了理解成本,但不应被当作业务逻辑的长期依赖。
原因有两点:
- 优化器未来可能引入更多谓词重排或表达式改写能力;
- 即使当前版本行为确定,依赖该行为的 SQL 也缺乏跨库和跨版本可移植性。
1.3 对比总结
| 维度 | Oracle | KES 部分版本 |
|---|---|---|
| 执行顺序保证 | 不建议依赖,优化器可能改写 | 当前可能按书写顺序执行 |
| 谓词重排 | 支持 | 取决于版本与优化策略 |
| 跨版本风险 | 较高 | 需要关注版本行为变化 |
| 工程建议 | 不依赖顺序 | 同样不依赖顺序 |
结论很简单:无论数据库当前如何实现,都不建议把业务正确性建立在 WHERE 子句函数执行顺序上。
二、为什么这种写法风险较高?
2.1 会话污染:连接池中的隐性状态
回到开头的例子:
sql
SELECT * FROM employees
WHERE get_department_id() = set_department_id('IT') + 0;
如果 set_department_id 修改的是会话级变量,在连接池环境中就容易出现状态残留:
text
连接 A: set_department_id('IT') -> 查询 -> 归还连接池
会话变量仍为 'IT'
连接 B: 复用该连接 -> get_department_id()
可能读到上一次请求留下的值
这种问题通常不会报错,只会返回错误数据,因此排查成本很高。
2.2 优化器改写带来的行为变化
数据库优化器的目标是生成更低成本的执行计划。随着版本演进,优化器可能引入新的表达式重排、谓词下推、常量折叠或短路执行策略。
如果 SQL 依赖 get 一定在 set 之后执行,那么任何求值顺序变化都可能导致:
- 读取到旧的会话状态;
- 触发次数与预期不一致;
- 查询结果错误但没有异常提示。
这类问题属于典型的静默行为变化,生产风险高于显式报错。
2.3 函数挥发度的影响
数据库通常会通过函数挥发度描述函数行为:
| 挥发度 | 含义 | 优化器可能行为 |
|---|---|---|
IMMUTABLE |
相同输入永远返回相同输出,无副作用 | 可缓存、提前求值 |
STABLE |
同一事务内相同输入返回相同输出 | 可在事务内复用结果 |
VOLATILE |
每次调用可能返回不同结果,或存在副作用 | 通常需要按需求值 |
如果有副作用的函数被错误声明为 STABLE 或 IMMUTABLE,优化器可能缓存、合并甚至跳过调用,导致副作用没有按预期发生。
三、如何安全处理"先 Set 后 Get"的需求
3.1 用存储过程或匿名块显式完成状态设置
把有副作用的操作从 SQL 表达式中剥离出来:
sql
BEGIN
set_department_id('IT');
FOR rec IN (
SELECT *
FROM employees
WHERE dept_id = get_department_id()
) LOOP
-- 处理结果
END LOOP;
END;
/
这种方式的优势是:
- 执行顺序由语句边界明确控制;
- 副作用与查询条件分离;
- 代码意图更容易审查和测试。
3.2 用参数替代全局状态
如果只是传递过滤值,优先使用参数:
sql
PREPARE stmt AS
SELECT * FROM employees WHERE dept_id = $1;
EXECUTE stmt('IT');
或在过程接口中显式传参:
sql
CREATE OR REPLACE PROCEDURE query_by_dept(p_dept_id VARCHAR) AS
BEGIN
FOR rec IN (
SELECT * FROM employees WHERE dept_id = p_dept_id
) LOOP
-- 处理结果
END LOOP;
END;
/
参数化能从源头避免连接池复用导致的会话污染。
3.3 正确声明函数挥发度
对无副作用的函数,应根据实际行为声明挥发度:
sql
CREATE OR REPLACE FUNCTION get_department_name(dept_id INTEGER)
RETURNS VARCHAR
STABLE
AS $$
SELECT dept_name FROM departments WHERE id = $1;
$$ LANGUAGE SQL;
CREATE OR REPLACE FUNCTION calculate_bonus(salary NUMERIC)
RETURNS NUMERIC
IMMUTABLE
AS $$
SELECT salary * 0.1;
$$ LANGUAGE SQL;
对于存在写入、状态修改、随机值、时间依赖或外部交互的函数,不应声明为稳定或不可变。
3.4 CTE 方案只适合作为临时约束
某些版本中,CTE 可能表现为优化边界:
sql
WITH setup AS (
SELECT set_department_id('IT') AS result
)
SELECT *
FROM employees, setup
WHERE dept_id = get_department_id();
但这种方式依赖具体实现和优化策略,不适合作为长期设计。只要需求涉及状态设置,仍建议使用显式语句或参数传递。
四、工程建议
- 不要在
WHERE中放置有副作用的函数,例如修改变量、写日志、发送消息或改表数据。 - 需要状态变更时,用存储过程、匿名块或应用层逻辑显式完成。
- 查询过滤值优先通过参数传递,不依赖全局或会话变量。
- 正确声明函数挥发度,不把有副作用函数伪装成稳定函数。
- 不把数据库当前执行顺序当作业务正确性的前提。
总结
在 WHERE 子句中依赖函数执行顺序,是一种不稳定的 SQL 设计。它可能在当前环境可用,但容易受到连接池、优化器改写、函数挥发度和版本差异影响。
更稳妥的做法是:把状态变更从查询表达式中移出,使用显式过程、参数化查询和清晰的函数声明,让 SQL 的结果只依赖数据和参数,而不是隐含执行顺序。